Event loop, корутины и зелёные потоки

Как один поток держит десятки тысяч соединений, что на самом деле делает await, почему одна тяжёлая функция вешает весь Node-сервер и чем горутины отличаются от async def.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

C10K: поток на соединение

Самая простая модель сервера — на каждое соединение отдельный поток, который блокируется в read(). Код линейный и понятный. Но поток стоит денег, даже когда спит.

  • Стек: 8 МБ виртуальной памяти (ulimit -s; в Java -Xss 1 МБ), реально занято — десятки КБ. Плюс стек ядра 16 КБ и структуры планировщика.
  • Каждый пришедший запрос — разбудить поток и усыпить: два переключения контекста по 2–5 мкс с учётом промахов кэша.
  • Потолки ядра: vm.max_map_count (два отображения на стек), kernel.threads-max, ulimit -u.

Двигайте ползунки и сравните с циклом событий: один поток, а на соединение — только объект с его состоянием.

Блокирующий и неблокирующий ввод-вывод

Блокирующий read() усыпляет поток, пока не придут данные. С флагом O_NONBLOCK он сразу возвращает EAGAIN: данных нет, приходите позже.


fcntl(fd, F_SETFL, O_NONBLOCK);
n = read(fd, buf, sizeof buf);
if (n < 0 && errno == EAGAIN) {
    // данных нет — к другому сокету
}

Один поток и три сокета. Данные приходят на B, потом на C, потом на A. Выберите стратегию и нажмите «Запустить».

select, poll, epoll, kqueue

Мультиплексирование: один системный вызов ждёт сразу много дескрипторов и говорит, какие готовы. select и poll каждый раз передают ядру весь список и проверяют его целиком. epoll (Linux) и kqueue (BSD, macOS) хранят список в ядре и возвращают только готовые.


int ep = epoll_create1(0);
// регистрация сокета — один раз
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev);
for (;;) {
    int n = epoll_wait(ep, events, 64, -1);
    for (int i = 0; i < n; i++)
        handle(events[i].data.fd);
}

Выберите механизм, задайте число соединений и вызовите ожидание.

io_uring: не «можно читать», а «прочитано»

  • Готовность (epoll, kqueue): ядро сообщает, что сокет готов, а читаете вы сами — ещё один системный вызов на каждую операцию.
  • Завершение (io_uring, Windows IOCP): вы ставите операцию в очередь, ядро выполняет её и кладёт результат. Две кольцевые очереди в общей памяти, один вызов на пачку операций.
  • Обычный файл для epoll всегда «готов», а read() всё равно ждёт диск. Поэтому Node (libuv) читает файлы в пуле потоков, а io_uring умеет настоящий асинхронный файловый ввод-вывод.

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

Цикл событий

Event loop — это обычный бесконечный цикл вокруг epoll_wait. Он засыпает до ближайшего таймера или готового сокета и по очереди вызывает колбэки. Пока колбэк работает, цикл стоит.

  • Node.js — libuv, фазы: таймеры → отложенные → poll (сокеты) → setImmediate → закрытия.
  • Python — asyncio: _run_once() вокруг selectors, uvloop — на libuv.
  • То же самое внутри nginx, Redis, Netty, Tokio.

Между любыми двумя колбэками Node и браузер выполняют все микрозадачи — колбэки промисов и продолжения после await.


// упрощённо — так устроен любой цикл событий
while (true) {
  const timeout = timers.nextDeadline() - now();
  const ready = epoll_wait(ep, timeout);   // спим здесь

  for (const t of timers.expired()) {
    t.callback();          // макрозадача
    runAllMicrotasks();    // промисы, await
  }
  for (const ev of ready) {
    ev.callback();         // данные из сокета
    runAllMicrotasks();
  }
}

Микрозадачи и макрозадачи

Макрозадача — колбэк таймера или ввода-вывода: одна за оборот цикла. Микрозадача — колбэк промиса или продолжение после await: их цикл выполняет все сразу, пока очередь не опустеет, и только потом берёт следующую макрозадачу.

Выберите пример и проходите его по шагам. Сначала попробуйте угадать, в каком порядке появится вывод.

Что выведет программа?


console.log('A');
setTimeout(() => console.log('B'), 0);
queueMicrotask(() => console.log('C'));
(async () => {
  console.log('D');
  await 0;
  console.log('E');
})();
console.log('F');
Синхронно: A, тело async-функции до await — D, затем F. В очереди микрозадач к этому моменту C, а за ней продолжение после await — E: они выполняются в порядке постановки. Таймер — макрозадача, поэтому B последний, хотя задержка 0.

Колбэки → промисы → async/await

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

колбэки

db.get(id, (err, user) => {
  if (err) return done(err);
  api.posts(user, (err, posts) => {
    if (err) return done(err);
    done(null, posts.length);
  });
});

Вложенность растёт, ошибку легко потерять, исключение из колбэка не поймать снаружи.

промисы

db.get(id)
  .then(user => api.posts(user))
  .then(posts => posts.length)
  .catch(handle);

Цепочка вместо вложенности, ошибки идут в один .catch. Но циклы и ветвления по-прежнему неудобны.

async/await

async function load(id) {
  const user = await db.get(id);
  const posts = await api.posts(user);
  return posts.length;
}

Выглядит как синхронный код: обычные try/catch, for, if. Компилятор сам разрежет функцию на куски — дальше покажем как.

async-функция — это конечный автомат

Компилятор режет функцию по каждому await. Локальные переменные, нужные после паузы, переезжают со стека в объект в куче, а номер следующего куска хранится в поле state. Так работают C#, Rust, Kotlin; в JS и Python тот же принцип реализован через сохранённый кадр генератора.

Это бесстековые корутины: при паузе стек полностью освобождается, и поток свободен для других задач. Нажимайте «Шаг» и следите за стеком и объектом в куче.

Одна тяжёлая задача вешает всех

Цикл событий кооперативный: колбэк не прервать. Разбор большого JSON, bcrypt, синхронное сжатие, регулярка с катастрофическим откатом — и все остальные запросы ждут, хотя процессор свободен на других ядрах.

  • Разбить работу на куски и отдавать управление: await new Promise(setImmediate).
  • Вынести в worker_threads (или пул piscina), в Python — в ProcessPoolExecutor.

Сервер получает 200 запросов в секунду по 2 мс. Запустите тяжёлую задачу на 300 мс в разных режимах и смотрите на задержку остальных запросов.

Скрытый пул потоков libuv

Не всё в Node асинхронно на уровне ядра. Файлы, dns.lookup (а значит, и http.get по имени хоста), crypto.pbkdf2, scrypt, zlib выполняются в пуле из 4 потоков. Пятая задача ждёт в очереди.

Классический инцидент: медленный DNS занял все четыре потока, и встали чтения файлов и хеширование паролей. Размер задаётся UV_THREADPOOL_SIZE до старта процесса (максимум 1024). Двигайте ползунки.

Проверьте себя

В Node-сервере кто-то написал «асинхронное» ожидание флага:


while (!ready) {
  await Promise.resolve();
}

Флаг ready выставляет обработчик сетевого запроса. Что будет с сервером?

await отдаёт управление только до следующей микрозадачи. Каждый оборот ставит новую микрозадачу, а цикл событий не берёт макрозадачи, пока очередь микрозадач не пуста. Сетевой обработчик, который выставит ready, никогда не запустится. То же с рекурсивным process.nextTick. Отдать управление по-настоящему — await new Promise(r => setImmediate(r)) или setTimeout.

Python asyncio: где теряется параллельность

  • fetch() без await только создаёт объект корутины: код не выполняется, в логе — RuntimeWarning.
  • time.sleep, requests, синхронный драйвер БД внутри async def блокируют весь цикл.
  • Блокирующий вызов — в поток: asyncio.to_thread() или loop.run_in_executor(). Счёт на CPU — в процессы: GIL не даст потокам считать параллельно.
  • PYTHONASYNCIODEBUG=1 предупреждает о колбэках дольше 100 мс.

Выберите вариант кода. Нижняя дорожка — фоновая задача, которая печатает каждые 100 мс: если цикл заблокирован, она молчит.

Проверьте себя


@app.get("/price")
async def price():
    r = requests.get(RATES_URL)   # 200 мс
    return r.json()

FastAPI, один процесс uvicorn. Одновременно пришло 50 запросов. Сколько ждёт последний?

requests.get блокирует поток цикла событий, и 50 × 200 мс выполняются строго подряд. Парадокс: если убрать async и объявить обычный def, FastAPI сам запустит обработчик в пуле потоков (40 по умолчанию), и стало бы быстро. Правильно — асинхронный клиент: await httpx.AsyncClient().get(...).

Зелёные потоки: M задач на N потоков

Стековая корутина — свой маленький стек, который рантайм умеет отложить в сторону. Приостановиться можно на любой глубине вызовов, и код пишется как обычный блокирующий, без async. Рантайм раскладывает тысячи таких задач по нескольким потокам ОС.

  • Go: стек от 2 КБ растёт по мере нужды, вытесняет долгие циклы через 10 мс. Подробнее о GMP — в презентации про горутины.
  • Java 21+: виртуальные потоки поверх ForkJoinPool, кадры стека копируются в кучу. Не вытесняются по времени.
  • Erlang/Elixir: процесс ~2,7 КБ со своей кучей, вытеснение по счётчику редукций.

Выберите рантайм. Действие выполняет задача на первом работающем потоке.

Раскраска функций

В мире бесстековых корутин функции двух цветов. Асинхронную можно вызвать только из асинхронной, и async расползается вверх по всему стеку вызовов.

  • Из синхронной функции дождаться асинхронной нельзя, не заблокировав цикл: asyncio.run() внутри работающего цикла — ошибка.
  • Библиотеки раздваиваются: requests и httpx.AsyncClient, синхронный и асинхронный режимы в psycopg 3, fs.readFileSync и fs.promises.
  • Колбэк в map или sort не может сделать await.
  • Go, Java с виртуальными потоками и Erlang цветов не имеют: любая функция может заблокироваться.

def total(ids):              # синяя: обычная
    return sum(price(i) for i in ids)

def price(i):
    return fetch_price(i)    # нужна сеть…

async def fetch_price(i):    # красная: async
    ...

# Чтобы price() могла сделать await,
# она должна стать async. Тогда и total(),
# и все, кто вызывает total(), — до main().

Сравнение моделей

МодельПамять на задачуВытеснениеЦветаБрать, когда
Поток ОС на запросJava до 21, Python WSGI, Rails десятки КБ + 8 МБ адресовда, ядронет Сотни одновременных запросов, блокирующие библиотеки, нужен простой код.
Цикл событий + async/awaitNode, asyncio, Tokio, C# сотни байт — КБнет, кооперативнода Много соединений и мало счёта: прокси, шлюзы, WebSocket, чаты, вызовы внешних API.
ГорутиныGo от 2 КБ, растётда, рантаймнет Сетевые сервисы с конкурентностью и счётом одновременно, все ядра без усилий.
Виртуальные потокиJava 21+ сотни байт — КБ в кученет, только на блокировкахнет Существующий блокирующий Java-код (JDBC, Spring MVC), которому нужны тысячи запросов.
Процессы ErlangErlang, Elixir ~2,7 КБ, своя кучада, по редукциямнет Миллионы долгоживущих соединений, изоляция сбоев, предсказуемые задержки.

Ни одна модель не делает быстрее сам ввод-вывод. Они экономят память и переключения, пока задачи ждут. Если узкое место — CPU или база данных, асинхронность не поможет.

Типичные грабли

Так ломается

  • Promise.all или gather на 10 000 запросов: кончаются сокеты и лимиты API.Ограничивайте: семафор, p-limit, пул воркеров.
  • Тысячи виртуальных потоков на пул БД из 10 соединений: все стоят в очереди за соединением.Лимит конкурентности — по самому узкому ресурсу.
  • Ожидание без таймаута: зависший сокет держит задачу вечно.asyncio.timeout, AbortSignal.timeout, context.WithTimeout.
  • Задача без ссылки: asyncio.create_task(x) может исчезнуть со сборщиком мусора, а ошибка промиса — потеряться.Храните задачи, используйте TaskGroup.

Так видно

  • Задержка цикла событий: monitorEventLoopDelay() в Node, режим отладки asyncio.
  • Блокирующий вызов в стеке: py-spy dump, node --cpu-prof.
  • Горутины и где они висят: /debug/pprof/goroutine?debug=2.
  • Закреплённые виртуальные потоки: событие JFR jdk.VirtualThreadPinned.

Как это увидеть у себя

  • strace покажет, что процесс на самом деле ждёт: epoll_wait — цикл событий простаивает, долгий read или futex — что-то заблокировано.
  • Число потоков и переключений — в /proc. Много невольных переключений — потокам не хватает ядер.
  • perf sched и pidstat -w считают переключения по процессам.
  • У Go — трассировка планировщика, у Java — дамп виртуальных потоков.

# какие системные вызовы и сколько времени
$ strace -f -c -p $PID
$ strace -f -e trace=epoll_wait,read,write -p $PID

# потоки и переключения контекста
$ ls /proc/$PID/task | wc -l
$ grep ctxt /proc/$PID/status
voluntary_ctxt_switches:     183022
nonvoluntary_ctxt_switches:  4211
$ pidstat -w -p $PID 1

# соединения на сокетах
$ ss -s

# Go: состояние планировщика раз в секунду
$ GODEBUG=schedtrace=1000 ./server
# Java: дамп всех потоков, включая виртуальные
$ jcmd $PID Thread.dump_to_file -format=json t.json

Главное

  • Поток на соединение прост, но стоит памяти и переключений контекста. Начиная с тысяч соединений нужна другая модель.
  • Основа всех моделей — неблокирующие сокеты и epoll/kqueue: один вызов возвращает готовые дескрипторы. io_uring сообщает уже о выполненных операциях.
  • Цикл событий кооперативный: одна долгая функция останавливает всех. Микрозадачи выполняются до опустошения очереди, раньше таймеров и ввода-вывода.
  • async/await — синтаксис для конечного автомата. Плата — раскраска функций и необходимость всё время помнить про блокирующие вызовы.
  • Горутины, виртуальные потоки и процессы Erlang прячут цикл событий в рантайм: код блокирующий, а потоков ОС мало.

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