Go under the hood
Go: Under the Hood

9.5 Управление потоками

9.1 заложил трёхуровневую структуру GMP: G — единица выполнения на уровне пользователя, P — разрешение на планирование и носитель локальных ресурсов, а M — «нога», одолженная у операционной системы. Предыдущие разделы сосредоточились преимущественно на G и P; этот раздел переключает внимание на M и отвечает на ряд вопросов, откладывавшихся до сих пор: что такое M на самом деле, откуда он берётся, почему GOMAXPROCS ограничивает количество P, тогда как число потоков нередко оказывается больше, почему единственный блокирующий системный вызов не тормозит остальные G, и какую цену платит рантайм, когда пользователь хочет закрепить горутину за конкретным потоком (LockOSThread).

Через весь раздел проходит одна мысль: поток — это дорогой ресурс. Создание потока означает переход в режим ядра, выделение стека и регистрацию маски сигналов; уничтожение обходится не дешевле. Многие решения планировщика Go — повторное использование простаивающих M, передача P во время системного вызова, предохранитель в десять тысяч потоков — подчинены одному принципу: создавать как можно меньше, переиспользовать как можно больше.

9.5.1 M — это поток операционной системы

M (machine) — это абстракция одного потока операционной системы. При запуске процесса загрузочный поток оборачивается в m0 — глобальную переменную, существующую на протяжении всей жизни процесса и не размещаемую в куче; в дальнейшем каждый M соответствует потоку ядра, явно созданному рантаймом. Отношение M и G — «поток выполняет горутины»: получив P, M берёт G из локальной очереди P и исполняет их. Приведённый ниже упрощённый набросок оставляет лишь поля, связанные с управлением потоками:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// m: абстракция рантайма для одного потока операционной системы (набросок)
type m struct {
    g0   *g       // горутина системного стека для планирования: выполняет код планировщика, обрабатывает сигналы
    curg *g       // пользовательская горутина, выполняющаяся на данном M в настоящий момент
    p    puintptr // удерживаемый в данный момент P; может быть изъят при входе в системный вызов
    nextp puintptr // P, к которому следует привязаться после пробуждения (используется при пробуждении stopm)
    oldp  puintptr // P, который удерживался до входа в системный вызов; сохраняется, чтобы exitsyscall мог быстро вернуть его

    park     note  // поток засыпает и просыпается на этом «семафоре»; основной механизм повторного использования M
    schedlink muintptr // ссылки в список простаивающих M / список newmHandoff

    lockedg  guintptr // взаимная блокировка с некоторой G (LockOSThread), см. 9.5.6
    lockedExt int32   // счётчик внешних (пользовательских) блокировок
    lockedInt int32   // счётчик внутренних (рантаймовых) блокировок
    incgo    bool     // выполняется ли в данный момент вызов cgo
    isextra  bool     // является ли данный M extra-M, созданным для обратного вызова cgo, см. 9.5.5
}

Новый поток создаётся по цепочке newm -> newm1 -> newosproc. На Linux newosproc в конечном счёте сводится к единственному системному вызову clone(2), чьи флаги очерчивают границу между «потоком» и «процессом»:

1
2
3
4
5
6
7
// флаги clone, используемые для создания нового потока ядра на Linux (runtime/os_linux.go)
cloneFlags = _CLONE_VM |     // разделять адресное пространство
    _CLONE_FS |              // разделять информацию о файловой системе (текущий каталог и т. д.)
    _CLONE_FILES |           // разделять таблицу файловых дескрипторов
    _CLONE_SIGHAND |         // разделять таблицу обработчиков сигналов
    _CLONE_SYSVSEM |         // разделять список отмены для семафоров SysV
    _CLONE_THREAD            // принадлежать к одной группе потоков (разделять PID)

Именно эти разделяемые компоненты отличают поток от процесса: адресное пространство, файловые дескрипторы и обработка сигналов общие, лишь регистры и стек остаются раздельными. Тем не менее создание потока обходится недёшево: оно требует перехода в режим ядра через clone, подготовки системного стека для g0 и установки маски сигналов (newosproc отключает сигналы через sigprocmask до вызова clone и восстанавливает их после, чтобы новый поток начинал с чистого состояния). После входа в mstart новый поток должен ещё пройти раунд инициализации в minit. Именно эта совокупность расходов лежит в основе принципа «переиспользуй вместо того, чтобы создавать без нужды». Попытка повтора при EAGAIN в newosproc с намёком «может потребоваться увеличить максимальное количество пользовательских процессов» лишний раз подтверждает: поток — дефицитный ресурс, на который операционная система накладывает квоты.

9.5.2 Повторное использование: простаивающий M на семафоре

Раз создание дорого, рантайм не уничтожает M, как только тот завершил работу. Когда M заканчивает текущее задание и временно не имеет P для привязки, он не завершается, а паркуется, ожидая повторной диспетчеризации. Эту пару «парковка — возобновление» реализуют две функции: stopm и startm.

stopm возвращает текущий M в глобальный список простаивающих, после чего M засыпает на своём park:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func stopm() {
    gp := getg()
    // предусловие: в этот момент M не держит блокировку, не держит P и не находится в состоянии spinning
    lock(&sched.lock)
    mput(gp.m)        // помещаем в список простаивающих M sched.midle
    unlock(&sched.lock)
    mPark()           // засыпаем на примитиве m.park note, ожидая пробуждения
    acquirep(gp.m.nextp.ptr()) // при пробуждении будитель уже поместил P для привязки в nextp
    gp.m.nextp = 0
}

Ядро mPark — вызов notesleep(&gp.m.park). note — внутренний одноразовый примитив событий рантайма, который на каждой платформе реализуется через соответствующий механизм сна ядра: futex или семафор. Иными словами, запаркованный M не занимает CPU; он спит внутри ядра, ожидая вызова notewakeup, который его разбудит.

Путь пробуждения проходит через startm: когда P нужен поток для работы (появилась готовая G, системный вызов передал свой P и т. п.), startm сначала вызывает mget, чтобы извлечь запаркованный M из списка простаивающих, записывает целевой P в его nextp и будит его через notewakeup на park; и только когда список простаивающих оказывается пуст, функция обращается к newm и действительно создаёт новый поток. Именно этот порядок — «сначала переиспользовать, создавать лишь когда взять нечего» — не даёт созданию потоков попасть на горячий путь выполнения.

stateDiagram-v2
    [*] --> Spinning: newm / пробуждение
    Spinning --> Running: захватил P и G
    Running --> Spinning: нет локальной G, идёт красть
    Running --> Syscall: входит в системный вызов
    Syscall --> Running: exitsyscall возвращает P
    Spinning --> Parked: нет P для привязки, stopm паркует
    Parked --> Spinning: startm будит (mget + notewakeup)
    Running --> [*]: mexit (редко, обычно вызывается LockOSThread)

Стоит подчеркнуть: на нормальном пути M практически никогда не завершается. mexit вызывается лишь в нескольких ситуациях, наиболее типичная из которых описана в разделе 9.5.6: «G, заблокировавшая поток, завершается, не сняв блокировку». В обычном режиме M функционирует в цикле «парковка, пробуждение, снова парковка» — подобно постоянному дежурному, всегда готовому к вызову, а не временному работнику, нанятому и уволенному за один раз.

9.5.3 GOMAXPROCS ограничивает P, а не M

Читатели нередко придерживаются ошибочного представления: установить GOMAXPROCS в 8 — значит иметь не более 8 потоков. На самом деле ограничивается количество P, то есть верхняя граница параллелизма при выполнении кода Go, но не число M. Число M определяется динамически — «сколько потоков в данный момент имеют работу» — и вполне может превышать GOMAXPROCS.

Наиболее распространённая причина превышения — системные вызовы. Когда M застревает в блокирующем системном вызове (9.5.4), его P передаётся другому M для выполнения остальных G; в этот же момент сосуществуют «M, застрявший в системном вызове», и «M, захвативший P», и суммарное число потоков превышает число P. LockOSThread и extra-M для обратных вызовов cgo также ведут к тому, что M оказывается больше, чем P. С другой стороны, P — это «разрешение на выполнение кода Go», чьё общее число неизменно ограничено; M — лишь «нога», одолженная для выполнения кода; когда одна «нога» спотыкается о системный вызов, её P занимает другая, а спотыкнувшаяся разрешения не держит.

Оставлять число потоков неограниченным опасно: бесконтрольные системные вызовы или обратные вызовы cgo могут привести к тому, что рантайм будет создавать потоки без ограничений и в конечном счёте уничтожит весь процесс. Поэтому Go устанавливает предохранитель — sched.maxmcount, по умолчанию равный 10 000:

1
2
3
4
5
6
7
8
9
// проверить, что общее число M не превышает лимит; при превышении — fatal (runtime/proc.go)
func checkmcount() {
    // extra-M не учитываются в этом лимите (они обслуживают обратные вызовы cgo и считаются отдельно)
    count := mcount() - int32(extraMInUse.Load()) - int32(extraMLength.Load())
    if count > sched.maxmcount {
        print("runtime: program exceeds ", sched.maxmcount, "-thread limit\n")
        throw("thread exhaustion")
    }
}

Как только число потоков достигает этой отметки, программа аварийно завершается с сообщением thread exhaustion. Порог установлен не для нормальных программ, а как предохранитель, который «срабатывает рано при аномалии, не давая положить машину». Пользователь может изменить его через debug.SetMaxThreads (реализовано через setmaxthreads; передача -1 возвращает текущее значение). Обратите внимание: checkmcount исключает extra-M, поскольку их количество определяется параллелизмом внешних обратных вызовов — это отдельный счёт от «потоков, созданных самим кодом Go».

9.5.4 Системные вызовы и передача P

Это ключевой момент раздела. 9.1 дал обещание: горутина, застрявшая в блокирующем системном вызове, не будет морить голодом остальные горутины на том же P. Механизм, выполняющий это обещание, — именно «передача P во время системного вызова».

Интуиция такова: M собирается войти в системный вызов, который может надолго не возвращать управление, и в это время он не может выполнять код Go, а значит, P в его распоряжении простаивает. Вместо того чтобы заставлять P ждать вместе с ним, лучше отсоединить P и передать его другому M для обслуживания остальных G из очереди. После возврата из системного вызова исходный M пытается снова получить P и продолжить работу. Вокруг этой идеи рантайм различает быстрый и медленный пути.

Вход в системный вызов происходит через entersyscall (построен на reentersyscall). Функция переводит G в состояние _Gsyscall, сохраняет стек и PC для обратной трассировки GC, записывает указатель текущего P в m.oldp и копирует p.syscalltick, чтобы впоследствии определить, «был ли P изъят». Ключевой момент: entersyscall не освобождает P и не меняет его состояние. m.p по-прежнему указывает на исходный P, P остаётся в состоянии _Prunning, изменяется лишь то, что G перешла в _Gsyscall. Рантайм оптимистично рассчитывает, что системный вызов вернётся быстро, и оставляет P нетронутым на M в расчёте воспользоваться им сразу по возврате. oldp — лишь пометка «какой P использовался до входа в системный вызов», доступная медленному пути для попытки вернуть P в случае, если тот был изъят.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func reentersyscall(pc, sp, bp uintptr) {
    gp := getg()
    gp.m.locks++              // запретить вытеснение в этом окне: g в состоянии Gsyscall, но sched-информация может быть несогласованной
    gp.throwsplit = true      // запретить разбиение стека в этом окне
    gp.m.syscalltick = gp.m.p.ptr().syscalltick // запомнить tick, чтобы позже определить, был ли P изъят
    pp := gp.m.p.ptr()
    gp.m.oldp.set(pp)         // лишь пометка о P, использовавшемся до системного вызова; m.p не очищается, состояние P не меняется
    save(pc, sp, bp)          // оставить информацию о стеке для GC и обратной трассировки
    casgstatus(gp, _Grunning, _Gsyscall) // «отсюда P может быть потерян в любой момент, не трогать его больше»
    // ... только при необходимости будить sysmon (entersyscallWakeSysmon), P не освобождать
}

По возврате управление переходит в exitsyscall, которая сначала оптимистично переводит G обратно в _Grunning, затем проверяет, сохранился ли P (pp := gp.m.p.ptr()):

  • Быстрый путь: если m.p по-прежнему ненулевой (системный вызов был столь коротким, что sysmon не успел изъять P), P используется немедленно, без единой блокировки. P никуда не уходил, поэтому расходов на повторную привязку нет.
  • Медленный путь: если P уже был изъят sysmon (m.p == nil), вызывается exitsyscallTryGetP(oldp) для попытки вернуть исходный oldp; если это не удаётся — захватывается свободный P; если и это невозможно — G помещается обратно в глобальную очередь и вызывается stopm для парковки.

Так кто же и когда изымает P? Ответ — поток-монитор sysmon (9.8). sysmon периодически обходит все P и в retake сравнивает syscalltick каждого P в состоянии _Prunning: если обнаруживается, что M в имени данного P застрял в системном вызове более чем на один тик sysmon (не менее 20 мкс), он действует и изымает P. Здесь в игру вступает относительно новый механизм — setBlockOnExitSyscall: он сначала приостанавливает этот поток, не давая ему упреждающе вернуть себе P внутри exitsyscall, затем takeP отсоединяет P от данного M, и наконец handoffp передаёт P другому M. Этот порог позволяет тратиться на передачу лишь для системных вызовов, «действительно заблокировавшихся надолго»; короткий системный вызов возвращается по быстрому пути задолго до того, как sysmon успевает среагировать.

Описанная конструкция — «P сохраняет состояние, sysmon принудительно изымает» — результат недавней эволюции. В прежней реализации при входе P в системный вызов ему назначалось специальное состояние _Psyscall, и возвращающийся M либо sysmon оспаривали владение через CAS по этому состоянию. В Go 1.26 _Psyscall был убран (в исходном коде он понижен до _Psyscall_unused); вместо этого sysmon активно и явно изымает P через setBlockOnExitSyscall/takeP, не полагаясь больше на то, что M «самостоятельно обнаружит» потерю P. Семантика осталась прежней, но автомат состояний стал проще, а окно конкуренции — чётче.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// handoffp: передать P имеющемуся или новому M для выполнения (runtime/proc.go, логика в сокращении)
func handoffp(pp *p) {
    // в P есть локальные или глобальные готовые G — немедленно запустить M для их выполнения
    if !runqempty(pp) || !sched.runq.empty() {
        startm(pp, false, false)
        return
    }
    // есть работа GC / трассировки — также немедленно запустить M
    // ...
    // уже есть spinning или простаивающий M — нет нужды добавлять ещё; иначе запустить spinning M
    if sched.nmspinning.Load()+sched.npidle.Load() == 0 &&
        sched.nmspinning.CompareAndSwap(0, 1) {
        startm(pp, true, false)
        return
    }
    // действительно нечего делать — вернуть P в пул простаивающих
    pidleput(pp, 0)
}

Если изобразить всю цепочку в виде временно́й диаграммы, сразу становится наглядным, как «один системный вызов блокируется, а остальные G продолжают работу»:

sequenceDiagram
    participant M1 as M1 (застрявший в системном вызове)
    participant P as P (держит G для выполнения)
    participant SM as sysmon
    participant M2 as M2 (подхватывает работу)
    M1->>M1: entersyscall: G переходит в Gsyscall, P не освобождается (m.p сохраняется, oldp записывается как пометка)
    M1->>SM: при необходимости будит sysmon
    Note over M1: медленный системный вызов, долго не возвращается
    SM->>P: обход retake, сравнивает syscalltick, задержка более ~20 мкс
    SM->>M1: setBlockOnExitSyscall (приостановка, запрет упреждающего возврата P)
    SM->>P: takeP отсоединяет P от M1 (очищает m.p у M1)
    SM->>M2: handoffp вызывает startm (переиспользует или создаёт M2)
    M2->>P: acquirep, затем выполняет остальные G на P
    Note over M1: системный вызов наконец возвращается
    M1->>M1: медленный путь exitsyscall: m.p уже пуст, возврат P через oldp или захват свободного P, при неудаче — stopm паркует

Если изменить сценарий на «короткий системный вызов», шаги sysmon вообще не происходят, m.p у M1 никогда не очищается, и exitsyscall видит P на месте и продолжает использовать его напрямую — весь быстрый путь обходится без единой блокировки. Два пути — быстрый и медленный — делают типичный случай практически безрасходным, гарантируя при этом, что редкая длительная блокировка не снизит параллелизм. Именно этот механизм выполняет обещание из 9.1.

Следует разграничивать разные виды блокировок. Сетевой I/O и таймеры не идут по описанному выше пути «блокировка с удержанием M»; они передаются сетевому опросчику netpoll (9.9): G приостанавливается, M и P немедленно идут выполнять другие G, а когда I/O готов, netpoll переводит G обратно в состояние готовности. По-настоящему занимают M синхронные системные вызовы, не поддающиеся асинхронному исполнению, — файловый I/O, fork/exec, — а также вызовы cgo; именно они нуждаются в передаче P как страховке.

9.5.5 Обратные вызовы cgo и extra-M

Все рассмотренные до сих пор M были созданы самим рантаймом через clone, и рантайм знает их происхождение и состояние. Но существует и другой вид потоков — те, что Go не создавал: когда код на C вызывает обратный вызов в Go на потоке, который Go не создавал (cgo callback), у этого потока нет g0, нет P, рантайм ничего о нём не знает, и тем не менее код Go должен на нём выполняться.

Ответ рантайма — заготовить набор extra-M. Они предварительно выделяются функцией oneNewExtraM, навешиваются на отдельный список; каждый extra-M несёт собственную горутину-заглушку в состоянии _Gdeadextra и связан с ней взаимно через lockedg/lockedm. Когда внешний поток осуществляет обратный вызов, needm берёт extra-M из этого списка и устанавливает его на себя, тем самым получая g0 и контекст, необходимые для выполнения кода Go; по завершении обратного вызова dropm возвращает extra-M обратно. mstartm0 вызывает newextram на раннем этапе запуска рантайма, обеспечивая наличие в списке хотя бы одного extra-M — иначе при первом входящем обратном вызове произойдёт дедлок.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// oneNewExtraM: подготовить один extra-M для обратного вызова cgo (фрагмент)
func oneNewExtraM() {
    mp := allocm(nil, nil, -1) // выделить M без привязки к P
    gp := malg(4096)           // создать горутину-заглушку
    casgstatus(gp, _Gidle, _Gdeadextra) // невидима для обратной трассировки и сканирования стека
    mp.isextra = true
    mp.lockedInt++             // extra-M изначально взаимно заблокирован со своей g
    mp.lockedg.set(gp)
    gp.lockedm.set(mp)
    allgadd(gp)
    sched.ngsys.Add(1)         // считается системной горутиной, не попадает в gcount
    addExtraM(mp)              // навесить на список extra
}

extra-M не учитываются в maxmcount из 9.5.3, поскольку их количество определяется параллелизмом внешних обратных вызовов и не относится к категории «потоков, созданных кодом Go». Когда хост-процесс многократно вызывает один и тот же поток C через ключ pthread для обратных вызовов, cgoBindM также привязывает extra-M к этому потоку C, избавляя от расходов на заимствование и возврат при каждом вызове. Этот механизм — необходимый слой склейки между мирами Go и C, а также ещё одна причина, по которой число потоков превышает GOMAXPROCS.

9.5.6 LockOSThread

До сих пор M были свободно взаимозаменяемы: не имело значения, какой M выполняет какую G. Но некоторые сценарии требуют, чтобы горутина всегда выполнялась на одном и том же потоке ОС, — именно для этого предназначена runtime.LockOSThread. Потребность возникает в двух случаях: во-первых, ряд библиотек C (как правило, графических — OpenGL, GLib) хранит состояние в thread-local storage (TLS) и должен вызываться строго на фиксированном потоке; во-вторых, программа изменила состояние ядра потока через системный вызов (например, применив unshare с CLONE_NEWNS для помещения потока в изолированное пространство имён Linux), после чего этот поток «приватизирован» и более не пригоден для использования другими горутинами.

Внутренняя функция рантайма lockOSThread проста: она увеличивает счётчик и вызывает dolockOSThread, которая заставляет g и m указывать друг на друга:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//go:nosplit
func lockOSThread() {
    getg().m.lockedInt++
    dolockOSThread()
}

//go:nosplit
func dolockOSThread() {
    gp := getg()
    gp.m.lockedg.set(gp) // m запоминает заблокированную g
    gp.lockedm.set(gp.m) // g запоминает заблокированный m
}

Публичная функция LockOSThread, доступная пользователю, делает ещё один шаг: лениво запускает шаблонный поток (template thread) по требованию. Это контрмера против скрытой проблемы, которую несёт с собой блокировка потока. Как только поток «приватизирован» пользователем (изменены пространство имён, маска сигналов и т. п.), его состояние ядра становится «нестандартным», и клонирование нового потока с его помощью скопировало бы эту нестандартность. Шаблонный поток — это резервный поток, всегда находящийся в «заведомо корректном состоянии»; он не выполняет пользовательские G и отвечает исключительно за безопасное создание новых потоков. Поэтому в newm есть проверка: если функция обнаруживает, что выполняется на заблокированном M или потоке cgo, она не клонирует поток самостоятельно, а помещает запрос на создание потока в список newmHandoff, предоставляя шаблонному потоку выполнить его вместо неё.

Но как простая запись двух полей lockedg/lockedm гарантирует, что g выполняется только на данном m? Ответ скрыт в цикле планирования (9.4). В самом начале schedule проверяет, есть ли у текущего M заблокированная g:

1
2
3
4
5
6
7
8
9
func schedule() {
    gp := getg()
    // m.lockedg становится ненулевым после LockOSThread
    if gp.m.lockedg != 0 {
        stoplockedm()                 // передать P, запарковаться
        execute(gp.m.lockedg.ptr(), false) // при пробуждении — напрямую выполнить заблокированную g, никогда не возвращается
    }
    // ... иначе найти G в обычном порядке
}

И обратно: когда заблокированная g не может немедленно выполняться по какой-либо причине (например, заблокирована), stoplockedm передаёт P этого M другому через handoffp, паркует его и ждёт, пока g снова не станет готовой к выполнению и не разбудит его — тогда acquirep забирает P обратно для её эксклюзивного обслуживания. Здесь и проявляется цена: данный M монополизирован одной g и не может обслуживать другие; P приходится передавать туда-обратно всякий раз, когда g блокируется; а если заблокированная g завершается без вызова UnlockOSThread, рантайм просто завершает этот M вместе с g (mexit) — это одна из немногих ситуаций, когда M завершается на нормальном пути. UnlockOSThread лишь уменьшает счётчик, и когда он достигает нуля, очищает оба поля без какой-либо специальной обработки.

Именно из-за этих побочных эффектов LockOSThread едва ли можно назвать образцовой возможностью. Она создаёт немало трудностей в управлении для планировщика, и единственная причина её существования — поддержка написанных в прошлом веке библиотек C, зависящих от thread-local состояния. Если бы экосистема была достаточно богата, чтобы обходиться без таких библиотек, эта возможность вполне могла бы не существовать.

9.5.7 Кто управляет потоками: родословная

Рассмотрение подхода Go в историческом контексте позволяет лучше понять сделанные компромиссы. Вопрос «как пользовательский код отображается на потоки ядра» исторически решался несколькими типичными способами:

  • 1:1 (каждый пользовательский поток соответствует одному потоку ядра): сюда относятся потоки POSIX и ранняя модель потоков в Java. Просто и прямолинейно, но создание, переключение и память (у каждого потока довольно большой стек) оцениваются в расчёте на один поток ядра, и с ростом параллелизма подход перестаёт справляться.
  • N:1 (множество пользовательских потоков на одном потоке ядра): ранние «зелёные потоки» работали именно так. Переключение крайне дёшево, но есть фатальный изъян: стоит одному пользовательскому потоку выполнить блокирующий системный вызов — и единственный поток ядра вместе со всеми пользовательскими потоками останавливается; кроме того, невозможно задействовать несколько ядер.
  • Пул потоков: не решает проблему модели отображения, лишь амортизирует стоимость создания, накапливая потоки для повторного использования. Не отвечает на вопрос «что если задача блокируется»: заблокированная задача продолжает занимать поток в пуле.
  • M:N динамическое управление (подход Go): M горутин мультиплексируются на N потоков ядра, причём рантаймовый планировщик выступает посредником между ними. Это объединяет дешевизну переключения из N:1 с возможностью использовать несколько ядер и устойчивостью к блокировкам из 1:1: переключение на уровне пользователя не требует входа в ядро, экономя стоимость схемы 1:1; а опираясь на передачу P из этого раздела и повторное использование M из 9.5.2, он обходит фатальный изъян N:1 — «одна блокировка останавливает всё». Цена — значительный рост сложности рантайма; разделы этой главы, предыдущие и последующие, и есть развёртывание этой сложности.

Данная линия рассуждений не уникальна для Go. В Erlang/BEAM давно существует планировщик, отображающий лёгкие процессы на небольшое число потоков ОС; внутренняя практика Google в области fiber имеет тот же исток. Особого внимания заслуживает Java: долгое время она придерживалась модели 1:1, а виртуальные потоки, официально поставленные с JDK 21 в 2023 году (Project Loom, JEP 444), по существу являются движением в сторону Go — мультиплексированием большого числа виртуальных потоков на небольшое число потоков-носителей с отмонтированием виртуального потока от носителя при блокирующем вызове, чтобы носитель мог выполнять другие виртуальные потоки. Это та же инженерная интуиция, что и передача P в данном разделе и освобождение M горутиной во время системного вызова. Схождение двух независимо развивавшихся направлений к схожему решению само по себе свидетельствует: в условиях ограничений «требуется массовый параллелизм, дешёвое переключение и устойчивость к блокировкам одновременно» M:N динамическое управление — почти неизбежный ответ.

Выгоды производительности никогда не достаются бесплатно. Go сосредотачивает всю сложность управления потоками в рантайме: парковка и пробуждение, передача P, патрулирование sysmon и многочисленные частные случаи extra-M и шаблонного потока. Пользователь при этом может практически не подозревать о существовании потоков — писать десятки тысяч горутин, не задумываясь, на каком потоке они окажутся. За этим удобством «делать потоки невидимыми для пользователя» стоит стопка механизмов, которые рантайм берёт на себя.

Дополнительная литература

  1. The Go Authors. runtime/proc.go (newm/newm1/mstart, stopm/startm/mPark, entersyscall/exitsyscall/reentersyscall, handoffp/retake, acquirep/releasep, checkmcount, а также связанные с extra-M функции newextram/oneNewExtraM/needm/dropm). https://github.com/golang/go/blob/master/src/runtime/proc.go
  2. The Go Authors. runtime/os_linux.go (newosproc и cloneFlags, создание потока через clone). https://github.com/golang/go/blob/master/src/runtime/os_linux.go
  3. Linux man-pages. clone(2). https://man7.org/linux/man-pages/man2/clone.2.html (семантика флагов CLONE_VM/CLONE_THREAD, граница между потоком и процессом)
  4. The Go Authors. Документация runtime.LockOSThread / UnlockOSThread. https://pkg.go.dev/runtime#LockOSThread
  5. Трекер задач Go: #20458 (уточнение семантики LockOSThread), #20395 (завершение заблокированного потока при выходе горутины), #21827 (происхождение шаблонного потока), #22227 (отключение newmHandoff на plan9), #18023 (аномальное замедление из-за LockOSThread), #14592 (завершение простаивающих потоков ОС). https://github.com/golang/go/issues
  6. Ulrich Drepper. Futexes Are Tricky. Red Hat, 2011. https://www.akkadia.org/drepper/futex.pdf (семантика и подводные камни futex, от которых зависит приостановка/возобновление M)
  7. Sape Mullender, Russ Cox. Semaphores in Plan 9. 2008. https://swtch.com/semaphore.pdf (проектный исток примитивов note и semaphore рантайма)
  8. Ron Pressler, Alan Bateman. JEP 444: Virtual Threads. OpenJDK, 2023. https://openjdk.org/jeps/444 (Project Loom: движение Java к модели M:N)
  9. Эта книга: 9.1 Задача планирования и модель GMP, 9.4 Цикл планирования, 9.8 Системный мониторинг, 9.9 Сетевой опросчик.