Горутины, корутины, потоки: модели конкурентности в разных языках

Как C++, Go, Rust, Java, Python и Node решают один вопрос — как исполнять независимую работу параллельно с чужими стеками и планировщиками: потоки ОС, green/virtual threads, stackless-корутины и event loop. Вытеснение против кооперации, stackful против stackless, 1:1 против M:N — и чем каждая модель платит

go func(), async/await, std::thread, launch { } — на поверхности это разный синтаксис для «сделай несколько дел одновременно». Но синтаксис здесь снова следствие. За ним стоит одно решение, которое язык либо принимает за тебя, либо оставляет на выбор: как задачи раскладываются на потоки операционной системы и кто решает, когда одна задача уступает место другой. Из ответа растёт всё — цена одной «единицы конкурентности», можно ли блокироваться, заражает ли async сигнатуры функций и получаешь ли ты вообще параллелизм или только конкурентность.

Эта статья — про то, как C++, Go, Rust, Java, Python и Node отвечают на этот вопрос по-разному. Это сиблинг статей «Ошибки, паники и исключения» и «Куча, стек и сборщик мусора»: там язык навязывал модель отказов и модель памяти, здесь — модель конкурентности.

Схема-панель: три модели отображения задач на потоки ОС — 1:1 (поток на задачу), M:N (много лёгких задач через планировщик на несколько потоков), 1:N (event loop на одном потоке); шкала «preemption ↔ cooperation»; эмблемы Go, Rust, C++, Java, Python, Node

Статья обзорная и концептуальная — карта моделей, а не hands-on cookbook. Примеры ниже иллюстративны (типы вроде Body и функции fetch/do_chunk намеренно не определены): их задача — показать форму модели, а не собраться и запуститься. Запускаемый код по каждому языку — в соответствующих deep-dive статьях и стендах.

В статье

Конкурентность против параллелизма

Два слова, которые постоянно путают, — а разница определяет весь дальнейший разговор. Конкурентность — это про структуру: программа устроена как множество независимых задач, которые могут находиться «в полёте» одновременно и чередоваться во времени. Параллелизм — это про исполнение: несколько задач физически считаются в один и тот же момент на разных ядрах процессора.

Одно не влечёт другого. Однопоточный event loop в Node конкурентен — тысячи соединений «в полёте», — но не параллелен: в каждый момент исполняется ровно один кусок JS-кода. Наоборот, программа на потоках ОС может быть параллельна (потоки разъезжаются по ядрам), даже если задачи между собой почти не структурированы. CPython добавляет третий сюжет: потоки там настоящие, но GIL (global interpreter lock) не даёт двум из них исполнять байт-код одновременно — конкурентность есть, CPU-параллелизма для чистого Python-кода нет.

Дальше всё сводится к одному вопросу: как задачи раскладываются на потоки ОС и кто решает, когда одна задача уступает место другой. Из ответа растут и цена единицы конкурентности, и можно ли внутри неё блокироваться, и заражает ли async сигнатуры.

Оси, по которым различаются модели

Прежде чем идти по языкам, зафиксируем четыре независимые оси. Модель конкретного языка — это точка в этом пространстве, а не позиция на одной шкале «лучше/хуже».

Вытеснение против кооперации. Кто отбирает у задачи управление? При вытеснении это делает планировщик извне — в произвольный момент (потоки ОС; горутины Go, где с версии 1.14 работает асинхронное вытеснение по сигналу, так что даже цикл без вызовов функций не заблокирует планировщик). При кооперации задача сама уступает управление — только в явных точках (await, yield). Кооперация дешевле и предсказуемее, но одна задача, не дошедшая до точки уступки, останавливает всех соседей на своём потоке.

Stackful против stackless. Есть ли у единицы конкурентности собственный стек вызовов? У stackful (потоки ОС, горутины, virtual threads) он есть — можно приостановиться на любой глубине вложенности, посреди обычного вызова. Stackless (futures в Rust, корутины C++20, async/await в Python и JS) — это машина состояний, которую компилятор строит из тела функции; приостановка возможна только на верхнем уровне самой корутины, в точках await. Stackless плотнее по памяти, но «заражает» вызовы (см. следующую ось).

Отображение на потоки ОС. 1:1 — единица конкурентности есть поток ОС (C++ std::thread, платформенные потоки Java). M:N — много лёгких единиц мультиплексируются планировщиком на меньший пул потоков ОС (горутины Go, virtual threads Java). 1:N — много задач на одном потоке через event loop (asyncio, Node).

Раскраска функций (colored functions). В stackless-моделях async-функцию нельзя вызвать как обычную — только через await из другой async-функции. Цвет протекает вверх по стеку вызовов: одна асинхронная функция делает асинхронными всех, кто её вызывает. Это colored-мир (Rust, C++, Python, JS). А горутины и virtual threads colorless: обычная функция запускается конкурентно без изменения сигнатуры, и блокирующий вызов внутри неё выглядит как обычный блокирующий вызов.

flowchart TB subgraph OneToOne["1:1 — поток ОС на задачу"] A1[задача] --> T1[поток ОС] A2[задача] --> T2[поток ОС] end subgraph MtoN["M:N — лёгкие задачи на пуле потоков"] G1[горутина] --> S[планировщик] G2[горутина] --> S G3[горутина] --> S S --> P1[поток ОС] S --> P2[поток ОС] end subgraph OneToN["1:N — event loop на одном потоке"] C1[корутина] --> L[event loop] C2[корутина] --> L L --> P3[поток ОС] end

flowchart TB
  subgraph OneToOne["1:1 — поток ОС на задачу"]
    A1[задача] --> T1[поток ОС]
    A2[задача] --> T2[поток ОС]
  end
  subgraph MtoN["M:N — лёгкие задачи на пуле потоков"]
    G1[горутина] --> S[планировщик]
    G2[горутина] --> S
    G3[горутина] --> S
    S --> P1[поток ОС]
    S --> P2[поток ОС]
  end
  subgraph OneToN["1:N — event loop на одном потоке"]
    C1[корутина] --> L[event loop]
    C2[корутина] --> L
    L --> P3[поток ОС]
  end
Три способа отобразить единицы конкурентности на потоки ОС

Дальше — по языкам, каждый как точка в этих осях, а в конце сведём в таблицу.

C++: потоки ОС и stackless-корутины

C++ отдаёт конкурентность программисту максимально «сырой». Базовый примитив — std::thread (с C++20 — std::jthread, который сам присоединяется в деструкторе и несёт stop_token для кооперативной отмены). Это stackful, вытесняющая, строго 1:1-модель: каждый объект-поток отображается на поток ОС напрямую. Значит, он полноценно параллелен на ядрах — и настолько же дорог: у потока ОС свой большой стек и переключение через ядро, поэтому «поток на задачу» не масштабируется до десятков тысяч соединений.

#include <thread>
#include <stop_token>

// jthread передаёт stop_token первым аргументом автоматически
void worker(std::stop_token st) {
    while (!st.stop_requested()) {   // кооперативно проверяем запрос остановки
        do_chunk();                  // работа порциями
    }
}

int main() {
    std::jthread t(worker);          // старт на отдельном потоке ОС
    // ...
    // деструктор t сам вызовет request_stop() и join() — течь нечему
}

Для плотной конкурентности без «потока на задачу» C++20 ввёл корутиныstackless и colorless-по-синтаксису, но colored-по-сути: co_await/co_return превращают функцию в машину состояний, а сама функция становится асинхронной и требует такого же обращения от вызывающего. Язык даёт только механизм (типы promise_type, awaitable), но не даёт рантайма: планировщик, который эти корутины исполняет, — сторонний (Asio, будущие executors из направления senders/receivers). Как устроен этот стек на практике — в статье про асинхронный C++ и корутины на AsioСкоро.

Синхронизация в C++ тоже ручная: std::mutex, std::atomic и явная модель памяти (memory_order). Это максимум контроля и максимум способов ошибиться — гонки данных здесь неопределённое поведение, и никакой компилятор их по умолчанию не поймает. Где проходит граница между стеком, кучей и временем жизни объектов при этом — разбор в сиблинге «Куча, стек и сборщик мусора».

Go: горутины и каналы

Go построен вокруг одной идеи: конкурентность должна быть настолько дешёвой и незаметной в синтаксисе, чтобы ей пользовались не задумываясь. Горутина — stackful единица с маленьким растущим стеком (стартует с пары килобайт и растёт по мере надобности), которую M:N-планировщик рантайма мультиплексирует на пул потоков ОС (его размер задаёт GOMAXPROCS), с work stealing между ними. Планирование вытесняющее: с Go 1.14 рантайм умеет снимать горутину асинхронно, поэтому даже плотный цикл без вызовов не заморозит остальных.

Главное — Go colorless: любую обычную функцию запускают горутиной через go, и её сигнатура от этого не меняется. Блокирующий вызов внутри горутины (сетевой, файловый) выглядит как обычный блокирующий код, но под капотом рантайм снимает горутину с потока ОС и ставит другую — блокируется горутина, не поток.

func fetchAll(ctx context.Context, urls []string) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup
    for _, u := range urls {
        wg.Add(1)
        go func(u string) {          // обычная функция как горутина — цвет не меняется
            defer wg.Done()
            r := fetch(ctx, u)       // блокирующий вызов: снимется горутина, не поток ОС
            select {
            case out <- r:           // отдать результат
            case <-ctx.Done():       // либо уйти по отмене, не подвиснув навсегда
            }
        }(u)
    }
    go func() { wg.Wait(); close(out) }()
    return out
}

Коммуникация в Go идёт по каналам и select — девиз «share memory by communicating» вместо разделяемого состояния под мьютексом (хотя sync.Mutex/atomic тоже есть — см. примитивы синхронизации). Что именно гарантирует видимость записей между горутинами, формализует модель памяти Go, а типовые сборки конкурентного кода — в паттернах и базовом разборе горутин и каналов.

Цена дешевизны — обратная сторона той же медали: горутину так легко запустить, что так же легко её забыть. Горутина, заблокированная на отправке в канал, который никто уже не читает, живёт вечно — это утечка. Отсюда дисциплина: context для отмены пробрасывается везде, а у каждой запущенной горутины должен быть ясный ответ на вопрос «как она завершится».

Rust: futures без встроенного рантайма

Rust выбирает stackless-async и делает его почти бесплатным по накладным расходам: async fn компилируется в машину состояний (Future), которая не аллоцирует отдельный стек и по размеру равна ровно тому, что нужно сохранить между точками await. Но Future в Rust ленив — сам по себе он ничего не исполняет, пока его кто-то не начнёт опрашивать (poll). Опрашивает — рантайм, и его в стандартной библиотеке нет: его подключают снаружи, чаще всего Tokio. Это осознанный выбор: язык для систем от embedded до серверов не может навязать всем один планировщик.

use tokio::task::JoinSet;

async fn fetch_all(urls: Vec<String>) -> Vec<Body> {
    let mut set = JoinSet::new();
    for url in urls {
        // future отдаётся рантайму; замыкание обязано быть Send для work-stealing
        set.spawn(async move { fetch(url).await });
    }
    let mut out = Vec::new();
    while let Some(res) = set.join_next().await {
        out.push(res.expect("task panicked"));
    }
    out
}

Ключевое отличие Rust от всех остальных — безопасность конкурентности проверяет компилятор. Трейты Send (значение можно передать в другой поток) и Sync (на значение можно ссылаться из нескольких потоков) — часть системы типов, и попытка разделить не-Sync данные между задачами не скомпилируется. Гонок данных в безопасном Rust нет не по соглашению, а по построению; это цена, уплаченная строгими правилами владения (о них — сиблинг про управление памятью).

За плотность и контроль Rust расплачивается раскраской функций и болезненной проблемой блокировки в async: если внутри async-задачи вызвать синхронную операцию, которая надолго занимает поток (тяжёлый CPU-расчёт или блокирующий системный вызов), она застопорит планировщик — рантайм не сможет крутить на этом потоке другие задачи. Лечится это явно: тяжёлое уносят в spawn_blocking или в отдельный пул. Потоки ОС при этом тоже доступны (std::thread) — async не единственная модель, а инструмент под IO-bound нагрузку.

Java: от потоков ОС к virtual threads

Java прошла путь, который стоит проследить целиком. Исходная модель — платформенный поток: тонкая обёртка над потоком ОС, 1:1, stackful, вытесняющий. Он честно параллелен, но дорог, поэтому годами господствовал паттерн «пул потоков»: держим ограниченное число потоков и раздаём им задачи. Как только задач с блокирующим I/O становится много, пул превращается в бутылочное горло — и индустрия ушла в реактивное программирование (CompletableFuture, Reactor), которое масштабируется, но раскрашивает весь код в асинхронный и разрывает естественную структуру вызовов.

Virtual threads (Project Loom, стабильны с Java 21) возвращают простоту, не теряя масштаба. Виртуальный поток — stackful, но лёгкий: рантайм JVM мультиплексирует множество виртуальных потоков на небольшой пул потоков-носителей (carrier), то есть это M:N. Когда виртуальный поток упирается в блокирующий вызов, JVM откручивает его стек с носителя и ставит другой — носитель не простаивает. Модель colorless: пишешь обычный синхронный блокирующий код и держишь сотни тысяч задач на блокирующем I/O, не уходя в реактивщину. Но «бесплатно» — только про ожидание: CPU-bound работу virtual threads не ускоряют (несущих потоков столько же, сколько ядер), а планировщик не делает вытесняющего тайм-слайсинга — виртуальный поток уступает носитель на блокирующем вызове, а не по таймеру. Заняли носитель тяжёлым счётом без блокировок — и соседи по нему ждут.

// на каждую задачу — свой virtual thread; их могут быть сотни тысяч
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Body>> futures = urls.stream()
        .map(u -> executor.submit(() -> fetch(u)))  // fetch блокирует — и это нормально
        .toList();
    for (var f : futures) {
        process(f.get());
    }
} // try-with-resources закроет executor и дождётся всех задач

Поверх этого Java развивает structured concurrency (JEP 505; в JDK 25 это всё ещё preview-API, не стабильная часть языка): задачи, порождённые в пределах области видимости, живут не дольше неё — ошибка одной отменяет соседей, и подвисших «сирот» не остаётся (тот же мотив, что context в Go). Опереться на неё в проде пока можно только с флагом --enable-preview. Отдельная альтернативная модель на JVM — stackless корутины Kotlin с раскраской через suspend. Полное сравнение virtual threads, реактивщины и Kotlin-корутин — в статье про конкурентность на JVM.

Python и Node: event loop и предел параллелизма

Обе эти среды объединяет 1:N-модель: множество задач на одном потоке через event loop, кооперативно уступающих в точках await. И обе упираются в один и тот же потолок — отсутствие CPU-параллелизма в основном сценарии.

CPython несёт GIL: даже настоящие потоки ОС (threading) не исполняют байт-код Python одновременно — блокировка пропускает по одному. Для I/O это не проблема (поток отпускает GIL на время системного вызова), но CPU-bound работу потоки не ускоряют. Идиоматичный путь к конкурентности здесь — asyncio: stackless корутины на event loop, плотные и дешёвые, но colored (async def заражает вызовы) и полностью кооперативные. Настоящий CPU-параллелизм добывают через multiprocessing — отдельные процессы с собственными интерпретаторами. Подробный разбор связки — в «asyncio и GIL». (Уже видны сдвиги: свободные от GIL (free-threaded) сборки CPython — экспериментально с 3.13 — и субинтерпретаторы, но по умолчанию картина пока такова.)

import asyncio

async def fetch_all(urls: list[str]) -> list[bytes]:
    # корутины делят один поток (event loop); CPU-параллелизма нет из-за GIL
    async with asyncio.TaskGroup() as tg:          # Python 3.11+: как structured concurrency
        tasks = [tg.create_task(fetch(u)) for u in urls]
    return [t.result() for t in tasks]             # выход из блока дождётся всех

Node.js устроен похоже, но честнее в одном: там event loop — не опция, а фундамент. Один поток исполняет JS, а блокирующий I/O уходит в системные механизмы и пул libuv, отдавая результат обратно через колбэки/промисы. async/await — сахар над промисами, модель та же 1:N, stackless, colored, кооперативная.

// один поток обслуживает JS; await уступает управление на время I/O
async function fetchAll(urls) {
  const bodies = await Promise.all(
    urls.map(async (u) => {
      const res = await fetch(u);   // не блокирует поток — цикл крутит другие задачи
      return res.arrayBuffer();
    }),
  );
  return bodies;
}

Ограничение у обоих одинаковое: заблокировал единственный поток — встало всё. Тяжёлый синхронный расчёт посреди обработчика останавливает event loop для всех соединений. Лечится симметрично: Node выносит CPU-bound в worker_threads, Python — в процессы или C-расширения, отпускающие GIL. Пока нагрузка IO-bound, 1:N держит десятки тысяч соединений на одном ядре; как только появляется существенный CPU — модель требует явного «вынести за поток».

Сводная таблица

Язык / модель Вытеснение или кооперация Stackful / stackless Отображение на потоки ОС Раскраска Где ловятся гонки данных
C++ std::thread/jthread вытеснение stackful 1:1 нигде (UB, ручная синхронизация)
C++20 корутины кооперация stackless зависит от рантайма colored нигде (UB)
Go горутины вытеснение (async с 1.14) stackful M:N colorless инструментом (-race, в рантайме)
Rust futures (Tokio) кооперация stackless M:N (на рантайме) colored компилятором (Send/Sync)
Java платформенные потоки вытеснение stackful 1:1 colorless нигде (дисциплина, synchronized)
Java virtual threads JVM-планировщик: размонтаж на блокировке, без тайм-слайсинга stackful M:N colorless нигде (та же модель памяти JMM)
Python asyncio кооперация stackless 1:N (GIL) colored GIL сериализует байт-код
Node event loop кооперация stackless 1:N colored однопоточность (нет разделяемой памяти по умолчанию)

Главные водоразделы читаются по столбцам. По раскраске мир делится надвое: colorless (Go, Java) позволяет писать обычный блокирующий код при масштабе; colored (Rust, C++, Python, Node) даёт плотность и контроль ценой заражения сигнатур. По гонкам данных уникален Rust — единственный, кто ловит их компилятором; у остальных это либо инструмент (Go race detector), либо чистая дисциплина. В 1:N-моделях (asyncio, Node) гонок данных нет по построению — нет ни вытеснения посреди операции, ни разделяемой памяти между потоками. Но это не отменяет логических race conditions: пока корутина висит на await, состояние вокруг может измениться, и «проверил — затем сделал» через точку уступки ломается ровно как в многопоточном коде. «Нет гонок данных» — это не «нет гонок».

Сквозные проблемы

Модели разные, а сложности у всех те же — меняется лишь то, кто и когда их ловит.

  • Гонки данных. Две задачи пишут в одну память без синхронизации. Rust запрещает это на компиляции; Go даёт детектор гонок (go test -race), находящий их в рантайме на реальном исполнении; в C++ и Java это неопределённое поведение либо тонкая ошибка видимости, которую ловит только дисциплина и ревью. 1:N-среды (asyncio, Node) от гонок данных избавлены по построению — разделяемой памяти между задачами нет.
  • Дедлоки и инверсия приоритетов. Взаимная блокировка на мьютексах/каналах не зависит от модели: два потока, ждущие ресурсы друг друга в противоположном порядке, встанут в любом языке. Кооперативные модели добавляют свой вариант — «самодедлок», когда задача ждёт результат, который может произвести только она сама на том же заблокированном потоке.
  • Утечки задач. Подвисшая горутина на отправке в никем не читаемый канал, Future, который никто не опрашивает, промис, который никогда не резолвится, — везде это тихая утечка ресурса. Дешевизна запуска (Go, virtual threads) делает утечку особенно лёгкой: забыть завершить проще, чем запустить.
  • Блокировка в кооперативной модели. Синхронный тяжёлый вызов в async-задаче (Rust, Python, Node) занимает поток целиком и морит голодом соседей. Симметричное лечение: вынести за поток (spawn_blocking, worker_threads, отдельный пул).
  • Отмена и backpressure. Задачу нужно уметь остановить и притормозить. Явные механизмы отмены — context в Go, stop_token в C++, structured concurrency в Java, отмена задач в asyncio. Backpressure — обратное давление, когда потребитель не успевает за производителем; каналы Go с буфером, ограниченные очереди и явные семафоры не дают быстрому производителю переполнить систему.

Из практики: горутина, которая пережила запрос

Небольшой случай, показательный ровно тем, что упирается в «утечку задач» и «отмену» разом. В одном из сервисов обработчик HTTP-запроса запускал вспомогательную горутину: сходить во внешний API, дополнить ответ и записать результат в канал, который читал основной поток обработчика. Логика верная — пока внешний API отвечает быстро.

Однажды он начал отвечать медленно. Клиент по своему таймауту разрывал соединение, обработчик завершался — но вспомогательная горутина об этом не знала. Она продолжала висеть на медленном вызове, а затем — на отправке в канал, который уже никто не читал. Каждый оборвавшийся запрос оставлял по одной вечной горутине; под нагрузкой их число росло, утягивая память и дескрипторы, пока сервис не деградировал.

Корень — тот же, о чём вся статья. Дешевизна горутины сделала её запуск незаметным, а colorless-модель скрыла, что у этой единицы конкурентности нет ответа на вопрос «как я завершусь, если результат уже не нужен». Починка была ровно в двух точках из кода выше: пробросить в горутину context запроса (он отменяется, когда клиент уходит) и отдавать результат через select с веткой <-ctx.Done(), чтобы отправка не висела вечно на брошенном канале. Модель не была виновата — виновата была незамеченная задача без границы жизни. Именно поэтому дешёвая конкурентность требует не меньше дисциплины, чем дорогая, а больше.

Что дальше

«Какой язык конкурентнее» — неверный вопрос. Верный: какая модель под какую нагрузку. Для IO-bound с десятками тысяч соединений colorless + M:N (Go, virtual threads Java) даёт простой блокирующий код при масштабе; stackless-async (Rust, C++, Python, Node) — плотность и контроль там, где важна каждая точка приостановки. Для CPU-bound всё решает не модель конкурентности, а наличие настоящего параллелизма: GIL и однопоточный event loop здесь потолок, который обходят процессами и worker-ами.

Каждый язык заслуживает отдельного погружения: горутины и каналы в Go, futures и Tokio в Rust, virtual threads на JVM, asyncio и GIL в Python, корутины на Asio в C++Скоро. А как эти же языки решают соседние вопросы — в сиблингах серии: «Ошибки, паники и исключения» и «Куча, стек и сборщик мусора». Если тема интересна — напишите в Telegram-группе, какую модель разобрать подробнее в первую очередь.

Обсуждение в Telegram

Присоединиться →

Комментарии