В Go есть go-рутины — это такие «потоки исполнения» пользовательского уровня – go-рутины переключаются в рамках одного системного треда, у каждой из них есть свой стек. Нужно отметить, что организация модели многопоточности в Go имеет под собой строгую теорию, а именно — теорию взаимодействующих последовательных процессов Хоара (
CSP), применяемую для формального описания функционирования параллельных систем.
Go известен своей возможностью создавать огромное количество (десятки и сотни тысяч) go-рутин. Это возможно благодаря тому, что изначально все go-рутины требуют для своего создания совсем немного ресурсов – а именно, все они создаются с небольшим размером стека (2K стека против 2M в случае стандартного потока на Linux/x86-32), а в процессе работы их стек переаллоцируется по мере необходимости. Средством взаимодействия go-рутин является
канал.
Здесь возникает вопрос: как в эту систему впишутся С-вызовы? Будет ли увеличиваться стек для нужд С-функции так же, как это происходит для go-рутин? Как будет осуществляться планирование go-рутины, когда она делает внешний вызов? Сперва всё это достаточно неочевидно.
Давайте сначала разберемся в общем механизме работы go-рутин, без внешних вызовов. Чтобы понять, как всё устроено, рассмотрим внутренние сущности Go, которые принято обозначать буквами
g,
m и
p. Кто-то, вероятно, знаком с ними по
статье об устройстве go-планировщика, перевод которой
также доступен.
g – структура-дескриптор go-рутины. Она содержит всю специфическую для одной go-рутины информацию, в том числе данные о текущих границах её стека, program counter, и указатель на дескриптор текущего системного потока, в котором данная go-рутина исполняется (он обозначается
m, и о нем чуть ниже). Go-рутины ставятся на исполнение планировщиком, который вызывается в подходящий для этого момент — например когда исполняемая go-рутина делает системный вызов или операцию ввода-вывода.
Каждый системный поток, созданный средой исполнения Go, в свою очередь, описываются структурой
m. В m содержится внутренние данные потока: его идентификатор, текущая исполняющаяся в нем go-рутина, есть поля описания состояния (spinning, blocked, dying) и прочие специфические для Go поля. Но в числе всего прочего там лежат данные, которые будут нас интересовать дальше — это границы выделенного потоку стека, а также указатель на нечто, что называется «контекст исполнения».
Контекст исполнения
p – это тоже специальная структура. Контекст нужен системному потоку
m для того, чтобы выполнять go-рутины. Внутри себя p хранит локальную очередь go-рутин, готовых к исполнению. Как только
m заполучил некоторый контекст
p, то он может исполнять go-рутины из локальной очереди, принадлежащей этому контексту. Если локальная очередь опустела, то будет происходить work-stealing из очередей других
p. Также существует и глобальная очередь, которую все
p периодически проверяют.
Важно понимать, что системных потоков
m обычно одновременно существует много – часто их больше, чем контекстов. Но заниматься выполнением go-рутин могут лишь те потоки, которые имеют контекст. Количество контекстов может быть задано переменной окружения GOMAXPROCS, а по умолчанию оно равно количеству ядер процессора. За счет того, что число контекстов – фиксированное, происходит мультиплексирование всего множества go-рутин на заданное число ОС-потоков.
Смысл введения контекста в том, что его можно передавать между разными ОС-потоками, и обычные go-рутины из локальной очереди контекста не заметят разницы. Когда это может понадобиться? Тогда, когда нужно освободить текущий поток для какой-то блокирующей задачи. Давайте представим, что передача контекста невозможна. Тогда если какая-то одна go-рутина делает системный вызов, то для оставшихся в локальной очереди это будет означать простаивание. А поскольку системный вызов может оказаться блокирующим, то и go-рутины могут оставаться заблокированными неопределенное время, а этого нельзя допустить. Передача контекста решает эту пробему и позволяет go-рутинам из очереди продолжать исполняться даже в таких случаях. Именно поэтому перед тем, как совершить системный вызов, контекст передается какому-нибудь другому свободному системному потоку
m, и на этом новом потоке очередь продложает беспрепятственно работать. Cистемный вызов же будет осуществлен на исходном потоке, но уже без контекста исполнения.
Итак, мы рассмотрели базовые сущности
g, m, p и немного затронули системные вызовы. Теперь мы подошли к вопросу внешних вызовов в Go.