go func(), async/await, std::thread, launch { } — на поверхности это разный синтаксис для «сделай несколько дел одновременно». Но синтаксис здесь снова следствие. За ним стоит одно решение, которое язык либо принимает за тебя, либо оставляет на выбор: как задачи раскладываются на потоки операционной системы и кто решает, когда одна задача уступает место другой. Из ответа растёт всё — цена одной «единицы конкурентности», можно ли блокироваться, заражает ли async сигнатуры функций и получаешь ли ты вообще параллелизм или только конкурентность.
Эта статья — про то, как C++, Go, Rust, Java, Python и Node отвечают на этот вопрос по-разному. Это сиблинг статей «Ошибки, паники и исключения» и «Куча, стек и сборщик мусора»: там язык навязывал модель отказов и модель памяти, здесь — модель конкурентности.
Статья обзорная и концептуальная — карта моделей, а не hands-on cookbook. Примеры ниже иллюстративны (типы вроде
Bodyи функцииfetch/do_chunkнамеренно не определены): их задача — показать форму модели, а не собраться и запуститься. Запускаемый код по каждому языку — в соответствующих deep-dive статьях и стендах.
В статье
- Конкурентность против параллелизма
- Оси, по которым различаются модели
- C++: потоки ОС и stackless-корутины
- Go: горутины и каналы
- Rust: futures без встроенного рантайма
- Java: от потоков ОС к virtual threads
- Python и Node: event loop и предел параллелизма
- Сводная таблица
- Сквозные проблемы
- Из практики: горутина, которая пережила запрос
- Что дальше
Конкурентность против параллелизма
Два слова, которые постоянно путают, — а разница определяет весь дальнейший разговор. Конкурентность — это про структуру: программа устроена как множество независимых задач, которые могут находиться «в полёте» одновременно и чередоваться во времени. Параллелизм — это про исполнение: несколько задач физически считаются в один и тот же момент на разных ядрах процессора.
Одно не влечёт другого. Однопоточный 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
Дальше — по языкам, каждый как точка в этих осях, а в конце сведём в таблицу.
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-группе, какую модель разобрать подробнее в первую очередь.
Комментарии