Linux глазами C++-разработчика
C++-программа не работает напрямую с железом. Между вашим кодом и процессором, памятью, диском стоит ядро операционной системы. Всё, что выходит за пределы вычислений в собственной памяти — открыть файл, создать поток, выделить страницу, послать данные в сокет — это просьба к ядру, оформленная как системный вызов.
Важно различать ядро и ОС: ядро — это центральный компонент (управление процессами, памятью, файловыми системами, драйверы); операционная система — это ядро плюс пользовательское окружение (libc, оболочка, система инициализации, утилиты). Linux строго говоря — ядро; «дистрибутив Linux» — это ядро с подобранным окружением.
Зачем это C++-разработчику? Потому что целый класс багов не виден в коде на C++, но виден в поведении процесса: утечка файловых дескрипторов упирается в EMFILE, брошенный (не пожатый родителем) дочерний процесс превращается в зомби, проверка «файл существует?» перед открытием открывает гонку TOCTOU. Понимание ОС переводит такой сбой из разряда «загадочных» в разряд объяснимых.
Процессы и потоки
Процесс — это исполняемая программа со своим адресным пространством и ресурсами (открытые файлы, обработчики сигналов). Каждый процесс опознаётся по PID. Поток — это контекст исполнения внутри процесса; потоки одного процесса делят одно адресное пространство.
Новый процесс в Unix создаётся через fork() — он дублирует вызывающий процесс. Дочерний процесс получает копию адресного пространства, но физически страницы не копируются сразу: ядро помечает их copy-on-write (CoW) и дублирует конкретную страницу только при первой записи в неё.
pid_t pid = fork();
if (pid == 0) {
// дочерний процесс — отдельная копия памяти
execlp("ls", "ls", "-l", nullptr); // заменяет образ программы
_exit(127); // сюда попадём, только если exec упал
} else {
int status;
waitpid(pid, &status, 0); // родитель ОБЯЗАН пожать потомка
}
fork() и exec() всегда работают в паре: fork() создаёт процесс, exec() заменяет его программный образ новой программой. После fork() запись потомка в унаследованную память не видна родителю — CoW развёл их копии; общими страницы остаются только до первой записи.
Поток создаётся системным вызовом clone() с флагами разделения адресного пространства (в Windows — CreateThread, непереносимо). Поток дешевле процесса именно потому, что новое адресное пространство и таблицы страниц не нужны — поток переиспользует существующие. Но «дешевле» не значит «бесплатно»: создание потока — это микросекунды, и спаунить поток на каждый запрос без пула под нагрузкой накладно.
Контекстное переключение и планировщик
Контекстное переключение — это смена потока на ядре. ОС сохраняет регистры текущего потока, при смене адресного пространства сбрасывает TLB (если нет ASID), и в итоге платит конвейерными простоями — порядка 1–10 мкс. Поэтому сотни потоков не дают линейного ускорения: на каком-то пороге переключения начинают доминировать.
Планировщик Linux (CFS) делит процессорное время между готовыми потоками по их весу. nice — это не процент CPU, а соотношение долей между конкурирующими задачами. Реального времени политики (SCHED_FIFO) без ограничения работы могут заморить голодом всё остальное.
Отдельная ловушка — зомби: процесс, который завершился, но родитель не вызвал wait(). Зомби занимает строку в таблице процессов, пока его не пожнут. PID при этом не вечны — счётчик переполняется (по умолчанию после 32768), так что сохранять PID и потом считать, что это тот же процесс, нельзя.
Системные вызовы
Системный вызов — это запрос к ядру. В отличие от обычного вызова функции он пересекает границу привилегий: специальная инструкция (syscall) переключает процессор в режим ядра, ядро выполняет операцию и возвращает управление. Это переключение режима стоит в разы дороже обычного вызова — поэтому делать по syscall на каждый прочитанный байт расточительно; читайте крупными буферами.
// glibc-обёртка ≠ сам syscall: read() здесь — тонкая обёртка,
// а fread() добавляет ещё и буферизацию поверх неё
char buf[65536];
ssize_t n = read(fd, buf, sizeof buf); // один syscall на 64 КБ, а не на байт
Обёртки glibc — не сам системный вызов: они добавляют буферизацию, обработку errno, иногда кэширование. Системный вызов может быть прерван сигналом — тогда он вернёт ошибку EINTR, и вызов нужно повторить.
С этим связана реентерабельность. Не любая функция libc потокобезопасна: strtok хранит состояние в статической переменной, и два потока, разбирающие разные строки, затрут состояние друг друга. Для таких функций исторически вводили _r-варианты (strtok_r). Внутри обработчика сигнала ограничение ещё жёстче — допустимы только async-signal-safe функции.
Файлы и дескрипторы
Файловый дескриптор (FD) — это small-int, индекс в таблице открытых файлов процесса. Стандартные дескрипторы: 0 — stdin, 1 — stdout, 2 — stderr. Не считайте их всегда терминалом: демон, запущенный через systemd, может иметь их перенаправленными в /dev/null — проверяйте.
После fork() потомок наследует все дескрипторы родителя и делит с ним смещение в файле — пока один из них не закроет дескриптор.
int fd = open("data.bin", O_RDONLY);
pid_t pid = fork();
// и родитель, и потомок могут читать через fd —
// и они двигают ОДНО общее смещение, пока кто-то не закроет дескриптор
Флаг O_CLOEXEC закрывает дескриптор при exec() (но не при fork()) — без него потомок, который не делает exec, утащит за собой все дескрипторы родителя. Утечка дескрипторов в долгоживущем процессе рано или поздно упирается в лимит, и open() начинает возвращать EMFILE; закрывайте дескрипторы и на путях ошибок.
Несколько ловушек, которые любят на собеседованиях:
- Эксклюзивное создание файла. «Создать, только если не существует» — это
open(path, O_CREAT | O_EXCL). Сделать сначалаstat(), а потомopen()— значит открыть гонку TOCTOU: между проверкой и открытием конкурирующий процесс успеет создать файл.O_CREATбезO_EXCLсуществующий файл просто откроет;O_EXCLзаставит вызов упасть сEEXIST. statпротивlstat. На символической ссылкеstat()описывает цель, аlstat()— саму ссылку (и её «размер» — это длина строки пути). Обходя дерево каталогов, используйтеlstat(), иначе зациклитесь на симлинках.unlinkоткрытого файла.unlinkубирает одну ссылку из каталога — но инод (и данные) живут, пока есть хотя бы одна ссылка ИЛИ хотя бы один открытый дескриптор. Процесс, читающий файл, продолжит читать его послеunlink; место на диске освободится только после последнегоclose.- Неблокирующий режим. Дескриптор переводят в неблокирующий режим через
fcntl: прочитать флагиF_GETFLи до-вписатьO_NONBLOCK— именноOR, а не присвоить, иначе сотрёте остальные флаги. В этом режимеEAGAIN— не ошибка, а сигнал «повтори позже», аwrite()может записать меньше, чем просили.
Права доступа
Каждый файл принадлежит пользователю (UID) и группе (GID), и для трёх классов — владелец, группа, остальные — заданы права на чтение, запись и исполнение (rwx). Новый файл создаётся с правами mode & ~umask: umask маскирует биты, и стандартный umask 022 снимает запись для группы и остальных.
Стандартные права Unix допускают ровно одного пользователя и одну группу на файл; когда нужны правила для конкретных пользователей — это ACL (getfacl / setfacl), отдельный механизм поверх обычных прав. Бит setuid на исполняемом файле запускает процесс с правами владельца файла, а не запустившего. Это мощный, но опасный механизм: setuid-программы — классическая поверхность атаки, их полномочия нужно проверять предельно тщательно.
Виртуальная память
У каждого процесса своё виртуальное адресное пространство — на x86-64 это 128 ТБ в пользовательской части. Но виртуальный адрес — это ещё не память: каждое отображение должно быть подкреплено физической страницей или swap. Виртуальное пространство большое, физическая память — нет.
Метрики памяти процесса нельзя путать:
- VSZ — суммарный виртуальный размер. Может расти без расхода RAM (например,
mmapсPROT_NONE), поэтому рост VSZ — не признак утечки. - RSS — резидентный размер: физические страницы, реально занятые процессом. Суммировать RSS по процессам нельзя — общие разделяемые библиотеки посчитаются многократно.
- PSS — пропорциональный размер: разделяемая страница делится между всеми, кто её использует. Это честная метрика для суммирования.
CoW-страницы (после fork()) до первой записи числятся общими — это и экономит память при fork().
Сигналы
Сигнал — это асинхронное уведомление процессу (SIGTERM, SIGSEGV, SIGCHLD и др.). Если обработчик не установлен, выполняется действие по умолчанию. Распространённое заблуждение — что необработанный сигнал просто игнорируется: у большинства сигналов действие по умолчанию — завершить процесс. Но не у всех: у SIGCHLD и SIGURG по умолчанию — игнорировать.
SIGKILL и SIGSTOP — особые: их нельзя перехватить, заблокировать или переопределить им действие. Это гарантия ядра, что процесс всегда можно убить и остановить.
Код внутри обработчика сигнала сильно ограничен — допустимы только async-signal-safe функции. Обработчик может прервать выполнение в произвольной точке:
void handler(int sig) {
printf("got signal\n"); // ⚠️ UB — printf не async-signal-safe,
// мог прервать другой printf на середине
// mutex здесь — самодедлок, если прервали удерживающего блокировку
}
Межпроцессное взаимодействие
Процессы изолированы по памяти, поэтому для обмена данными нужны явные механизмы IPC.
Канал (pipe) — однонаправленный байтовый поток через буфер ядра (по умолчанию ~64 КБ). Неиспользуемые концы канала надо закрывать: если родитель не закроет конец на запись, read() потомка никогда не вернёт EOF — ядро видит, что писать ещё кто-то может. Для двусторонней связи нужны два канала или socketpair(). Именованный канал (FIFO) — тот же канал, но с именем в файловой системе, поэтому им могут пользоваться неродственные процессы.
Разделяемая память — самый быстрый IPC: процессы отображают один и тот же участок физической памяти. POSIX-вариант — shm_open() плюс mmap(); System V — shmget() с ключом от ftok(). Разделяемая память сама по себе не синхронизирована — нужны атомики или process-shared мьютексы. Забыть shm_unlink() — значит оставить утечку между запусками.
Мультиплексирование ввода-вывода
Чтобы один поток обслуживал много соединений, не блокируясь на каждом, используют мультиплексирование:
select()— переносимый, но ограниченFD_SETSIZEи на Linux перезаписывает переданныйtimeval(его надо сбрасывать перед каждой итерацией цикла). Кстати,select()с пустым набором дескрипторов — это ещё и точный таймер с долями секунды, в отличие отsleep(), который берёт только целые секунды.poll()— без ограниченияFD_SETSIZE, тоже переносимый.epoll()— масштабируется на десятки тысяч соединений, но это Linux-специфичный механизм (аналог —kqueueна BSD). Для переносимого кода беритеpoll()или абстракцию вродеlibevent.
Загрузка программы
Исполняемый файл на Linux — это ELF. У ELF два взгляда на одни и те же данные: секции (link view — .text для кода, .data для инициализированных данных, .bss для нулевых, и т.д.) и сегменты (load view — то, что загрузчик отображает в память). Путать их — частая ошибка. .bss не занимает места в файле (хранится только размер), но занимает память в runtime — поэтому большой нулевой массив держат в .bss, а не в .data.
Динамический загрузчик (ld.so) при старте процесса находит и подгружает разделяемые библиотеки, разрешает символы. Порядок поиска задают RPATH, переменная LD_LIBRARY_PATH и RUNPATH (RPATH проверяется до LD_LIBRARY_PATH, RUNPATH — после). LD_PRELOAD заставляет загрузить указанную библиотеку первой — её символы перекроют одноимённые (интерпозиция функций — приём для профилирования и подмены). Для setuid-бинарников LD_PRELOAD отключён из соображений безопасности.
Контейнерные примитивы
Контейнер — это не виртуальная машина: контейнеры делят ядро хоста. Их собирают из двух ортогональных механизмов ядра:
- Namespaces изолируют вид системы. У процесса может быть свой PID-namespace, mount-namespace, network-namespace, user-namespace и т.д. — внутри он видит собственное дерево процессов, свои точки монтирования, свою сеть.
- cgroups ограничивают и учитывают ресурсы — процессор, память, ввод-вывод. cgroups v2 использует единую (unified) иерархию; смешивать её с v1 нельзя.
Namespaces отвечают на вопрос «что процесс видит», cgroups — «сколько процесс может потребить». Путать их не нужно — это разные механизмы. Поверх них действуют ещё и лимиты ресурсов на отдельный процесс (setrlimit / ulimit — например, RLIMIT_NOFILE на число дескрипторов).
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Нет wait() после fork() | Потомок остаётся зомби в таблице процессов |
stat()/access() перед open() для проверки существования | Гонка TOCTOU — конкурент вклинится между проверкой и открытием |
O_CREAT без O_EXCL в расчёте «не открывать существующий» | Существующий файл будет молча открыт |
| Не закрыт неиспользуемый конец канала | read() никогда не вернёт EOF |
| Дескрипторы не закрываются на путях ошибок | Утечка FD → open() возвращает EMFILE |
O_CLOEXEC путают с наследованием через fork() | O_CLOEXEC влияет на exec, а не на fork |
printf или мьютекс в обработчике сигнала | UB / самодедлок — функция не async-signal-safe |
Попытка перехватить SIGKILL / SIGSTOP | Невозможно по замыслу ядра |
| Суммирование RSS по процессам | Разделяемые библиотеки посчитаны многократно |
| Рост VSZ принят за утечку памяти | VSZ растёт и без расхода RAM (mmap PROT_NONE) |
unlink считают мгновенным удалением | Инод жив, пока есть ссылка или открытый дескриптор |
| Namespaces путают с cgroups | Изоляция вида ≠ ограничение ресурсов |
nice понимают как процент CPU | Это соотношение долей между конкурентами |
Значение для собеседований
ОС и Linux — частая тема на интервью C++-разработчика уровня middle и выше, особенно в backend, embedded и системной разработке. Проверяют не зубрёжку команд, а понимание механики: что именно делает ядро за вашим вызовом и какой ценой.
Что проверяет интервьюер:
- Различие процесса и потока на уровне ОС и почему поток дешевле
fork/exec, copy-on-write и почему запись потомка не видна родителю- Файловые дескрипторы: наследование через
fork, общее смещение,O_CLOEXEC, утечки - Гонку TOCTOU и атомарные операции с файлами (
O_EXCL) - Сигналы: действия по умолчанию, async-signal-safety, неперехватываемость
SIGKILL - Метрики памяти (RSS / VSZ / PSS) и почему их нельзя наивно суммировать
- Понимание контейнеров: namespaces vs cgroups, общее ядро хоста
Типичные вопросы:
- Чем поток дешевле процесса на уровне ОС?
- Что происходит с памятью после
fork()и что такое copy-on-write? - Наследует ли потомок открытый файл после
fork()и что с общим смещением? - Как атомарно создать файл, только если его ещё нет?
- Что произойдёт, если процесс A читает файл, а процесс B его
unlinkнул? - Чем namespaces отличаются от cgroups и почему контейнер — не виртуальная машина?
Типичная ошибка: перечислять команды и системные вызовы, не объясняя механику. Интервьюер ищет понимание границы user/kernel, времени жизни ресурсов и стоимости операций — а не пересказ man-страниц.