Operating System Basics for Architects: process vs thread, userspace vs kernelspace, syscalls, virtual memory, file descriptors, CFS scheduler, signals, IPC primitives (pipes/shm/mq/unix), epoll/kqueue/io_uring async I/O models. Concept page.
Сервис «странно тормозит» — в 80% случаев причина на OS-уровне: FD-leak, page-fault storm, syscall overhead, context-switch storm, OOM-kill, GIL-конвой. Архитектор без OS-модели не отвечает на простые вопросы: «почему Node упёрся в 1024 соединения», «почему postgres съел всю память при fork() для backup», «почему python-сервис не масштабируется добавлением потоков», «почему strace показывает 80% времени в futex». Все эти явления видны и измеримы, но только если знаешь, где смотреть.
Ещё важнее: выбор между process / thread / async на старте проекта определяет потолок производительности. Поменять модель concurrency позже — обычно переписывание с нуля. Postgres process-per-connection живёт с этим решением 30 лет и закрывает дыру pgBouncer'ом сверху; Node навсегда single-threaded; Go получил M
goroutines в 2009 году и сегодня лидирует в network services именно поэтому.Userspace просит — kernel делает. Между ними тонкая граница (syscall), и каждый переход стоит ~100 нс + flush регистров + кэш-эффекты. Хочешь быстрее — батчуй, или вообще убирай переходы (io_uring SQPOLL, shared memory, kernel bypass).
Три ring-уровня железа: ring 3 (userspace) → ring 0 (kernel) → ring -1 / DMA (железо). Процесс видит виртуальный address space (свою «вселенную»), kernel знает физический. MMU + TLB переводят виртуальные адреса в физические на каждом доступе к памяти. Page tables хранят это сопоставление; их обновление = invlpg или TLB shootdown (дорого, особенно в multi-core).
Три модели concurrency:
| Process | Thread | Coroutine / async | |
|---|---|---|---|
| Memory | отдельный address space | shared heap | shared heap |
| Switch cost | ~1–10 μs | ~1 μs | ~0.1 μs (без kernel) |
| Crash isolation | да | нет (один SIGSEGV — весь процесс) | нет |
| Communication | IPC: pipe / socket / shm / mq | mutex, atomic, futex | channel, async/await |
| Scale ceiling | ~1–5K | ~10K | миллионы (Go, Erlang) |
| Когда выбирать | изоляция критична (БД, sandbox) | shared state + CPU | network I/O dominated |
Четыре группы — это четыре «миры» Linux-системы:
/dev/shm), C (Node.js: event loop + libuv thread pool, FD-table 1023/1024 — на грани EMFILE). Каждый процесс имеет собственную FD-table — нумерация дескрипторов локальна процессу; fd=7 у A и fd=7 у B указывают на разные объекты.syscall handler (точка входа), VFS (read/write/open), TCP/IP stack, CFS scheduler, MM (page tables), epoll, io_uring, signals, OOM killer.Узлы FD-table подсвечены как capacity — они моделируют реальный лимит соединений. У process C он намеренно занижен (1023/1024) чтобы сценарий 3 показал EMFILE-каскад.
1. syscall-flow — анатомия одного read(). Прикладной код вызывает read(fd, buf, 4096). libc исполняет инструкцию syscall — CPU переключается с ring 3 на ring 0, регистры сохраняются в kernel stack (это и есть ~100 нс «overhead» каждого syscall'а). Kernel ищет struct file по fd, вызывает f_op->read, идёт в page cache. Page miss — block I/O в NVMe — ~100 μs ожидания (в 100 000 раз медленнее L1). DMA копирует данные в kernel buffer, IRQ будит ожидающий поток, copy_to_user переносит байты в user buffer (TLB walk + memcpy). sysret возвращает управление в userspace. Итог: 1M мелких read'ов с буфером 1 байт = 100 мс одного только overhead'а — отсюда правило батчинга (readv, writev, sendfile, splice).
2. threads-shm-ipc — потоки и shared memory. CFS scheduler выбирает следующий поток по минимальному vruntime в красно-чёрном дереве; квант обычно 4 мс. Поток T1 пишет в shared ring buffer (MAP_SHARED mmap), обозначает готовность через futex_wake. Process B — отдельный процесс! — видит те же физические страницы через свой mmap. Это zero-copy: нет копирования через kernel, только синхронизация через futex (кстати, futex дёшев когда нет contention — uncontended fast-path вообще не идёт в kernel). Control plane идёт через медленный pipe (ack-сообщения), data plane — через shm. Bonus-демонстрация: SIGSEGV ловит висячий указатель в shared heap — coredump, и оба потока процесса B умирают вместе (нет crash isolation внутри процесса).
3. epoll-c10k-fdleak — C10K и FD-leak до EMFILE. Один поток Node.js обслуживает 10K соединений: каждый новый сокет регистрируется в epoll через epoll_ctl(EPOLL_CTL_ADD) (O(log N), RB-tree); epoll_wait возвращает только готовые дескрипторы (O(1), отдельный двусвязный список) — это и есть решение C10K (Kegel, 1999): старый select() требовал бы 10K итераций массива на каждый poll. CPU-тяжёлые задачи (crypto, compress) offload'ятся в libuv thread pool (4 worker'а). Затем — error path забыл close(): FD-table растёт 500 → 800 → 1023, accept() падает с EMFILE, каскад: open(log) тоже не работает (нет fd!), debugging blind, OOM killer выбирает жирный процесс и SIGKILL'ит без shutdown hooks. Фикс: ulimit -n 1048576, try/finally close, lsof мониторинг fd-count, miграция на io_uring.
4. io-uring-async — следующее поколение I/O. Userspace mmap'ит две очереди (SQ — submission, CQ — completion) в shared memory с kernel. Прикладной код пишет SQE напрямую в ring (без syscall!), один io_uring_enter подаёт batch операций. Kernel worker thread исполняет их асинхронно, completion event пишет в CQ ring. В режиме IORING_SETUP_SQPOLL kernel-поток спинит SQ — ноль syscalls на data path (ScyllaDB pattern). Результат: 10M ops/sec на одно ядро vs ~1M у epoll vs ~10K у blocking. Caveat в скрипте показан честно: io_uring имел chain'ы CVE в 2023, отключён в дефолтных профилях Google/CrowdStrike — trade-off security vs perf.
fdtable-c)ADR-001: Threads vs Processes vs Async (полный текст — кликни на узел FD table - 1023/1024 процесса C). Краткая выжимка:
Решение по задаче: CPU-bound + изоляция (DB engine, browser tab, Lambda) → процессы + cgroups. Mixed I/O + CPU + shared state (Java app server) → thread pool с bounded concurrency (Tomcat maxThreads). Network I/O dominated (HTTP server, proxy, gateway, chat) → async event loop + io_uring/epoll. Не выбирай «потоки» по умолчанию: Python потоки бесполезны для CPU из-за GIL (используй multiprocessing); Java виртуальные потоки (Project Loom, JDK 21+) дают async-производительность с blocking-стилем кода. Никогда не fork() в горячем цикле — exec + page table copy дорог, используй pre-fork worker pool (Gunicorn, Unicorn).
mmap для shared buffers (8 KB pages), fork() для bgworker'ов и checkpointer'ов.multiprocessing или JIT.fork() в горячем цикле — каждый exec копирует page tables и FD-table. Pre-fork worker pool — норма (Gunicorn, Apache prefork).ulimit -n — дефолтный soft limit 1024 в production = бомба замедленного действия. Ставь 1M в LimitNOFILE= (systemd) или /etc/security/limits.conf.semaphore / errgroup с лимитом.O_NONBLOCK + spin loop — это antipattern из 1990-х, эпохи до select. Сжигает CPU без полезной работы. Используй epoll / kqueue / io_uring.async-signal-unsafe (включая printf, malloc). Safe set очень мал: write, _exit, signal, атомарные read/write. Используй self-pipe trick или signalfd.-20 — может вытеснить kernel-критичные таски (kworker, ksoftirqd) и привести к kernel stalls.«OS-thinking» — это не «всё пишем на C с epoll». Большинство приложений работают на uvicorn / Gunicorn / Tomcat / Rails Puma и им хватает. Думать про OS нужно когда:
sys% (top/htop) — syscall overhead становится значимым.futex_wait / epoll_wait / read в top-3 функциях.В CRUD-сервисе с 50 RPS все эти оптимизации — over-engineering. Сначала измерь, потом оптимизируй.
accept()read()