Асинхронность — слово, от которого у разработчиков дергается глаз и теплеет сердце. Корутины, горутины, event loop, трэдпулы — за этими терминами скрывается целая философия, меняющая взгляд на то, как писать высоконагруженные системы.

Привет, Хабр! Меня зовут Дмитрий Буров, я Golang-разработчик, автор Telegram-канала Go Advocate, а также лидер Go-сообщества в Lamoda Tech. В IT свитчнулся из военного дирижера. В коммерческой разработке — более 10 лет, начинал как фуллстек-разработчик на стеке JS, PHP, CSS, а последние шесть пишу только на Go. В этой статье по мотивам моего доклада для Golang Conf расскажу про асинхронность и её роль в современных высоконагруженных системах. Разберём исторический аспект, концепцию и реализацию корутин в разных языках, эволюцию асинхронных подходов, сравним корутины и горутины, выясним, зачем Go добавил в рантайм пакет coro и чем это может обернуться.
Немного истории
Для начала вернемся в конец 90-х, в кабинет администратора публичного FTP сервера Дэна Кегеля, который сформулировал проблему C10k.
Дэн обнаружил, что узел на гигабитном канале не справлялся с десятью тысячами одновременных соединений. Что было с сервером в тот момент, несложно представить.
Если обобщить, ключевых проблем оказалось несколько:
Ограничения потоков в ��С. Раньше работало по модели один поток на соединение.
Проблемы с ресурсами ОС. Дескрипторы, сокеты, буферы — всё это имело свои ограничения.
Блокирующий ввод-вывод. Один клиент с медленным интернетом мог заблокировать весь поток.
Сложность отладки и сопровождения. На сотнях и тысячах потоков воспроизводимость проблем и отладка проблематичны.
Снижение общей производительности всеми факторами в совокупности.
Постепенно нашёлся набор решений, который помог справиться с этими сложностями. Появились событийно-ориентированная архитектура и мультиплексирование ввода-вывода. Потоки начали работать с множеством соединений, задачи из ядра перешли в рантайм. Помимо рантайма появились асинхронные фреймворки, оптимизировались окружение и настройка операционных систем. Для решения проблем с сетью появился Keep-Alive и connection pooling.
Сегодня историческая проблема приобрела другой масштаб. Теперь это скорее проблема 100 тысяч, либо даже 1 миллиона соединений.

Архитектура постоянно усложняется, но проблемы остаются те же — это борьба за ресурсы и масштабирование.
Говоря про асинхронность, мы не можем не затронуть асинхронную модель.
Асинхронная модель

Способ исполнения программ, при котором операция может быть запущена и продолжена позже, при этом не блокируя текущий поток.

Это своего рода ключ к масштабируемости. Но чтобы применять асинхронную модель осознанно, нужно понимать, что именно ограничивает производительность в ваших задачах.
Парадигмы задач
Классификация задач по их основному ограничению или узкому месту (bottleneck), влияющему на производительность.
Правильная классификация задач поможет:
Правильно проектировать архитектуру приложения.
Не «асинхронизировать» всё подряд без смысла и избежать лишней асинхронности.
Выбирать правильные инструменты (горутины, корутины, потоки, очереди и т. д.).
Чаще всего задачи делят на три класса:
IO-bound — ограничены ожиданием ввода-вывода: HTTP-запросы, дисковые операции, обращение к базе данных, тайм-ауты.

CPU-bound — ограничены вычислительной мощностью процессора: криптография, ML, сложные математические расчёты.

Memory-bound — ограничены пропускной способностью или латентностью работы с памятью: большие массивы данных, алгоритмы, который требуют постоянного доступа к памяти.

Получилось достаточно большое количество асинхронных подходов:
многопоточность (Threads);
событийно-ориентированная модель (Event loop);
callback;
Future / Promise;
Coroutine;
Fiber, Green thread и др.
Сегодня нас интересуют корутины.
Корутины
Корутина — это обобщённый вызов функции, который может быть приостановлен и возобновлён в произвольный момент времени.

Это своего рода функция, которая просто может ставиться на паузу. Но прежде чем перейти к реализации корутин, вспомним модели мультиплексирования.
Модели мультиплексирования

На изображении выше, П — поток ОС, К — корутина.
1:1 — один поток обслуживает одно соединение. Самая простая и немасштабируемая модель.
1:N — поток обслуживает множество корутин. Уже лучше по ресурсам, но требует реализации планировщика.
M:N — несколько потоков обслуживает множество корутин. Реализация гибкая, но самая сложная. Кстати, именно она используется в Go.
Виды корутин
Есть два вида корутин: Stackful и Stackless корутины.
Stackful-корутина
Stackful-корутина во время остановки либо паузы формирует снапшот полностью всего стека. Это позволяет ей гибко управлять иерархией вызовов, включая те, что происходят глубоко в цепочке.

Во время остановки в корутине сохраняется достаточно большое количество данных:
Стек вызовов.
Состояние регистров.
Указатели на текущие инструкции.
Локальные переменные.
...
Далее всё это возвращается в вызывающую функцию (здесь Main).
После некоторых операций мы можем сделать resume, и наша корутина будет полностью восстановлена и продолжена, как будто бы не останавливалась.
У Stackful-корутин два вида стека:
Динамический, который может изменять свой размер во время выполнения, увеличиваясь по мере необходимости.
Фиксированный, изначально заданного размера, который не меняется после инициализации.
Из преимуществ Stackful-корутин можно отметить:
Гибкость, поскольку можно сделать точку остановки очень глубоко в стеке.
Не «красят» функцию, поскольку не нужны дополнительные ключевые слова.
Эффективны по памяти для большого количества вызовов. На малой глубине вызовов Stackful-корутины не так эффективны.
Удобство отладки благодаря stacktrace.
Подходит для сложных сценариев, когда нужно взаимодействовать и поддерживать полную глубину вызовов.
Недостатки у Stackful-корутин тоже есть:
Потребление памяти — всё это добро надо где-то хранить.
Оверхед на переключение контекста*.
*При небольшой глубине вызова мы оверхед как раз почувствуем.
Более сложная реализация.
Не для легковесных задач, поскольку у лёгких задач, как правило, небольшой стек вызовов, и мы как раз возвращаемся в пункт 2 (оверхед).
Простой пример (язык программирования Lua):
local function taskA() print("[taskA] Шаг 1") coroutine.yield() -- точка остановки print("[taskA] Шаг 2") end local function taskB() print("[taskB] Шаг 1") coroutine.yield() print("[taskB] Шаг 2") end
Две идентичные функции — мы выводим шаг 1 до остановки и шаг 2 после остановки. Остановка вызывается через coroutine.yield.
-- создаем корутины local coA = coroutine.create(taskA) local coB = coroutine.create(taskB) -- запускаем корутины, зная кол-во остановок for i = 1, 3 do coroutine.resume(coA) coroutine.resume(coB) end
Порождаем корутины. В данный момент они на паузе. И поскольку мы знаем количество остановок, то делаем в цикле resume.
На выходе имеем два шага по каждой из корутин: шаг 1 до остановки и шаг 2 после остановки.
По ссылке можно посмотреть код целиком и запустить в нужной вам среде.
Stackless-корутины
Это, можно сказать, противоположность Stackful-корутинам. Они не требуют отдельного стека вызовов. Вместо этого корутина реализуется как state machine (машина состояний)*, где всё состояние явно сохраняется в структуре данных, либо в цепочке вызовов. Всё зависит от языка программирования.
Stackless-корутины позволяют приостановить выполнение только в верхнем уровне функции, как правило, в местах suspend/await и не могут быть глубоко внутри.Они ставятся на паузу только кооперативно, тогда как Stackful-корутины могут вытесняюще (в редких случаях).
Преимущества:
Не требуют отдельного стека.
Низкие накладные расходы на старте*.
*Но если использовать большую цепочку вызовов, то накладные расходы будут расти.Явное управление состоянием.
Безопасны, поскольку мы не переключаем контекст, не выделяем какой-то стек, всё это делается в рамках структуры.
Хорошая совместимость с Event loop (с событийно-ориентированной моделью).
Недостатки также имеются:
Сложность управления большой иерархией вызовов. Когда мы имеем достаточно большую глубину вызовов, цепочку вызовов, управление становится сложным.
Требует поддержки состояния ЯП.
Ограниченные точки остановки — только на верхнем уровне функции.
Когнитивная нагрузка при написании и отладке — при большой цепочке вызовов.
Простой пример на Python:
import asyncio async def bar(): print("Старт -- bar()") await asyncio.sleep(1) print("Конец -- bar()") async def foo(): print("Старт foo()") await bar() print("Конец foo()") asyncio.run(foo())
Здесь используется библиотека asyncio.
Есть функция foo и функция bar, в которых через await вызываются точки остановки. Мы выводим «Старт» до остановки и «Конец» после. То есть, стартуем foo, переходим в bar, стартуем bar, заканчиваем bar, выходим из него в foo и завершаем foo.
Вывод можно произвести и посмотреть по ссылке.
Происходит цепочка вызовов:
async def func(): await foo() await bar()

Под капотом — event loop. Это как раз событийно-ориентированная модель. Func попадает в очередь на исполнение. Во время вызова не выделяется стек, но передаётся управление в foo. Но func в этот момент как раз падает в ожидание. Во время передачи в foo происходит так называемый reschedule. То же самое происходит с bar, и далее в обратном порядке, когда мы завершаем наши функции.
Пример на Kotlin — абсолютно идентичен:
import kotlinx.coroutines.* suspend fun bar() { println("Старт -- bar()") delay(1000) println("Конец -- bar()") } suspend fun foo() { println("Старт foo()") bar() println("Конец foo()") } fun main() = runBlocking { foo() }
Через suspend мы также делаем foo и bar, выводим «Старт» и «Конец» с небольшой остановкой в середине.
Вывод аналогичный:

По ссылке можно этот код воспроизвести.
Та же самая функция, но реализованная через машину состояний:
suspend fun func() { foo() bar() } state = 0 when (state) { 0 -> { state = 1 return suspendHere(a, continuation) } 1 -> { state = 2 return suspendHere(b, continuation) } 2 -> return done }

Машина состояний реализует это через компилятор. У нас есть базовый state и continuation класс, который поддерживает наше состояние, условие перехода и пробрасывается дальше по стейтам. На схеме видно, что запускается func и затем идёт передача по состояниям.
Но перейдём к горутинам, которые мы все любим за то, что они быстрые и легковесные.
Ко Горутины
Лёгкий поток выполнения, управляемый райнтаймом языка, позволяет выполнять код параллельно и конкурентно, не создавая при этом огромного количества тяжеловесных потоков операционной системы.
Простой пример:
var wg sync.WaitGroup n := 2 worker := func(id int) { defer wg.Done() fmt.Printf("Воркер %d старт\n", id) time.Sleep(2* time.Second) fmt.Printf("Воркер %d завершен\n", id) } wg.Add(n) for i := 1; i <= n; i++ { go worker(i) } wg.Wait() fmt.Println("Все воркеры выполнены")
Воркер стартует в начале, затем останавливается на 2 секунды и завершает работу. Далее мы добавляем два воркера через горутины, синхронизируем их, ждём, пока выполнится, и завершаем. Вывод ожидаемый.
Код для воспроизведения также доступен по ссылке.
Нас интересует непосредственно вызов той самой команды go. Чуть-чуть кода из ассемблера:

Посмотрите на функцию runtime.newproc. Внутри:
// go 1.24 // src/runtime/proc.go func newproc(fn *funcval) { gp := getg() // текущая горутина (G) pc := sys.GetCallerPC() // адрес возврата systemstack(func() { newg := newproc1(fn, gp, pc, false, waitReasonZero) pp := getg().m.p.ptr() // текущий процессор (P) runqput(pp, newg, true) // ставим горутину в очередь if mainStarted { wakep() // будим процессор, чтобы выполнить на треде (М) } }) }
Здесь происходит получение горутины, её обогащение стеком, выделение стека и контекста. Всё это делается в рамках системного стека. Горутине присваивается статус, она получает свой логический процессор и ставится в очередь. Когда запускается main, мы через процессор пробуждаем её для выполнения на нашем треде. Этот цикл координируется GMP.
Дальше нас интересует, что такое горутина. Сама её структура — достаточно большая. Я постарался вывести сокращённую версию — только то, что нам сейчас потребуется.
// go 1.24 // src/runtime/runtime2.go type g struct { goid uint64 stack stack // граница стека (lo и hi) _panic *_panic // активные паники в горутине _defer *_defer // складывается каждый defer m *m // тред ОС (М), где исполняется G sched gobuf // контекст исполнения atomicstatus atomic.Uint32 // текущий статус G coroarg *coro ... }
У горутины есть ID, границы стека (верхняя и нижняя), паники, отложенные вызовы, ссылка на тред и sched. Это структура gobuf, которая пробрасывается при переключении и имеет свои пойнтеры и контекст.

Вспомним жизненный цикл. Я чуть-чуть его упростил, привёл к более табличному виду:

Планировщик при вызове начинает искать по статусам готовые к запуску горутины. Когда находит, начинает исполнять. Горутина попадает в функцию gogo, где происходит переключение контекста, тот самый gobuf пробрасывается в эту функцию.
Далее есть два пути:
Если горутина требует syscall, например, IO-bound, то это как раз сетевая задача с ожиданием. Он запускает горутину, но оставляет её активной, и после завершения возвращает в планировщик до следующего запуска.
Вариант, когда горутину нужно завершить. Есть некий aggressive shutdown, где мы завершаем горутину, освобождаем ресурсы, а далее она может быть либо переиспользована, либо положена в очередь завершённых горутин.
Горутина == Корутина?!

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


Стек у горутин и Stackful есть свой, динамический. У Stackless функций по сути стека нет — только вызываемой функции.
Планировщик в Go встроенный. У Stackful корутин мы явно вызывали, у Stackless корутин он реализован через event loop в Python.
Потоки в Go используются по M:N-модели, в корутинах — один поток.
Конкурентность и параллелизм в Go поддерживаются, тогда как в корутинах используется только кооперативная конкурентность.
Сложность реализации в Go высокая как раз из-за модели M:N. В корутинах более упрощённая реализация, в Stackless корутинах — чуть посложнее по сравнению со Stackful.
Переключение в горутинах полностью автоматическое, хотя и есть пара методов. В корутинах используется явное переключение через yield или await.
Полноценный stacktrace есть и у Stackful и горутин. У Stackless — только стек у вызываемой функции.
Вызвать синхронную функции без какой-либо покраски функции можно только в горутинах и Stackful корутинах. В Stackless нужны дополнительные ключевые слова и немного сахара.
А что, если…
Можно ли реализовать корутинное поведение в Go.
Гипотеза корутин в Go
Есть интересное исследование на эту тему от команды разработчика языка Russ Cox в июле 2023 года.
Вот небольшое резюме статьи по следам этого исследования:
Go не предоставляет корутины в классическом понимании.
В Go невозможно просто так «приостановить» выполнение корутины и затем «возобновить» её с той же точки стека.
Но не прошло и полугода, как появился любопытный коммит в исходнике языка.


Самые наблюдательные заметили, что в структуре горутины есть поле coroarg. Она реализует структуру coro. Вот что это за структура:
// src/runtime/coro.go type coro struct { gp guintptr // указатель на G f func(*coro) // функция выполнения mp *m // поток ОС lockedExt uint32 lockedInt uint32 }
Базовая структура coro предоставляет пойнтер на нашу горутину, функцию выполнения, ссылку на тред и внешние и внутренние счётчики. Максимальное описание реализации низкоуровневое. Пакет недоступен в публичном API, поэтому это только низкоуровневая реализация.
Разбор низкоуровневой реализации пакета — по ссылке: с графиками и разбором всех функций, которые там используются.
Упомяну основные аспекты из этого файла:
Одна из основных функций — это newcoro.
func newcoro(f func(*coro)) *coro { c := new(coro) c.f = f // функция выполнения gp := getg() // текущая горутина G ... systemstack(func() { mp := gp.m gp = newproc1(startfv, gp, pc, true, waitReasonCoroutine) if mp.lockedExt+mp.lockedInt != 0 { c.mp = mp // если M привязан к горутине c.lockedExt = mp.lockedExt c.lockedInt = mp.lockedInt } }) gp.coroarg = c // устанавливаем корутину в coroarg G c.gp.set(gp) // сохраняем указатель на созданную горутину }
Если вкратце, то newcoro создаёт горутину, паркует её и ожидает функции coroswitch, чтобы её запустить и переключить. Сразу скажу, что переключается она кооперативно. Функция очень похожа на рантайм newproc, но с оговоркой, что мы не попадаем в очередь. Мы её просто паркуем и через newproc1 ставим флаг true. Также выделяем всё на системном стеке и привязываем соответствующий тред, если он был. Дальше формируем эту структуру и отдаём обратно.
Corostart, который подменяет готовность на старт нашей корутины, реализует функцию выполнения, то есть ту самую «полезную» функцию, которая пробрасывается в структуру:
func corostart() { gp := getg() // текущая G c := gp.coroarg gp.coroarg = nil // очищаем чтобы избежать утечки defer coroexit(c) // финализатор корутины после f(c) c.f(c) // “полезная” функция выполнения } Через defer делаем выход, завершение корутины. func coroexit(c *coro) { gp := getg() gp.coroarg = c gp.coroexit = true// сигнализируем о завершении mcall(coroswitch_m) // низкоуровневое переключение горутин } Ставим флаг true, сигнализируя о завершении. Потом вызываем через системный вызов coroswitch. Это выглядит так: func coroswitch_m(gp *g) { ... // Проверяет и снимает флаги, блокировки // Завершает или приостанавливает текущую горутину // Находит и активирует следующую горутину // Переключается на новую горутину с gogo ... }
Когда мы подменяем горутины, планировщик думает, что мы работаем с одной горутиной, а по факту под капотом обходим и подменяем g и m для того, чтобы выполнить горутину, а потом переключить обратно.
func coroswitch(c *coro) { gp := getg() gp.coroarg = c mcall(coroswitch_m) }
Чуть выше есть обёртка над coroswitch_m. Сейчас узнаем, откуда это появляется, где пробрасывается корутина в coroarg для того, чтобы её свитчнуть.
Этот подход используется в новых итераторах, в методе iter.Pull (я его также сократил):
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func()) { c := newcoro(func(c *coro) { ... yield := func(v1 V) bool { ... } // ловит панику из seq defer func() {...}() seq(yield) // выполняет итератор }) next = func() (v1 V, ok1 bool) { ... } stop = func() { ... } }
С самого старта метода мы видим, что выделяется корутина, чётко прослеживается три основных функции yield, next и stop. Можно сказать, что next — это своего рода функция resume. Также есть отлов паники и ceq, который выполняет тот самый yield, то есть итератор.
Посмотрим подробнее на yieldNext:
// функция, которую получает seq и передает наружу значения yield := func() (v1 V) bool { if done { return } // итерация завершена if yieldNext { panic("iter.Pull: next called again before yield") } yieldNext = false // подтверждает передачу значения v, ok := v1, true // сохраняет результат race.Release(...) // отдает контроль coroswitch(c) // переключает корутину обратно в next race.Acquire(...) // захватывает контроль }
Мы пробрасываем наружу значения, которые были получены при реализации функции итератора — обычные флаги, стартовые проверки. Получаем значения. Также через race детектор отдаём и забираем контроль. Дальше пробрасываем coroswitch. Затем идём в next. Это своего рода функция resume:
// переключается на корутину, где итератор вызовет yield(...) // после возврата, возвращает сохранённое значение next = func() (v1 V, ok1 bool) { race.Write(...) // detect races if done { return } // итерация завершена if yieldNext { panic("iter.Pull: next called again before yield") } yieldNext = true race.Release(...) coroswitch(c) // переключает корутину (внутрь seq) race.Acquire(...) if panicValue != nil { ... // передача паники от seq } }
То есть, мы идём по пути Stackful-корутин. Можно сказать, yield и next переключаются между собой через кооперативное управление.
Аналогичные проверки проводим на завершение итерации и паники. Указываем, что у нас есть следующий вызов, и также через race-детектор обкладываем coroswitch, который переключает нашу корутину снова. Не забыли и про передачу паники.
Функция stop:
// завершает выполнение coroutine, освобождает ресурсы stop = func() { race.Write( ... ) // detect races if !done { done = true // чтобы завершилась (внутри yield) race.Release( ... ) coroswitch(c) // передает управление в корутину race.Acquire( ... ) } }
Функция stop завершает корутину и освобождает через тот же самый coroswitch, обкладываясь race-детектором.
Специфика задач для корутин
Как правило, для корутин подходят специфичные решения, например, модульная поддержка. Есть функция, которая умеет ходить по коллекциям, и функция, которая может работать с сайтами с этими коллекциями. Нам нужно как-то их смэтчить. Сделать это можно через каналы или другие реализации. Я попытался симулировать эту задачу:
// Структура дерева type Tree struct { Val int Left, Right *Tree }
Есть два бинарных дерева на 100 тысяч элементов. Задача — обойти их одновременно, сравнить элементы в каждом и реализовать через итераторы и канал��. Код можно посмотреть по ссылке и запустить бенч.
В результате получаем интересные цифры:
// go test -bench=. -benchmem BenchmarkCompareMethods/PullIter_100000-2 114 10293867 ns/op 743 B/op 26 allocs/op BenchmarkCompareMethods/Channels_100000-2 28 41349506 ns/op 418 B/op 4 allocs/op
Мы видим, что метод iter.Pull в четыре раза производительней, чем каналы, поскольку на каналах есть блокировки и переходы. Мы делаем это в четыре раза быстрее, но затратнее по памяти, и ещё затратнее по аллокациям. По аллокациям мы упираемся в кооперативные переключения, которые требуют счётчиков и низкоуровневых блокировок. Каналы же в четыре раза медленнее, но зато более оптимизированы по памяти и аллокациям.
Я бы сказал, что это компромисс нашей производительности. Задача специфичная, нужно решать по метрикам, как реализовывать.
Итоги
Асинхронное программирование и корутины отлично решают проблемы современных высоконагруженных систем.
Горутины — это особый вид корутин, корутины «на стероидах». Не просто асинхронный инструмент, а мощный автоматизированный механизм с собственной реализацией.
Классическое корутинное поведение в Go может быть полезно для создания более специфичных решений, когда задачи требуют оптимизации и производительности. В 99% случаях классическое корутинное поведение не понадобится.
В Go уже добавлен пакет coro. Это шаг в сторону ограниченного, но мощного управления выполнением, аналогичного классическим корутинам. Нет, это не замена корутинам, скорее, дополнение в язык для реализации аналога классических корутин. В целом, как мы выяснили, это реализация корутиноподобного поведения, как Stackful корутины.
Скрытый текст
Если вы пишете на Go, присматриваетесь к кодингу на языке Go или используете Go-инструменты — приходите на Golang Conf в следующем году! Вас ждет только новое и интересное из цифровой отрасли: обзор новых фишек в Go, парадигмы и паттерны Go; hardcore (ассемблер, кишки, декомпиляция) и много-много реальных кейсов от коллег.
