Операционные системы: карта трека и что программисту нужно знать об ОС
Большинство программистов знакомятся с операционной системой в момент, когда она ломается.
Приложение работало на ноутбуке и упало в контейнере с OOMKilled. Сервис держал 10 000
соединений и начал отдавать EMFILE: too many open files. Latency p99 выросла в двадцать
раз, хотя код не менялся — просто соседний под на том же узле начал писать логи.
Профилировщик показывает 40% времени в sys, и никто в команде не может сказать,
что это значит.
Во всех этих историях общее одно: абстракция протекла. ОС обещает, что у вас бесконечная память, монопольный процессор и файлы, которые просто читаются. Обещание сдержано ровно до тех пор, пока нагрузка невелика. Дальше проступает механика — и без понимания этой механики диагностика вырождается в перезапуск сервиса.
Этот трек — про механику. Обзорная статья задаёт рамку: что такое ОС строго, какой у неё единственный настоящий механизм, какие три абстракции держат всю конструкцию, сколько они стоят в наносекундах и чем Linux принципиально отличается от FreeBSD, OpenBSD, macOS и Windows NT.
Что такое операционная система, если говорить строго
Определение «программа, которая управляет железом» бесполезно — под него подходит и BIOS, и драйвер видеокарты. Рабочее определение состоит из трёх обязательных работ (классическая формулировка — Tanenbaum, Modern Operating Systems, гл. 1).
1. Мультиплексирование ресурсов. Физических процессоров восемь, а процессов — пять
тысяч; памяти 32 ГиБ, а суммарный VmSize процессов — терабайты. ОС делит ограниченный
ресурс во времени (процессор — планировщик)
и в пространстве (память — страничная организация).
2. Абстрагирование железа. Программист пишет write(fd, buf, n), не зная, что под
fd — NVMe с очередями команд, сокет с TCP-стеком или псевдотерминал. Драйверы,
VFS, блочный слой
и сетевой стек превращают зоопарк устройств
в несколько единообразных интерфейсов.
3. Арбитраж и защита. Процесс A не может прочитать память процесса B, переписать ядро или занять процессор навсегда. Это не свойство «хорошего кода», а свойство, которое ОС навязывает силой, опираясь на аппаратуру (статья 09). Отсюда главное: ОС — единственная программа, которой аппаратура даёт особые полномочия. Библиотека не может защитить память от другой библиотеки в том же процессе; ядро может — потому что процессор физически различает два режима исполнения. Всё остальное надстроено над этим различием.
Реализованы эти работы через три абстракции, и почти любая продовая проблема — протёкшая абстракция из этой тройки:
| Абстракция | Что виртуализирует | Как ломается на практике |
|---|---|---|
| Процесс / поток | процессор | throttling в cgroup, голодание, инверсия приоритетов |
| Адресное пространство | физическая память | OOM-килл, свопинг, промахи TLB, фрагментация кучи |
| Файловый дескриптор | устройство ввода-вывода | EMFILE, блокирующий fsync, утечка сокетов в CLOSE_WAIT |
Держите эту таблицу в голове при чтении остального трека: статьи 03–08 — это, по сути, подробный разбор каждой строки.
Единственный настоящий механизм: граница привилегий
Процессор x86-64 работает в одном из четырёх колец защиты; реально используются два — ring 0 (ядро) и ring 3 (пользовательский код). На ARM64 это уровни исключений (EL0 приложение, EL1 ядро, EL2 гипервизор, EL3 secure monitor), на RISC-V — режимы U, S, M.
Из ring 3 нельзя выполнить привилегированную инструкцию, переписать таблицы страниц
или обратиться к памяти с supervisor-only PTE. Попытка даёт исключение (#GP или #PF),
и управление принудительно уходит в ядро по заранее записанному ядром адресу.
Пользовательский код не может выбрать, куда прыгнуть: набор точек входа фиксирован.
Осмысленный переход в ядро — системный вызов. На x86-64 это инструкция syscall:
она берёт адрес обработчика из MSR LSTAR, переключает стек и режим, сохраняет RIP
в RCX и флаги в R11. Номер вызова идёт в RAX, аргументы — в RDI, RSI, RDX, R10, R8, R9 (именно R10, а не RCX, как в обычном System V ABI: RCX занят возвратом).
Вот тот же вызов без всякой libc:
// write(1, msg, len) напрямую через инструкцию syscall, минуя glibc.
// Номер write в Linux x86-64 — 1 (см. /usr/include/asm/unistd_64.h).
static long raw_write(int fd, const char *buf, unsigned long len) {
long ret;
__asm__ volatile (
"syscall"
: "=a"(ret) // возврат в RAX
: "a"(1L), "D"((long)fd), "S"(buf), "d"(len) // RAX=1, затем RDI, RSI, RDX
: "rcx", "r11", "memory" // syscall затирает RCX и R11
);
return ret; // < 0 означает -errno; глобальную errno выставляет обёртка libc
}
Здесь видно обычно скрытое: ядро возвращает -errno в регистре, а глобальная переменная
errno — целиком выдумка libc. Поэтому errno thread-local, а не поле ядра, и поэтому
проверять её без проверки кода возврата бессмысленно: успешный вызов её не обнуляет.
Реальный поток системных вызовов виден и без всякой сборки:
$ strace -e trace=openat,read,close /bin/echo hi
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\243\2\0\0\0\0\0"..., 832) = 832
close(3) = 0
openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, ".../C.UTF-8/LC_IDENTIFICATION", O_RDONLY) = -1 ENOENT (No such file or directory)
$ strace -c -f /bin/true # сводка: 29 вызовов, чтобы напечатать ничего
calls syscall calls syscall
8 mmap 2 openat
3 mprotect 2 fstat
1 execve 1 brk … итого 29, из них 1 с ошибкой
Из двадцати девяти вызовов восемь — mmap: это динамический линковщик раскладывает
libc.so по адресному пространству. Практическое следствие: статическая линковка
и LD_BIND_NOW реально влияют на время старта короткоживущих процессов в CI и serverless.
Почему clock_gettime не является системным вызовом
Не каждый вызов из libc, выглядящий как обращение к ядру, им является. Измерим:
// bench.c: gcc -O2 -o bench bench.c (нужны <time.h>, <unistd.h>, <sys/syscall.h>)
const int N = 2000000;
long t0, t1;
struct timespec ts;
// getpid — самый дешёвый системный вызов: он не делает ничего, кроме чтения поля.
// Значит, мы меряем чистую стоимость пересечения границы привилегий.
t0 = now(); for (int i = 0; i < N; i++) syscall(SYS_getpid); t1 = now();
printf("syscall(SYS_getpid) %.1f нс/оп\n", (double)(t1 - t0) / N);
t0 = now(); for (int i = 0; i < N; i++) clock_gettime(CLOCK_MONOTONIC, &ts); t1 = now();
printf("clock_gettime (vDSO) %.1f нс/оп\n", (double)(t1 - t0) / N);
$ ./bench
syscall(SYS_getpid) 460.1 нс/оп
getpid() via glibc 448.2 нс/оп
clock_gettime (vDSO) 26.1 нс/оп
Разница в 17 раз. Причина — vDSO (virtual dynamic shared object): ядро отображает
в адресное пространство каждого процесса кусочек своего кода и страницу с данными
([vdso] и [vvar] в карте памяти), и clock_gettime просто читает оттуда счётчик
времени, не пересекая границу привилегий вообще. Аналог есть у всех: во FreeBSD —
общая страница с timehands, в macOS — commpage, в Windows — KUSER_SHARED_DATA
по фиксированному адресу 0x7FFE0000. Идея одна: что часто читают и редко меняют,
дешевле опубликовать, чем отдавать через системный вызов.
Про абсолютные цифры: 460 нс за getpid — много, машина виртуализирована и нагружена.
На bare-metal без мер против Meltdown/KPTI типично 50–120 нс. Сам разброс в 10 раз
полезен как факт: стоимость системного вызова зависит от процессора, включённых
mitigation’ов и seccomp-фильтра сильнее, чем от того, какой это вызов.
Как выглядит адресное пространство изнутри
Раскладка видна напрямую: /proc/self/maps — дамп списка VMA (virtual memory areas):
$ cat /proc/self/maps
57c8ff8cf000-57c8ff914000 r--p 00000000 fc:01 11183727 /usr/bin/cat
57c8ff914000-57c8ffba2000 r-xp 00045000 fc:01 11183727 /usr/bin/cat
57c8ffdd5000-57c8ffdda000 rw-p 00506000 fc:01 11183727 /usr/bin/cat
57c92525b000-57c9252ff000 rw-p 00000000 00:00 0 [heap]
741e50800000-741e50828000 r--p 00000000 fc:01 11404220 /usr/lib/.../libc.so.6
741e50828000-741e509b0000 r-xp 00028000 fc:01 11404220 /usr/lib/.../libc.so.6
7ffd4bd16000-7ffd4bd37000 rw-p 00000000 00:00 0 [stack]
7ffd4bdcd000-7ffd4bdcf000 r-xp 00000000 00:00 0 [vdso]
Три вещи, которые стоит увидеть сразу.
Один ELF-файл даёт 4–5 отдельных отображений. Раздельные VMA нужны для минимальных
прав: .text — r-x (исполняемый, но не записываемый), .rodata — r--, .data/.bss —
rw- (записываемый, но не исполняемый). Это и есть аппаратный W^X, он же NX-бит.
Отдельный r--p перед rw-p — RELRO: таблица переходов, которую линковщик делает
read-only сразу после разрешения символов, чтобы её не переписал эксплойт.
VmSize не имеет отношения к потреблению памяти. У того же процесса VmSize = 1.3 ГиБ,
а VmRSS = 6.8 МиБ (/proc/self/status) — разница в 190 раз. VmSize — сумма длин VMA,
то есть обещаний; физическая страница выделяется лениво, при первом обращении, через page
fault. Мониторинг, алертящий по VIRT из top, гарантированно ложный.
Нулевая страница не отображена намеренно. Разыменование NULL — обращение
к неотображённому адресу, #PF, который ядро транслирует в SIGSEGV. Не «магия
компилятора», а свойство раскладки памяти.
Путь одного read() сверху донизу
Сколько слоёв прячется за одной строкой кода — от вызова в приложении до контроллера накопителя и обратно:
проверка номера по sys_call_table Sys->>VFS: ksys_read, поиск struct file по fd, разрешение f_op VFS->>PC: есть ли страница в кэше alt Попадание в page cache PC-->>VFS: страница есть VFS-->>App: copy_to_user, возврат за ~2 мкс else Промах — major page fault PC->>FS: прочитать логический блок FS->>Blk: трансляция смещения по extent-дереву, bio-запрос Blk->>Drv: планировщик ввода-вывода слил запросы, команда в submission queue Drv->>HW: запись в doorbell-регистр, дальше данные идут по DMA Note over App,HW: процесс снят с процессора, состояние D,
планировщик отдал CPU другому HW-->>Drv: прерывание о завершении, данные уже в RAM Drv-->>PC: завершение bio, страница помечена uptodate PC-->>VFS: готово VFS-->>App: copy_to_user, возврат за ~80–150 мкс end
Отсюда вытекает почти вся практическая интуиция про ввод-вывод.
Быстрый и медленный путь различаются в 50 раз, и различие решает один вопрос: попали
ли в page cache. Отсюда ценность «прогрева» и то, почему первый запрос после деплоя всегда
медленный. При промахе процесс не «ждёт» — он снимается с процессора и засыпает,
а процессор исполняет чужой код; поэтому 100% утилизации CPU и медленный ввод-вывод —
совместимые состояния, а «загрузка CPU 30%, значит есть запас» — некорректный вывод.
И наконец, copy_to_user — это реальное копирование байт: именно его убирают mmap,
sendfile, splice и zero-copy в io_uring
(подробности).
Цена абстракций
Эта шкала отвечает на вопрос «а можно ли так делать» быстрее любого профилировщика:
- Системный вызов в горячем цикле — обычно нормально. 100 000 вызовов в секунду при цене 200 нс — это 2% одного ядра. Паника вокруг «syscall дорогой» унаследована от эпохи, когда он стоил микросекунды.
- Переключение контекста — уже дорого, и не из-за самих ~2 мкс, а из-за холодных кэшей и TLB после него: реальная цена — десятки микросекунд. Отсюда пулы потоков вместо потока на запрос и pinning для latency-критичных сервисов.
- Блокирующий дисковый ввод-вывод в event loop — катастрофа. Один major fault на 80 мкс на тысяче итераций — это 80 мс, и весь цикл стоит.
- Порядок величин важнее точных чисел. Граница между наносекундами и микросекундами архитектурная: что дешевле микросекунды — можно делать на запрос, что дороже — надо батчить, кэшировать или выносить в фон.
Из этой арифметики выросли epoll/kqueue (один вызов вместо тысячи), io_uring
(кольцевые буферы вместо вызова на операцию), page cache и huge pages. Это не разные
идеи, а одна: сократить количество дорогих переходов, а не ускорить каждый.
Жизненный цикл процесса
Процесс — центральная абстракция, и его состояния напрямую видны в ps. Схема
для Linux; отличия других систем отмечены ниже.
read с сокета, futex, poll S --> R_ready : данные пришли или доставлен сигнал R_running --> D : ожидание, НЕ прерываемое сигналом
ввод-вывод на диск, зависший NFS D --> R_ready : ввод-вывод завершён R_running --> T : SIGSTOP, SIGTSTP, ptrace T --> R_ready : SIGCONT R_running --> Z : exit_group, вызов завершился Z --> [*] : родитель сделал wait4 и забрал статус note right of D D не убивается через kill -9: сигнал доставляется, но обрабатывается лишь при возврате в userspace, куда процесс не возвращается. Лечится только устранением причины ввода-вывода. end note note right of Z Зомби не занимает память и CPU, только запись в таблице процессов и PID. Утечка зомби = родитель не вызвал wait. Классический баг PID 1 в контейнере. end note
Про D есть ещё один важный факт: в Linux это состояние входит в load average,
а в классических Unix — нет. Поэтому load average 40 на восьмиядерной Linux-машине может
означать не перегрузку процессора, а зависший NFS-mount; во FreeBSD и Solaris показатель
считает только runnable-задачи и интерпретируется интуитивно. Честная метрика
в современном Linux — PSI:
$ cat /proc/pressure/cpu /proc/pressure/io
some avg10=3.22 avg60=3.22 avg300=4.93 total=32018369264 # cpu: ждала хоть одна задача
some avg10=0.00 avg60=0.01 avg300=0.25 total=1973580339 # io: ждал кто-то
full avg10=0.00 avg60=0.00 avg300=0.16 total=1237360639 # io: ждали все, система стояла
В отличие от безразмерного load average, это прямой процент деградации (подробнее).
Где семейства ОС расходятся по-настоящему
«Всё это Unix, разница косметическая» — распространённое и вредное заблуждение. Различия есть, и они бьют по портируемому коду в конкретных местах.
| Аспект | Linux | FreeBSD | OpenBSD | macOS / XNU | Windows NT |
|---|---|---|---|---|---|
| Архитектура ядра | монолитное, модульное | монолитное, модульное | монолитное, минимум модулей | гибрид Mach + BSD | гибрид, подсистемы |
| Стабильный ABI | номера syscall стабильны навсегда | стабилен libc, не syscall | libc, syscall из чужого кода запрещён | только libSystem, syscall приватны | только ntdll, номера меняются |
| Мультиплексор событий | epoll, io_uring |
kqueue |
kqueue |
kqueue, GCD |
IOCP, RIO |
| Изоляция | namespaces + cgroups | jails + rctl | pledge + unveil | Seatbelt sandbox | Job objects, silos |
| Интроспекция | /proc, /sys |
sysctl, procstat |
sysctl, fstat |
sysctl, vmmap |
ETW, счётчики |
| Трассировка | eBPF, ftrace, perf | DTrace, ktrace | btrace, ktrace |
DTrace, Instruments | ETW, WPA |
| Фильтрация syscall | seccomp-bpf | Capsicum | pledge | sandbox profiles | — |
Разберём три различия, которые чаще всего ломают код.
Стабильность ABI — самое глубокое расхождение. В Linux действует правило Линуса
«we do not break userspace»: бинарник, собранный под ядро 2.6, обязан работать на 6.x.
Номера системных вызовов зафиксированы навсегда, поэтому Go долгие годы вызывал их
напрямую, без libc, и это работало. Во всех BSD и в macOS контракт другой: стабилен
интерфейс libc, а не ядра. Apple явно запрещает прямые системные вызовы, и Go пришлось
на macOS перейти на libSystem после того, как обновления ломали программы. OpenBSD
пошёл дальше всех: механизм pinsyscall разрешает вызовы только с конкретных адресов
внутри libc.so, любой другой — немедленное завершение процесса. Практический вывод:
прямые системные вызовы допустимы только на Linux, и то с оговорками.
Мультиплексор событий определяет архитектуру асинхронного кода. epoll и kqueue
не изоморфны: kqueue одним интерфейсом отслеживает не только дескрипторы, но и таймеры,
сигналы, изменения файлов и завершение процессов. В Linux для этого пришлось изобретать
timerfd, signalfd, inotify, pidfd — четыре отдельных механизма, чтобы всё стало
дескриптором и влезло в epoll. IOCP в Windows отличается принципиально: это модель
завершения (completion), а не готовности (readiness) — вы отдаёте буфер заранее и получаете
уведомление о выполненной операции. Поэтому у libuv, Tokio и .NET три разных бэкенда,
а не один с #ifdef. Показательно, что io_uring — это как раз переход Linux
к completion-модели, то есть туда, где Windows был с 1993 года.
Модели изоляции несводимы друг к другу. Linux собирает контейнер из ортогональных
кирпичей: namespaces (что процесс видит), cgroups (сколько может), seccomp (что может
вызвать), capabilities (какие привилегии). FreeBSD jail — единый цельный механизм,
созданный сразу как «виртуальная система». OpenBSD pledge — вообще про другое: программа
сама декларирует нужные ей классы вызовов, и ядро убивает её при выходе за рамки. Разница
философская: Linux даёт конструктор оркестратору, OpenBSD — инструмент автору программы
(статья 09,
статья 18).
Мелочи, ломающие портируемость не менее эффективно: getconf PAGESIZE даёт 4096 на Linux
и 16384 на Apple Silicon (захардкоженная константа 4096 ломает mmap и выравнивание);
O_DIRECT в macOS отсутствует, вместо него F_NOCACHE; семантика st_atime, поведение
sendfile и poll на устройствах различаются везде. Лечится это не #ifdef,
а абстрагирующей библиотекой.
Карта трека
Слои читаются почти независимо друг от друга.
Фундамент. История объясняет, почему
интерфейсы именно такие: fork без аргументов, разделение /usr и /, раскол BSD
и System V — большая часть странностей POSIX выводится из решений 1970-х.
Архитектуры ядер дают систему координат:
монолит, микроядро, гибрид, экзоядро, unikernel, и почему спор Таненбаума с Торвальдсом
до сих пор не закрыт.
Ядро изнутри — сердцевина курса.
Процессы и планировщики: CFS, EEVDF,
cgroup-квоты и почему CPU throttling в Kubernetes бьёт по p99.
Память: таблицы страниц, TLB, huge pages,
overcommit, OOM killer, аллокаторы.
Файловые системы: VFS, журналирование, ext4/XFS/ZFS,
семантика fsync и почему БД теряет транзакцию.
Ввод-вывод: прерывания, DMA, очереди NVMe.
Системные вызовы и IPC: детализация того, что
здесь дано конспективно, плюс пайпы, разделяемая память, futex.
Сеть: от socket() до кольца драйвера, GRO, XDP.
Безопасность: UID, capabilities, SELinux,
namespaces, cgroups, jails, pledge.
Жизненный цикл: загрузка (UEFI, GPT, GRUB, initramfs, Secure Boot) и init-системы (systemd, socket activation, launchd, rc.d) — раздел, окупающийся в первый день дежурства.
Практика. Если вы пришли за прикладным навыком, начните отсюда:
Linux для программиста,
командная строка,
наблюдаемость — /proc, strace,
perf, ftrace, eBPF, метод USE.
Другие миры: BSD, macOS, Windows NT, Solaris, Plan 9, RTOS, графический стек, виртуализация и контейнеры, как пишут свою ОС.
Минимальный практикум
Команды, покрывающие 80% диагностики. Прогоните каждую прямо сейчас — чтение не работает.
# 1. Процессы и состояния. STAT: R=выполняется, S=спит, D=непрерываемый сон (диск!),
# Z=зомби. Колонка wchan — функция ядра, в которой процесс спит: часто это прямой
# ответ на вопрос «чего мы ждём».
ps -eo pid,ppid,stat,pcpu,rss,wchan:20,comm --sort=-pcpu | head
# 2. Куда уходит время процесса
strace -c -p <PID> # сводка по системным вызовам
strace -T -e trace=write -p <PID> # длительность каждого вызова
# 3. Что процесс держит открытым (файлы, сокеты, пайпы) и его лимиты
ls -l /proc/<PID>/fd ; lsof -p <PID> ; grep -i files /proc/<PID>/limits
# 4. Реальная память процесса, а не VIRT. Pss = Rss с честным делением разделяемых
# страниц: складывать по процессам Rss нельзя, Pss — можно.
grep -E '^(Rss|Pss|Private)' /proc/<PID>/smaps_rollup
# 5. Память и давление на ресурсы. В free смотреть на available, а не на free.
free -h ; cat /proc/pressure/{cpu,io,memory}
# 6. Диск: задержки и насыщение
iostat -xz 1 # await и %util по устройствам
pidstat -d 1 # ввод-вывод по процессам
# 7. Профиль по стекам, без изменения кода
perf record -F 99 -a -g -- sleep 30 && perf report --stdio | head -40
# 8. Точечная трассировка ядра через eBPF
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'
bpftrace -e 'kprobe:vfs_read { @bytes = hist(arg2); }'
# 9. Параметры ядра: посмотреть, поменять на лету и закрепить
sysctl -a | grep -E 'vm.swappiness|net.core.somaxconn|fs.file-max'
sysctl -w vm.swappiness=10
echo 'vm.swappiness=10' >> /etc/sysctl.d/99-tuning.conf
Аналоги в других системах, чтобы не растеряться при переходе:
| Задача | Linux | FreeBSD | OpenBSD | macOS | Windows |
|---|---|---|---|---|---|
| трассировка вызовов | strace |
truss, dtrace |
ktrace + kdump |
dtruss, fs_usage |
Procmon, ETW |
| открытые дескрипторы | lsof, /proc/PID/fd |
procstat -f |
fstat -p |
lsof |
handle.exe |
| карта памяти процесса | /proc/PID/maps |
procstat -v |
procmap |
vmmap |
VMMap |
| параметры ядра | /proc/sys, sysctl |
sysctl |
sysctl |
sysctl |
реестр, bcdedit |
Диагностическое дерево
Когда «всё тормозит», порядок проверок важнее знания команд. Метод USE Брендана Грегга (utilization, saturation, errors) в виде решающего дерева:
perf record -g, горячие функции] C -->|sys — ядро| E[Слишком много системных вызовов
strace -c, затем батчить и кэшировать] B -->|Нет| F{Есть процессы в D?} F -->|Да| G[Ждём ввод-вывод
iostat -xz 1, колонки await и util] G --> H{await большой?} H -->|Да| I[Диск или сеть насыщены — шумный сосед,
деградация NVMe, зависший NFS] H -->|Нет| J[Синхронные fsync в горячем пути
или O_DIRECT без очереди] F -->|Нет| K{available память падает?} K -->|Да| L[dmesg grep -i oom
vmstat 1, колонки si и so] L --> M[Утечка, малый лимит cgroup
либо reclaim не успевает] K -->|Нет| N{Блокировки?} N -->|Да| O[Вынужденные переключения контекста
pidstat -w 1, futex в strace] N -->|Нет| P[Проблема вне машины — зависимость,
DNS, балансировщик, очередь клиента] style D fill:#7fa87f,fill-opacity:0.25 style E fill:#c46a6a,fill-opacity:0.25 style I fill:#5aa5a5,fill-opacity:0.25 style M fill:#a07fb5,fill-opacity:0.25 style O fill:#b58a5a,fill-opacity:0.25
Ключевой узел — первое разветвление us против sys: оно решается одной командой
(vmstat 1) и сразу отсекает половину гипотез. Второй по ценности — проверка на D,
отличающая «не хватает процессора» от «мы вообще не работаем, мы ждём».
Типичные заблуждения
«Свободная память — это free». Нет, это available. Выше free = 1.2 ГиБ,
а available = 3.2 ГиБ: page cache ядро отдаёт по первому требованию. Система с большим
free — это система, которая не использует память под кэш, то есть работает медленнее,
чем могла бы. Алертить надо на available и счётчики reclaim, а не на free.
«Потоки в Linux дешевле процессов». И то и другое создаётся одним clone(), разница
лишь в флагах: поток — это CLONE_VM | CLONE_FILES | CLONE_SIGHAND, процесс — тот же
clone без них, а внутри ядра оба живут в одинаковых task_struct. Дешевле потоки
не созданием, а тем, что при переключении между ними не надо менять таблицы страниц
и сбрасывать TLB.
«fork() копирует память процесса». Копируются таблицы страниц, сами страницы
помечаются copy-on-write. Неприятное следствие: fork из процесса с кучей на 20 ГиБ
строит таблицы на десятки мегабайт и занимает десятки миллисекунд, а при
vm.overcommit_memory=2 может вернуть ENOMEM, хотя копироваться ничего не будет.
Для «форкнуть и сразу exec» есть posix_spawn, vfork и clone3 с CLONE_VFORK.
«В Unix всё — файл». Формула красивая, но неточная. Сокет нельзя открыть через
open(), ему нужны socket/bind/connect, а для адресата — sendto, потому что
write не принимает адрес. Устройства управляются через ioctl — свалку из тысяч
несовместимых команд, которую единообразным интерфейсом назвать нельзя. Ближе всех
к идеалу подошёл Plan 9 (статья 16).
Точная формулировка — «всё есть файловый дескриптор», и она полезнее: она объясняет,
почему работает epoll.
«kill -9 убивает что угодно». Не убивает процесс в состоянии D: сигнал попадёт
в маску ожидающих, но обработается лишь при возврате в userspace, куда застрявший
на зависшем NFS процесс не вернётся. Не работает также для PID 1 в namespace
и для процессов ядра ([kworker/...] в квадратных скобках).
«Load average 8 на 8 ядрах — это 100% загрузки». Только там, где load average считает
исключительно runnable-задачи. В Linux в него входит D, поэтому число смешивает нагрузку
на процессор с ожиданием диска. Используйте PSI или прямые метрики утилизации.
«ОС — это дистрибутив». Ubuntu, Debian, Fedora и Arch используют одно ядро Linux, часто одной версии; различия — в пакетном менеджере, init-конфигурации и политике обновлений, а не в системных вызовах. Зато Alpine отличается по-настоящему (musl вместо glibc), и это ломает код с зависимостью на glibc-специфику: DNS-резолвинг через NSS, размер стека потока по умолчанию 128 КиБ вместо 8 МиБ.
«Контейнер — это лёгкая виртуальная машина». Контейнер — это обычный процесс на общем
ядре с урезанным видом на систему. У него нет своего ядра, своего планировщика, своей
таблицы страниц верхнего уровня. Поэтому uname -r внутри контейнера покажет ядро хоста,
поэтому уязвимость в ядре пробивает границу контейнера и не пробивает границу KVM,
и поэтому лимит CPU в cgroup реализован через throttling, дающий скачки latency.
Как читать этот трек
Линейно читать не обязательно — четыре разумных траектории:
- Прикладной разработчик, который хочет перестать бояться прода: 00 → 12 → 13 → 03 →
04 → 14 → 09. Минимум, после которого понятны
top,straceи OOM-килл, а Dockerfile и systemd-unit пишутся осмысленно. - SRE и инфраструктурный инженер: 00 → 03 → 04 → 05 → 06 → 08 → 14 → 11 → 18. Упор на диагностику, ресурсы и границы изоляции.
- Системный программист на C, Rust, Go или Zig: 00 → 02 → 03 → 04 → 07 → 06 → 08 → 19.
Здесь важны не команды, а контракты: семантика
fsync, модель памяти, сигналы, ABI. - Любопытствующий: 00 → 01 → 02 → 15 → 16 → 19 — от истории через сравнение архитектур к собственной ОС на QEMU.
Смежные треки портала: алгоритмы и структуры данных — планировщики и аллокаторы целиком построены на классике (red-black деревья в CFS, B-деревья в ФС, хэш-таблицы в page cache); Go, рантайм которого реализует собственный планировщик поверх потоков ОС.
Источники
Книги, которые стоит держать под рукой:
- OSTEP — Arpaci-Dusseau, Operating Systems: Three Easy Pieces, бесплатно и легально: pages.cs.wisc.edu/~remzi/OSTEP. Лучший вводный текст: три части ровно по трём работам ОС.
- Michael Kerrisk, The Linux Programming Interface — библия системного программирования под Linux; дополняется man7.org/linux/man-pages.
- Stevens & Rago, Advanced Programming in the UNIX Environment, 3-е изд. — портируемый POSIX-взгляд, контрапункт Linux-центричному Kerrisk.
- McKusick, Neville-Neil, Watson, The Design and Implementation of the FreeBSD Operating System, 2-е изд. — архитектура промышленного Unix-ядра от его авторов.
- Russinovich et al., Windows Internals, 7-е изд. — доказательство того, что Unix-way не единственный способ построить ОС.
- Jonathan Levin, *OS Internals (newosxbook.com) — XNU, Mach-порты, launchd, sandbox.
- Brendan Gregg, Systems Performance, 2-е изд. и BPF Performance Tools — метод USE и инструменты: brendangregg.com.
- Tanenbaum & Bos, Modern Operating Systems, 5-е изд. — академическая рамка.
Первоисточники и документация:
- Ritchie & Thompson, «The UNIX Time-Sharing System», CACM 17(7), 1974 (PDF) — двенадцать страниц, определивших следующие полвека.
- POSIX / Single UNIX Specification, издание 2024: pubs.opengroup.org.
- docs.kernel.org — разделы
admin-guide/mm,filesystems,scheduler; FreeBSD Handbook и OpenBSD man pages как эталон документации. - Engler, Kaashoek, O’Toole, «Exokernel», SOSP 1995 — идея, к которой мир вернулся через двадцать лет в виде unikernel и DPDK; Klein et al., «seL4: Formal Verification of an OS Kernel», SOSP 2009 — доказанная корректность микроядра.
- Lipp et al., «Meltdown» (arXiv:1801.01207) и Kocher et al., «Spectre» (arXiv:1801.01203) — статьи, из-за которых системные вызовы подорожали в разы, а в ядре появился KPTI.
Мини-итог
- ОС делает три вещи: мультиплексирует ресурсы, абстрагирует железо, арбитрирует доступ. Третье возможно только потому, что процессор аппаратно различает привилегированный и пользовательский режимы.
- Системный вызов — единственный легальный способ пересечь эту границу. 50–500 нс в зависимости от процессора и mitigation’ов, и это, как правило, не узкое место. Узкие места на 3–5 порядков выше: переключения контекста, page fault, дисковый ввод-вывод.
- Три абстракции держат всё: процесс (виртуальный процессор), адресное пространство (виртуальная память), файловый дескриптор (виртуальное устройство). Почти любая проблема в проде — протёкшая абстракция из этой тройки.
/procи/sysпревращают состояние ядра в обычные файлы; в BSD и macOS ту же роль играютsysctlи специализированные утилиты, в Windows — ETW. Инструменты разные, вопросы одинаковые.- Различия семейств ОС не косметические: стабильность ABI, модель мультиплексора событий и модель изоляции устроены принципиально по-разному — портируемый код обязан это учитывать.
Что дальше
Дальше — о том, откуда взялась вся эта конструкция: почему fork не принимает
аргументов, почему BSD и System V разошлись, как из провала Multics вырос Unix
и почему Linux победил, не будучи технически лучшим:
История ОС: от Multics и Unix до BSD, Linux и наших дней.