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

Привет, Хабр! Меня зовут Дмитрий Буров и я Golang-разработчик, а также лидер 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-корутина во время остановки либо паузы формирует снапшот полностью всего стека. Это позволяет ей гибко управлять иерархией вызовов, включая те, что происходят глубоко в цепочке.

Во время остановки в корутине сохраняется достаточно большое количество данных:
Стек вызовов.
Состояние регистров.
Указатели на текущие инструкции.
Локальные переменные.
...
Далее всё это возвращается в вызывающую функцию (здесь 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 после остановки.
По ссылке можно посмотреть код целиком и запустить в нужной вам среде.
Это, можно сказать, противоположность 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.
Есть интересное исследование на эту тему от команды разработчика языка 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 (ассемблер, кишки, декомпиляция) и много-много реальных кейсов от коллег.