awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Блокирующий vs неблокирующий I/O: два способа ждать

Суть Сервер большую часть жизни ждёт I/O. Блокирующий I/O паркует целый поток на каждое ожидание, поэтому конкурентность стоит памяти; неблокирующий отдаёт ожидание ядру и даёт одному потоку жонглировать тысячами сокетов через event loop.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 11 min

Замерь время типичного обработчика запроса — и удивишься, насколько мало в нём твоего кода. Он читает строку из Postgres, дёргает платёжный API, пишет строку в лог — и тратит 95% реального времени просто на ожидание ответа. Вся суть бэкенд-конкурентности сводится к одному: что делает программа, пока ждёт? На этот вопрос есть два ответа, делящие всю область пополам. Один паркует поток на каждое ожидание. Другой отказывается кого-либо парковать и просит ядро тронуть его за плечо, когда данные готовы.

Ожидание — это и есть работа

Бэкенд — это в основном I/O-машина. Чтение с диска, запросы к базе, исходящий HTTP, запись в сокет — каждая операция медленная относительно CPU (микросекунды и миллисекунды, тогда как процессор выполняет миллиарды инструкций в секунду). Поэтому первый вопрос проектирования никогда не «как быстр мой код», а «как рантайм проводит ожидание». Две модели I/O дают противоположные ответы, и выбор определяет, как сервер масштабируется, сколько памяти ест и как падает под нагрузкой.

Блокирующий I/O: поток на соединение

В блокирующей модели поток вызывает read(), и операционная система приостанавливает этот поток до прихода байтов. Поток запаркован — занимает свой стек и слот планировщика — и ничего полезного не делает. Чтобы обслужить второе соединение конкурентно, нужен второй поток, третьему — третий, и так далее: поток-на-соединение (thread-per-connection).

Это просто и легко рассуждать — код читается сверху вниз, каждая строка ждёт предыдущую — но масштабируется добавлением потоков, а потоки не бесплатны. Каждый поток ОС резервирует примерно 1–2 МБ стека, поэтому 10 000 конкурентных соединений означают порядка 10+ ГБ памяти только под стеки, плюс тысячи переключений контекста в секунду, пока планировщик тасует запаркованные потоки. Модель меняет память на простоту.

Неблокирующий I/O: один поток, много сокетов

В неблокирующей модели сокет переводится в неблокирующий режим, и read() возвращается немедленно — либо с данными, либо с «пока не готово». Вместо того чтобы парковаться, поток регистрирует интерес к множеству сокетов через средство ядра — epoll на Linux, kqueue на BSD/macOS — и задаёт один вопрос: «кто из этих тысяч файловых дескрипторов готов прямо сейчас?» Ядро возвращает только готовые, примерно за O(1) независимо от того, сколько их под наблюдением. Поток обслуживает эти, затем спрашивает снова. Этот цикл и есть event loop.

Один поток поэтому способен тянуть десятки тысяч соединений, потому что трогает только те сокеты, у которых есть реальная работа. Цена — другая форма кода: нельзя читать сверху вниз и «ждать» — ты регистрируешь колбэк (или await), и цикл вызовет тебя позже. Логика, которая была прямой линией, превращается в набор продолжений (continuations).

Почему это работает

Почему средство ядра так важно? Наивный способ следить за множеством сокетов — пройти по всем и спросить «готов? готов? готов?» — это select/poll, и он стоит O(n) за проход, поэтому слежка за 10 000 сокетов означает сканирование всех 10 000 каждый раз, даже если готов один. epoll/kqueue переворачивают это: ты регистрируешь набор один раз, а ядро отдаёт только те дескрипторы, что стали готовы, поэтому стоимость зависит от числа активных соединений, а не всех. Это и есть механизм, делающий «один поток, 50 000 простаивающих keep-alive соединений» по-настоящему дешёвым — простаивающие почти ничего не стоят, потому что цикл не заглядывает к ним, пока у них нет данных.

Постановка C10k и реальный компромисс

Этот раскол назвали проблемой C10k (~1999): как обслужить 10 000 конкурентных клиентов на одной машине? Поток-на-соединение упёрся в стену памяти и переключений контекста; модель event loop — Nginx, Node.js, Netty, Redis — стала ответом. Честная сводка:

  • Блокирующий / поток-на-соединение меняет память и накладные расходы на переключение контекста на простоту. Хорош, когда число соединений умеренное или работа CPU-тяжёлая; код остаётся линейным.
  • Неблокирующий / event loop меняет сложность кода (колбэки, продолжения, никакой парковки) на масштабируемость при множестве конкурентных, в основном простаивающих соединений.

Ни одна не «быстрее» в принципе. Для I/O-нагрузок с высокой конкурентностью event loop решительно выигрывает по памяти и числу соединений. Для CPU-нагрузок один поток event loop не быстрее любого другого одного потока — предел, который следующие уроки сделают резким.

Блокирующий (поток-на-соединение)Неблокирующий (event loop)
ОжиданиеПоток запаркован ОСЯдро следит за FD, поток идёт дальше
10k соединений~10+ ГБ стеков, много переключенийОдин поток, память ~ активным соединениям
Форма кодаЛинейная, сверху внизКолбэки / await, продолжения
Масштабируется черезДобавление потоковПропускную способность готовых событий
Лучше дляУмеренной конкурентности, CPU-тяжёлойВысокой конкурентности, I/O-bound
Викторина

Почему серверу с потоком-на-соединение тяжело держать 50 000 в основном простаивающих keep-alive соединений?

Викторина

Что `epoll`/`kqueue` дают event loop, чего не даёт наивное сканирование `select`/`poll`?

Расставь шаги по порядку

Расставь по порядку, что делает неблокирующий сервер, чтобы обслужить чтение на одном из множества сокетов:

  1. 1 Перевести сокет в неблокирующий режим и зарегистрировать в epoll/kqueue
  2. 2 Спросить ядро, какие из наблюдаемых дескрипторов готовы
  3. 3 Ядро возвращает только готовые дескрипторы
  4. 4 Запустить колбэк для каждого готового сокета, читая доступные байты
  5. 5 Вернуться к началу и снова спросить ядро
Вспомните перед уходом
  1. 01
    Почему «как рантайм проводит ожидание» — центральный вопрос для бэкенда, а не сырая скорость кода?
  2. 02
    Как работает блокирующий поток-на-соединение и какова его цена масштабирования?
  3. 03
    Как неблокирующий I/O с event loop обслуживает много соединений на одном потоке и что вносят epoll/kqueue?
Итог

Бэкенд большую часть жизни ждёт I/O, поэтому модель того, как он ждёт, определяет всё ниже по течению. Блокирующий I/O паркует поток на каждое ожидание: линейный, лёгкий код, но каждый поток стоит примерно 1–2 МБ и слот планировщика, поэтому поток-на-соединение превращает 10 000 соединений в 10+ ГБ стеков и шторм переключений контекста — память в обмен на простоту. Неблокирующий I/O делает сокеты неблокирующими, возвращается немедленно и регистрирует их в epoll или kqueue, поэтому один поток спрашивает ядро, какие дескрипторы готовы, и обслуживает только их — масштабируясь до десятков тысяч соединений, потому что простаивающие почти ничего не стоят, ценой кода в форме колбэков или await. Проблема C10k назвала этот раздел, и event loop стал стандартным ответом для высококонкурентных I/O-bound серверов. Следующий урок раскрывает сам цикл: упорядоченные фазы, которые он выполняет, очередь микрозадач, которую он осушает между ними, и почему эта конкурентность кооперативная, а не параллельная.

Связанные уроки
встречается в185
Продолжить восхождение ↑Event loop: один поток, упорядоченные фазы
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.