Понимание параллелизма и многопоточных программ

И как сделать свои программы высокоэффективными

Недавно мне пришлось научиться программировать на Go, потому что я разрабатывал многопользовательскую игру для браузера.

Я спросил в сообществе r / gamedev Reddit, и мне порекомендовали взглянуть на GO для внутреннего сервера.

Go - это относительно новый язык, созданный инженерами Google, в котором функции параллелизма встроены прямо в язык.

Мы узнаем о go и многопользовательских видеоиграх в следующей статье, но сначала мы должны понять, как работает параллелизм.

Что, черт возьми, такое параллельное выполнение?

Вместо того, чтобы сразу переходить к параллелизму, было бы полезно понять, какое параллельное выполнение

Если вы занимаетесь информатикой, возможно, вы слышали о параллельном программировании, но не волнуйтесь, если не слышали.

В основном это означает выполнение программы параллельно.

Одновременный запуск двух разных частей кода

Это означает, что обе программы работают в любой момент времени. Один может закончиться раньше другого, но они не выполняются последовательно - одна инструкция за другой - как в обычной программе.

Это здорово, потому что это означает, что мы можем удвоить скорость нашей программы за то же время. Делать две вещи одновременно быстрее, чем одну, верно?

Но с большой силой приходит большая ответственность и цена. Ну, вообще-то два.

Условия для параллельного запуска программ

Для параллельного выполнения требуется дополнительное оборудование для одновременной работы.

  • Требуется одно ядро ​​для каждой программы, выполняемой параллельно

Обе программы должны быть строго не связаны между собой, чтобы работать параллельно.

  • Это связано с тем, что мы не можем получить доступ к одному и тому же источнику данных в обеих программах, не заблокировав одну из них, что сделало бы программу больше не параллельной.

Давайте обсудим ограничение №1, чтобы лучше понять, почему нам нужно как минимум два ядра для параллельной работы.

Немного о процессорах

Программы и потоки выполняются процессором нашего компьютера. Процессор состоит из ядер, и каждое ядро ​​может обрабатывать только одну инструкцию за раз.

К счастью для нас, большинство современных процессоров имеют 4 ядра или больше. Вот почему мы называем их четырехъядерными процессорами. Меньшие устройства, такие как таблицы, могут иметь менее четырех ядер для экономии заряда батареи или потому, что они требуют меньшей вычислительной мощности.

Например, мой macbook air 2019 года имеет двойной процессор. ** Плачет параллельно **

Поскольку каждое ядро ​​может обрабатывать только одну вещь за раз. Нам нужно как минимум два ядра, чтобы иметь возможность выполнять любое параллельное выполнение. Итак, если у вас четыре ядра, теоретически вы можете одновременно выполнять четыре разные программы. Это в 4 раза быстрее! Но опять же, они должны быть СТРОГО не связаны.

Две связанные программы нельзя запускать параллельно, потому что для правильной работы им требуется какая-то синхронизация.

Параллелизм для победы

Ученые-информатики поняли, что многие программы не могут выполняться параллельно, потому что они связаны друг с другом.

Ядра также обычно имеют по 4 потока.

Поток - это просто подпроцесс программы с меньшими накладными расходами.

Многопоточная программа будет использовать дополнительные потоки - и ядра - для более эффективного распределения нагрузки программы, в отличие от того, чтобы одно бедное ядро ​​выполняло всю работу, а другие просто наблюдали.

Предпосылка параллелизма состоит в том, чтобы запускать две или более разных программ одновременно.

Использование потраченных впустую циклов ЦП

Предположим, у нас есть программа, которая ожидает ввода пользователя, чтобы напечатать «Hello {user’s input}», а не «Hello World».

Процесс (поток также является процессом) имеет 5 состояний, в которых он может находиться в любой момент своего жизненного цикла.

Всякий раз, когда программа находится в состоянии готовности или ожидания, циклы процессора тратятся впустую, пока программа ожидает срабатывания какого-либо ввода или события.

Параллелизм использует это «потраченное впустую время» для выполнения другой программы, в то время как первая ожидает указанных событий, например, когда пользователь вводит свое имя пользователя и пароль.

Сегодняшние компьютеры настолько быстры, что их операционные системы используют эту стратегию для запуска программ «одновременно», но на самом деле переключение между программами происходит очень быстро, и мы не видим разницы.

Хотя параллелизм не так быстр, как параллелизм, он может значительно ускорить выполнение программы.

Параллельное программирование - это ТРУДНО

Теперь, когда мы знаем, что такое параллелизм и чего он может достичь, давайте рассмотрим некоторые проблемы, с которыми мы можем столкнуться при его реализации.

Условия гонки

Состояние гонки создается, когда два процесса борются за один и тот же ресурс. Это означает, что программа в целом становится непредсказуемой, поскольку одна программа может изменить значение до того, как другая программа его прочитает.

Пример снова!

Допустим, мы реализуем игру и сохраняем местоположения игроков (X, Y) в хэш-карте.

Теперь предположим, что игрок A стреляет в игрока B, и нам нужно проверить, попадет ли пуля в игрока B и нанесет ли он урон.

Компоненты обнаружения столкновений и ввода игрока в нашей игре работают одновременно, поэтому нам нужно убедиться, что не создаются условия гонки.

Итак, если у нас есть функция, которая проверяет столкновение между пулей игрока A и персонажем игрока B, но игрок B может двигаться одновременно, программа обнаружения столкновений может изменить положение игрока B в массиве, пока мы ищем столкновения .

В лучшем случае это создаст несоответствия в логике игры, например, игрок не будет затронут коллизией, но в худшем случае наша игра выйдет из строя, потому что память не может быть прочитана и записана одновременно.

В любом случае, мы не хотим, чтобы это происходило, потому что эти проблемы может быть чрезвычайно сложно отладить, поскольку мы не знаем, когда выполняется код.

Разрешение параллельных проблем

Мы будем использовать следующий дизайн многопользовательской игры, чтобы обсудить некоторые реальные сценарии, в которых можно использовать параллелизм.

Обмен данными между потоками

Иногда некоторые данные должны быть разделены между потоками, и это может привести к условиям гонки, если все сделано неправильно.

Один из способов решения этой проблемы - предоставить способ синхронизации потоков, чтобы только один из них мог получить доступ к выбранному источнику или прочитать его в любой момент времени.

На ходу это решение называется каналом. Не пугайтесь названия, потому что канал - это просто очередь с некоторыми дополнительными функциями.

Когда поток читает из канала, он блокирует любой доступ к любому другому потоку, пока текущий поток не закончит его использовать. Если два или более потока хотят получить доступ к каналу, им придется подождать, пока он станет доступным. Таким образом, мы можем гарантировать отсутствие условий гонки.

Создание копии данных

Мы можем столкнуться с ситуацией, когда нам нужно обновлять данные много раз в секунду, в то время как в то же время пользователь может изменять данные в одно и то же время. Иногда мы не можем использовать один и тот же канал для всего, если не боимся, чтобы все наши компоненты зависели друг от друга. Разделение - хороший способ обеспечить чистый проект, который легко поддерживать.

Например, вернемся к игровому примеру. Когда игрок стреляет, компонент отвечает за создание снаряда, а также за обновление игрока, если он движется.

Используя первый метод, мы можем просто отправлять каждый входной запрос и создаваемый снаряд через канал и обновлять все из одного источника, чтобы предотвратить состояние гонки.

Но может быть лучшим решением напрямую обновить позицию игрока и послать только снаряд. Таким образом, нам нужно беспокоиться только об обновлении снарядов и проверке столкновений в нашем физическом компоненте, а не обрабатывать также и движение игрока.

Для этого мы создаем копию позиций игрока и проверяем коллизии с этой копией, поскольку исходные данные могут измениться, пока мы проверяем их.

Поскольку мы обновляем снаряд и проверяем столкновение 60 раз в секунду, это предотвращает чтение данных, которые изменяются одновременно, а также сохраняет нашу физическую логику отдельно от входной логики нашего игрока.

Другой способ, как упоминалось выше, - это обработать все в физическом компоненте, что может сделать наш код менее читаемым и зависимым от компонентов, которые не связаны между собой.

Что дальше?

Параллелизм может значительно ускорить наши программы за счет оптимизации использования ресурсов и времени ожидания, которое в противном случае было бы потрачено впустую. Во многих случаях использование параллелизма может улучшить программу, но это часто требуется для высокопроизводительных систем, таких как приложения реального времени и видеоигры.

В следующей статье мы обсудим различные стратегии, используемые для решения этих проблем, а также реализацию параллельных программ в GO.

Тем временем вы можете начать изучать Go, потому что он отличается от традиционных языков и может занять некоторое время, чтобы к нему привыкнуть.

Хорошего дня!

использованная литература

[1] MIT, Concurrency (2014), https://web.mit.edu/6.005/www/fa14/classes/17-concurrency/

[2] Ян Харрис, Параллелизм в GO (2019), https://www.coursera.org/learn/golang-concurrency