Горутины: всё, что вы хотели знать, но боялись спросить

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

Что за горутины?

Горутина (goroutine) — это функция, выполняющаяся конкурентно с другими горутинами в том же адресном пространстве.

Запустить горутину очень просто:
go normalFunc(args...)

Функция normalFunc(args...) начнет выполняться асинхронно с вызвавшим ее кодом.

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

Сколько вешать в граммах?

Чтобы было проще ориентироваться, рассмотрим цифры полученные опытным путем.

В среднем можно рассчитывать примерно на 4,5kb на горутину. То есть, например, имея 4Gb оперативной памяти, вы сможете содержать около 800 тысяч работающих горутин. Может этого и недостаточно, чтобы впечатлить любителей Erlang, но мне кажется, что цифра весьма достойная ?

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

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

«Достаточно велико» — это сколько? Сложный вопрос. Скорее всего решать придется в зависимости от конкретной ситуации. Скажу только, что из моего опыта на моем железе (atom d525 64bit) это ~50мкс. А вообще, тестируйте и развивайте чутье ?

Системные потоки

В исходном коде (src/pkg/runtime/proc.c) приняты такие термины:
G (Goroutine) — Горутина
M (Machine) — Машина

Каждая Машина работает в отдельном потоке и способна выполнять только одну Горутину в момент времени. Планировщик операционной системы, в которой работает программа, переключает Машины. Число работающих Машин ограничено переменной среды GOMAXPROCS или функцией runtime.GOMAXPROCS(n int). По умолчанию оно равно 1. Обычно имеет смысл сделать его равным числу ядер.

Планировщик Go

Цель планировщика (scheduler) в том, чтобы распределять готовые к выполнению горутины (G) по свободным машинам (M).

Рисунок и описание планировщика взяты из работы Sindre Myren RT Capabillites of Google Go.

Готовые к исполнению горутины выполняются в порядке очереди, то есть FIFO (First In, First Out). Исполнение горутины прерывается только тогда, когда она уже не может выполняться: то есть из-за системного вызова или использования синхронизирующих объектов (операции с каналами, мьютексами и т.п.). Не существует никаких квантов времени на работу горутины, после выполнения которых она бы заново возвращалась в очередь. Чтобы позволить планировщику сделать это, нужно самостоятельно вызвать runtime.Gosched().

Как только функция вновь готова к выполнению, она снова попадает в очередь.

Выводы

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

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

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