Переключение горутины с одного потока ОС на другой довольно затратно и может значительно замедлить работу приложения, если это происходит слишком часто. Однако со временем эту проблему решил планировщик Go путем обеспечения привязки горутин к потоку (scheduler affinity) в условиях конкурентной работы (concurrently). А чтобы нам лучше понять всю прелесть этой доработки, давайте вернемся назад в прошлое и посмотрим, как было до.

Первоначальная проблема

На ранних этапах существования языка Go (во времена версий 1.0 и 1.1) была проблема со снижением производительности при выполнении конкурентного кода с большим количеством потоков ОС, т.е. с высоким значением GOMAXPROCS. Посмотрим, как это выглядело, на примере вычисления простых чисел, используемого в документации:

https://play.golang.org/p/9U22NfrXeq

А вот бенчмарк вычисления первых ста тысяч простых чисел на Go 1.0.3 с несколькими значениями GOMAXPROCS:

name     time/op
Sieve    19.2s ± 0%
Sieve-2  19.3s ± 0%
Sieve-4  20.4s ± 0%
Sieve-8  20.4s ± 0%

Чтобы проанализировать эти результаты, нам нужно понять, что было заложено в планировщик при разработке. В первой версии Go планировщик имел только одну глобальную очередь, в которой каждый из потоков мог отправлять и брать оттуда горутины. Вот пример приложения, работающего с максимум двумя потоками ОС (GOMAXPROCS присвоено значение два) M на схеме ниже:

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

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

  • Горутина #7 блокируется на канале и ожидает сообщения. Как только сообщение получено, горутина становится в глобальную очередь:

  • Затем канал отправляет сообщения, и горутина #X запускается в свободном потоке, в то время как горутина #8 блокируется на канале:

  • Горутина #7 теперь запущена в свободном потоке:

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

Total: 8679 samples
3700  42.6%  42.6%     3700  42.6% runtime.procyield
1055  12.2%  54.8%     1055  12.2% runtime.xchg
753   8.7%  63.5%     1590   18.3% runtime.chanrecv
677   7.8%  71.3%      677    7.8% dequeue
438   5.0%  76.3%      438    5.0% runtime.futex
367   4.2%  80.5%     5924   68.3% main.filter
234   2.7%  83.2%     5005   57.7% runtime.lock
230   2.7%  85.9%     3933   45.3% runtime.chansend
214   2.5%  88.4%      214    2.5% runtime.osyield
150   1.7%  90.1%      150    1.7% runtime.cas

procyield, xchg, futex и lock связаны с глобальным мьютексом планировщика Go. Мы отчетливо видим, что приложение большую часть времени находится в блокировке.

Эти проблемы не позволяют Go использовать все преимущества процессоров. Решением же этих проблем стало создание нового планировщика в Go 1.1.

Привязка к потоку в условиях конкурентной работы

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

Поскольку потоки могут блокироваться при системных вызовах, а количество заблокированных потоков не ограничено, Go привнес концепцию процессоров. Процессор P представляет собой работающий поток ОС, который управляет локальными очередями горутин. Вот, как теперь выглядит новая схема:

Новый бенчмарк с новым планировщиком Go 1.1.2:

name     time/op
Sieve    18.7s ± 0%
Sieve-2  8.26s ± 0%
Sieve-4  3.30s ± 0%
Sieve-8  2.64s ± 0%

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

Total: 630 samples
163  25.9%  25.9%      163  25.9% runtime.xchg
113  17.9%  43.8%      610  96.8% main.filter
93  14.8%  58.6%      265   42.1% runtime.chanrecv
87  13.8%  72.4%      206   32.7% runtime.chansend
72  11.4%  83.8%       72   11.4% dequeue
19   3.0%  86.8%       19    3.0% runtime.memcopy64
17   2.7%  89.5%      225   35.7% runtime.chansend1
16   2.5%  92.1%      280   44.4% runtime.chanrecv2
12   1.9%  94.0%      141   22.4% runtime.lock
9   1.4%  95.4%       98    15.6% runqput

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

Ограничение привязки

Для того, чтобы понять какие есть ограничения привязки, мы должны иметь представление о том, что именно распределяется в локальные и глобальные очереди. Локальная очередь будет использоваться для всех операций, таких как операции блокирования каналов и вызовы select, обслуживание таймеров и блокировок, за исключением системных вызовов. Однако есть две функции, которые могут ограничить привязку между горутиной и потоком:

  • Кража горутин (work-stealing). Когда процессору P недостает работы в своей локальной очереди, он будет красть горутины у других P, если глобальная очередь и сетевой поллер (network poller) пусты. После чего, горутины будут работать в другом потоке.

  • Системные вызовы. Когда системный вызов происходит (например файловые операции, http-вызовы, операции с базами данных и т.д.), Go перемещает запущенный поток ОС в режим блокировки, позволяя новому потоку обработать локальную очередь на текущем P.

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

Упорядочивание для улучшения привязки

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

Горутина #9 возобновляет работу после блокировки на канале. Однако перед запуском ей придется подождать выполнения горутин #2, #5, и #4. В этом примере горутина #5 перехватывает поток, задерживая выполнение горутины #9 и появляется риск кражи этой горутины другим процессором. Начиная с Go 1.5, благодаря специальному свойству P, горутины, возвращающиеся из блокирующего канала, теперь будут выполняться в первую очередь:

Горутина #9 теперь помечена как следующая на выполнение. Эта новая система приоритетности позволяет горутине выполнить работу, прежде чем она снова будет заблокирована на канале. Потом у других горутин будет время для выполнения их работы. Это изменение в целом положительно повлияло на стандартную библиотеку Go, улучшив производительность некоторых пакетов.


Материал подготовлен в рамках курса «Golang Developer. Professional».

Комментарии (4)


  1. bouncycastle
    17.11.2021 23:17
    +1

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

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


    1. Alexei_987
      18.11.2021 12:51

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


  1. serjeant
    18.11.2021 14:24

    Отличная статья! Большое вам спасибо!


  1. TOTEMbl4
    19.11.2021 16:59
    -4

    Вопрос не по теме, прошу простить и понять.

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