golang

За кулисами асинхронности: корутины, горутины и правда между ними

  • среда, 12 ноября 2025 г. в 00:00:12
https://habr.com/ru/companies/oleg-bunin/articles/958566/

Асинхронность — слово, от которого у разработчиков дергается глаз и теплеет сердце. Корутины, горутины, 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), влияющему на производительность.

Правильная классификация задач поможет:

  • Правильно проектировать архитектуру приложения.

  • Не «асинхронизировать» всё подряд без смысла и избежать лишней асинхронности.

  • Выбирать правильные инструменты (горутины, корутины, потоки, очереди и т. д.).

Чаще всего задачи делят на три класса:

  1. IO-bound — ограничены ожиданием ввода-вывода: HTTP-запросы, дисковые операции, обращение к базе данных, тайм-ауты.

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

  1. 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-корутин два вида стека:

  1. Динамический, который может изменять свой размер во время выполнения, увеличиваясь по мере необходимости.

  2. Фиксированный, изначально заданного размера, который не меняется после инициализации.

Из преимуществ 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 пробрасывается в эту функцию.

Далее есть два пути:

  1. Если горутина требует syscall, например, IO-bound, то это как раз сетевая задача с ожиданием. Он запускает  горутину, но оставляет её активной, и после завершения возвращает в планировщик до следующего запуска.

  2. Вариант, когда горутину нужно завершить. Есть некий 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 (ассемблер, кишки, декомпиляция) и много-много реальных кейсов от коллег.