Как один поток держит десятки тысяч соединений, что на самом деле делает await, почему одна тяжёлая функция вешает весь Node-сервер и чем горутины отличаются от async def.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Самая простая модель сервера — на каждое соединение отдельный поток, который блокируется в read(). Код линейный и понятный. Но поток стоит денег, даже когда спит.
ulimit -s; в Java -Xss 1 МБ), реально занято — десятки КБ. Плюс стек ядра 16 КБ и структуры планировщика.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 (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);
}
Выберите механизм, задайте число соединений и вызовите ожидание.
read() всё равно ждёт диск. Поэтому Node (libuv) читает файлы в пуле потоков, а io_uring умеет настоящий асинхронный файловый ввод-вывод.Двигайте ползунок: сколько системных вызовов нужно, чтобы ответить на пачку запросов.
Event loop — это обычный бесконечный цикл вокруг epoll_wait. Он засыпает до ближайшего таймера или готового сокета и по очереди вызывает колбэки. Пока колбэк работает, цикл стоит.
setImmediate → закрытия.asyncio: _run_once() вокруг selectors, uvloop — на libuv.Между любыми двумя колбэками 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.Механизм под всеми тремя один и тот же — цикл событий. Меняется только то, как вы записываете «продолжи, когда будет готово».
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 function load(id) {
const user = await db.get(id);
const posts = await api.posts(user);
return posts.length;
}
Выглядит как синхронный код: обычные try/catch, for, if. Компилятор сам разрежет функцию на куски — дальше покажем как.
Компилятор режет функцию по каждому await. Локальные переменные, нужные после паузы, переезжают со стека в объект в куче, а номер следующего куска хранится в поле state. Так работают C#, Rust, Kotlin; в JS и Python тот же принцип реализован через сохранённый кадр генератора.
Это бесстековые корутины: при паузе стек полностью освобождается, и поток свободен для других задач. Нажимайте «Шаг» и следите за стеком и объектом в куче.
Цикл событий кооперативный: колбэк не прервать. Разбор большого JSON, bcrypt, синхронное сжатие, регулярка с катастрофическим откатом — и все остальные запросы ждут, хотя процессор свободен на других ядрах.
await new Promise(setImmediate).worker_threads (или пул piscina), в Python — в ProcessPoolExecutor.Сервер получает 200 запросов в секунду по 2 мс. Запустите тяжёлую задачу на 300 мс в разных режимах и смотрите на задержку остальных запросов.
Не всё в 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.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(...).Стековая корутина — свой маленький стек, который рантайм умеет отложить в сторону. Приостановиться можно на любой глубине вызовов, и код пишется как обычный блокирующий, без async. Рантайм раскладывает тысячи таких задач по нескольким потокам ОС.
Выберите рантайм. Действие выполняет задача на первом работающем потоке.
В мире бесстековых корутин функции двух цветов. Асинхронную можно вызвать только из асинхронной, и async расползается вверх по всему стеку вызовов.
asyncio.run() внутри работающего цикла — ошибка.requests и httpx.AsyncClient, синхронный и асинхронный режимы в psycopg 3, fs.readFileSync и fs.promises.map или sort не может сделать await.
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().
Ни одна модель не делает быстрее сам ввод-вывод. Они экономят память и переключения, пока задачи ждут. Если узкое место — CPU или база данных, асинхронность не поможет.
Promise.all или gather на 10 000 запросов: кончаются сокеты и лимиты API.Ограничивайте: семафор, p-limit, пул воркеров.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.jdk.VirtualThreadPinned.strace покажет, что процесс на самом деле ждёт: epoll_wait — цикл событий простаивает, долгий read или futex — что-то заблокировано./proc. Много невольных переключений — потокам не хватает ядер.perf sched и pidstat -w считают переключения по процессам.
# какие системные вызовы и сколько времени
$ 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 — синтаксис для конечного автомата. Плата — раскраска функций и необходимость всё время помнить про блокирующие вызовы.Асинхронность не ускоряет работу — она позволяет не тратить ресурсы на ожидание.