Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv

This commit is contained in:
Kodlo-chan
2026-09-24 13:38:58 +07:00
parent e0ad8e0fee
commit 4259fbce75
17 changed files with 1688 additions and 115 deletions
+122 -17
View File
@@ -3,10 +3,11 @@
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
## 1. Что такое O-нотация (без воды)
## 1. Что такое O-нотация
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n).
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
100 и множитель 3 не меняют форму роста, поэтому их не пишут.
Три правила, которых хватает для 90% вопросов:
@@ -18,14 +19,32 @@ O-нотация отвечает на вопрос «как растёт вре
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
**Амортизированная сложность** — средняя стоимость операции, если редкая дорогая операция
«размазывается» по множеству дешёвых. Классический пример: `std::vector::push_back` —
обычно O(1), но при переполнении копирует весь массив за O(n); в среднем всё равно
**амортизированное O(1)**, потому что ёмкость удваивается.
**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за
геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после
k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех
реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть
суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку.
Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив —
суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации.
Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на
элементы вектора, потому что блок памяти физически переехал — источник use-after-free,
который ловит ASAN.
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
**Факты для карточек**
- base | Во что превращается 3n + 100 в O-нотации? — O(n)
- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20)
- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n²
- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти)
- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²)
Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам.
## 2. Таблица сложностей, которую надо знать
| Структура | Поиск | Вставка | Удаление | Память |
@@ -38,17 +57,44 @@ O-нотация отвечает на вопрос «как растёт вре
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель),
операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в
поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход.
Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит
диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить
непрерывность блока памяти.
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
частый вопрос), вставка одного элемента — O(log n), взятие максимума — O(1).
частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
вверх на высоту кучи), взятие максимума — O(1) (это корень).
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя.
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
bubble, counting. Неустойчивы: quick, heap, selection.
**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование
цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту
порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при
обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных
переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов
между вызовами rehash.
**Факты для карточек**
- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n)
- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort
- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного
- core | Нижняя граница сортировки сравнениями? — Ω(n log n)
- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа
- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash
Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n).
## 3. Хеш-таблица: как устроена
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
@@ -57,7 +103,8 @@ bubble, counting. Неустойчивы: quick, heap, selection.
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
загрузки** (сколько занято от общего размера).
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка.
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m
корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения).
Два способа разрешения:
@@ -66,17 +113,44 @@ bubble, counting. Неустойчивы: quick, heap, selection.
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
Быстрее по кэшу, но есть проблема **удаления**: если просто очистить ячейку, цепочка
зондирования порвётся и поиск не найдёт элемент дальше. Решение — **tombstone**
(надгробие): помечаем ячейку «был элемент», поиск идёт дальше, вставка может её занять.
Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
**tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
дальше по цепочке зондирования.
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
чем плотнее, тем длиннее пробеги. При превышении — **rehash**: выделяем массив вдвое
больше и переносим все элементы (это O(n), но редко, поэтому амортизированно дёшево).
чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
деления по модулю. Отсюда же требование: хеш-функция должна хорошо перемешивать младшие
биты (для строк — FNV-1a или `std::hash<std::string>`).
деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
для строк — FNV-1a или `std::hash<std::string>`.
**Ловушки**
- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек.
- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false.
- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы.
- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком.
**Факты для карточек**
- base | Формула фактора загрузки? — load = size / capacity
- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7
- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже
- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре
- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap)
- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n²
Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность.
## 4. Разбор примера: считаем сложности
@@ -109,6 +183,16 @@ bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²);
(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на
вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого
O(n) вставок и O(n) поисков вместо O(n²) сравнений.
**Факты для карточек**
- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²)
- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество
Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы.
## 5. Разбор примера: как руками собрать хеш-таблицу
@@ -168,11 +252,21 @@ struct HashTable {
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
**все** узлы, потому что индекс зависит от размера массива.
**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
тот же результат быстрее, именно потому что cap — степень двойки.
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
до свободной ячейки, а при удалении ставим tombstone.
**Факты для карточек**
- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
## 6. Что спросят на собеседовании (готовые ответы)
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
@@ -184,6 +278,10 @@ struct HashTable {
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
**Факты для карточек**
- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
## 7. Материалы (первопартийные)
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
@@ -198,6 +296,8 @@ struct HashTable {
3. `heapify` из произвольного массива — за сколько?
4. Какая из сортировок устойчива: quick, merge, heap?
5. Зачем tombstone при открытой адресации?
6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
<details>
<summary>Ответы</summary>
@@ -208,6 +308,11 @@ struct HashTable {
4. merge (устойчива), quick и heap — нет.
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
ячейки до элемента, который был вставлен за ней.
6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
n операций ≈ n, а не n².
7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
дешевле для процессора.
</details>
## 9. Ссылки на задачи этого дня
+205 -25
View File
@@ -1,20 +1,30 @@
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
Тут всё держится на одной картинке: процесс = адресное пространство + поток выполнения +
открытые дескрипторы. `fork` копирует это, `exec` заменяет содержимое, `wait` собирает
результат, сигнал — асинхронное уведомление.
Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток
выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
(`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec`
заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у
ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
## 1. fork(): что реально происходит
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места.
Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых
дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в
ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
планировщика. Само копирование данных при этом не происходит — см. COW ниже.
Возвращает **дважды** — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0);
- в ребёнке — 0;
- при ошибке — −1 (и `errno`), ребёнок не создан.
Поэтому классический код всегда ветвится:
Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
единственный способ понять, в какой копии сейчас исполняется код, это проверить
возвращённое значение. Поэтому классический код всегда ветвится:
```cpp
pid_t pid = fork();
@@ -31,13 +41,41 @@ if (pid == 0) {
}
```
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни страницы,
помеченные «только чтение». При первой записи в страницу ядро делает её копию — только
тогда. Поэтому `fork` дешёвый, даже если процесс занимает гигабайты. Именно это спрашивают
в формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
при первой записи**.
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические
страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц
только пишущего процесса — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает
гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в
формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
при первой записи**, а не при самом `fork`.
Совет: `fflush(stdout)` перед `fork`, иначе буфер вывода может продублироваться в ребёнке.
Причина именно такого устройства — частый паттерн «`fork` сразу за которым `exec`»: если бы
ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы
выброшенной, ведь `exec` тут же заменяет содержимое памяти новой программой.
Совет: `fflush(stdout)` перед `fork`, иначе непустой буфер `stdout` скопируется в ребёнка
вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.
**Ловушки**
- Не проверить возвращаемое значение `fork()` и не разветвить логику по нему → родитель и
ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в
`ps aux`, делающих одну и ту же работу.
- Ребёнок долго не вызывает `exec()`, активно пишет в большие структуры данных → всплеск
реального потребления памяти именно в момент записи (COW-копирование страниц), а не в
момент `fork` → видно по росту RSS в `top`/`ps` уже после fork, а не сразу.
- Вызвать `exit()` вместо `_exit()` в ребёнке после неудачного `exec` → `exit()` сбрасывает
стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может
продублировать ранее не выведенный текст родителя.
**Факты для карточек**
- base | Что возвращает `fork()` в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1
- core | Что происходит со страницами памяти при `fork()`? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault)
- core | Почему `fork` дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят
- base | Что нужно сделать с `stdout` перед `fork`, если он не пуст? — вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке
- deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ
Почему дальше: раз память после `fork` временно общая и почти всегда тут же заменяется — что конкретно делает `exec` с этим адресным пространством?
## 2. exec(): замена образа
@@ -45,40 +83,122 @@ if (pid == 0) {
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы.
Механизм: `execve` (системный вызов, к которому в итоге сводится всё семейство `exec*`)
загружает исполняемый файл с диска, разбирает его как ELF, строит новый `mm_struct` —
новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство
текущего `task_struct`, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые
до `exec`, остаются открытыми в новой программе **кроме** помеченных флагом `FD_CLOEXEC` —
это то, чем перенаправление ввода-вывода (`dup2` на 0/1/2 перед `exec`) переживает замену
образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы: `fork` даёт
новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы
для редиректа), а `exec` в этом новом процессе подгружает нужную программу, не трогая
родителя. Если вызвать `exec` без предварительного `fork`, текущая программа заменится и не
вернёт управление — например, `exec` внутри shell-скрипта заменяет саму оболочку.
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
**Ловушки**
- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает
исполняться как будто это родительская логика → дублирование родительской работы в
дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в
запущенную внешнюю программу → видно в `/proc/<pid>/fd` запущенного процесса — там лишний
открытый файл, которого «не должно быть».
**Факты для карточек**
- base | Чем `exec` отличается от `fork`? — `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового
- core | Что сохраняется у процесса после успешного `exec`? — PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC`
- core | Почему `exec` при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом
- base | Разница между `execlp`, `execv`, `execvp`? — `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`
Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?
## 3. wait/waitpid и коды возврата
Завершившийся ребёнок не исчезает: ядро держит его запись, пока родитель не заберёт код
возврата. Такой процесс называется **зомби** (состояние `Z` в `ps`). Зомби не занимает
память, но занимает слот в таблице процессов — их накопление плохо.
Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его
`task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс
называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает
слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
сообщить не может.
- `wait(&status)` — ждёт любого ребёнка;
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
Разбор `status` делается макросами, а не вручную:
Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
```cpp
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
```
Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и
`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`).
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
**Сирота** — процесс, чей родитель умер: его усыновляет init/systemd (PID 1), он не зомби.
**Сирота** — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1,
либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому
сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но
нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал
`wait`; сирота — родитель умер, но дождаться его теперь придётся init.
**Ловушки**
- Родитель никогда не вызывает `waitpid` для завершившихся детей → записи зомби копятся →
видно как растущий список `ps aux | grep Z`, в пределе — упор в лимит PID.
- Сравнивать `status` напрямую с кодом возврата вместо `WEXITSTATUS(status)` → в `status`
закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем,
что вернула программа.
- Долгоживущий процесс с `fork`-воркерами не занимается сбором детей вообще → зомби
накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни
аптайма ростом числа `Z`-процессов.
**Факты для карточек**
- base | Зачем нужен `waitpid`? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов
- core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait`
- core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1)
- base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую
- deep | Что показывает `WCOREDUMP(status)`? — что завершение по сигналу сопровождалось записью core-дампа на диск
Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?
## 4. Сигналы
Сигнал — асинхронное уведомление процессу. Основные: SIGINT (2, Ctrl+C), SIGKILL (9,
нельзя перехватить или проигнорировать), SIGTERM (15, «завершись корректно»), SIGSEGV (11),
SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17, ребёнок завершился).
Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное
исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя
действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить).
Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя
перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается),
SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe),
SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).
Обработчик ставится `sigaction` (надёжнее устаревшего `signal`), внутри обработчика можно
менять только `volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe).
Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности
управления системой: администратору и супервизору всегда нужен гарантированный способ
остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик
сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика,
снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM
устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы,
сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают
SIGTERM и ждут; так, `systemctl stop`/`docker stop` по умолчанию ждут несколько секунд
(в systemd таймаут задаётся `TimeoutStopSec`, по умолчанию около 90 секунд) и только затем,
если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные
на диск, и незавершённая операция может остаться в неконсистентном состоянии.
Обработчик ставится `sigaction` (надёжнее устаревшего `signal` — поведение `signal`
исторически различалось между Unix-системами), внутри обработчика можно менять только
`volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe: `malloc` не
реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур,
что при вызове `malloc`/`printf` из обработчика способно повредить кучу или подвесить
процесс).
```cpp
static volatile sig_atomic_t stop = 0;
@@ -91,12 +211,35 @@ int main() {
}
```
**Ловушки**
- Вызвать `printf`/`malloc`/`free` внутри обработчика сигнала → не async-signal-safe → в
редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу
ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой.
- Использовать `signal()` вместо `sigaction()` → поведение (сброс обработчика в default
после первого срабатывания, поведение при повторном сигнале) исторически различается
между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой.
- Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик →
`docker stop`/`systemctl stop` в итоге шлют SIGKILL по таймауту → в логах виден резкий
обрыв процесса без финализации (незакрытые файлы, недописанные данные).
**Факты для карточек**
- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
- core | Что можно делать внутри обработчика сигнала? — только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe)
- base | Чем `sigaction` лучше `signal`? — поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику
- deep | Что происходит, если сервис игнорирует SIGTERM при `systemctl stop`? — по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно
Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?
## 5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
можно только сразу после ошибки. EINTR — вызов прерван сигналом, надо повторить;
EAGAIN — данных сейчас нет на неблокирующем дескрипторе, повторить позже;
EINPROGRESS — неблокирующее соединение в процессе.
можно только сразу после ошибки, до вызова любой другой функции, которая может сама
переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов
прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или
`/proc/PID/fd`.
```cpp
ssize_t n = read(fd, buf, sizeof buf);
@@ -107,6 +250,20 @@ if (n < 0) {
}
```
**Ловушки**
- Проверить `errno` без предварительной проверки, что вызов вообще вернул ошибку → `errno`
может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание.
- Вызвать любую функцию (даже `printf`) между системным вызовом и чтением `errno` →
промежуточный вызов может переписать `errno` → в обработчике окажется код чужой ошибки.
**Факты для карточек**
- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
- core | Когда безопасно читать `errno`? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать
- deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся `ulimit -n`)
Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?
## 6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
@@ -157,11 +314,28 @@ int main() {
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
несуществующая команда даёт 127.
несуществующая команда даёт 127. Дочерний процесс перед `execvp` уже унаследовал от
родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через `fork`, поэтому вывод запущенной
программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не
требуется перенаправление в файл или pipe.
Проверка на утечки и падения — санитайзеры:
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
**Ловушки**
- Не проверять, пуст ли `argv` перед `execvp` → `execvp(nullptr, ...)` на пустой строке →
неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже
предусмотрено проверкой `if (argv.empty()) continue;`, но при рефакторинге легко потерять).
- Забыть `_exit(127)` после неудачного `execvp` → дочерний процесс продолжит исполнять
тело цикла `while` наравне с родителем → двойной ввод команд из одного терминала.
**Факты для карточек**
- base | Какой код возврата у шелла даст несуществующая команда? — 127
- base | Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork`
- core | Каким флагом собрать бинарник для проверки на утечки и UB? — `-fsanitize=address,undefined -fno-omit-frame-pointer`
Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?
## 7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
@@ -190,6 +364,8 @@ int main() {
3. Что такое зомби и кто его убирает?
4. Откуда код возврата 137 и 139?
5. Почему `exec` не возвращает управление при успехе?
6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
<details>
<summary>Ответы</summary>
@@ -200,9 +376,13 @@ int main() {
если родитель умер).
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
SIGKILL принудительно.
</details>
## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
</content>