Skip to content

Latest commit

 

History

History
77 lines (50 loc) · 14.6 KB

AsyncProgramming-ru.md

File metadata and controls

77 lines (50 loc) · 14.6 KB

Сборник нюансов по асинхронному программированию.
Про асинхронную архитектуру написано отдельно тут.

Атомарные операции (atomics)

Особенности:

  • Чтение атомика дешевое, если его значение уже хранится в кэше и не было изменено в другом потоке.
  • Изменение атомика дешевое, если это не CAS-операция (сравнение и изменение), тогда может возникнуть конкуренция между потоками, когда значение долго не получается изменить.
  • relaxed на ARM платформах дает существенное ускорение.

Как оптимизировать:

  • Если атомик выровнен по 64 байта (128 на некоторых платформах), то скорость чтения и записи увеличивается.
  • Данные, связанные с атомиком, желательно размещать в 64 байтном блоке рядом с атомиком.
  • Другие атомики, которые меняются в разное время, не должны размещаться в той же кэшлинии (64 байта), это приводит к false sharing - чтение всех атомиков становится дорогим, если изменился хотя бы один из них.

При конкуренции за один атомик можно:

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

Корутины (coroutine)

В версии C++20 корутины могут быть опасны:

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

Корутины со стэком и без:

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

Привязка потоков к ядрам ЦП (Thread affinity)

Если привязывать потоки:

  • На ЦП с одинаковыми ядрами и гипертредингом (2 потока одновременно работают на 1 ядре) надо привязывать потоки с высоким приоритетом к физическим ядрам. Потоки, с низким приоритетом, а также те, кто могут засыпать при чтении файлов или на примитивах синхронизации, могут привязываться к группе ядер или к виртуальным ядрам.
  • На ЦП с производительными и энергоэффективными ядрами, потоки с высоким приоритетом привязываются к производительным ядрам, а потоки с низким приоритетом - к энергоэффективным ядрам.
  • При одновременном использовании двух приложений с жесткой привязкой к потокам возникает конкуренция за одни и те же ядра ЦП, что снижает производительность. В таком случае планировщик ОС лучше распределяет потоки, хоть иногда и возникают лишние простои.

Если НЕ привязывать потоки:

  • Иногда ОС запускает два потока приложения на одном виртуальном ядре, что приводит к сильной конкуренции и потере производительности. Возможно это связано с использованием одного примитива синхронизации, из-за чего ОС решает запустить второй поток там же для лучшей локальности кэша.
  • ОС пытается снизить нагрев одного ядра ЦП, поэтому перекидывает потоки на разные ядра, что не очень хорошо для кэша.
  • Производительность каждого ядра немного отличается, у ОС есть информация об этом, а у программистов - нет, поэтому планировщик ОС может эффективнее распределять нагрузку на ЦП.

Примитивы синхронизации

  • Любой примитив синхронизации в чем-то ограничивает параллельное выполнение, поэтому от них нужно избавляться в пользу зависимостей между асинхронными задачами.
  • Для отладки лучше использовать спинлоки вместо мьютексов, тогда в профайлере видно, когда поток не может его захватить, иначе найти заснувший поток намного сложнее, либо требуются другие способы профилирования.
  • Как альтернатива мьютексу - AsyncMutex, он формирует цепочку зависимостей между асинхронными задачами, которые хотят его захватить, поэтому потоки не простаивают.
  • Мьютексами можно защищать данные, параллельное обращение к которым маловероятно. Это имеет смысл, когда зависимости между асинхронными задачами ставятся вручную и можно пропустить какое-то условие, тогда данные не будут повреждены. Рано или поздно в процессе оптимизации этот момент будет замечен и исправлен, тогда как бороться с порчей данных намного сложнее.
  • Conditional variable позволяет приостановить поток и быстро его пробудить, в отличие от функции sleep, которая на Windows работает с частотой 1с/64. Применимо в редких случаях, когда создано много потоков а нагрузки на них нет.

Планировщик задач (Task scheduler)

Есть очевидные преимущества типа минимизации context switch, но интереснее сравнить автоматическое распределение задач с ручным планированием.

Автоматическое планирование задач - когда пользователь устанавливает только необходимые зависимости между задачами, чтобы не было гонки данных (data race). В этом случае задачи, результат которых нужен в конце кадра, могут выполняться в самом начале кадра, задерживая более важные задачи. В автоматическом режиме проблема решается построением сложных графов зависимостей, но для этого нужно получить все задачи на кадр и долго их анализировать.

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

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

Приостановка потоков

Когда у потока заканчиваются задачи на выполнение он может продолжить крутить цикл проверки на появление новых задач. Это позволит мгновенно начать выполнение задачи, когда она появится, ЦП при этом остается прогретым (частота не падает, энергоэффективный режим не включается), но при этом расходуется много энергии. Серверные ЦП рассчитаны на такой режим работы на всех ядрах, тогда как домашние ЦП больше рассчитаны на выполнение редких задач на более высокой частоте и начинают перегреваться и снижать частоты при длительной нагрузке на все ядра, в большей степени это касается и мобильных ЦП, которые перегреваются намного быстрее.

Другой вариант - поток засыпает на короткое время, частоты снижаются, энергопотребление уменьшается, перегрева не происходит. Если после пробуждения задач все еще нет, то поток засыпает на все большее время. Функция Sleep() на Windows, а также std::this_thread::sleep_for(), работают с шагом ~15мс, для игр это означает пропуск целого кадра, а то и больше для 120Гц+ мониторов, поэтому приостановку потока надо начинать с более коротких интервалов.
Ожидание в 1мкс (10x _mm_pause()) позволяет выполняться другому потоку на том же физическом ядре, частота ЦП не снижается, context switch не происходит.
Ожидание в 0.5мс это минимальное время для таймеров ОС (SetWaitableTimer(), nanosleep()), при котором они выдают стабильную точность. Частота ЦП при этом снижается, context switch ???
Недостаток такого подхода - поток сам просыпается на короткое время и снова засыпает, много времени уходит на работу ОС.

Третий вариант - использовать conditional variable и аналогичные примитивы синхронизации, они позволяют пробудить поток за 5-20мкс. Производительность теряется, когда поток слишком рано останавливается. Сложно рассчитать сколько потоков нужно пробудить.