Операционные системы Архитектуры ядер: монолитное, микроядро, гибридное, экзоядро, unikernel
0%

Архитектуры ядер: монолитное, микроядро, гибридное, экзоядро, unikernel

Архитектуры ядер: монолитное, микроядро, гибридное, экзоядро, unikernel

Спор об архитектуре ядра выглядит как схоластика — пока вы не начинаете отлаживать kernel panic из-за драйвера сетевой карты, не выясняете, почему ваш прокси не тянет 10 Гбит/с, или не пытаетесь понять, зачем в автомобиле стоит QNX, а не Linux. Все эти вопросы упираются в одно решение: где провести границы защиты внутри ОС.

Эта статья — про то, как разные семейства ОС отвечают на этот вопрос, чем платят за свой ответ, и что из этого следует лично для вас, когда вы пишете прикладной код.

Единственный вопрос, который решает архитектура ядра

Начнём с первых принципов. Процессор умеет работать в нескольких режимах привилегий: на x86-64 их формально четыре («кольца», ring 0–3), реально используются два — ring 0 (ядро) и ring 3 (приложения); на ARM это EL0/EL1, на RISC-V — U-mode/S-mode. В ring 0 можно всё: править таблицы страниц, обращаться к портам ввода-вывода, маскировать прерывания. В ring 3 привилегированная инструкция вызывает исключение. Плюс к этому есть виртуальная память: процесс физически не может дотянуться до чужих страниц.

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

  • Монолит — всё в ring 0 и в одном адресном пространстве: быстро, но одна ошибка валит систему.
  • Микроядро — в ring 0 минимум, ФС, сетевой стек и драйверы вынесены в процессы ring 3: надёжно, но каждое взаимодействие превращается в IPC.
  • Гибрид — микроядерная структура, но всё в одном адресном пространстве ради скорости.
  • Экзоядро — ядро не абстрагирует ресурсы, а только безопасно их разделяет; абстракции живут в библиотеках приложения.
  • Unikernel — границы нет вообще: приложение и ОС слинкованы в один бинарь, изоляция вынесена на уровень гипервизора.

Три способа расставить границы защиты внутри ОС

Сколько стоит граница: измеряем

Прежде чем спорить об архитектурах, надо понять цену вопроса. Напишем микробенчмарк, который сравнивает обычный вызов функции и системный вызов:

#define _GNU_SOURCE
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
static long noop(long x) { return x + 1; }
static struct timespec a, b;
static const long N = 3000000;

#define BENCH(label, body) do {                                     \
    volatile long s = 0;                                            \
    clock_gettime(CLOCK_MONOTONIC, &a);                             \
    for (long i = 0; i < N; i++) { body; }                          \
    clock_gettime(CLOCK_MONOTONIC, &b);                             \
    printf("%-22s %6.1f нс\n", label,                               \
      ((b.tv_sec-a.tv_sec)*1e9 + (b.tv_nsec-a.tv_nsec)) / (double)N); \
} while (0)

int main(void) {
    struct timespec t;
    BENCH("вызов функции:",        s += noop(i));            /* границы нет */
    BENCH("syscall(SYS_getpid):",  s += syscall(SYS_getpid)); /* граница есть */
    BENCH("clock_gettime (vDSO):", clock_gettime(CLOCK_MONOTONIC, &t); s += t.tv_nsec);
    return 0;                      /* формально syscall, фактически чтение памяти */
}
$ gcc -O2 -o bench bench.c && ./bench
вызов функции:          0.5 нс
syscall(SYS_getpid):  466.9 нс
clock_gettime (vDSO):  27.3 нс

Замер сделан внутри виртуальной машины — на голом железе getpid() обычно укладывается в 50–90 нс. Но соотношение важнее абсолютных чисел, и оно устойчиво: вызов функции — единицы наносекунд (часто инлайнится в ноль), пересечение границы ядра — на два-три порядка дороже, обход границы через vDSO возвращает почти всю разницу обратно.

Что именно раздувает syscall на вашей машине, видно тут:

# смягчения спекулятивных уязвимостей — каждое добавляет такты к входу в ядро
$ grep -r . /sys/devices/system/cpu/vulnerabilities/ | sed 's|.*vulnerabilities/||'
spectre_v1:Mitigation: usercopy/swapgs barriers and __user pointer sanitization
spectre_v2:Mitigation: Retpolines; IBPB: conditional; IBRS_FW; RSB filling
meltdown:Not affected      # будь тут "Mitigation: PTI" — это смена CR3 и частичный
                           # сброс TLB на каждом входе и выходе из ядра

Анатомия системного вызова и способ его обойти

Теперь ключевая мысль. В монолите read() пересекает границу один раз туда и один обратно. В микроядре тот же запрос идёт приложение → ядро → сервер ФС → ядро → драйвер диска → ядро → сервер ФС → ядро → приложение: границ уже не две, а восемь. Вот и вся суть трёх десятилетий спора.

Монолитное ядро

Идея. Ядро — одна большая программа, подсистемы вызывают друг друга напрямую как функции. Так устроены Linux, FreeBSD, NetBSD, OpenBSD, illumos и (с оговорками) AIX. Слово «монолитное» звучит как «немодульное», и это неверно: Linux аккуратно расслоён (VFS, block layer, netdev, driver model), у него есть внутренние API и загружаемые модули. Монолитность — не про отсутствие структуры, а про отсутствие аппаратной изоляции между слоями.

$ uname -r
6.8.0-124-generic
$ lsmod | head -3
Module                  Size  Used by
unix_diag              12288  0
ib_core               507904  0

$ lsmod | wc -l          # то же, что /proc/modules — только загружаемые модули
132
$ ls /sys/module | wc -l # + все вкомпилированные подсистемы с параметрами
258
$ cat /sys/module/kvm/parameters/nx_huge_pages   # параметры доступны на лету
auto
$ sudo modprobe -v nbd max_part=8                # загрузка с параметром
insmod /lib/modules/6.8.0-124-generic/kernel/drivers/block/nbd.ko max_part=8

Загруженный модуль исполняется в том же адресном пространстве, что и всё ядро, с полными привилегиями. Разыменование NULL в драйвере — это не segfault процесса, это Oops, а часто и полная остановка системы.

Состояние «загрязнения» ядра (cat /proc/sys/kernel/tainted, где 0 — чистое ядро) — первое, что спросит kernel-разработчик при разборе паники: проприетарный модуль в трассировке снимает с апстрима всю ответственность.

Различия внутри лагеря монолитов

Тут семейства ОС расходятся сильнее, чем принято думать.

Система Загружаемые модули Команды Особенность
Linux да, повсеместно lsmod, modprobe, rmmod, modinfo автозагрузка через udev/modalias
FreeBSD да, KLD kldstat, kldload, kldunload ядро GENERIC + loader.conf
NetBSD да modstat, modload плюс rump-ядра: подсистемы ядра как userspace-библиотеки
OpenBSD нет, убрали в 5.7 статическое ядро; KARL перелинковывает ядро при каждой загрузке
illumos да modinfo, modload DTrace как штатный инструмент интроспекции ядра
$ kldstat                          # FreeBSD
Id Refs Address                Size Name
 1   14 0xffffffff80200000  1f5c1a8 kernel
 2    1 0xffffffff8216d000    3a3e18 zfs.ko

Решение OpenBSD показательно: они сознательно отказались от гибкости ради того, чтобы уменьшить поверхность атаки и сделать адреса функций ядра непредсказуемыми для эксплойтов — ядро /bsd при каждой загрузке пересобирается из объектных файлов в случайном порядке. Подробнее о философии семейства — в статье https://courses.digitable.life/post/operating-systems/15-bsd-family/.

Trade-offs монолита. ✅ Внутренние вызовы бесплатны, поэтому легко делать сквозные оптимизации (zero-copy, общий на все подсистемы page cache); экосистема драйверов не имеет конкурентов. ❌ Баг в любом драйвере компрометирует всё ядро — а драйверы это ~60–70% кода Linux; обновление подсистемы требует перезагрузки (частично лечится live patching); формально верифицировать 30+ млн строк невозможно даже теоретически.

Микроядро

Идея. В ядре оставляем только то, что принципиально невозможно вынести наружу: управление адресными пространствами, планирование потоков и передачу сообщений (IPC). Всё остальное — ФС, сетевой стек, драйверы, даже подкачку — реализуем обычными процессами в ring 3. Выигрыш очевиден: упавший драйвер USB — это упавший процесс, а не мёртвая машина, и его можно перезапустить. В MINIX 3 этим занимается специальный reincarnation server:

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

Почему микроядра «проиграли» и почему это не совсем так

Первое поколение (Mach, CMU, 1985–1994) действительно было медленным. Йохен Лидтке в работе «Improving IPC by Kernel Design» (SOSP ‘93) измерил на i486/50 МГц: кросс-адресный IPC в Mach — около 115 мкс, в его собственном L3 — около 5 мкс. Двадцатикратная разница. Вывод Лидтке был не «микроядра плохи», а «Mach плохо реализован»: IPC надо проектировать под конкретную архитектуру CPU, минимизируя промахи кэша и переключения TLB. Его следующая работа, «On µ-Kernel Construction» (SOSP ‘95), сформулировала принцип минимальности: в ядре должно быть только то, что при вынесении наружу сделало бы невозможной реализацию требуемой функциональности.

Второе поколение (семейство L4) довело IPC до сотен тактов. Кульминация — seL4: микроядро на ~10 тысяч строк C, для которого машинно проверено соответствие реализации формальной спецификации (Klein et al., SOSP 2009). Это означает буквально: доказано отсутствие переполнений буфера, разыменований NULL и утечек в ядре.

И микроядра никуда не «проигрывали» — просто они живут там, где вы их не видите:

  • QNX Neutrino — автомобильные мультимедийные системы (сотни миллионов машин), медицинское оборудование, промышленные контроллеры;
  • seL4 — авионика, военные и космические применения, где нужна сертификация;
  • MINIX 3 — исполнялся внутри Intel Management Engine начиная с ME 11: на большинстве x86-машин мира крутится микроядро, о котором владелец не знает;
  • L4/OKL4 — модемные процессоры миллиардов телефонов; Zircon — ядро Google Fuchsia.

Полноценных «настольных» микроядерных ОС общего назначения действительно почти нет: GNU Hurd, начатый в 1990 на базе Mach, за 35 лет так и не стал продакшн-системой — скорее по организационным причинам и из-за сложности отладки многосерверной архитектуры, чем из-за архитектурной несостоятельности.

Trade-offs микроядра. ✅ Отказ компонента локализован и лечится перезапуском; доверенная база (TCB) — десяток тысяч строк вместо десятков миллионов, что делает возможной формальную верификацию, обязательную для сертификации по DO-178C и IEC 61508. ❌ IPC на критическом пути, и даже быстрый IPC не бесплатен; сквозные оптимизации сложнее (нет общего page cache, zero-copy требует явных грантов на память); отладка ОС, размазанной по десятку процессов, тяжелее отладки одного бинаря; экосистема драйверов на порядки беднее.

Гибридные ядра: Windows NT и XNU

Это самая мифологизированная категория. Коротко: гибридное ядро — это микроядерная структура, размещённая внутри одного адресного пространства ring 0. Модульность на уровне дизайна есть, аппаратной изоляции между модулями нет.

Windows NT

Дэйв Катлер спроектировал NT под влиянием Mach и своей же VMS: есть Executive (менеджеры объектов, процессов, памяти, I/O), есть микроядро KE, есть HAL, есть «подсистемы окружения» (Win32, когда-то POSIX и OS/2) в userspace.

Ключевой поворот случился в NT 4.0 (1996): графическая подсистема и оконный менеджер были перенесены из userspace-процесса CSRSS прямо в ядро — win32k.sys. Причина чисто в производительности: рисование через IPC оказалось слишком медленным. Плата пришла через двадцать лет — win32k.sys стал одним из главных источников эскалации привилегий в Windows, отсюда Win32k Lockdown и Type Isolation в современных версиях. Для драйверов Windows при этом двигается в обратную сторону: UMDF (User-Mode Driver Framework) позволяет писать драйверы принтеров, USB-устройств и камер как обычные userspace-компоненты — упавший UMDF-драйвер не роняет систему.

PS> driverquery /v                 # что загружено в режиме ядра
PS> sc query type= driver

XNU (macOS, iOS)

Название расшифровывается как «X is Not Unix». XNU состоит из трёх частей: Mach (на базе OSF/mk 7.3) даёт адресные пространства, порты, сообщения и планирование; BSD-слой (родословная от FreeBSD) — процессы, сигналы, VFS, сокеты, POSIX API; IOKit — объектную модель драйверов на подмножестве C++. Все три исполняются в одном адресном пространстве в ring 0. Mach-порты — реальный и активно используемый механизм IPC (на нём построены XPC, launchd, GCD), но микроядром XNU от этого не становится: APFS и сетевой стек живут внутри ядра.

$ kextstat | head -3                # расширения ядра (ring 0)
Index Refs Address            Size       Name (Version)
    1  190 0                  0          com.apple.kpi.bsd (24.5.0)
$ systemextensionsctl list          # современная замена — userspace
$ sudo lsmp -p $(pgrep -n Safari)   # Mach-порты конкретного процесса

Apple с macOS Catalina (10.15) объявила kext-ы устаревшими и продвигает DriverKit и System Extensions — драйверы в userspace, ровно микроядерная идея поверх гибридного ядра. Мотивация та же, что у Microsoft: сторонние драйверы в ring 0 — главный источник нестабильности. Подробнее об XNU и NT — в https://courses.digitable.life/post/operating-systems/16-other-operating-systems/.

Экзоядро: а что если не абстрагировать вообще?

В 1995 году группа из MIT (D. Engler, M. F. Kaashoek, J. O’Toole) опубликовала работу «Exokernel: An Operating System Architecture for Application-Level Resource Management» с радикальным тезисом: любая абстракция ОС кому-то вредит. Ядро даёт вам «файл», но СУБД хочет управлять раскладкой блоков на диске сама; даёт «сокет», но нагруженный сервер хочет свою реализацию TCP; даёт «страницу виртуальной памяти», но сборщик мусора знает о своей куче больше, чем алгоритм вытеснения ядра.

Экзоядро (Aegis, затем XOK) не предоставляет абстракций. Оно только безопасно мультиплексирует физические ресурсы: раздаёт конкретные блоки диска, конкретные фреймы физической памяти, конкретные интервалы процессорного времени — и следит, чтобы приложения не залезли на чужое. Абстракции живут в библиотечной ОС (libOS), слинкованной с приложением; хотите POSIX — линкуете libOS с POSIX, хотите свой TCP — пишете свой. Веб-сервер Cheetah на XOK со специализированным стеком обгонял обычные серверы в разы именно за счёт отказа от универсальных абстракций.

Экзоядер как продукта не существует, но идея победила полностью — просто под другими названиями, и все они есть в вашем Linux прямо сейчас:

$ ls /dev/vfio/      # VFIO: PCI-устройство напрямую процессу или виртуалке
vfio
$ ls /sys/class/uio/ # UIO: userspace-драйвер через отображение BAR-регионов
uio0
$ grep -i hugepages_total /proc/meminfo   # приложение само управляет раскладкой памяти
HugePages_Total:       0
$ grep -c io_uring /proc/kallsyms         # кольцевые буферы вместо syscall на операцию
187

Всё это — экзоядерные решения внутри монолита: DPDK и SPDK выносят сетевой и NVMe-стек в userspace, обходя ядро; io_uring убирает системный вызов с горячего пути; eBPF позволяет загрузить в ядро свою логику вместо встроенной, не рискуя стабильностью благодаря верификатору байткода. Подробнее — в https://courses.digitable.life/post/operating-systems/06-io-and-drivers/ и https://courses.digitable.life/post/operating-systems/14-observability-and-performance/.

Unikernel: убрать границу совсем

Финальный шаг в логике экзоядра: если абстракции всё равно в библиотеке — зачем вообще нужны два уровня привилегий? Unikernel — это ваше приложение, слинкованное с библиотечной ОС в один загружаемый образ, стартующий прямо на гипервизоре: один процесс, ring 0, единое адресное пространство, write() вырождается в обычный вызов функции. Изоляция обеспечивается снаружи гипервизором (KVM, Xen, Firecracker), а не кольцами внутри. Ключевая работа — Madhavapeddy et al., «Unikernels: Library Operating Systems for the Cloud» (ASPLOS 2013), представившая MirageOS на OCaml.

Проект Язык Особенность
MirageOS OCaml типобезопасность всего стека, включая TCP/IP и TLS
IncludeOS C++ ориентация на сетевые сервисы
OSv C, запуск JVM/Go совместимость с существующими Linux-бинарями
Unikraft C, модульный EuroSys 2021, лучшие показатели по производительности
rumprun C поверх rump-ядер NetBSD — настоящие драйверы NetBSD как библиотеки

Отдельно стоит rump-ядро из NetBSD (Антти Кантее, «anykernel architecture»): драйверы, ФС и сетевой стек собираются как переносимые библиотеки, запускаемые в userspace, в unikernel или внутри другого ядра — редчайший пример монолита, спроектированного так, чтобы его части можно было вынимать.

Trade-offs unikernel. ✅ Старт за десятки миллисекунд и образ в мегабайтах — идеально для serverless; радикально меньшая поверхность атаки; нулевые накладные расходы на переход между приложением и «ОС». ❌ Отладка: нет strace, нет привычного gdb, некуда сделать ssh; один процесс — нет fork(), часто нет вытеснения; эксплойт в приложении сразу даёт ring 0 внутри виртуалки, то есть изоляция от соседей есть, а внутренней нет; инструментарий несопоставим с Linux.

Практика 2020-х расставила всё по местам: там, где обещания unikernel были нужны (быстрый старт и малая поверхность атаки для serverless), победили микро-VM вроде Firecracker с обычным урезанным Linux внутри — совместимость оказалась важнее последних процентов эффективности. Подробнее — в https://courses.digitable.life/post/operating-systems/18-virtualization-and-containers/.

Эволюция: как менялись ответы

Обратите внимание на последнюю строку: современный тренд — не переносить границы, а делать код внутри границы безопасным другими средствами. Rust в ядре Linux и верификатор eBPF — попытки получить надёжность микроядра без его накладных расходов.

Типичные заблуждения

«Монолитное ядро — значит немодульное» и «Linux гибридный, ведь у него есть модули» — обе ошибки об одном. Linux максимально модулен на уровне исходников и умеет загружать модули на лету, но модуль попадает в то же адресное пространство ring 0 и вызывается напрямую. Монолитность — про изоляцию, а не про структуру кода; гибрид — про микроядерную декомпозицию с Mach-портами, реализованную без изоляции.

«Микроядра медленные». Это утверждение из 1990-х про Mach. Современный IPC в seL4 занимает сотни тактов. Замедление реально, но оно в разы, а не в десятки раз, и на многих задачах его вообще не видно за I/O.

«Микроядро надёжнее, значит выберем его для нашего сервиса». Надёжность микроядра — свойство изоляции драйверов, а не гарантия отсутствия ваших багов. Для типичного веб-сервиса разница нулевая, а вот отсутствие зрелых ФС и инструментов ударит сразу.

«Unikernel безопаснее, ведь поверхность атаки меньше». Меньше поверхность входа, но нет внутренней изоляции: RCE в вашем HTTP-парсере даёт полный контроль над всем образом, включая ключи TLS. Это компромисс, а не однозначный выигрыш.

«Архитектура ядра определяет производительность приложения». Почти всегда вы упираетесь в алгоритмы, базу и сеть, а не в стоимость syscall. Считать такты входа в ядро имеет смысл при миллионах операций в секунду — тогда приходят io_uring и kernel bypass.

Что из этого следует на практике

Если вы пишете обычный прикладной софт. Главный вывод: пересечение границы стоит денег, поэтому батчируйте. Один writev() вместо десяти write(), sendmmsg() вместо цикла sendmsg(), буферизованный ввод-вывод вместо посимвольного.

/* плохо: N пересечений границы */
for (int i = 0; i < n; i++)
    write(fd, chunks[i].base, chunks[i].len);

/* хорошо: одно пересечение на весь набор */
struct iovec iov[IOV_MAX];
for (int i = 0; i < n; i++) {
    iov[i].iov_base = chunks[i].base;
    iov[i].iov_len  = chunks[i].len;
}
writev(fd, iov, n);
$ strace -c -f ./myserver          # сводка: сколько границ и каких
% time     seconds  usecs/call     calls    errors syscall
 41.2    0.084112           2     42055           epoll_wait
 28.7    0.058604           1     58210           write

# то же без замедления от ptrace, через eBPF
$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

Если вы выбираете ОС под задачу. Сертифицируемое встраиваемое устройство с жёстким реальным временем — QNX или seL4. Сетевое оборудование или гипервизор — Linux с DPDK или обходом ядра. Всё остальное — Linux, потому что экосистема побеждает архитектурную чистоту почти всегда.

Если вы пишете что-то, что живёт в ядре. Вы отказались от всех привычных гарантий: нет malloc() в привычном виде, нет плавающей точки без специальных обёрток, стек 8–16 КБ, любая разыменованная ошибка — паника. Прежде чем писать модуль, проверьте, не решается ли задача через eBPF, FUSE, uio/vfio или netlink. В девяти случаях из десяти решается — и это ровно микроядерный подход внутри монолита.

# FUSE — драйвер файловой системы в userspace: падение не роняет ядро
$ sshfs user@host:/data /mnt/data && mount | grep fuse
user@host:/data on /mnt/data type fuse.sshfs (rw,nosuid,nodev,user_id=1000)

Мини-итог

Критерий Монолит Микроядро Гибрид Экзоядро Unikernel
Размер TCB огромный минимальный огромный малый средний
Цена внутреннего вызова вызов функции IPC вызов функции вызов функции вызов функции
Отказ драйвера паника перезапуск паника зависит паника
Верифицируемость нет да нет частично частично
Экосистема максимальная узкая большая нет зачаточная
Примеры Linux, BSD seL4, QNX NT, XNU Aegis, XOK MirageOS, Unikraft

Ни одна архитектура не «правильная» — все они разные точки на кривой размена между производительностью, надёжностью и удобством разработки, и реальные системы давно перестали быть чистыми: Linux заимствует микроядерные идеи (FUSE, eBPF, vfio), Windows и macOS выносят драйверы в userspace, unikernel растворился в микро-VM. Знать исходные принципы нужно не для того, чтобы выбрать лагерь, а чтобы читать эти заимствования и понимать, чем именно за них платят.

Источники

Первоисточники:

Книги: A. Tanenbaum, H. Bos, Modern Operating Systems (5-е изд., включая разбор дебатов с Торвальдсом) · R. Love, Linux Kernel Development · M. K. McKusick et al., The Design and Implementation of the FreeBSD Operating System · P. Yosifovich, M. Russinovich et al., Windows Internals (7-е изд.) · Arpaci-Dusseau, Operating Systems: Three Easy Pieces — бесплатно: https://pages.cs.wisc.edu/~remzi/OSTEP/

Документация: man 8 modprobe, man 2 syscall, man 7 vdso, man 8 kldstat · https://www.kernel.org/doc/html/latest/ · https://sel4.systems/Info/Docs/seL4-manual-latest.pdf · https://unikraft.org/docs/ · https://mirage.io/docs · https://developer.apple.com/documentation/driverkit

Что дальше

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

Процессы, потоки и планировщики

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

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

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

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