Операционные системы Операционные системы: карта трека и что программисту нужно знать об ОС
0%

Операционные системы: карта трека и что программисту нужно знать об ОС

Операционные системы: карта трека и что программисту нужно знать об ОС

Большинство программистов знакомятся с операционной системой в момент, когда она ломается. Приложение работало на ноутбуке и упало в контейнере с 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-фильтра сильнее, чем от того, какой это вызов.

Как выглядит адресное пространство изнутри

Адресное пространство процесса x86-64 и граница ядра

Раскладка видна напрямую: /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 нужны для минимальных прав: .textr-x (исполняемый, но не записываемый), .rodatar--, .data/.bssrw- (записываемый, но не исполняемый). Это и есть аппаратный 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() сверху донизу

Сколько слоёв прячется за одной строкой кода — от вызова в приложении до контроллера накопителя и обратно:

Отсюда вытекает почти вся практическая интуиция про ввод-вывод.

Быстрый и медленный путь различаются в 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; отличия других систем отмечены ниже.

Про 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) в виде решающего дерева:

Ключевой узел — первое разветвление 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 и наших дней.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов