ОС · ОС в проде · 15 мин
ОС глазами бэкендера: собираем всё вместе
Зачем эта глава
Весь курс мы по кусочкам разбирали ОС: процессы, потоки, память, диск, файлы, сеть. Каждый кусок жил отдельно. Но в реальном проде они работают вместе, и именно на стыках прячется то, ради чего вас наняли, — latency (задержка, сколько ждёт один запрос) и throughput (пропускная способность, сколько запросов в секунду тянет сервис).
Давайте определим эти два слова сразу, по-простому.
- Latency (задержка) — время от «пришёл запрос» до «ушёл ответ» для одного запроса. Меряют в перцентилях: p50 (медиана, типичный запрос), p99 (худший из 100, «хвост»). Бытовая аналогия: сколько ты стоишь в очереди в кассу.
- Throughput (пропускная) — сколько запросов сервис обслуживает за секунду (RPS). Аналогия: сколько человек касса пропускает за час.
Эти двое часто тянут в разные стороны — ровно как turnaround и response у планировщика. Хороший бэкендер не зубрит «как устроено ядро», он умеет по симптому в метриках назвать механизм ОС, который виноват. Этим и займёмся.
Жизнь одного запроса: полный путь через ОС
Возьмём типичный сценарий: пользователь дёрнул HTTP-ручку, сервис на Go сходил в базу и вернул ответ. Проследим запрос через все слои, что мы изучили, и отметим, где сколько времени теряется.
ЖИЗНЬ ОДНОГО ЗАПРОСА (HTTP → БД → ответ)
[1] Пакет из сети ~µs (сетевая карта)
│ NIC принял байты, DMA кладёт их в RAM
▼ (DMA = карта пишет в память сама, без CPU)
[2] Прерывание (IRQ) ~µs
│ карта дёргает CPU: "данные готовы"
▼ ядро в обработчике прерывания, softirq
[3] netpoller / epoll ~µs
│ ядро: "сокет читаемый" → epoll_wait
▼ рантайм Go будит горутину, ждавшую сокет
[4] Планировщик даёт CPU ~µs..ms
│ горутина в runnable → шедулер Go на M:P
▼ если все P заняты — ждём в очереди (latency!)
[5] Код парсит запрос, читает данные ~ns..ms
│ page cache HIT → наносекунды (из RAM)
│ page cache MISS → миллисекунды (с диска)
▼
[6] Запрос в БД: открыть сокет, послать ~ms (сеть+БД)
│ снова [1..4] но уже на стороне БД
▼ БД пишет WAL: fsync → ждём подтверждения диска
[7] fsync / запись на диск ~ms (HDD) / ~100µs (SSD)
│ durability: данные точно на носителе
▼
[8] Формируем ответ, write() в сокет ~µs
│ syscall → копирование в буфер ядра
▼ NIC отправляет пакеты
[9] Ответ ушёл клиентуЧто нарисовано: один запрос проходит девять слоёв, и каждый — это отдельная глава курса. Обратите внимание на порядки времени справа: переключения и прерывания живут в микросекундах, page cache hit — в наносекундах, а вот диск (fsync) и поход по сети в БД — это миллисекунды, в тысячи раз дороже. Латентность почти всегда прячется там, где появляется буква «ms».
Где именно прячется латентность
Разложим тот же путь по «стоимости» и привяжем к главам, где механизм разобран подробно.
ДЁШЕВО (ns) СРЕДНЕ (µs) ДОРОГО (ms)
┌───────────┐ ┌──────────────┐ ┌──────────────────┐
│ page cache│ │ syscall │ │ диск random I/O │
│ HIT │ │ context │ │ fsync (durability│
│ обращение │ │ switch │ │ WAL) │
│ к RAM │ │ прерывание │ │ page cache MISS │
│ TLB hit │ │ epoll wakeup │ │ своп (page out) │
└───────────┘ └──────────────┘ │ сеть до БД │
└──────────────────┘
▲ ▲ ▲
из RAM/кэша вход в ядро физический носитель
или другая машинаЧто нарисовано: грубая «лестница цен». Запомните правило большого пальца — RAM в ~100 000 раз быстрее диска, а SSD random-чтение в ~100 раз быстрее HDD. Поэтому первая интуиция при «тормозит»: мы случайно ушли из RAM на диск или в сеть?
Каждая ступенька — это глава:
- page cache hit/miss → своп и page cache, память в проде
- TLB → TLB
- syscall → стоимость системного вызова
- context switch → прямое исполнение, планирование
- random vs sequential I/O → HDD, SSD
- fsync/WAL → fsync и долговечность, журналирование
- epoll/netpoller → event-based concurrency
Как читать метрики: симптом → причина
Самое полезное умение — посмотреть на top/htop/дашборд и назвать механизм.
Разберём четыре главных столбца CPU и спутники.
КАРТА СИМПТОМОВ В МЕТРИКАХ CPU
high us (user) → код реально считает на CPU.
Норма для CPU-bound. Если неожиданно
высоко — горячий цикл, сериализация,
регэкспы, GC.
high sy (system) → много времени В ЯДРЕ.
Симптом: слишком много syscall'ов.
Причины: мелкие read/write вместо
батчей, много соединений, лог в файл
на каждый запрос. → syscall-cost
high wa (iowait) → CPU простаивает, ЖДЁТ диск.
Симптом: упёрлись в диск (random I/O,
fsync, холодный page cache, своп).
→ hdd / ssd / fsync-durability
high si/hi(softirq)→ много сетевых прерываний.
Симптом: шторм пакетов, мелкие
пакеты, один CPU разгребает всю сеть.
+ cs (context switches) высокий → потоки/горутины дерутся
за CPU, частые блокировки на локах,
слишком много рантайм-потоков.
+ page faults (major) высокий → ходим на диск за памятью:
холодный mmap, своп. → swappingЧто нарисовано: шпаргалка «какой столбец о чём кричит». Ключевое различие, которое
любят на собесе: высокий us — это «мы много считаем» (часто ок), а высокий
sy — это «мы много дёргаем ядро» (почти всегда подозрительно, см.
стоимость syscall). А высокий wa (iowait) — это
вообще не «CPU занят», это «CPU стоит без дела и ждёт диск», лечится совсем иначе.
Отдельно про major page fault (большой промах страницы): это когда процесс обратился к адресу, а нужной страницы нет в RAM — её надо тащить с диска (своп или ленивый mmap). Один major fault = миллисекунды. Если их много — вы либо в свопе, либо постоянно читаете «холодные» данные, см. свопинг.
Чеклист ОС-интуиции для бэкендера
Четыре ресурса, четыре вопроса. Держите в голове как чеклист при разборе инцидента.
┌──────────┬─────────────────────────────────────────────┐
│ РЕСУРС │ ЧТО ПРОВЕРИТЬ И ЧЕМ ГРОЗИТ │
├──────────┼─────────────────────────────────────────────┤
│ ПАМЯТЬ │ RSS растёт? упёрлись в cgroup limit? │
│ │ → OOM kill (exit 137). VIRT≠RSS. │
│ │ GOMEMLIMIT < лимита. → memory-cloud │
├──────────┼─────────────────────────────────────────────┤
│ CPU │ throttling по cgroup quota? GOMAXPROCS │
│ │ под лимит? много context switch? │
│ │ → хвост latency. → containers-namespaces │
├──────────┼─────────────────────────────────────────────┤
│ ДИСК │ random или sequential? fsync на каждый │
│ │ запрос? холодный page cache? │
│ │ → iowait, p99. → hdd / fsync-durability │
├──────────┼─────────────────────────────────────────────┤
│ СЕТЬ │ epoll/netpoller, число соединений, │
│ │ мелкие пакеты, RTT до зависимостей. │
│ │ → latency. → event-based-concurrency │
└──────────┴─────────────────────────────────────────────┘Что нарисовано: четыре «оси», по которым бэкендер диагностирует прод. Память чаще всего убивает резко (OOM), CPU и диск — портят хвост latency постепенно, сеть — добавляет фиксированный «налог» на каждый поход к зависимости.
Разберём важные нюансы каждой оси.
Память. В контейнере процесс видит память всей ноды, но ограничен
cgroup-лимитом. Превысил memory limit →
OOM killer убивает процесс с SIGKILL, exit code = 128+9 = 137, даже если
на хосте памяти полно. VIRT (виртуальное АП, «обещания») — не то же, что RSS
(физическая RAM прямо сейчас), большой VIRT — не утечка. Подробно —
память в проде.
CPU. В отличие от памяти, превышение CPU-лимита cgroup не убивает, а
троттлит: ядро отбирает у процесса CPU в конце периода квоты, и горутины
замирают на десятки миллисекунд — характерный «пилообразный» хвост p99. Лечится
выравниванием GOMAXPROCS под CPU-лимит, см.
контейнеры и namespaces и
планировщик Go.
Диск. Главная мысль из глав про железо: для диска random I/O в разы дороже
sequential, а fsync (форсированный сброс на носитель ради
долговечности) — это синхронное ожидание физического
устройства. Базы поэтому пишут WAL/журнал
последовательно, а не разбрасывают записи.
Сеть. Современный сервер не держит поток на соединение — он использует
epoll: один поток ждёт события на тысячах
сокетов. В Go это спрятано за netpoller'ом: вы пишете «блокирующий» conn.Read,
а под капотом горутина паркуется и будится по epoll-событию. Это связывает
главу про горутины с механикой ОС.
Сводная таблица: симптом в проде → механизм ОС → глава
| Симптом в проде | Что происходит в ОС | Куда смотреть |
|---|---|---|
| Первые запросы после деплоя медленные | Холодный page cache, читаем с диска | swapping, memory-cloud |
Высокий sy (system CPU) |
Слишком много syscall'ов, мелкие read/write | syscall-cost |
Высокий wa (iowait), p99 скачет |
Упёрлись в диск: random I/O или fsync | hdd, ssd, fsync-durability |
| Контейнер падает с exit 137 | Превышен cgroup memory limit → OOM | containers-namespaces, memory-cloud |
| p99 «пилит», CPU будто не загружен | CPU-троттлинг по cgroup quota | containers-namespaces |
| Много major page faults, тормоза | Своп / ленивый mmap тащит страницы с диска | swapping |
| Много context switches | Драка за CPU, частые блокировки на локах | scheduling, locks |
| Запись «потерялась» после краша | Данные были в page cache, не было fsync | fsync-durability, journaling |
| 10k соединений, а потоков мало | epoll/netpoller: событийная модель | event-based-concurrency |
| GC агрессивно жрёт CPU под нагрузкой | Давление на память близко к лимиту | memory-cloud |
Что в таблице: готовый «справочник дежурного» — увидел строку слева, назвал механизм по центру, пошёл углубляться по ссылке справа. Именно так выглядит зрелый разбор инцидента.
Как всё это связано: одна картинка
ЗАПРОС
│
▼
┌─────────┐ epoll/netpoller ┌──────────────┐
│ СЕТЬ │ ─────────────────▶ │ ПЛАНИРОВЩИК │ ← context switch
│ (NIC,DMA)│ │ (CPU, P/M/G) │ = µs налог
└─────────┘ └──────┬───────┘
│ syscall = вход в ядро
▼
┌──────────────┐
│ ПАМЯТЬ │ ← page cache hit = ns
│ (RAM, cgroup) │ miss = ms (диск)
└──────┬───────┘ limit → OOM
│
▼
┌──────────────┐
│ ДИСК │ ← fsync/WAL = ms
│ (page cache, │ random ≫ seq
│ fsync) │
└──────────────┘Что нарисовано: те же четыре ресурса из чеклиста, но как они передают запрос друг другу. Запрос «втекает» через сеть, планировщик даёт ему CPU, код трогает память (дёшево, если в кэше) и в конце упирается в диск (дорого, если нужна долговечность). Узкое место вашего сервиса — это всегда один из этих четырёх стыков, и ваша работа — по метрикам понять, какой.
Сервис тормозит, в top высокий показатель sy (system CPU). Что это вероятнее всего значит?
В метриках высокий iowait (wa). Что из этого верно?
Какие операции на пути запроса измеряются в миллисекундах (а не нано/микросекундах)?
Контейнер в Kubernetes упал с exit code 137, при этом на ноде свободной памяти достаточно. Причина?
Чему равен exit code процесса, убитого OOM killer'ом (сигнал SIGKILL, номер 9)?
Как один поток в современном сервере обслуживает десятки тысяч соединений одновременно?
Что спрашивают на собесе
- «Опиши путь HTTP-запроса от пакета до ответа». Сильный ответ идёт по слоям: NIC+DMA → прерывание → epoll/netpoller будит горутину → планировщик даёт CPU → код читает данные (page cache hit/miss) → запрос в БД с fsync/WAL → ответ. Покажите, что знаете, где наносекунды, а где миллисекунды.
- «Высокий sys CPU — о чём это и чем отличается от user?»
us— код считает (часто норма),sy— много времени в ядре, то есть слишком много syscall'ов (мелкие read/write вместо батчей). Лечится батчингом, буферизацией, переиспользованием соединений. См. syscall-cost. - «iowait высокий — что делать?» Это CPU простаивает, ждёт диск: упёрлись в random I/O, fsync или холодный page cache. Не лечится добавлением CPU — нужно смотреть на диск, паттерн доступа (random→sequential), кэш.
- «Контейнер упал с exit code 137 при свободной памяти на ноде». cgroup memory limit + OOM killer внутри группы; 137 = 128+9 (SIGKILL). Хост здоров, лимит контейнера превышен. CPU-лимит, в отличие от memory, не убивает, а троттлит.
- «Сервис медленный сразу после деплоя, через минуту быстрый — почему?» Холодный page cache: первые чтения идут на диск, потом данные оседают в RAM. Назвать именно page cache, а не только прогрев GC/JIT.
- «Как один поток обслуживает 10k соединений?» epoll/netpoller: событийная
модель, поток спит на
epoll_waitи будится только на готовых сокетах; в Go это спрятано за горутинами и netpoller'ом.