Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+10 -7
View File
@@ -7,10 +7,12 @@
- `WEEK7.md` — **рабочий документ**: интенсив на 7 дней (D0 23.09 → D7 30.09), пересобранный
по диагностике (квиз 14/32, карточки 11/60). День устроен так:
**урок (теория + разбор) → карточки → ступени задачи → зачёт `grade.py`**.
- `lessons/` — уроки по дням: теория по-русски, рабочие примеры кода с разбором, готовые
ответы на вопросы собеседования, ссылки на первопартийные материалы. Начинать с них.
- `lessons/` — уроки по дням (14 файлов, D1–D7): D1 `algo`/`linux`, D2 `algo`/`linux`/`cpp`,
D3 `algo`/`threads`/`gdb`, D4 `algo`/`net`, D5 `algo`/`net`/`bash`, D6 `kernel`/`docker`,
D7 `mock`. В каждом: механизм вместо определений, блоки **Факты для карточек** (уровень |
вопрос — ответ), **Ловушки** (что ломается и как это видно), **Проверь себя** и ссылки на
первопартийные материалы. Начинать с них.
- `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref).
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом.
- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref).
@@ -23,9 +25,10 @@
epoll-сервер, bash по логу, разбор падения в gdb;
- `tasks/10..13` — добор под слабые домены: хеш-таблица с открытой адресацией, куча
(`heapify` O(n), top-K), Дейкстра, арифметика подсетей;
- `facts_drill.py` + `facts.json` — 101 карточка «числа и факты» с пояснениями и
уровнями: `base` 41 (начинать отсюда, у каждой есть объяснение), `core` 52,
`deep` 8 (нишевое, в базовый прогон не попадает);
- `facts_drill.py` + `facts.json` — **149 карточек** «числа и факты» с пояснениями и
уровнями: `base` 89 (начинать отсюда), `core` 52, `deep` 8 (нишевое);
- `cards_src.tsv` — выжимка фактов из всех уроков и задач под новые карточки
(level/domain/question/answer/ref);
- `grade.py` — автопроверка задач (ASAN/UBSAN, таймауты, PASS/FAIL + полный лог);
- `questions_export.txt` — вопросы квиза текстом.
@@ -63,7 +66,7 @@ python3 grade.py # все 13 задач с санитайзер
python3 grade.py 10 11 13 # выборочно
python3 grade.py --fast # без санитайзеров
python3 facts_drill.py --level base --learn # база с пояснениями
python3 facts_drill.py --level all --all # все 101 карточка
python3 facts_drill.py --level all --all # все 149 карточек
python3 facts_drill.py --list --level base # вывести вопросы с ответами и пояснениями
```
+22
View File
@@ -100,6 +100,10 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
- **Зачёт дня:** `PASS` по `01`, `03`, `10`; карточки `base` ≥ 80%.
## D2 — 25.09: деревья и куча | epoll и неблокирующие сокеты
- **Урок:** `lessons/D2_algo.md` (деревья, BST-инвариант, обходы, куча, `heapify` за O(n), top-K),
`lessons/D2_linux.md` (файловый ввод-вывод, `mmap`, `select`/`poll`/`epoll`, LT/ET, `SO_REUSEADDR`),
`lessons/D2_cpp.md` (padding, правило 0/3/5, virtual-деструктор, `std::move`, UB).
- Карточки (30 мин) — `algo`: heap insert O(log n), top-K, обходы деревьев, сложность DFS
по памяти.
- Утро, алгоритмы (3 ч) — бинарные деревья и BST: обходы рекурсией и итерацией,
@@ -118,6 +122,10 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
## D3 — 26.09: списки/стек | многопоточность и gdb
- **Урок:** `lessons/D3_algo.md` (списки, разворот, цикл Флойда, кольцевой буфер, монотонный стек),
`lessons/D3_threads.md` (мьютекс, `condition_variable`, атомики и `memory_order`, дедлоки, пул потоков),
`lessons/D3_gdb.md` (gdb: break/watch/bt/frame, core dump, почему краш далеко от причины).
- Карточки (30 мин) — `linux`: epoll O(1) против select O(n), mmap, waitpid, 137/139.
- Утро, алгоритмы (3 ч) — связные списки (разворот, **цикл Флойда**, k-й с конца),
стек/очередь/ring buffer, монотонный стек. Практика: `tasks/02_list`, `tasks/03_ring`
@@ -134,6 +142,10 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
## D4 — 27.09: графы | L2/L3 и подсети
- **Урок:** `lessons/D4_algo.md` (BFS/DFS, топосорт, Дейкстра, union-find),
`lessons/D4_net.md` (OSI и инкапсуляция, Ethernet 14 байт, VLAN +4, ARP, IPv4 по полям, TTL,
подсети и маски, ICMP).
- Карточки (30 мин) — `net`: 14 байт Ethernet, VLAN +4, /26 = 62, TTL, SYN/SYN-ACK/ACK.
- Утро, алгоритмы (3 ч) — графы: представления, BFS/DFS, топологическая сортировка
(только для DAG), компоненты связности, **Дейкстра (веса ≥ 0)**, union-find.
@@ -148,6 +160,10 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
## D5 — 28.09: ДП | TCP глубоко, bash и /proc
- **Урок:** `lessons/D5_algo.md` (рюкзак 0/1, монеты, Edit Distance, НВП за O(n log n)),
`lessons/D5_net.md` (заголовок TCP, автомат состояний, окна и ретрансмиссии, TIME_WAIT, UDP,
NAT, DNS/DHCP, `tcpdump`), `lessons/D5_bash.md` (пайплайны, `set -euo pipefail`, awk/sed, `/proc`, `ulimit`).
- Карточки (30 мин) — `net` + `c_cpp` (20 байт IPv4/TCP, MTU 1500, DNS 53, ASAN/UBSAN).
- Утро, алгоритмы (3 ч) — ДП: состояния и переходы, рюкзак 0/1, **НВП за O(n log n)**,
строки (Edit Distance, Word Break). Практика: `tasks/05_lis` + Climbing Stairs,
@@ -163,6 +179,9 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
## D6 — 29.09: закрытие слабых мест | ядро обзорно, docker, резюме
- **Урок:** `lessons/D6_kernel.md` (модули, `printk`, `file_operations`, device tree, cross-compile,
uboot), `lessons/D6_docker.md` (namespaces и cgroups, слои, multi-stage, volumes).
- Карточки (30 мин) — все домены, полный прогон 60 карточек.
- Утро, алгоритмы (3 ч) — **только слабые места по трекеру**: добить `10..13` до PASS,
повторить hash/BST/heap/Дейкстра/ДП там, где были FAIL или «не знаю».
@@ -176,6 +195,9 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
## D7 — 30.09: повтор и два мок-интервью
- **Урок:** `lessons/D7_mock.md` — сценарий двух мок-интервью (алгоритмы и системное), карта
слабых мест по урокам, вопросы работодателю, легенда по опыту.
- 2 ч — повтор: только карточки с промахами и слабые темы из трекера.
- 3 ч — мок-интервью №1: алгоритмы, 2 задачи вслух с таймером (я веду и разбираю).
- 2 ч — мок-интервью №2: C/C++ + Linux + сети, включая разбор дампа.
+503 -56
View File
@@ -1,54 +1,507 @@
level domain question answer ref
base algo Во что превращается 3n + 100 в O-нотации O(n) урок D1_algo.md
base algo Сколько шагов у бинарного поиска в массиве из 10⁶ элементов 20 (log₂ 10⁶ ≈ 20) урок D1_algo.md
core algo Почему push_back в среднем O(1), а не O(n) ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² урок D1_algo.md
core algo Что ломает удвоение ёмкости у vector указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) урок D1_algo.md
deep algo Что будет, если ёмкость vector растить на +1 за раз вместо удвоения суммарная стоимость n вставок станет O(n²) урок D1_algo.md
base algo Сложность построения кучи (heapify) из произвольного массива O(n), не O(n log n) урок D1_algo.md
base algo Какая сортировка всегда O(n log n) и устойчива mergesort урок D1_algo.md
core algo Худший случай quicksort и когда он достигается O(n²), на уже отсортированном массиве при плохом выборе опорного урок D1_algo.md
core algo Нижняя граница сортировки сравнениями Ω(n log n) урок D1_algo.md
core algo За счёт чего map держит высоту log n инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа урок D1_algo.md
deep algo Почему нельзя полагаться на порядок обхода unordered_map порядок бакетов не гарантирован и может меняться при rehash урок D1_algo.md
base algo Формула фактора загрузки load = size / capacity урок D1_algo.md
base algo Порог load factor при открытой адресации, после которого делают rehash обычно ≤ 0.7 урок D1_algo.md
core algo Зачем tombstone при открытой адресации чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже урок D1_algo.md
core algo Почему ёмкость хеш-таблицы берут степенью двойки idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре урок D1_algo.md
core algo Что физически происходит при rehash выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) урок D1_algo.md
deep algo Почему rehash даёт амортизированное O(1), а не O(n) на вставку та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² урок D1_algo.md
base algo Сложность has_pair (вложенный цикл по всем парам) O(n²) урок D1_algo.md
core algo Сложность has_pair_fast по времени и по памяти O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество урок D1_algo.md
base algo Какие константы использует FNV-1a в этом коде (offset basis / prime) 1469598103934665603 / 1099511628211 урок D1_algo.md
core algo Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны урок D1_algo.md
core algo Чем открытая адресация выигрывает у цепочек по производительности и почему она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами урок D1_algo.md
deep algo Может ли load factor у цепочек быть больше 1 да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) урок D1_algo.md
base linux Что возвращает `fork()` в родителе и в ребёнке в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 урок D1_linux.md
core linux Что происходит со страницами памяти при `fork()` ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) урок D1_linux.md
core linux Почему `fork` дешёвый даже для процесса с гигабайтами памяти копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят урок D1_linux.md
base linux Что нужно сделать с `stdout` перед `fork`, если он не пуст вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке урок D1_linux.md
deep linux Какой размер страницы памяти на x86-64, о которой копия делается при COW 4 КБ урок D1_linux.md
base linux Чем `exec` отличается от `fork` `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового урок D1_linux.md
core linux Что сохраняется у процесса после успешного `exec` PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` урок D1_linux.md
core linux Почему `exec` при успехе никогда не возвращает управление старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом урок D1_linux.md
base linux Разница между `execlp`, `execv`, `execvp` `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` урок D1_linux.md
base linux Зачем нужен `waitpid` забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов урок D1_linux.md
core linux Что такое зомби и почему он не исчезает сам процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` урок D1_linux.md
core linux Чем зомби отличается от сироты зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) урок D1_linux.md
base linux Откуда код возврата 137 и 139 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу урок D1_linux.md
core linux Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую урок D1_linux.md
deep linux Что показывает `WCOREDUMP(status)` что завершение по сигналу сопровождалось записью core-дампа на диск урок D1_linux.md
base linux Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD 2/9/15/11/13/17 урок D1_linux.md
core linux Чем SIGTERM отличается от SIGKILL SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую урок D1_linux.md
core linux Что можно делать внутри обработчика сигнала только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) урок D1_linux.md
base linux Чем `sigaction` лучше `signal` поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику урок D1_linux.md
deep linux Что происходит, если сервис игнорирует SIGTERM при `systemctl stop` по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно урок D1_linux.md
base linux Что означает EINTR и как на него реагировать вызов прерван доставкой сигнала; корректная реакция — повторить вызов урок D1_linux.md
base linux Чем EAGAIN отличается от обычной ошибки чтения данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» урок D1_linux.md
core linux Когда безопасно читать `errno` сразу после ошибки вызова, до любого другого вызова, способного его перезаписать урок D1_linux.md
deep linux Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса EMFILE (лимит задаётся `ulimit -n`) урок D1_linux.md
base linux Какой код возврата у шелла даст несуществующая команда 127 урок D1_linux.md
base linux Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` урок D1_linux.md
core linux Каким флагом собрать бинарник для проверки на утечки и UB `-fsanitize=address,undefined -fno-omit-frame-pointer` урок D1_linux.md
base algo Во что превращается 3n + 100 в O-нотации O(n) урок D1_algo
base algo Сколько шагов у бинарного поиска в массиве из 10⁶ элементов 20 (log₂ 10⁶ ≈ 20) урок D1_algo
core algo Почему push_back в среднем O(1), а не O(n) ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² урок D1_algo
core algo Что ломает удвоение ёмкости у vector указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) урок D1_algo
deep algo Что будет, если ёмкость vector растить на +1 за раз вместо удвоения суммарная стоимость n вставок станет O(n²) урок D1_algo
base algo Сложность построения кучи (heapify) из произвольного массива O(n), не O(n log n) урок D1_algo
base algo Какая сортировка всегда O(n log n) и устойчива mergesort урок D1_algo
core algo Худший случай quicksort и когда он достигается O(n²), на уже отсортированном массиве при плохом выборе опорного урок D1_algo
core algo Нижняя граница сортировки сравнениями Ω(n log n) урок D1_algo
core algo За счёт чего map держит высоту log n инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа урок D1_algo
deep algo Почему нельзя полагаться на порядок обхода unordered_map порядок бакетов не гарантирован и может меняться при rehash урок D1_algo
base algo Формула фактора загрузки load = size / capacity урок D1_algo
base algo Порог load factor при открытой адресации, после которого делают rehash обычно ≤ 0.7 урок D1_algo
core algo Зачем tombstone при открытой адресации чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже урок D1_algo
core algo Почему ёмкость хеш-таблицы берут степенью двойки idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре урок D1_algo
core algo Что физически происходит при rehash выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) урок D1_algo
deep algo Почему rehash даёт амортизированное O(1), а не O(n) на вставку та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² урок D1_algo
base algo Сложность has_pair (вложенный цикл по всем парам) O(n²) урок D1_algo
core algo Сложность has_pair_fast по времени и по памяти O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество урок D1_algo
base algo Какие константы использует FNV-1a в этом коде (offset basis / prime) 1469598103934665603 / 1099511628211 урок D1_algo
core algo Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны урок D1_algo
core algo Чем открытая адресация выигрывает у цепочек по производительности и почему она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами урок D1_algo
deep algo Может ли load factor у цепочек быть больше 1 да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) урок D1_algo
base linux Что возвращает `fork()` в родителе и в ребёнке в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 урок D1_linux
core linux Что происходит со страницами памяти при `fork()` ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) урок D1_linux
core linux Почему `fork` дешёвый даже для процесса с гигабайтами памяти копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят урок D1_linux
base linux Что нужно сделать с `stdout` перед `fork`, если он не пуст вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке урок D1_linux
deep linux Какой размер страницы памяти на x86-64, о которой копия делается при COW 4 КБ урок D1_linux
base linux Чем `exec` отличается от `fork` `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового урок D1_linux
core linux Что сохраняется у процесса после успешного `exec` PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` урок D1_linux
core linux Почему `exec` при успехе никогда не возвращает управление старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом урок D1_linux
base linux Разница между `execlp`, `execv`, `execvp` `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` урок D1_linux
base linux Зачем нужен `waitpid` забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов урок D1_linux
core linux Что такое зомби и почему он не исчезает сам процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` урок D1_linux
core linux Чем зомби отличается от сироты зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) урок D1_linux
base linux Откуда код возврата 137 и 139 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу урок D1_linux
core linux Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую урок D1_linux
deep linux Что показывает `WCOREDUMP(status)` что завершение по сигналу сопровождалось записью core-дампа на диск урок D1_linux
base linux Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD 2/9/15/11/13/17 урок D1_linux
core linux Чем SIGTERM отличается от SIGKILL SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую урок D1_linux
core linux Что можно делать внутри обработчика сигнала только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) урок D1_linux
base linux Чем `sigaction` лучше `signal` поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику урок D1_linux
deep linux Что происходит, если сервис игнорирует SIGTERM при `systemctl stop` по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно урок D1_linux
base linux Что означает EINTR и как на него реагировать вызов прерван доставкой сигнала; корректная реакция — повторить вызов урок D1_linux
base linux Чем EAGAIN отличается от обычной ошибки чтения данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» урок D1_linux
core linux Когда безопасно читать `errno` сразу после ошибки вызова, до любого другого вызова, способного его перезаписать урок D1_linux
deep linux Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса EMFILE (лимит задаётся `ulimit -n`) урок D1_linux
base linux Какой код возврата у шелла даст несуществующая команда 127 урок D1_linux
base linux Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` урок D1_linux
core linux Каким флагом собрать бинарник для проверки на утечки и UB `-fsanitize=address,undefined -fno-omit-frame-pointer` урок D1_linux
base algo Из чего состоит узел бинарного дерева при представлении указателями значение + два указателя (left, right) урок D2_algo
core algo Размер `struct TreeNode { int val; TreeNode *left, *right; }` на x86-64 24 байта: 4 байта `val` + 4 байта паддинга (выравнивание указателя на 8) + 8 + 8 байт под указатели урок D2_algo
core algo Когда массивное представление дерева компактно, а когда нет компактно только для полного/близкого к полному дерева (куча); для разреженного дерева индексы `2i+1/2i+2` требуют до `2^h` ячеек, большинство из которых пустует урок D2_algo
deep algo Почему куча хранится в массиве, а не через указатели куча всегда почти полное дерево (заполнена по уровням слева направо без пропусков), индексная арифметика не тратит память впустую и не требует отдельной аллокации на узел урок D2_algo
base algo Порядок посещения в inorder-обходе left, node, right урок D2_algo
base algo Какой обход даёт отсортированный порядок для BST inorder урок D2_algo
core algo Память DFS (рекурсия или явный стек) по глубине дерева O(h) — пропорционально высоте урок D2_algo
core algo Память BFS (очередь) O(w) — пропорционально максимальной ширине уровня урок D2_algo
core algo Для полного сбалансированного дерева из 10⁶ узлов: высота и ширина последнего уровня h ≈ 20 (log₂10⁶≈20), ширина последнего уровня ≈ 500 000 — BFS может требовать памяти на порядки больше, чем DFS урок D2_algo
deep algo Какой обход используют, чтобы освободить дерево снизу вверх postorder (сначала оба ребёнка, потом сам узел) урок D2_algo
base algo Инвариант BST для каждого узла все значения в левом поддереве меньше значения узла, все значения в правом — больше урок D2_algo
base algo Сложность поиска в BST O(h), где h — высота дерева урок D2_algo
core algo Что даёт inorder-обход BST значения по возрастанию — прямое следствие инварианта урок D2_algo
core algo Высота BST в лучшем и худшем случае для n узлов сбалансированное: h ≈ log₂n; вырожденное: h = n урок D2_algo
deep algo Что произойдёт при вставке 1,2,...,n по порядку в обычный (небалансирующийся) BST дерево вырождается в цепочку (каждый новый узел — правый ребёнок предыдущего), поиск деградирует до O(n) урок D2_algo
base algo Почему нельзя валидировать BST, сравнивая узел только с непосредственным родителем нарушение инварианта может возникнуть через поколение (правый потомок левого поддерева больше корня, но меньше своего прямого родителя) — локальная проверка это не ловит урок D2_algo
core algo Как правильно валидировать BST рекурсивно передавать вниз границы (min, max): узел должен строго лежать между ними, для левого поддерева ужесточается верхняя граница, для правого — нижняя урок D2_algo
core algo Сложность LCA в BST и почему O(h): если оба искомых значения меньше текущего узла — влево, оба больше — вправо, иначе текущий узел и есть LCA урок D2_algo
core algo Сложность LCA в обычном бинарном дереве без порядка O(n): нет инварианта, отсекающего часть дерева, нужна рекурсия postorder по потенциально всем узлам урок D2_algo
deep algo Альтернативный способ валидации BST без явных границ (min, max) inorder-обход с проверкой строгого возрастания относительно предыдущего посещённого значения урок D2_algo
base algo Индексы детей и родителя в куче на массиве для узла i дети 2i+1, 2i+2; родитель (i-1)/2 (целочисленное деление) урок D2_algo
base algo Сложность доступа к максимуму (top) в max-heap O(1) — максимум всегда в корне по инварианту урок D2_algo
core algo Сложность sift-up/sift-down O(log n) — путь ограничен высотой дерева урок D2_algo
core algo За какое время строится куча через bottom-up heapify против n последовательных push O(n) против O(n log n): работа sift-down в узле пропорциональна его высоте h, а сумма Σ h/2^h по всем узлам сходится к константе урок D2_algo
deep algo С какого индекса стартует bottom-up heapify и куда идёт с последнего нелистового узла, индекс n/2 - 1, к корню (индекс 0) урок D2_algo
base algo Что такое `std::priority_queue` по умолчанию max-heap поверх vector; для min-heap передают компаратор std::greater<> урок D2_algo
core algo Сложность top-K через полную кучу O(n + k log n): O(n) heapify + k раз pop по O(log n) урок D2_algo
core algo Когда используют min-heap размера k вместо полной кучи на потоке данных, не помещающемся в память целиком: min-heap размера k хранит только k текущих кандидатов, O(n log k), память O(k) урок D2_algo
core algo Средняя и худшая сложность `nth_element` (quickselect) в среднем O(n), в худшем O(n²) — та же причина, что у quicksort: устойчиво плохой выбор опорного урок D2_algo
deep algo Почему top-K через nth_element не даёт готовый отсортированный список nth_element только частично упорядочивает вокруг k-го элемента без порядка внутри половин; нужна досортировка k-элементного префикса урок D2_algo
deep algo Как получить top-K частых элементов за O(n) без log-фактора подсчёт хеш-таблицей O(n), затем bucket sort по частоте: частота не превышает n, массив корзин размера n+1 индексируется частотой напрямую урок D2_algo
base c_cpp sizeof(struct { char a; int b; char c; }) на x86-64 12 байт урок D2_cpp
core c_cpp Смещения полей a, b, c в этой структуре a=0, b=4 (после 3 байт паддинга), c=8 урок D2_cpp
core c_cpp Почему в конце структуры ещё 3 байта паддинга размер всей структуры округляется вверх до кратного выравниванию самого строгого поля (здесь int, выравнивание 4): 9 → 12 урок D2_cpp
deep c_cpp Что делает #pragma pack(1) и какая у него цена убирает паддинг (sizeof(S) станет 6), но доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим выравниванием урок D2_cpp
base c_cpp Какие 5 функций входят в правило пяти деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением урок D2_cpp
base c_cpp Что делает сгенерированный компилятором конструктор копирования по умолчанию побитовое (memberwise) копирование каждого поля урок D2_cpp
core c_cpp Почему копирование объекта с владеющим сырым указателем даёт double-free оба объекта получают одно и то же значение указателя (адрес), оба деструктора вызывают delete на этом адресе — второй раз на уже освобождённой памяти урок D2_cpp
core c_cpp Что такое rule of zero не объявлять ни одну из пяти спецфункций вручную, доверив владение ресурсом готовым RAII-обёрткам (unique_ptr, vector, string) — их сгенерированные копирование/перемещение уже корректны урок D2_cpp
base c_cpp Что добавляет в объект наличие хотя бы одной virtual-функции скрытый указатель на vtable, обычно 8 байт на 64-битной платформе урок D2_cpp
core c_cpp Что произойдёт при delete через Base*, если ~Base() не virtual, а объект на деле Derived вызовется только ~Base(), ~Derived() не вызовется вообще — утечка ресурсов Derived, формально UB урок D2_cpp
core c_cpp Чем это ловится LeakSanitizer, со стеком выделения внутри конструктора Derived урок D2_cpp
deep c_cpp Что вызовет virtual-функция, вызванная из конструктора базового класса версию базового класса, а не переопределённую в наследнике: vtable объекта на этом этапе ещё указывает на таблицу Base урок D2_cpp
base c_cpp Что физически делает std::move во время выполнения программы ничего: это static_cast к rvalue-ссылке (T&&), явный каст, не операция урок D2_cpp
core c_cpp Что реально выполняет перемещение данных move-конструктор/move-оператор присваивания конкретного типа, выбранный благодаря касту std::move урок D2_cpp
core c_cpp Что происходит с vector при перемещении и за какое время копируются 3 внутренних указателя (начало, конец данных, конец ёмкости) в новый объект, у источника они обнуляются — O(1), без копирования элементов урок D2_cpp
deep c_cpp В каком состоянии находится объект после std::move(obj) по стандарту в валидном, но неопределённом состоянии — использование старых данных не UB, но логическая ошибка, не ловится санитайзерами урок D2_cpp
base c_cpp Что проверяет ASAN, а что UBSAN? — ASAN: ошибки работы с памятью (границы, use-after-free, double-free, утечки); UBSAN: операции, являющиеся UB по стандарту (переполнение, сдвиг, выравнивание) урок D2_cpp
base c_cpp Значение INT_MAX для 32-битного int 2147483647 урок D2_cpp
core c_cpp Почему UB опасен именно тем, что код может работать в отладочной сборке и падать в релизной оптимизатор релизной сборки строит код в предположении, что UB не происходит, и может убрать проверки, которые, по мнению программиста, должны были сработать урок D2_cpp
core c_cpp Какой флаг компилятора включает сразу оба санитайзера -fsanitize=address,undefined урок D2_cpp
deep c_cpp Почему разыменование nullptr на практике обычно даёт SIGSEGV, а не тихо читает мусор ОС намеренно не отображает страницу по адресу 0 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает урок D2_cpp
base linux Какие флаги комбинируются битовым ИЛИ при open() O_RDONLY/O_WRONLY/O_RDWR, O_APPEND, O_CREAT, O_TRUNC, O_NONBLOCK урок D2_linux
base linux Что возвращает read() при достижении конца файла 0 урок D2_linux
core linux Что делает lseek() и меняет ли он содержимое файла двигает позицию чтения/записи файла, содержимое не трогает урок D2_linux
core linux Чем O_APPEND отличается от ручного lseek(fd, 0, SEEK_END) перед каждой записью O_APPEND атомарно смещает позицию в конец непосредственно перед самой записью на уровне ядра, ручной lseek+write у двух процессов может гонку: оба сделают lseek, потом оба write, и один перезапишет данные другого урок D2_linux
base linux Может ли write() записать меньше байт, чем попросили, без ошибки да, это не ошибка, нужен цикл дозаписи урок D2_linux
core linux Чем fdatasync отличается от fsync fsync сбрасывает на диск данные и все метаданные файла, fdatasync — только данные и те метаданные, что нужны для последующего чтения (например, размер), пропуская остальные (время доступа) урок D2_linux
core linux Какой errno означает «вызов прерван сигналом, нужно просто повторить» EINTR урок D2_linux
base linux В чём разница MAP_SHARED и MAP_PRIVATE MAP_SHARED: изменения видны другим процессам и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны и не попадают в файл на диске урок D2_linux
base linux Что делает msync принудительно сбрасывает изменения MAP_SHARED-области на диск, не дожидаясь ядра урок D2_linux
core linux Чем minor page fault отличается от major minor: страница уже в страничном кэше, только добавляется отображение (дёшево); major: страница реально читается с диска (дорого) урок D2_linux
core linux Когда mmap проигрывает read по скорости при однократном последовательном чтении небольшого файла — накладные расходы на отображение и page fault не амортизируются урок D2_linux
deep linux Что ограничивает mmap на 32-битных системах размер доступного виртуального адресного пространства (порядка нескольких гигабайт) — файл целиком отобразить может не получиться урок D2_linux
base linux Жёсткий лимит числа дескрипторов у select и его значение FD_SETSIZE, 1024 урок D2_linux
base linux Сложность select и poll на один вызов O(n) от общего числа отслеживаемых дескрипторов, независимо от того, сколько готовы урок D2_linux
core linux Три функции epoll и их роль epoll_create1 (создать инстанс), epoll_ctl (ADD/MOD/DEL в списке интереса), epoll_wait (забрать готовые из списка готовых) урок D2_linux
core linux На чём построен список интереса epoll внутри ядра на красно-чёрном дереве урок D2_linux
core linux Сложность epoll_wait на одно готовое событие O(1) — ядро уже отфильтровало готовые дескрипторы заранее, работа не зависит от общего числа зарегистрированных урок D2_linux
base linux В чём разница level-triggered и edge-triggered LT: событие повторяется, пока условие истинно; ET: событие сообщается один раз, в момент перехода в готовое состояние урок D2_linux
core linux Почему ET требует неблокирующих дескрипторов на блокирующем дескрипторе цикл чтения до исчерпания данных на последнем вызове заблокируется навсегда вместо возврата EAGAIN урок D2_linux
core linux Что произойдёт, если в ET-режиме не дочитать данные до EAGAIN оставшиеся данные не будут сигнализированы повторно, пока состояние дескриптора не изменится снова (например, не придут новые данные) урок D2_linux
deep linux Какой флаг epoll_ctl включает edge-triggered режим EPOLLET урок D2_linux
base linux Что означает EAGAIN/EWOULDBLOCK на неблокирующем дескрипторе данных для чтения нет (или буфер записи полон) прямо сейчас — не ошибка, повторить позже урок D2_linux
core linux Совпадают ли числовые значения EAGAIN и EWOULDBLOCK на Linux да, это синонимы с одним и тем же значением урок D2_linux
base linux Сколько времени сокет проводит в TIME_WAIT 2×MSL (удвоенное время жизни сегмента в сети) урок D2_linux
core linux Зачем нужно состояние TIME_WAIT чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения и не спутать их с новым, если порт переиспользуют слишком быстро урок D2_linux
core linux Что даёт SO_REUSEADDR разрешает bind на адрес/порт, у которого уже есть сокеты в состоянии TIME_WAIT — иначе bind вернёт «Address already in use» урок D2_linux
base algo Сложность вставки в список, если указатель на место уже есть O(1) урок D3_algo
base algo Сложность поиска элемента в связном списке O(n) урок D3_algo
core algo Почему список медленнее массива на практике, хотя вставка O(1) на бумаге узлы разбросаны по куче, промахи кэша при обходе урок D3_algo
core algo Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего лишним указателем `prev` в каждом узле урок D3_algo
base algo Сложность разворота списка тремя указателями O(n) по времени, O(1) по памяти урок D3_algo
core algo Зачем нужен третий указатель `next` сохранить связь с остатком списка до перезаписи `curr->next` урок D3_algo
base algo Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины один урок D3_algo
core algo На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения на k шагов урок D3_algo
base algo На сколько узлов за шаг двигается `fast` в поиске середины на 2, `slow` — на 1 урок D3_algo
core algo Что вернёт `find_middle` для списка из 4 узлов (1→2→3→4) узел 3 (второй из двух средних) урок D3_algo
deep algo Почему условие цикла `fast && fast->next`, а не только `fast`? — без `fast->next` обращение `fast->next->next` на последнем узле разыменует `nullptr` урок D3_algo
base algo С какой относительной скоростью `fast` догоняет `slow` внутри цикла 1 узел за шаг урок D3_algo
core algo Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи один, второй остаётся в точке встречи, оба идут дальше по 1 шагу урок D3_algo
deep algo Почему после первой встречи путь от головы длиной `a` и путь от точки встречи длиной `c-b` сходятся в одной точке из равенства `2(a+b) = a+b+n·c`, откуда `a = (n-1)c + (c-b)` урок D3_algo
base algo Что решает dummy-узел убирает отдельную ветку кода для вставки/удаления в начало списка урок D3_algo
core algo Что возвращают в конце вместо dummy `dummy->next` как настоящую голову результата урок D3_algo
base algo Сложность push/pop у стека на массиве O(1) урок D3_algo
core algo Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между `in` и `out` не более одного раза урок D3_algo
core algo Средняя (амортизированная) сложность pop в очереди на двух стеках O(1), несмотря на то что отдельный вызов с переливом стоит O(n) урок D3_algo
base algo Формула перехода индекса на начало массива в кольцевом буфере `idx = (idx + 1) % capacity` урок D3_algo
base algo Сложность push/pop в кольцевом буфере O(1), без сдвига элементов урок D3_algo
core algo Как отличить полный кольцевой буфер от пустого, если `head == tail` счётчик `size`, либо держать одну ячейку всегда свободной урок D3_algo
core algo Более быстрая замена `% capacity` без деления `if (++idx == capacity) idx = 0;` урок D3_algo
deep algo Где кольцевой буфер встречается в драйверах буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных урок D3_algo
base algo Наивная сложность Next Greater Element вложенным циклом O(n²) урок D3_algo
base algo Сложность Next Greater Element через монотонный стек O(n) урок D3_algo
core algo Почему монотонный стек даёт O(n), если внутри есть вложенный `while` каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход урок D3_algo
core algo Что хранит монотонный стек в задаче Next Greater Element значения или индексы? — индексы (значения по ним убывают снизу вверх) урок D3_algo
deep algo Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение урок D3_algo
base linux Какие два флага компиляции нужны для комфортной отладки в gdb `-g -O0` урок D3_gdb
base linux Как называется формат отладочной информации, который встраивает `-g` DWARF урок D3_gdb
core linux Почему `-O2` мешает отладке даже при наличии `-g` оптимизатор переставляет/удаляет инструкции и переиспользует регистры, отладочная информация перестаёт однозначно совпадать с исходником урок D3_gdb
base linux Какая команда печатает стек вызовов в gdb `bt` урок D3_gdb
base linux Что означает `x/16xb ptr` 16 байт в hex начиная с адреса `ptr` (формат `x/NFU addr`) урок D3_gdb
core linux Чем `frame N` отличается от `bt` `bt` печатает весь стек, `frame N` переключает текущий контекст `print`/`info locals` на конкретный кадр из этого стека урок D3_gdb
base linux Чем `tbreak` отличается от `break` `tbreak` удаляется автоматически после первого срабатывания урок D3_gdb
core linux Чем `rwatch` отличается от `watch` `watch` реагирует на изменение значения, `rwatch` — на чтение переменной урок D3_gdb
deep linux Сколько аппаратных регистров отладки на x86 ограничивают число одновременных аппаратных watchpoint 4 (`DR0`–`DR3`) урок D3_gdb
base linux Чем `next` отличается от `step` `next` не заходит внутрь вызываемых функций, `step` заходит урок D3_gdb
core linux Что делает `finish` выполняет до возврата из текущей функции и печатает возвращаемое значение урок D3_gdb
core linux Зачем нужен `until` внутри цикла продолжить до строки с номером больше текущей, не проходя цикл пошагово урок D3_gdb
base linux Каким кодом завершается процесс при SIGSEGV 139 (128+11) урок D3_gdb
base linux Какой командой разрешить сохранение core-файлов перед ожиданием падения `ulimit -c unlimited` урок D3_gdb
core linux Как открыть core dump вместе с бинарником в gdb `gdb prog core` урок D3_gdb
core linux Почему пересборка бинарника перед анализом core ломает разбор адреса в новом бинарнике не совпадают с адресами, записанными в core, gdb покажет несогласованный стек урок D3_gdb
base linux Какая команда gdb показывает все потоки процесса разом `info threads` урок D3_gdb
core linux Как переключиться на конкретный поток по номеру для `bt`/`print` `thread N` урок D3_gdb
core linux Как gdb помогает диагностировать дедлок, если программа просто зависла без вывода `info threads` + `bt` по каждому потоку показывают, кто на каком мьютексе застрял урок D3_gdb
core linux В чём разница между точкой, которую покажет gdb при краше, и точкой, которую покажет ASAN gdb показывает точку симптома (где реально упало), ASAN — точку причины (момент нарушения) урок D3_gdb
base linux Каким флагом компиляции включается ASAN `-fsanitize=address` урок D3_gdb
base linux Каким флагом компиляции включается UBSAN `-fsanitize=undefined` урок D3_gdb
base linux Какой модуль valgrind ищет ошибки памяти `memcheck` (`valgrind --tool=memcheck`) урок D3_gdb
core linux Почему valgrind медленнее ASAN эмулирует каждую машинную инструкцию в софтверной VM, а не добавляет проверки только вокруг обращений к памяти в нативном коде урок D3_gdb
core linux Когда valgrind предпочтительнее санитайзеров когда нет доступа к исходникам/пересборке (готовый бинарник, сторонняя библиотека) урок D3_gdb
base linux Какой командой собирают `crash.c` для отладки с санитайзерами `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash` урок D3_gdb
base linux Что печатает `./crash` без аргументов после починки `len=5`, код возврата 0 урок D3_gdb
core linux Почему падение может произойти не в строке с самой ошибкой, а при выходе из программы испорченный служебный указатель (стек/метаданные кучи) проявляется только в момент разрушения объекта, а не в момент самой порчи урок D3_gdb
base threads Когда именно стартует новый поток при `std::thread t(f)` сразу, в конструкторе урок D3_threads
base threads Примерный размер стека потока на типичной Linux-системе ~8 МБ урок D3_threads
core threads Что произойдёт, если объект `std::thread` с незавершённым и не присоединённым потоком уничтожится `std::terminate`, программа аварийно завершится урок D3_threads
core threads Каким системным вызовом Linux создаёт новый поток `clone` урок D3_threads
base threads Что такое data race одновременный доступ к одной памяти минимум с одной записью без синхронизации урок D3_threads
core threads Является ли гонка на обычном `int` без атомиков UB по стандарту C++ да, даже если на конкретном железе чтение/запись `int` физически атомарны урок D3_threads
core threads Почему компилятору мало того, что чтение `int` атомарно на железе без синхронизации он вправе кэшировать значение в регистре и переупорядочивать обращения к памяти урок D3_threads
base threads Какая RAII-обёртка нужна для работы с `condition_variable::wait` `std::unique_lock` урок D3_threads
base threads Что делает `std::scoped_lock` атомарно захватывает несколько мьютексов сразу урок D3_threads
core threads Почему дедлок ловится ThreadSanitizer, а не компилятором это динамическая ошибка, зависящая от конкретной раскладки потоков в рантайме, а не от статической структуры кода урок D3_threads
base threads Что атомарно делает `cv.wait(lock)` при входе освобождает мьютекс и усыпляет поток одной неделимой операцией урок D3_threads
core threads Почему `while`, а не `if`, вокруг `wait` из-за spurious wakeup: `wait` может вернуться без единого вызова `notify` урок D3_threads
core threads Обязательно ли вызывать `notify` под захваченным мьютексом нет, обязательно лишь менять состояние под мьютексом; `notify` после `unlock()` — оптимизация, не требование корректности урок D3_threads
deep threads На каком примитиве ОС реализован `condition_variable` на Linux `futex` урок D3_threads
core threads Назови 4 условия Коффмана для дедлока взаимное исключение, удержание-и-ожидание, невозможность отбора, круговое ожидание урок D3_threads
core threads Какое условие обычно разрушают на практике фиксированным порядком захвата мьютексов круговое ожидание урок D3_threads
base threads Какой командой gdb смотрят, на каком мьютексе застрял каждый поток при зависании `info threads` (и стек каждого потока) урок D3_threads
base threads Чем `std::atomic` дешевле мьютекса для простого счётчика одна аппаратная инструкция (например CAS), без перехода в ядро и усыпления потока урок D3_threads
core threads Что гарантирует `memory_order_relaxed` только атомарность операции, без ограничений порядка с другими обращениями к памяти урок D3_threads
core threads Что запрещает `acquire`, а что `release`? — acquire запрещает переносить более поздние обращения ДО себя; release запрещает переносить более ранние обращения ПОСЛЕ себя урок D3_threads
deep threads Сколько состояний у кэш-линии в протоколе MESI 4 (Modified, Exclusive, Shared, Invalid) урок D3_threads
deep threads Какой memory_order используется по умолчанию у операций `std::atomic` `seq_cst` урок D3_threads
base threads Каким исключением отвечает `push` после `close()` `std::runtime_error` урок D3_threads
core threads Почему `size()` в этой задаче требует того же мьютекса, что и данные очереди инкремент/декремент — не атомарная операция read-modify-write, без синхронизации это гонка данных урок D3_threads
base threads Примерно сколько потоков создают в пуле относительно ядер CPU порядка числа ядер процессора урок D3_threads
core threads Какую конкретно цену амортизирует пул потоков стоимость создания потока (системный вызов, стек, регистрация в планировщике) на каждую отдельную короткую задачу урок D3_threads
base threads Что возвращает `std::async` сразу после вызова `std::future` с будущим результатом урок D3_threads
core threads Сколько раз можно вызвать `future.get()` для одного результата один; второй вызов — неопределённое поведение (`valid() == false`) урок D3_threads
base threads Каким флагом компиляции включается ThreadSanitizer `-fsanitize=thread` урок D3_threads
core threads Почему чистый прогон под TSan не гарантирует отсутствие гонки в коде вообще TSan ловит гонку по факту конкретной раскладки потоков в конкретном запуске, а не статическим анализом всех возможных раскладок урок D3_threads
base algo Память матрицы смежности для V вершин O(V²) урок D4_algo
base algo Память списка смежности O(V+E) урок D4_algo
core algo Сложность перебора всех соседей вершины в списке смежности O(deg(v)), в матрице — всегда O(V) урок D4_algo
core algo Почему для графа 100 000 вершин нужен список смежности, а не матрица матрица заняла бы 10¹⁰ ячеек, не влезает в память урок D4_algo
base algo Сложность BFS на списке смежности O(V+E) урок D4_algo
base algo Какую структуру данных использует BFS очередь (FIFO) урок D4_algo
core algo Что гарантированно находит BFS в невзвешенном графе кратчайший путь по числу рёбер от источника урок D4_algo
core algo Почему BFS даёт кратчайший путь именно из-за очереди, а не из-за чего-то ещё FIFO-порядок раскрывает граф строго по слоям расстояния, вершина расстояния k не может обработаться раньше всех вершин расстояния <k урок D4_algo
base algo Сложность DFS на списке смежности O(V+E) урок D4_algo
base algo Какую структуру данных использует DFS стек (явный или стек вызовов рекурсии) урок D4_algo
core algo Гарантирует ли DFS кратчайший путь нет, в отличие от BFS урок D4_algo
deep algo Почему рекурсивный DFS опасен на графе-цепочке из 100 000 вершин глубина рекурсии равна длине цепочки, стек вызовов переполняется урок D4_algo
base algo Сложность подсчёта связных компонент через BFS/DFS от каждой непосещённой вершины O(V+E), суммарно по всем запускам урок D4_algo
core algo Что в задаче Number of Islands является «вершиной» и «ребром» вершина — клетка `'1'`, ребро — соседство по 4 направлениям урок D4_algo
base algo Для какого типа графа определена топологическая сортировка направленный ациклический граф (DAG) урок D4_algo
base algo Сложность алгоритма Кана O(V+E) урок D4_algo
core algo Что такое степень входа вершины в алгоритме Кана число входящих в неё рёбер (нерассмотренных зависимостей) урок D4_algo
core algo Как алгоритм Кана обнаруживает цикл счётчик обработанных вершин `order.size()` меньше `n` после завершения урок D4_algo
core algo Как задача Course Schedule сводится к топологической сортировке курсы — вершины, пререквизит — направленное ребро, цикл = невозможно пройти все курсы урок D4_algo
base algo Обязательное условие применимости Дейкстры все веса рёбер неотрицательны (w ≥ 0) урок D4_algo
base algo Сложность наивной Дейкстры (без кучи) O(V²) урок D4_algo
core algo Сложность Дейкстры с `priority_queue` O((V+E) log V) урок D4_algo
core algo Почему отрицательное ребро ломает Дейкстру вершина считается решённой сразу после извлечения минимума, а отрицательное ребро из ещё не рассмотренной вершины теоретически могло бы уменьшить это «зафиксированное» расстояние урок D4_algo
core algo Что проверяет `if (d != dist[v]) continue;` в реализации на куче что извлечённая запись не устарела (для v уже нашли путь короче) урок D4_algo
deep algo Почему куча может держать до O(E) записей вместо O(V) при каждом улучшении расстояния в кучу кладётся новая запись вместо обновления старой (нет decrease-key) урок D4_algo
base algo Сложность Беллман-Форда O(V·E) урок D4_algo
base algo Сколько раз алгоритм проходит по всем рёбрам в основном цикле V-1 раз урок D4_algo
core algo Как Беллман-Форд обнаруживает отрицательный цикл если расстояние ещё можно уменьшить на дополнительной V-й итерации, в графе есть отрицательный цикл урок D4_algo
core algo Почему V-1 итераций достаточно кратчайший путь без отрицательных циклов не может содержать больше V-1 ребра (иначе повторяет вершину) урок D4_algo
base algo Какие два приёма дают почти-константную сложность union-find сжатие пути и объединение по рангу урок D4_algo
base algo Амортизированная сложность операции union-find с обоими приёмами O(α(n)), обратная функция Аккермана, практически O(1) урок D4_algo
core algo Сложность алгоритма Kruskal и что в ней доминирует O(E log E), доминирует сортировка рёбер урок D4_algo
core algo Как union-find решает Redundant Connection первое ребро, для которого `unite` вернул false (вершины уже в одной компоненте), и есть лишнее урок D4_algo
core algo Как Network Delay Time сводится к Дейкстре вершина k — источник, ответ — максимум из всех кратчайших расстояний, -1 если что-то недостижимо урок D4_algo
deep algo Каково верхнее ограничение обратной функции Аккермана для практических n α(n) ≤ 4 для любого n, меньшего числа атомов во вселенной урок D4_algo
base net Сколько уровней в модели OSI 7 урок D4_net
base net Сколько уровней в модели TCP/IP 4 урок D4_net
core net Какие три уровня OSI объединяет прикладной уровень TCP/IP сеансовый, представления, прикладной урок D4_net
core net На каком уровне создаётся сокет транспортном (L4 OSI / транспортный TCP/IP) урок D4_net
base net Размер заголовка TCP-сегмента (без опций) 20 байт урок D4_net
base net Размер заголовка IP-пакета (без опций) 20 байт урок D4_net
base net Размер заголовка Ethernet-кадра и трейлера FCS 14 байт заголовок + 4 байта FCS урок D4_net
core net По какому полю верхний уровень при приёме узнаёт, что лежит внутри нижнего по полю типа протокола (EtherType в Ethernet, `protocol` в IP) урок D4_net
base net Длина Ethernet-заголовка 14 байт урок D4_net
base net Длина трейлера FCS 4 байта урок D4_net
base net Длина MAC-адреса в битах и байтах 48 бит, 6 байт урок D4_net
core net Из скольки байт состоит OUI в MAC-адресе и кто его назначает 3 байта, назначает IEEE производителю урок D4_net
core net Что меняется в заголовках при переходе пакета через маршрутизатор MAC или IP получателя? — MAC-адрес получателя, IP остаётся прежним урок D4_net
base net Как рассылается ARP-запрос broadcast или unicast? — broadcast урок D4_net
base net Как отправляется ARP-ответ unicast, от владельца адреса урок D4_net
core net Зачем нужен ARP-кэш не повторять broadcast-запрос для каждого исходящего пакета к уже известному узлу урок D4_net
core net Что такое gratuitous ARP и для чего он нужен незапрошенный ARP про собственный IP; обновление чужих кэшей заранее и обнаружение конфликта адресов урок D4_net
base net Сколько байт добавляет VLAN-тег 802.1Q к Ethernet-заголовку 4 байта (14 → 18) урок D4_net
base net Значение поля TPID для VLAN-тега 0x8100 урок D4_net
core net Из каких трёх полей состоит TCI и сколько бит занимает VID PCP (3 бита), DEI (1 бит), VID (12 бит) урок D4_net
core net Диапазон реально используемых VLAN ID 1–4094 (0 и 4095 зарезервированы) урок D4_net
base net Значение MTU для стандартного Ethernet 1500 байт урок D4_net
core net Что происходит с IP-пакетом крупнее MTU, если флаг DF не выставлен фрагментируется на несколько IP-пакетов меньшего размера урок D4_net
core net Что происходит, если пакет крупнее MTU и выставлен флаг DF пакет отбрасывается, отправителю летит ICMP о необходимости фрагментации урок D4_net
deep net Почему фрагментация считается дорогой операцией нагружает маршрутизаторы на пути, и потеря одного фрагмента роняет весь исходный пакет урок D4_net
base net Минимальный размер IPv4-заголовка 20 байт урок D4_net
base net Значение поля Protocol для TCP / UDP / ICMP 6 / 17 / 1 урок D4_net
core net Сколько бит занимает поле IHL и что оно означает 4 бита, длина заголовка в 32-битных словах урок D4_net
core net Какие условия делают IPv4-пакет некорректным при разборе (по `tasks/04_ipv4`) буфер <20 байт, version≠4, ihl<5, ihl·4>len, total_length<ihl·4 или >len урок D4_net
deep net Почему в `tasks/04_ipv4` запрещён `reinterpret_cast` на буфер невыровненный адрес и другой порядок байт на проводе дают UB при чтении полей как структуры напрямую урок D4_net
base net На сколько уменьшается TTL на каждом маршрутизаторе на 1 урок D4_net
base net Какой ICMP-тип отправляется при обнулении TTL Time Exceeded, тип 11 урок D4_net
core net Как traceroute находит промежуточные маршрутизаторы, не имея отдельного протокола обнаружения пути последовательно шлёт пакеты с TTL=1,2,3..., каждый умирает на очередном хопе и присылает ICMP Time Exceeded с адресом этого хопа урок D4_net
base net Номера типов Echo Request и Echo Reply 8 и 0 урок D4_net
base net Тип ICMP-сообщения Destination Unreachable тип 3 урок D4_net
core net Какой код Destination Unreachable запускает PMTUD "fragmentation needed and DF set" урок D4_net
core net Почему ICMP не имеет портов диагностика работает на сетевом уровне, где ещё нет понятия транспортного соединения урок D4_net
deep net Почему `ping` может не проходить, а `curl` на тот же хост работать? — ICMP заблокирован файрволом отдельно от TCP-порта, это два разных уровня фильтрации урок D4_net
base net Алгоритм контрольной суммы IPv4-заголовка сумма в дополнительном коде по 16-битным словам, затем инверсия урок D4_net
core net Какое значение должна давать сумма при проверке валидности (с учётом поля суммы) 0xFFFF урок D4_net
core net Почему контрольная сумма покрывает только заголовок, а не данные заголовок (минимум TTL) меняется на каждом хопе и пересчитывается заново; целостность данных проверяют TCP/UDP-checksum и FCS канального уровня урок D4_net
deep net Что такое end-around carry в one's complement сложении перенос из старшего бита не отбрасывается, а прибавляется обратно к младшему биту суммы урок D4_net
base net Сколько хостов доступно в подсети /24 254 урок D4_net
base net Сколько хостов доступно в подсети /26 62 урок D4_net
base net Сколько хостов доступно в подсети /30 2 урок D4_net
core net Чем /31 отличается от остальных масок по числу служебных адресов оба адреса хостовые (RFC 3021), нет отдельного сетевого/broadcast адреса урок D4_net
core net Что означает запись `/N` в CIDR длина префикса сети в битах вместо классовой адресации A/B/C урок D4_net
deep net Формула подбора самой узкой подсети под h хостов за O(1) `32 - ceil(log2(h+2))` урок D4_net
base net На каком уровне работает хаб / коммутатор / маршрутизатор L1 / L2 / L3 урок D4_net
base net Что такое FDB коммутатора по структуре данных хеш-таблица MAC-адрес → порт урок D4_net
core net Как коммутатор заполняет FDB без отдельного протокола объявления самообучением по source MAC каждого входящего кадра урок D4_net
core net Что делает коммутатор с кадром, чей MAC получателя не найден в FDB рассылает на все порты кроме входного (flooding) урок D4_net
core net Зачем нужен aging записей FDB удалять устаревшие пары MAC-порт, если устройство отключилось или переехало на другой порт урок D4_net
deep net Что разделяет маршрутизатор, чего не делает коммутатор широковещательные домены (домены коллизий разделяет уже коммутатор) урок D4_net
base algo Два условия применимости ДП оптимальная подструктура + перекрывающиеся подзадачи урок D5_algo
core algo Почему наивный рекурсивный `fib(n)` работает за экспоненциальное время одни и те же подзадачи (`fib(k)` для одного и того же k) пересчитываются заново много раз урок D5_algo
core algo Что ломается, если применить ДП-переход к задаче без оптимальной подструктуры переход не отражает реальную зависимость оптимумов, ответ будет неверным независимо от таблицы урок D5_algo
base algo Сложность по времени мемоизации/табуляции O(число состояний) урок D5_algo
core algo Чем мемоизация рискует, а табуляция нет? — переполнением стека при большой глубине рекурсии урок D5_algo
core algo Что нужно определить в ДП-задаче до написания кода состояние (параметры подзадачи) и переход (формула через уже решённые состояния) урок D5_algo
base algo Сложность по памяти Climbing Stairs при развёрнутых `prev1`/`prev2` O(1) урок D5_algo
core algo Почему рюкзак 0/1 нельзя ужать до O(1), только до O(W) переход `dp[i][w]` зависит от целой предыдущей строки по весу, а не от 1–2 соседних чисел урок D5_algo
base algo Временная сложность 0/1-рюкзака с одномерным массивом O(n·W) урок D5_algo
core algo Почему в одномерном 0/1-рюкзаке веса обходят от W к weight[i], а не наоборот иначе `dp[w-weight[i]]` уже обновлён текущим предметом в этой же итерации, предмет посчитается дважды урок D5_algo
deep algo Как называется вариант рюкзака, в который случайно превращается 0/1-рюкзак при прямом проходе весов unbounded knapsack (неограниченное число копий предмета) урок D5_algo
base algo Сложность Coin Change (минимум монет) по времени O(amount · число_номиналов) урок D5_algo
core algo Какой порядок циклов в Coin Change II даёт число комбинаций, а какой перестановок? — монета снаружи/сумма внутри → комбинации; сумма снаружи/монета внутри → перестановки урок D5_algo
base algo Размер таблицы LCS/Edit Distance для строк длины n и m (n+1)×(m+1) урок D5_algo
base algo Временная сложность LCS и Edit Distance O(n·m) урок D5_algo
core algo Почему при несовпадении последних символов в LCS берут max(dp[i-1][j], dp[i][j-1]) оба последних символа одновременно в общую подпоследовательность войти не могут, значит хотя бы один из них можно отбросить без потери оптимальности урок D5_algo
base algo Наивная сложность LIS через dp[i] O(n²) урок D5_algo
base algo Сложность LIS через массив хвостов и бинарный поиск O(n log n) урок D5_algo
core algo Что хранится в `tails[k]` минимально возможный последний элемент возрастающей подпоследовательности длины k+1 урок D5_algo
core algo Почему `tails` остаётся отсортированным после каждой замены/добавления новое значение всегда меньше заменяемого и больше всех элементов левее позиции, найденной `lower_bound` урок D5_algo
core algo `lower_bound` или `upper_bound` нужен для строго возрастающей LIS `lower_bound` урок D5_algo
deep algo Сколько операций у наивного O(n²) LIS на 100 000 элементах и почему это не укладывается в тест порядка 10¹⁰, тест роняет прогон при времени > 2 с урок D5_algo
base linux Чей код возврата хранит `$?` после `cmd1 | cmd2 | cmd3` только последней команды (`cmd3`) урок D5_bash
core linux Как узнать код возврата каждой команды пайплайна отдельно массив `PIPESTATUS` (`${PIPESTATUS[@]}`) урок D5_bash
base linux Что делает `-e` в `set -euo pipefail` скрипт завершается сразу при ненулевом коде возврата любой команды урок D5_bash
base linux Что делает `-u` обращение к неопределённой переменной — ошибка вместо пустой строки урок D5_bash
core linux Что делает `pipefail` и какую проблему из раздела 1 это закрывает код возврата пайплайна = код первой упавшей команды, а не только последней; закрывает потерю ошибок середины пайплайна урок D5_bash
core linux В каком месте `-e` не остановит скрипт при ошибке команды если команда — часть условия (`if`, `while`, после `&&`/`||`) или не последняя команда пайплайна без `pipefail` урок D5_bash
base linux Что по умолчанию входит в IFS пробел, таб, перевод строки урок D5_bash
core linux Что отключают двойные кавычки вокруг `"$var"` word splitting и globbing (`*`/`?`/`[...]` не раскрываются) урок D5_bash
core linux Как правильно перебрать массив с элементами, содержащими пробелы `for x in "${arr[@]}"` (с кавычками) урок D5_bash
base linux Что хранит `$!` PID последнего фонового процесса урок D5_bash
core linux Зачем `trap ... EXIT` используют для временных файлов гарантирует очистку при любом пути завершения скрипта (успех, ошибка, сигнал), без дублирования кода в каждой точке выхода урок D5_bash
base linux Чем `-0` в `xargs -0` отличается от поведения по умолчанию вход разделяется нулевыми байтами `\0` вместо пробелов/переводов строк урок D5_bash
core linux С какой опцией `find` обычно комбинируют `xargs -0` `-print0` урок D5_bash
base linux Что печатает `grep -c` количество совпавших строк, а не сами строки урок D5_bash
base linux Почему `uniq -c` нужно применять после `sort` `uniq` схлопывает только соседние одинаковые строки, несмежные повторы не видит урок D5_bash
core linux Чем `sort -u` эквивалентен по результату `sort | uniq`, но за один проход урок D5_bash
base linux Что означает `$1` в awk первое поле текущей строки урок D5_bash
base linux Что содержит `NR` номер текущей строки от начала потока урок D5_bash
core linux Почему awk на 1e6 строк быстрее bash-цикла с grep внутри awk — один процесс с линейным проходом по строкам, bash-цикл форкает новый процесс на каждую итерацию (миллион fork+exec против одного процесса) урок D5_bash
base linux Что делает флаг `g` в `s/OLD/NEW/g` заменяет все вхождения в строке, а не только первое урок D5_bash
core linux Чем `sed -i` отличается от `sed` без флага редактирует файл на месте вместо печати результата в stdout урок D5_bash
base linux Что такое /proc с точки зрения хранения данных виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске урок D5_bash
base linux Чем разделены аргументы в /proc/<pid>/cmdline нулевыми байтами `\0` урок D5_bash
core linux Что можно узнать из /proc/<pid>/maps диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон урок D5_bash
core linux Откуда `free -h` берёт данные о памяти из /proc/meminfo урок D5_bash
base linux Что ограничивает `ulimit -n` максимальное число открытых файловых дескрипторов на процесс урок D5_bash
core linux Почему перед отладкой редкого краша выставляют `ulimit -c unlimited` по умолчанию размер core dump часто ограничен нулём, без этого файл с состоянием памяти на момент падения не создастся урок D5_bash
base linux Расшифровка прав 755 rwxr-xr-x (владелец: полный доступ, группа и остальные: чтение+выполнение) урок D5_bash
base linux Какие числа кодируют r, w, x r=4, w=2, x=1 урок D5_bash
core linux Что делает `umask 022` с правами нового файла по умолчанию (666) вычитает 022, получается 644 (rw-r--r--) урок D5_bash
base linux Что показывает `strace` системные вызовы процесса с аргументами и результатом, построчно урок D5_bash
core linux Чем `lsof -p PID` и `/proc/<pid>/fd/` пересекаются по смыслу оба показывают список файловых дескрипторов, открытых процессом урок D5_bash
base net Минимальный размер заголовка TCP 20 байт урок D5_net
base net Сколько бит занимает порт в заголовке TCP 16 бит (диапазон 0–65535) урок D5_net
core net Максимальный размер опций TCP-заголовка и почему именно столько 40 байт, потому что Data Offset (4 бита) кодирует длину заголовка в 32-битных словах максимум 15×4=60 байт, минус 20 байт фиксированной части урок D5_net
core net В каких сегментах передаётся опция MSS только в сегментах с флагом SYN, при установке соединения урок D5_net
deep net Какие два флага TCP появились позже исходного RFC 793 и для чего ECE и CWR (RFC 3168), сигнализация перегрузки сети (ECN) вместо/вместе с потерей пакета урок D5_net
base net Сколько сегментов в three-way handshake 3 (SYN, SYN+ACK, ACK) урок D5_net
core net Почему для установки TCP-соединения недостаточно двух сегментов соединение полнодуплексное, серверу нужно подтверждение, что его SYN-ACK (и его ISN) реально дошёл до клиента урок D5_net
core net Что означает ISN и синхронизируется ли он в одном экземпляре на оба направления начальный порядковый номер; нет, у каждого направления свой собственный ISN урок D5_net
base net В каком состоянии сервер ждёт входящие подключения LISTEN урок D5_net
core net Чем отличаются пути клиента и сервера в конечном автомате TCP клиент проходит SYN_SENT, сервер — LISTEN и SYN_RECEIVED; роли в рукопожатии асимметричны урок D5_net
core net Какой командой в Linux видно текущее состояние TCP-сокета `ss -tan` (столбец State) урок D5_net
base net Сколько сегментов нужно для полного закрытия TCP-соединения 4 (FIN, ACK, FIN, ACK) урок D5_net
base net Формула длительности TIME_WAIT 2×MSL урок D5_net
core net Почему закрытие TCP асимметрично («полузакрытие»), а не мгновенное закрытие по первому FIN соединение дуплексное, получение FIN означает только «собеседник больше не пришлёт данные», но сама сторона может ещё дописывать данные в обратном направлении урок D5_net
deep net Сколько секунд реально длится TIME_WAIT в Linux и совпадает ли это с 2×MSL по RFC 793 60 секунд, фиксированная константа ядра; не совпадает с номинальными 4 минутами (2×2 мин) по RFC 793 урок D5_net
base net Что получает клиент в ответ на попытку подключиться к закрытому порту RST урок D5_net
core net Чем RST принципиально отличается от FIN по смыслу RST — аварийный немедленный сброс (ошибка/невозможность продолжить), FIN — согласованное закрытие направления урок D5_net
base net Чем измеряется flow control в заголовке TCP полем Window урок D5_net
core net Чем отличается flow control от congestion control по цели flow control защищает получателя от переполнения буфера, congestion control защищает сеть от перегрузки урок D5_net
core net Во сколько раз падает cwnd при обнаруженной потере вдвое (ssthresh = cwnd/2) урок D5_net
core net Что такое fast retransmit ретрансмиссия по 3 дублирующим ACK без ожидания полного таймаута RTO урок D5_net
deep net Какой алгоритм congestion control используется в Linux по умолчанию CUBIC урок D5_net
core net Формула RTO по Джекобсону/Карелсу SRTT + 4×RTTVAR урок D5_net
deep net Какие коэффициенты сглаживания используются для SRTT и RTTVAR α=1/8 для SRTT, β=1/4 для RTTVAR урок D5_net
base net Что делает флаг TCP_NODELAY отключает алгоритм Нейгла, данные отправляются сразу без задержки на накопление урок D5_net
core net Почему Nagle + delayed ACK вместе дают заметные задержки обе стороны ждут друг друга: отправитель — ACK перед следующей мелкой отправкой, получатель — данные для отправки в обратную сторону, чтобы не слать ACK отдельно урок D5_net
base net Размер заголовка UDP 8 байт урок D5_net
base net Какие поля есть в заголовке UDP порт источника, порт назначения, длина, контрольная сумма урок D5_net
core net Почему DNS исторически использует UDP, а не TCP типичный запрос-ответ короткий и умещается в одну датаграмму, устанавливать TCP-соединение ради одного маленького обмена избыточно медленно урок D5_net
base net Диапазон well-known портов 0–1023 урок D5_net
base net В каком диапазоне ОС обычно выделяет эфемерные порты клиентским соединениям 49152–65535 урок D5_net
core net Что вернёт второй `bind` на уже занятый порт ошибку «Address already in use» урок D5_net
base net Что делает NAT с исходящим пакетом подменяет внутренний IP:порт источника на внешний IP:порт, запоминая соответствие в таблице трансляций урок D5_net
core net Почему NAT ломает входящие P2P-соединения без проброса портов запись в таблице трансляций создаётся только исходящим трафиком, входящему от незнакомого узла не с чем сопоставиться урок D5_net
base net Порт DNS по умолчанию и протокол 53, UDP (переключение на TCP/53 для больших ответов) урок D5_net
base net Что хранит запись типа A соответствие имени домена IPv4-адресу урок D5_net
core net Зачем у DNS-записи есть TTL ограничивает время кэширования резолверами; компромисс между скоростью распространения изменений и нагрузкой повторными запросами урок D5_net
base net Порты DHCP-сервера и клиента сервер 67/UDP, клиент 68/UDP урок D5_net
core net Из каких четырёх шагов состоит DORA Discover, Offer, Request, Ack урок D5_net
base net Команда для захвата TCP-трафика на 80 порту `tcpdump tcp port 80` урок D5_net
base net Флаг tcpdump для сохранения дампа в файл `-w` урок D5_net
core net Как в выводе tcpdump выглядит three-way handshake три строки подряд: `[S]` от клиента, `[S.]` от сервера, `[.]` от клиента урок D5_net
base tools Из каких двух механизмов ядра Linux состоит изоляция контейнера namespaces (изоляция видимости ресурсов) и cgroups (ограничение потребления ресурсов) урок D6_docker
base tools Перечисли namespaces, разбираемые в этом разделе pid, mnt, net, uts, ipc, user урок D6_docker
core tools Почему контейнер стартует за доли секунды, а VM за секунды? — контейнер не грузит собственное ядро, старт — это fork/exec с применёнными namespaces; VM грузит через гипервизор целое гостевое ядро урок D6_docker
core tools Почему изоляция контейнера слабее, чем у VM, на уровне безопасности контейнер и хост используют одно и то же ядро; уязвимость ядра или неверные capabilities могут дать выход на хост, а у VM с отдельным ядром такой прямой путь отсутствует урок D6_docker
base tools Что порождает каждая инструкция `RUN`/`COPY` в Dockerfile отдельный слой (diff файловой системы) урок D6_docker
core tools Что происходит с последующими слоями, если один слой не совпал с кэшем все последующие слои пересобираются заново, даже если сами по себе не менялись урок D6_docker
core tools Какой порядок инструкций Dockerfile правильный для скорости пересборки сначала зависимости (меняются редко), потом исходный код (меняется часто) урок D6_docker
base tools Почему `apt-get install` и `rm -rf /var/lib/apt/lists/*` объединяют в одну инструкцию `RUN` слой фиксирует файловую систему на момент завершения инструкции; в отдельном RUN кеш пакетов уже необратимо запечён в предыдущем слое урок D6_docker
core tools Почему нельзя копировать в образ каталог `build/`, собранный на хосте он собран под окружение хоста (версия компилятора, пути, архитектура), не гарантированно совместим с окружением контейнера; сборка должна идти внутри образа урок D6_docker
base tools Что переносит `COPY --from=builder` во второй `FROM` только указанные готовые файлы (например бинарник) из первого этапа, без слоёв тулчейна урок D6_docker
core tools Когда для финального этапа multi-stage можно использовать `FROM scratch` когда бинарник собран полностью статически и не нуждается ни в одной библиотеке окружения урок D6_docker
core tools Чем `debian:bookworm-slim` в качестве финального образа лучше полного `debian:bookworm` для рантайма не несёт тулчейн сборки и лишние пакеты, меньше размер и меньше поверхность атаки урок D6_docker
base tools Кто физически управляет расположением volume на диске сам Docker, служебная директория, не путь, выбранный вручную урок D6_docker
core tools Почему bind mount, а не volume, используют для разработки с редактированием кода на хосте bind mount даёт прямой одновременный доступ к каталогу хоста, правки на хосте сразу видны в контейнере без пересборки образа урок D6_docker
base tools Что делает флаг `--rm` у `docker run` автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса урок D6_docker
base tools Какая команда даёт интерактивный shell в уже запущенном контейнере `docker exec -it <container> bash` урок D6_docker
core tools Чем `--network=host` отличается от обычного режима с `-p` контейнер напрямую использует сетевой стек хоста без собственного network namespace и без проброса портов, но и без сетевой изоляции урок D6_docker
base tools Какая команда поднимает все сервисы из `docker-compose.yml` разом `docker compose up -d` урок D6_docker
core tools Что удаляет `docker compose down -v`, чего не удаляет `docker compose down` без флага именованные volume и данные в них урок D6_docker
base tools Что физически происходит с тегом `:latest` при каждом `docker push` без явного тега он перезаписывается на новый образ, становится мутируемым указателем, а не фиксированной версией урок D6_docker
core tools Почему фиксация `myapp@sha256:...` вместо тега `:latest` важна для воспроизводимости CI дайджест неизменяем по определению хеша, а тег `:latest` может незаметно указывать на другое содержимое образа в разное время урок D6_docker
base tools От какого пользователя запускается процесс в контейнере по умолчанию, если не указано иное root (UID 0) урок D6_docker
core tools Почему root в контейнере опаснее, чем root в отдельной VM контейнер не имеет отдельного ядра; эскалация до root хоста возможна через уязвимость общего ядра, неверные capabilities или смонтированный docker.sock, чего с отдельным ядром VM добиться сложнее урок D6_docker
core tools Что делает флаг `--user uid:gid` запускает процесс контейнера от непривилегированного UID/GID вместо root урок D6_docker
base tools Каким сигналом и с каким кодом завершения убивает процесс превышение лимита `--memory` SIGKILL, код завершения 137 (128 + 9) урок D6_docker
core tools Что ограничивает `--cpus=1.5` технически квоту CPU-контроллера cgroups, эквивалент 1.5 ядра процессорного времени вне зависимости от простаивающих ядер хоста урок D6_docker
base linux В каком кольце защиты x86 работает ядро Linux в кольце 0 (пользовательские процессы — в кольце 3) урок D6_kernel
base linux Какая инструкция делает системный вызов на x86-64 `syscall` (на ARM — `svc`) урок D6_kernel
core linux Почему системный вызов отдельная инструкция процессора, а не обычный `call`? — обычный `call` не меняет уровень привилегий CPU, а переход в кольцо 0 требует именно смены режима процессора урок D6_kernel
base linux Чем `insmod` отличается от `modprobe` `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости урок D6_kernel
base linux Какая команда показывает метаданные модуля, не загружая его `modinfo` урок D6_kernel
core linux Откуда `modprobe` берёт карту зависимостей модулей из файла `modules.dep`, который строит утилита `depmod` урок D6_kernel
base linux Какой макрос ядра аналог `printf`? — `printk` урок D6_kernel
base linux Команда для чтения буфера сообщений ядра `dmesg` урок D6_kernel
core linux Сколько уровней важности у `printk` и какие крайние 8 уровней, от `KERN_EMERG` (0) до `KERN_DEBUG` (7) урок D6_kernel
core linux Что произойдёт с модулем без `MODULE_LICENSE("GPL")` ядро станет tainted и закроет модулю доступ к символам `EXPORT_SYMBOL_GPL` урок D6_kernel
core linux Кто вызывает функции, зарегистрированные `module_init`/`module_exit` ядро автоматически, при `insmod`/`modprobe` и `rmmod` соответственно, а не сам программист урок D6_kernel
base linux Как передать параметр модулю при загрузке `insmod modname.ko имя_параметра=значение` урок D6_kernel
core linux Что означает третий аргумент `module_param`, если он ненулевой параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа урок D6_kernel
base linux Чем отличается соглашение о содержимом файлов `/sys` от `/proc` в `/sys` одно значение на файл, в `/proc` файл может содержать несколько значений свободным текстом урок D6_kernel
core linux Откуда `cat /proc/meminfo` берёт данные ядро формирует ответ на лету в момент чтения, это не файл на диске урок D6_kernel
base linux Какие 4 обработчика минимально нужны в `file_operations` символьного драйвера `.open`, `.read`, `.write`, `.release` урок D6_kernel
core linux Что означают major и minor номера устройства major определяет драйвер, minor — конкретный экземпляр устройства внутри этого драйвера урок D6_kernel
core linux Сколько бит под major и minor в `dev_t` 12 бит major, 20 бит minor (32-битное число целиком) урок D6_kernel
core linux Почему в драйвере нельзя напрямую разыменовать указатель из user space это чужое адресное пространство, страница может быть не загружена или указатель некорректен; прямое разыменование роняет ядро или блокируется SMAP/SMEP урок D6_kernel
base linux В какой функции `file_operations` реализуется `ioctl` `.unlocked_ioctl` урок D6_kernel
core linux Зачем коды ioctl-команд собирают через `_IO`/`_IOR`/`_IOW`/`_IOWR`, а не пишут произвольным числом макросы кодируют направление передачи, магическое число драйвера и номер команды, снижая риск коллизии кодов между разными драйверами урок D6_kernel
base linux Во что компилируется `.dts` и какой утилитой в бинарный `.dtb`, утилитой `dtc` урок D6_kernel
core linux Зачем device tree вообще нужен, если можно было бы прописать адреса регистров прямо в коде драйвера один и тот же бинарник ядра работает на разных платах без пересборки под каждую; хардкод адресов требовал бы правки и компиляции ядра под каждую конкретную плату урок D6_kernel
base linux Что задаёт переменная `CROSS_COMPILE` префикс имени инструментов тулчейна (например `arm-linux-gnueabihf-`) урок D6_kernel
core linux Что произойдёт при запуске бинарника, собранного с неверным `-march`, на реальной плате крах «illegal instruction» (процессор не поддерживает часть использованных инструкций) или отказ сборки урок D6_kernel
core linux Какую проблему в embedded снимает статическая линковка несовпадение версии динамических библиотек (например glibc) на целевой плате с версией сборки урок D6_kernel
base linux В каком порядке идёт цепочка загрузки платы Boot ROM → U-Boot → ядро+dtb → initramfs → переключение на настоящий rootfs → init (PID 1) урок D6_kernel
core linux Зачем нужен initramfs, если ядро уже загружено и работает ядру для монтирования настоящего диска может понадобиться драйвер, который сам является модулем и ещё не загружен; initramfs даёт минимальную среду в памяти, чтобы его подгрузить перед переходом на постоянный rootfs урок D6_kernel
base linux Что делает `volatile` с точки зрения компилятора заставляет реально выполнять каждое обращение к памяти по адресу, не кэшируя значение в регистре и не убирая повторные чтения/записи урок D6_kernel
core linux Даёт ли `volatile` атомарность или барьер памяти между несколькими ядрами CPU нет, только запрещает компилятору кэшировать/убирать обращения к конкретной переменной урок D6_kernel
core linux Какая функция ядра отображает физический адрес MMIO-региона в виртуальный адрес для доступа как к указателю `ioremap()` урок D6_kernel
deep linux Зачем нужен `wmb()` между записью DMA-дескриптора и записью в регистр запуска устройства без барьера порядок этих двух записей, как его видит железо, не гарантирован, и устройство может прочитать старое содержимое дескриптора раньше, чем увидит новые данные урок D6_kernel
base general Сколько уроков покрывает план перед D7 13 файлов (`D1_algo`…`D6_docker`) по алгоритмам, Linux, C++, сетям, потокам, отладке, bash и ядру урок D7_mock
core general Где искать формулировки механизмов, если в уроке дня их не хватает `HR_BASE_deep.md` урок D7_mock
base general Сколько часов длится каждый мок сегодня мок №1 (алгоритмы) 3 часа, мок №2 (системное) 2 часа урок D7_mock
base general Средняя сложность операций хеш-таблицы амортизированное O(1) урок D7_mock
core general При каком load factor обычно триггерят rehash при open addressing около 0.7 урок D7_mock
core general Зачем capacity берут степенью двойки `idx = hash & (cap - 1)` вместо дорогого деления по модулю урок D7_mock
deep general Каков стандартный max_load_factor по умолчанию у `std::unordered_map` в большинстве реализаций (libstdc++, MSVC) 1.0 урок D7_mock
core general Какую кучу держат для потокового top-K наибольших элементов размера k min-heap размера k урок D7_mock
core general Средняя и худшая сложность nth_element среднее O(n), худшее O(n²) урок D7_mock
deep general Как получить top-K частых элементов за O(n) без log-множителя подсчёт хеш-таблицей O(n) + bucket sort по частоте (массив корзин размера n+1) урок D7_mock
base general Сложность обнаружения цикла алгоритмом Флойда по времени и памяти O(n) время, O(1) память урок D7_mock
core general Как найти вход в цикл после первой встречи указателей сбросить один указатель на head, оба двигать по 1 шагу — встретятся на входе урок D7_mock
base general Сложность BFS на сетке R×C O(R·C) урок D7_mock
core general Почему BFS гарантирует кратчайший путь по числу рёбер FIFO-очередь раскрывает граф строго по слоям расстояния урок D7_mock
core general В какой момент нужно помечать узел посещённым, чтобы избежать повторных вставок в очередь в момент постановки в очередь, а не при извлечении урок D7_mock
deep general Как обойти граф со весами рёбер только 0 и 1 за O(V+E) без полноценной Дейкстры 0-1 BFS: deque, вес 0 — push_front, вес 1 — push_back урок D7_mock
base general Временная и пространственная сложность LCS/Edit Distance O(n·m) и по времени, и по памяти урок D7_mock
core general Три операции, между которыми выбирают минимум в Edit Distance замена, удаление, вставка урок D7_mock
base general Сложность наивного LIS и продвинутого через tails[] O(n²) и O(n log n) соответственно урок D7_mock
core general Почему наивный O(n²) не проходит на N=100 000 за 2 секунды 10¹⁰ операций, не укладывается в типичный лимит времени внутреннего теста урок D7_mock
core general Что хранит tails[k] минимальный возможный последний элемент возрастающей подпоследовательности длины k+1 урок D7_mock
deep general Как называется классический приём построения tails[] с заменой через бинарный поиск patience sorting (раскладка карт по кучкам) урок D7_mock
core general sizeof той же структуры с полями в порядке char a; char c; int b 8 байт урок D7_mock
base general Что физически делает std::move во время выполнения ничего: это static_cast к rvalue-ссылке урок D7_mock
core general За какое время перемещается std::vector и что именно переносится O(1): три внутренних указателя (начало, конец данных, конец ёмкости) урок D7_mock
core general Флаг компилятора, включающий сразу ASAN и UBSAN -fsanitize=address,undefined урок D7_mock
base general Откуда взялись коды возврата 137 и 139 128 + номер сигнала: 137 = SIGKILL(9), 139 = SIGSEGV(11) урок D7_mock
core general Какие два сигнала нельзя перехватить или заблокировать SIGKILL (9) и SIGSTOP (19) урок D7_mock
base general Состояние зомби-процесса в выводе ps Z урок D7_mock
core general Что именно занимает зомби-процесс память или что-то другое? — слот в таблице процессов, не память урок D7_mock
base general Жёсткий лимит select и его значение FD_SETSIZE, 1024 урок D7_mock
core general Сложность epoll_wait на одно готовое событие против select/poll на весь набор epoll_wait O(1) на событие; select/poll O(n) на весь набор при каждом вызове урок D7_mock
base general Разница MAP_SHARED и MAP_PRIVATE MAP_SHARED: изменения видны другим и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны урок D7_mock
base general Длина Ethernet-заголовка и длина заголовка с VLAN-тегом 14 байт без тега, 18 байт с тегом 802.1Q урок D7_mock
core general Значение TPID и число бит поля VID TPID = 0x8100, VID = 12 бит (диапазон 1–4094) урок D7_mock
base general Сколько хостов доступно в подсети /26 и как выглядит маска в десятичном виде 62 хоста, 255.255.255.192 урок D7_mock
core general Сеть, broadcast и диапазон хостов для 10.0.1.130/26 сеть 10.0.1.128, broadcast 10.0.1.191, хосты 129–190 урок D7_mock
base general На сколько уменьшается TTL на каждом маршрутизаторе и какой ICMP-тип шлётся при обнулении на 1; ICMP Time Exceeded, тип 11 урок D7_mock
core general С какого TTL traceroute начинает зондирование и почему именно так восстанавливает маршрут с TTL=1, наращивая на 1 — каждый пакет умирает на очередном хопе и раскрывает его адрес урок D7_mock
base general Сколько сегментов в three-way handshake и в полном закрытии соединения 3 (SYN, SYN+ACK, ACK) и 4 (FIN, ACK, FIN, ACK) урок D7_mock
deep general Сколько секунд реально длится TIME_WAIT в Linux 60 секунд (константа TCP_TIMEWAIT_LEN), не совпадает с номинальными 4 минутами по RFC 793 урок D7_mock
base general Сколько вопросов работодателю стоит подготовить заранее на такое интервью 8, каждый — конкретный, не риторический урок D7_mock
core general Когда обычно задают вопросы работодателю на техническом скрининге в конце интервью, по приглашению интервьюера урок D7_mock
base general Из каких 4 частей состоит структура STAR для ответа про опыт Situation, Task, Action, Result урок D7_mock
core general Как правильно называть пробел в алгоритмах на собеседовании скрывать или называть прямо? — называть прямо: конкретная тема добирается сейчас, с указанием, что уже понятно (сложность, инварианты) урок D7_mock
base algo Сколько байт меняет местами `bswap32` 4 задача 01_bits
core algo Что делает `x & (x - 1)` гасит младший единичный бит `x` задача 01_bits
core algo Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF` 32 (по числу единичных бит) задача 01_bits
@@ -69,16 +522,13 @@ core net Почему `reinterpret_cast` буфера в `Ipv4Header*` UB? —
deep net Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем задача 04_ipv4
base algo Сложность наивного DP-решения LIS O(n²) задача 05_lis
core algo Сложность решения через `tails` + бинарный поиск O(n log n) задача 05_lis
core algo Что хранит `tails[k]` минимально возможный последний элемент возрастающей задача 05_lis
deep algo Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound` задача 05_lis
base threads Каким флагом компилятора включается ThreadSanitizer `-fsanitize=thread` задача 06_threads
base threads Что бросает `push` после `close()` `std::runtime_error` задача 06_threads
core threads Должен ли `size()` быть потокобезопасным в этой задаче да, его тоже вызывают из другого потока и он тоже под захватом мьютекса задача 06_threads
core threads Что происходит с ждущими потоками при `close()` все разблокируются (никакого вечного `wait`) задача 06_threads
base threads Что атомарно делает `cv.wait(lock)` при входе освобождает мьютекс и усыпляет поток одной неделимой операцией задача 06_threads
core threads Почему `while` вокруг `wait`, а не `if` из-за spurious wakeup: `wait` может вернуться без вызова notify задача 06_threads
core threads Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one` можно разбудить не тот поток, а нужный останется ждать задача 06_threads
deep threads На каком примитиве ОС реализован `condition_variable` на Linux futex задача 06_threads
base threads Какой командой запускается проверка задачи 06 `python3 grade.py 06` задача 06_threads
core threads Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом задача 06_threads
base linux На каком адресе и с каким флагом сокета должен слушать сервер 127.0.0.1, `SO_REUSEADDR` задача 07_epoll
@@ -86,7 +536,6 @@ base linux Сколько одновременных соединений сер
core linux По какому событию сервер закрывает соединение с клиентом по EOF со стороны клиента задача 07_epoll
base linux Что означает `EAGAIN` на неблокирующем сокете не ошибка, сигнал «данных пока нет, попробуй позже» задача 07_epoll
core linux В чём разница между LT и ET режимами epoll LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов» задача 07_epoll
core linux Почему ET требует неблокирующих дескрипторов цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет задача 07_epoll
deep linux Какая асимптотика у `epoll_wait` относительно select/poll O(1) на готовое событие против O(n) на весь набор у select/poll задача 07_epoll
base linux Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT 0 задача 07_epoll
core linux Что возвращает `epoll_wait`, если его прервал сигнал -1 с `errno == EINTR`, не ошибка выполнения задача 07_epoll
@@ -104,7 +553,6 @@ core linux Почему `local var=$(cmd)` не ловится `set -e`, есл
core linux Зачем `set -o pipefail` отдельно от `set -e` без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным задача 08_bash
base linux Каким кодом возврата должен завершаться `solution.sh` при успехе 0 задача 08_bash
core linux Что именно сверяет `grade.py 08` с эталоном точное совпадение вывода (все четыре строки) на фикстуре и на большом логе задача 08_bash
base linux Что печатает `./crash` без аргументов после починки `len=5`, код возврата 0 задача 09_gdb
base linux Что печатает `./crash ""` после починки `len=0`, код возврата 0 задача 09_gdb
core linux Какие два санитайзера должны не давать сообщений после починки ASan и UBSan (`-fsanitize=address,undefined`) задача 09_gdb
base linux Что даёт флаг `-g` при сборке для gdb отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных задача 09_gdb
@@ -152,7 +600,6 @@ core algo Что означает «ленивое удаление» устар
deep algo Почему `std::priority_queue` не используют с decrease-key напрямую у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop задача 12_dijkstra
core algo Почему Дейкстра ломается на отрицательных рёбрах алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается задача 12_dijkstra
base algo Какой алгоритм нужен при отрицательных весах без отрицательных циклов Беллман-Форд, O(V·E) задача 12_dijkstra
core algo Как Беллман-Форд обнаруживает отрицательный цикл если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл задача 12_dijkstra
base algo Почему self-loop с w≥0 не требует отдельной обработки добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию задача 12_dijkstra
base net Сколько узлов даёт /24 254 задача 13_subnet
base net Сколько узлов даёт /26 62 задача 13_subnet
Can't render this file because it contains an unexpected character in line 335 and column 82.
+287
View File
@@ -0,0 +1,287 @@
# D2, часть 1. Деревья, BST, куча (урок)
Дерево — рекурсивная структура: узел + набор поддеревьев. На собеседовании не спрашивают
определение, спрашивают следствия: как способ хранения (указатели vs массив) и выбранный
инвариант (порядок BST vs экстремум кучи) напрямую определяют сложность операций. Порядок
разбора: как дерево лежит в памяти → как его обходят → что даёт BST-инвариант → что даёт
heap-инвариант → куда это применяется в top-K, LCA, валидации.
## 1. Представление: узлы с указателями vs массив
**Узлы с указателями.** Типичный `TreeNode` — значение плюс два указателя на детей:
```c++
struct TreeNode {
int val;
TreeNode *left, *right;
};
```
На x86-64 `sizeof(TreeNode) == 24`: `val` (`int`, 4 байта) лежит по смещению 0, дальше 4
байта паддинга — указатель должен быть выровнен на 8 байт, — `left` по смещению 8 (8 байт),
`right` по смещению 16 (8 байт). Итого 24, а не 20 — та же механика паддинга, что и в
структурах вообще (`char+int+char` → 12 байт по тем же правилам выравнивания). Каждый узел —
отдельная аллокация в куче, значит отдельный вызов аллокатора и произвольный адрес — узлы
дерева обычно не лежат рядом в памяти, отсюда промахи кэша при обходе.
**Массив.** Для узла с индексом `i` дети — `2i+1` и `2i+2`, родитель — `(i-1)/2` (целочисленное
деление) — индексная арифметика заменяет указатели. Представление компактно только для
**полного или близкого к полному** дерева (все уровни заполнены слева направо без пропусков,
как в куче): тогда индексы плотно покрывают массив без дыр. Для разреженного дерева такое
представление может потребовать до `2^h` ячеек массива, из которых заполнена малая часть —
это и есть причина, по которой куча всегда хранится в массиве, а произвольное дерево (BST,
дерево поиска) — почти всегда через указатели.
**Факты для карточек**
- base | Из чего состоит узел бинарного дерева при представлении указателями? — значение + два указателя (left, right)
- core | Размер `struct TreeNode { int val; TreeNode *left, *right; }` на x86-64? — 24 байта: 4 байта `val` + 4 байта паддинга (выравнивание указателя на 8) + 8 + 8 байт под указатели
- core | Когда массивное представление дерева компактно, а когда нет? — компактно только для полного/близкого к полному дерева (куча); для разреженного дерева индексы `2i+1/2i+2` требуют до `2^h` ячеек, большинство из которых пустует
- deep | Почему куча хранится в массиве, а не через указатели? — куча всегда почти полное дерево (заполнена по уровням слева направо без пропусков), индексная арифметика не тратит память впустую и не требует отдельной аллокации на узел
Почему дальше: раз дерево можно линейно уместить в массив только при полном заполнении по уровням, как вообще перебирают узлы произвольного дерева — обходы.
## 2. Обходы: DFS (preorder/inorder/postorder) и BFS
Три порядка DFS отличаются местом, где посещается сам узел относительно детей:
- **preorder** (node, left, right) — узел раньше детей; используется, когда нужно
восстановить структуру дерева по порядку посещения (сериализация, копирование).
- **inorder** (left, node, right) — для BST даёт значения **по возрастанию** (см. раздел 3).
- **postorder** (left, right, node) — оба ребёнка раньше узла; используется, когда узел
нельзя обработать/освободить до того, как обработаны/освобождены его дети (удаление дерева
снизу вверх, вычисление арифметического дерева выражений).
**DFS рекурсией** использует неявный стек вызовов, глубина = высота дерева `h` → память
`O(h)`. **DFS итеративно** — явный `std::stack<TreeNode*>`, та же асимптотика `O(h)`, но без
риска переполнения стека потока: кадр функции тяжелее записи в explicit-стеке (адрес возврата
+ сохранённые регистры против одного указателя, 8 байт), а стек потока на Linux ограничен
(обычно порядка нескольких мегабайт) — на сильно вырожденном дереве (`h` порядка `n`)
рекурсия может упереться в этот лимит раньше, чем закончится память под явный стек.
**BFS** обходит уровень за уровнем через очередь; память — `O(w)`, где `w` — максимальная
ширина уровня, а не высота. Для полного сбалансированного дерева из `n` узлов высота
`h ≈ log₂ n`, но ширина последнего уровня — около `n/2`: при `n = 1 000 000` это `h ≈ 20`
(`log₂10⁶ ≈ 20`) против ширины последнего уровня ≈ 500 000. То есть BFS-очередь на пике может
требовать памяти на порядки больше, чем DFS-стек, для одного и того же дерева.
**Ловушки**
- Не проверить `node == nullptr` перед обращением к `node->val` в рекурсии → разыменование нулевого указателя → SIGSEGV, UBSAN отдельно ловит это с `-fsanitize=null`.
- В итеративном DFS перепутать порядок `push` детей (класть left раньше right вместо наоборот при эмуляции preorder через стек — стек разворачивает порядок) → обход посещает поддеревья не в том порядке, тихий баг, виден только сравнением с рекурсивным эталоном.
- Использовать рекурсивный DFS на сильно несбалансированном дереве (`h ≈ n`, например после вставки отсортированной последовательности) → глубина рекурсии `n` → переполнение стека потока, а не просто медленная работа.
**Факты для карточек**
- base | Порядок посещения в inorder-обходе? — left, node, right
- base | Какой обход даёт отсортированный порядок для BST? — inorder
- core | Память DFS (рекурсия или явный стек) по глубине дерева? — O(h) — пропорционально высоте
- core | Память BFS (очередь)? — O(w) — пропорционально максимальной ширине уровня
- core | Для полного сбалансированного дерева из 10⁶ узлов: высота и ширина последнего уровня? — h ≈ 20 (log₂10⁶≈20), ширина последнего уровня ≈ 500 000 — BFS может требовать памяти на порядки больше, чем DFS
- deep | Какой обход используют, чтобы освободить дерево снизу вверх? — postorder (сначала оба ребёнка, потом сам узел)
Почему дальше: обходы работают одинаково независимо от порядка значений в узлах; BST добавляет конкретный порядковый инвариант — разберём, что именно он даёт.
## 3. BST-инвариант и что он даёт
Инвариант: для каждого узла все значения в левом поддереве меньше значения узла, все значения
в правом — больше. Отсюда напрямую:
- **Поиск/вставка/удаление — O(h).** На каждом узле решение однозначно (значение меньше —
влево, больше — вправо), путь до цели или до `nullptr` не длиннее высоты дерева.
- **Inorder-обход = отсортированный порядок.** Прямое следствие инварианта: в любом
поддереве все значения левой части меньше корня, все значения правой — больше, рекурсивно
это верно на каждом уровне, поэтому обход left→node→right посещает значения строго по
возрастанию.
Высота `h` — не константа, а зависит от формы дерева: у сбалансированного BST (например,
построенного случайными вставками или явно балансирующегося, как красно-чёрное дерево)
`h ≈ log₂ n`; у **вырожденного** — `h = n`. Конкретный пример вырождения: вставка значений
`1, 2, 3, ..., n` по порядку в обычный (не самобалансирующийся) BST — каждый новый узел
становится правым ребёнком предыдущего максимума, дерево превращается в цепочку, и поиск,
ожидаемо `O(log n)`, на деле деградирует до `O(n)` — то есть до линейного перебора связного
списка.
**Факты для карточек**
- base | Инвариант BST? — для каждого узла все значения в левом поддереве меньше значения узла, все значения в правом — больше
- base | Сложность поиска в BST? — O(h), где h — высота дерева
- core | Что даёт inorder-обход BST? — значения по возрастанию — прямое следствие инварианта
- core | Высота BST в лучшем и худшем случае для n узлов? — сбалансированное: h ≈ log₂n; вырожденное: h = n
- deep | Что произойдёт при вставке 1,2,...,n по порядку в обычный (небалансирующийся) BST? — дерево вырождается в цепочку (каждый новый узел — правый ребёнок предыдущего), поиск деградирует до O(n)
Почему дальше: раз наивный BST может выродиться в список, встают два практических вопроса — как правильно проверить дерево на BST-инвариант и как искать общего предка, используя (или не используя) этот инвариант.
## 4. Валидация BST и LCA
**Частая ошибка валидации** — сравнивать узел только с непосредственным родителем
(`node->left->val < node->val` и `node->right->val > node->val` на каждом шаге). Контрпример:
корень `5`, его левый ребёнок `3`, а правый ребёнок узла `3` — `6`. Локальная проверка
`6 > 3` проходит (это правый ребёнок `3`, и `6` больше `3`), но `6` находится в левом
поддереве корня `5`, а `6 > 5` — инвариант BST для всего дерева нарушен. Локальное сравнение
с родителем этого не ловит, потому что оно ничего не знает про предков выше.
**Правильная валидация** — рекурсия с передачей вниз границ `(min, max)`: каждый узел должен
строго лежать между ними; при спуске влево верхняя граница ужесточается до значения узла
(`max = node->val`), при спуске вправо ужесточается нижняя (`min = node->val`). Альтернатива —
inorder-обход с проверкой строгого возрастания относительно последнего посещённого значения
(`O(n)` время, `O(1)` дополнительной памяти, если не копить весь список, а сравнивать на лету).
**LCA в BST** использует порядок значений: начиная с корня, если оба искомых значения меньше
текущего узла — идти влево, если оба больше — вправо, иначе текущий узел и есть точка, где
пути к двум значениям расходятся, то есть LCA. Сложность `O(h)` — та же логика, что у поиска.
**LCA в произвольном бинарном дереве** (без порядка) не может отбросить половину дерева на
каждом шаге — порядка, который это позволяет, нет. Стандартное решение — рекурсия postorder:
если узел `nullptr` или совпадает с одним из искомых — вернуть его; рекурсивно получить
результат из левого и правого поддерева; если оба непустые — текущий узел и есть LCA;
иначе вернуть тот результат, который непустой (продвинуть найденный узел вверх). Сложность
`O(n)` — в худшем случае нужно посетить каждый узел один раз; память `O(h)` на стек рекурсии.
**Ловушки**
- Валидировать BST сравнением только с прямым родителем → пропускает нарушения через поколение (контрпример: корень 5, левый 3, правый потомок 3 равен 6) → тест с таким деревом должен упасть, а наивная проверка его пропустит.
- Использовать нестрогое сравнение (`<=` вместо `<`) без явного решения, допустимы ли дубликаты и в какое поддерево они кладутся → BST с равными значениями валидируется непредсказуемо.
- В LCA по BST не проверить, что оба узла реально принадлежат дереву → функция вернёт правдоподобный, но неверный узел вместо явной ошибки.
**Факты для карточек**
- base | Почему нельзя валидировать BST, сравнивая узел только с непосредственным родителем? — нарушение инварианта может возникнуть через поколение (правый потомок левого поддерева больше корня, но меньше своего прямого родителя) — локальная проверка это не ловит
- core | Как правильно валидировать BST рекурсивно? — передавать вниз границы (min, max): узел должен строго лежать между ними, для левого поддерева ужесточается верхняя граница, для правого — нижняя
- core | Сложность LCA в BST и почему? — O(h): если оба искомых значения меньше текущего узла — влево, оба больше — вправо, иначе текущий узел и есть LCA
- core | Сложность LCA в обычном бинарном дереве без порядка? — O(n): нет инварианта, отсекающего часть дерева, нужна рекурсия postorder по потенциально всем узлам
- deep | Альтернативный способ валидации BST без явных границ (min, max)? — inorder-обход с проверкой строгого возрастания относительно предыдущего посещённого значения
Почему дальше: и валидация, и LCA используют дерево как структуру поиска по значению; для задач top-K важнее не порядок всех элементов, а быстрый доступ к экстремуму — для этого нужен другой инвариант, куча.
## 5. Куча: инвариант и индексы
Инвариант max-heap: значение в родителе не меньше значений в обоих детях. Хранится в массиве
(см. раздел 1): для индекса `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`.
- **`top()` — O(1).** По инварианту максимум всегда лежит в корне, то есть в начале массива —
прямое обращение по индексу 0, без поиска.
- **`sift-up`/`sift-down` — O(log n).** Оба переставляют элемент вдоль одного пути от узла до
корня или от корня до листа; длина этого пути ограничена высотой дерева.
**Bottom-up heapify — O(n), а не O(n log n).** Через `n` последовательных `push` (каждая —
`sift-up` до `O(log i)` на i-й вставке) суммарная стоимость — `Σ log₂(i)` для `i = 1..n`, это
порядка `n log₂ n`. Bottom-up heapify стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс 0), вызывая `sift-down` на каждом узле; работа `sift-down`
в узле пропорциональна **высоте именно этого узла `h`**, а не высоте всего дерева — узлов
большой высоты экспоненциально мало (в корне — один узел высоты `log n`, листьев высоты 0 —
около `n/2`, они вообще пропускаются). Сумма `Σ h/2^h` по всем высотам сходится к константе
(≈2) при росте `n`, поэтому суммарная работа — `O(n)`, а не `O(n log n)`.
**Факты для карточек**
- base | Индексы детей и родителя в куче на массиве для узла i? — дети 2i+1, 2i+2; родитель (i-1)/2 (целочисленное деление)
- base | Сложность доступа к максимуму (top) в max-heap? — O(1) — максимум всегда в корне по инварианту
- core | Сложность sift-up/sift-down? — O(log n) — путь ограничен высотой дерева
- core | За какое время строится куча через bottom-up heapify против n последовательных push? — O(n) против O(n log n): работа sift-down в узле пропорциональна его высоте h, а сумма Σ h/2^h по всем узлам сходится к константе
- deep | С какого индекса стартует bottom-up heapify и куда идёт? — с последнего нелистового узла, индекс n/2 - 1, к корню (индекс 0)
Почему дальше: куча даёт O(1) доступ к экстремуму и O(log n) перестройку — на этом строится вся группа задач top-K, и у кучи есть конкурент по сложности — quickselect.
## 6. priority_queue, top-K и nth_element
`std::priority_queue` по умолчанию — max-heap поверх `std::vector` (сравнение `std::less`,
для min-heap передают `std::greater<>` третьим параметром шаблона).
Три способа получить top-K:
1. **Полная куча.** `heapify` за `O(n)`, затем `k` раз `pop` (`sift-down` корня) по `O(log n)`
каждый — итого `O(n + k log n)`.
2. **Потоковый (min-heap размера k).** Когда данные не помещаются в память целиком: держат
min-heap ровно из `k` элементов, каждый новый элемент сравнивают с минимумом кучи (`top`,
O(1)), и если новый больше — минимум вытесняется, новый добавляется. Сложность
`O(n log k)`, память `O(k)` вместо `O(n)`.
3. **`nth_element` (quickselect).** Партиционирует диапазон вокруг опорного элемента и
рекурсивно спускается только в ту половину, где лежит нужный ранг: в среднем `T(n) =
T(n/2) + O(n) → O(n)`, в худшем случае (устойчиво плохой выбор опорного, та же причина,
что и у quicksort) — `O(n²)`. Результат — частичный порядок (всё слева не больше опорного,
всё справа не меньше, но без порядка внутри половин), не отсортированный список: для
готового top-K по убыванию `nth_element` придётся досортировать `k`-элементный префикс,
тогда как `pop` из кучи уже отдаёт элементы по убыванию сам по себе.
**Top K Frequent** — отдельный паттерн: подсчёт частот хеш-таблицей за `O(n)`, затем либо
heap размера `k` (`O(n log k)`), либо **bucket sort по частоте**: частота любого элемента не
превышает `n`, поэтому заводят массив корзин размера `n + 1`, кладут элемент в
`bucket[частота]` и сканируют от максимальной частоты к минимальной — `O(n)` без
логарифмического множителя вообще.
**Ловушки**
- Строить кучу через `push` в цикле вместо bottom-up `heapify` → результат корректен (инвариант тот же), но `O(n log n)` вместо `O(n)` — на миллионах элементов заметно медленнее, и это отдельный вопрос на собеседовании.
- Использовать `nth_element` там, где нужен полностью отсортированный top-K, и забыть досортировать k-элементный префикс → порядок в выводе неверный, хотя набор элементов правильный.
- В потоковом top-K сравнивать новый элемент с максимумом кучи вместо минимума (перепутать min-heap и max-heap для этой задачи) → кандидаты вытесняются в обратном порядке, итоговый top-K — неверный набор элементов.
**Факты для карточек**
- base | Что такое `std::priority_queue` по умолчанию? — max-heap поверх vector; для min-heap передают компаратор std::greater<>
- core | Сложность top-K через полную кучу? — O(n + k log n): O(n) heapify + k раз pop по O(log n)
- core | Когда используют min-heap размера k вместо полной кучи? — на потоке данных, не помещающемся в память целиком: min-heap размера k хранит только k текущих кандидатов, O(n log k), память O(k)
- core | Средняя и худшая сложность `nth_element` (quickselect)? — в среднем O(n), в худшем O(n²) — та же причина, что у quicksort: устойчиво плохой выбор опорного
- deep | Почему top-K через nth_element не даёт готовый отсортированный список? — nth_element только частично упорядочивает вокруг k-го элемента без порядка внутри половин; нужна досортировка k-элементного префикса
- deep | Как получить top-K частых элементов за O(n) без log-фактора? — подсчёт хеш-таблицей O(n), затем bucket sort по частоте: частота не превышает n, массив корзин размера n+1 индексируется частотой напрямую
Почему дальше: дерево, BST-инвариант и куча — это и есть механизмы за стандартными задачами интервью; дальше — какая задача проверяет какой именно из них.
## 7. Задачи-триггеры
- **Invert Binary Tree** — рекурсивно поменять местами `left`/`right` у каждого узла: `O(n)`
время (каждый узел посещается один раз), `O(h)` память на стек рекурсии.
- **Validate BST** — рекурсия с границами `(min, max)`, передаваемыми вниз (раздел 4).
- **Kth Largest** — min-heap размера `k` (`O(n log k)`) или `nth_element`/quickselect
(в среднем `O(n)`) (раздел 6).
- **Top K Frequent** — хеш-таблица + bucket sort по частоте (`O(n)`) или heap размера `k`
(`O(n log k)`) (раздел 6).
Ссылка на задачу дня: `tasks/11_heap` — там же ключевое требование «`heapify` только
bottom-up, не `push` в цикле», прямая проверка раздела 5.
## Проверь себя
<details>
<summary>1. Почему нельзя проверить BST, сравнивая каждый узел только с прямым родителем?</summary>
Потому что нарушение инварианта может произойти через поколение: узел может быть больше
своего прямого родителя, но при этом лежать в поддереве более раннего предка, для которого
он слишком велик (например, правый потомок левого ребёнка корня оказывается больше самого
корня). Правильная проверка передаёт вниз границы (min, max), актуальные для всего пути от
корня, а не только для одного родителя.
</details>
<details>
<summary>2. Почему bottom-up heapify — O(n), хотя каждый sift-down в отдельности — O(log n)?</summary>
Потому что работа sift-down в конкретном узле пропорциональна высоте именно этого узла, а не
высоте всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел
высоты log n, листьев высоты 0 — около n/2, для них sift-down почти ничего не делает). Сумма
Σ h/2^h по всем высотам сходится к константе, поэтому суммарная работа растёт линейно с n.
</details>
<details>
<summary>3. Чем LCA в BST отличается по сложности от LCA в обычном бинарном дереве и почему?</summary>
В BST — O(h): порядок значений позволяет на каждом шаге однозначно решить, идти влево, вправо
или остановиться. В обычном дереве такого порядка нет, поэтому приходится рекурсивно
обходить дерево (postorder) и в худшем случае посетить все n узлов — O(n).
</details>
<details>
<summary>4. Почему nth_element в среднем O(n), но не гарантирует худший случай?</summary>
Потому что рекурсия спускается только в ту часть, где лежит искомый ранг, отбрасывая
остальное после каждого партиционирования — в среднем это даёт T(n) = T(n/2) + O(n) → O(n).
Но при устойчиво плохом выборе опорного элемента (та же причина, что и у худшего случая
quicksort) партиционирование каждый раз отбрасывает лишь один элемент, и сложность
деградирует до O(n²).
</details>
<details>
<summary>5. Почему массив — плохое представление для сильно несбалансированного (не полного) дерева?</summary>
Потому что индексы 2i+1/2i+2 предполагают позицию узла в полном дереве соответствующей
глубины; если дерево заполнено не полностью (например, вырожденная цепочка), индексы,
которые физически используются, разбросаны на диапазон до 2^h, и большая часть массива
такого размера останется пустой.
</details>
## Задачи дня
- `tasks/11_heap` — куча: bottom-up `heapify` за O(n), `kth_largest`/`top_k`; ключевая
проверка — запрет строить кучу через `push` в цикле.
## Материалы
- Документация `std::priority_queue` (куча в top-K) — https://en.cppreference.com/w/cpp/container/priority_queue
- Документация `std::nth_element` (quickselect для k-го элемента) — https://en.cppreference.com/w/cpp/algorithm/nth_element
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
+268
View File
@@ -0,0 +1,268 @@
# D2, часть 3. C++ по промахам (45 минут, плотно)
Пять тем, которые почти гарантированно спросят и почти гарантированно проверят не на
определении, а на конкретном примере: посчитать размер структуры, найти double-free в коде,
объяснить, что делает `std::move`, назвать, что именно является UB. Разбор без разгона — сразу
к механизму.
## 1. Выравнивание и padding
```c++
struct S { char a; int b; char c; };
static_assert(sizeof(S) == 12);
static_assert(offsetof(S, b) == 4);
static_assert(offsetof(S, c) == 8);
```
Поля дают 6 байт (1+4+1), но `sizeof(S) == 12` на x86-64. Раскладка по смещениям:
`a` — смещение 0 (1 байт); дальше **3 байта паддинга**, потому что `int b` требует
выравнивания на 4 байта (адрес поля должен делиться на 4), а следующий свободный адрес — 1;
`b` занимает смещения 4–7; `c` — смещение 8 (1 байт). На этом полезные данные кончаются на
9 байте, но размер **всей структуры** округляется вверх до кратного выравниванию самого
строгого поля внутри (здесь `int`, выравнивание 4) — отсюда ещё **3 байта хвостового
паддинга**, и итоговый размер 12, а не 9.
Выравнивание — требование от процессора: обращение к `int` по адресу, не кратному 4, на x86
разрешено, но обходится дороже (может потребовать двух обращений к памяти вместо одного); на
архитектурах со строгим выравниванием (некоторые режимы ARM) — это аппаратное исключение.
Компилятор жертвует местом в памяти ради предсказуемой и быстрой работы с каждым полем.
`offsetof(S, member)` — макрос, дающий точное смещение поля в байтах, посчитанное так же, как
это делает компилятор при раскладке структуры (в примере выше — 4 и 8). `#pragma pack(1)`
убирает паддинг полностью — `sizeof(S)` станет 6, но каждое обращение к `b` и `c` идёт по
невыровненному адресу: цена — либо замедление на x86, либо падение на платформах со строгим
выравниванием. `#pragma pack` оправдан там, где формат байт фиксирован извне (сетевой
протокол, бинарный формат файла) и точное совпадение раскладки важнее скорости доступа.
**Ловушки**
- Сериализовать структуру побайтовым копированием (`memcpy` всей структуры целиком) и передать по сети/записать в файл, предполагая, что размер равен сумме полей → получатель на платформе с другим выравниванием прочитает мусор из паддинг-байтов как часть данных.
- Полагаться на порядок полей в памяти как на что-то определяемое исходным кодом → компилятор вправе вставлять паддинг между полями в порядке их объявления, но не обязан оптимизировать порядок сам — реальную раскладку проверяют `sizeof`/`offsetof`, а не читают код на глаз.
**Факты для карточек**
- base | sizeof(struct { char a; int b; char c; }) на x86-64? — 12 байт
- core | Смещения полей a, b, c в этой структуре? — a=0, b=4 (после 3 байт паддинга), c=8
- core | Почему в конце структуры ещё 3 байта паддинга? — размер всей структуры округляется вверх до кратного выравниванию самого строгого поля (здесь int, выравнивание 4): 9 → 12
- deep | Что делает #pragma pack(1) и какая у него цена? — убирает паддинг (sizeof(S) станет 6), но доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим выравниванием
Почему дальше: раскладка структуры в памяти — то, что копирует компилятор по умолчанию при копировании объекта; когда объект владеет ресурсом через указатель, это копирование становится опасным — отсюда правило 0/3/5.
## 2. Правило 0/3/5
Если класс сам управляет ресурсом (владеющий сырой указатель, файловый дескриптор, мьютекс) и
поэтому определяет деструктор — он почти наверняка должен явно определить и **конструктор
копирования**, и **оператор присваивания копированием** (правило трёх), а с C++11 — ещё и
**конструктор перемещения** с **оператором присваивания перемещением** (правило пяти). Если
ни одну из пяти функций не объявить, компилятор генерирует все пять сам; сгенерированная
версия копирования — **побитовое (memberwise) копирование** каждого поля.
```c++
class Buffer {
int* data;
size_t n;
public:
Buffer(size_t n) : data(new int[n]), n(n) {}
~Buffer() { delete[] data; }
// конструктор копирования и operator= не объявлены —
// компилятор сгенерирует побитовую копию указателя data
};
Buffer a(10);
Buffer b = a; // побитовая копия: b.data == a.data, один и тот же адрес
``` // при выходе из области видимости оба деструктора вызовут delete[] на одном адресе
Для указателя побитовая копия означает, что `b.data` получает **то же значение адреса**, что
и `a.data`, — не копию массива, а второй указатель на один и тот же блок памяти. Когда `a` и
`b` выходят из области видимости, оба деструктора вызывают `delete[]` на одном и том же
адресе — второй вызов освобождает уже освобождённую память. Это классический **double-free**,
UB; ASAN отмечает его явно, с двумя стеками вызовов — одним для первого `delete[]`, вторым
для попытки повторного.
**Rule of zero.** Вместо ручного написания всех пяти функций чаще доверяют владение готовым
RAII-члену (`std::unique_ptr`, `std::vector`, `std::string`) и не объявляют ни одной из пяти
функций вообще — сгенерированные компилятором версии корректно копируют/перемещают саму
обёртку, а обёртка уже сама правильно управляет ресурсом.
**Факты для карточек**
- base | Какие 5 функций входят в правило пяти? — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением
- base | Что делает сгенерированный компилятором конструктор копирования по умолчанию? — побитовое (memberwise) копирование каждого поля
- core | Почему копирование объекта с владеющим сырым указателем даёт double-free? — оба объекта получают одно и то же значение указателя (адрес), оба деструктора вызывают delete на этом адресе — второй раз на уже освобождённой памяти
- core | Что такое rule of zero? — не объявлять ни одну из пяти спецфункций вручную, доверив владение ресурсом готовым RAII-обёрткам (unique_ptr, vector, string) — их сгенерированные копирование/перемещение уже корректны
Почему дальше: правило 0/3/5 регулирует копирование данных объекта, но у полиморфных объектов есть отдельный, ещё более резкий способ потерять часть состояния при удалении — отсутствие virtual-деструктора.
## 3. virtual-деструктор и vtable
Если у класса есть хотя бы одна `virtual`-функция, компилятор добавляет в каждый объект
скрытый указатель на **vtable** — таблицу указателей на реализации виртуальных функций,
специфичную для конкретного класса (на типичной 64-битной платформе этот указатель — 8 байт,
и он увеличивает размер каждого объекта на эту величину). Вызов `obj->f()` через указатель на
базовый класс разрешается в рантайме: сначала читается указатель на vtable из объекта, потом
из таблицы берётся указатель на нужную функцию — и это уже реализация **фактического** типа
объекта, не типа указателя.
```c++
struct Base {
virtual ~Base() = default; // без virtual — источник утечки ниже
virtual void run() {}
};
struct Derived : Base {
int* owned = new int[1000];
~Derived() override { delete[] owned; }
};
Base* p = new Derived();
delete p; // если ~Base() не virtual: вызовется только ~Base(), ~Derived() не вызовется
```
Если деструктор `Base` **не** `virtual`, вызов `delete p` привязывается компилятором к типу
указателя (`Base*`) **на этапе компиляции** — вызовется только `~Base()`, `~Derived()` не
вызовется вообще, хотя объект физически был типа `Derived`. `Derived::owned` не освобождается
— прямая утечка. Формально это UB; LeakSanitizer покажет утечку со стеком выделения внутри
конструктора `Derived`. Правило: если класс задуман как базовый для полиморфного использования
(в нём уже есть другие `virtual`-методы, объекты удаляются через указатель на базовый класс),
его деструктор обязан быть `virtual`.
**Ловушки**
- Забыть `virtual` у деструктора базового класса, предназначенного для полиморфного использования → `delete` через `Base*` не вызывает `~Derived()` → утечка ресурсов, которыми владел `Derived`, видна по LeakSanitizer со стеком выделения в конструкторе `Derived`.
- Вызвать `virtual`-функцию из конструктора базового класса, ожидая переопределённое в `Derived` поведение → на этом этапе vtable объекта ещё указывает на таблицу `Base` (подобъект `Derived` ещё не построен), вызовется версия `Base` — тихий баг без ошибки компиляции.
**Факты для карточек**
- base | Что добавляет в объект наличие хотя бы одной virtual-функции? — скрытый указатель на vtable, обычно 8 байт на 64-битной платформе
- core | Что произойдёт при delete через Base*, если ~Base() не virtual, а объект на деле Derived? — вызовется только ~Base(), ~Derived() не вызовется вообще — утечка ресурсов Derived, формально UB
- core | Чем это ловится? — LeakSanitizer, со стеком выделения внутри конструктора Derived
- deep | Что вызовет virtual-функция, вызванная из конструктора базового класса? — версию базового класса, а не переопределённую в наследнике: vtable объекта на этом этапе ещё указывает на таблицу Base
Почему дальше: virtual-деструктор освобождает ресурс через уничтожение объекта; альтернативный способ распорядиться ресурсом объекта — не уничтожить его, а перенести владение — это `std::move`, и здесь часто путают, что именно он делает.
## 4. std::move — это каст, а не перемещение
`std::move(x)` не перемещает данные и не выполняет вообще никакого действия во время
выполнения — это `static_cast<T&&>(x)`, явное приведение объекта к rvalue-ссылке. Единственный
эффект — при выборе перегрузки компилятор теперь предпочитает конструктор/оператор
присваивания **перемещением**, а не копированием, если такой у типа определён.
```c++
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
// std::move(a) сам по себе ничего не делает — просто приводит a к vector<int>&&
// реальную работу выполняет move-конструктор vector: он копирует указатель на
// внутренний буфер a в b и обнуляет указатель у a — O(1), без копирования элементов
```
Реальную работу делает **move-конструктор** конкретного типа: для `std::vector` это означает
скопировать три указателя (начало, конец данных, конец ёмкости) в новый объект и обнулить их
у источника — O(1) вместо O(n) поэлементного копирования. `std::move` — это лишь явная пометка
программиста «мне больше не нужно значение этого именованного объекта (формально lvalue)»,
позволяющая выбрать move-перегрузку там, где без этой пометки компилятор выбрал бы копирующую.
Стандарт гарантирует только, что объект после перемещения находится в **валидном, но
неопределённом состоянии** — обращение к его старым данным (например, чтение элементов
`std::vector`, из которого только что сделали `std::move`) не UB и не ловится ни одним
санитайзером, но является логической ошибкой: конкретное содержимое непредсказуемо.
**Факты для карточек**
- base | Что физически делает std::move во время выполнения программы? — ничего: это static_cast к rvalue-ссылке (T&&), явный каст, не операция
- core | Что реально выполняет перемещение данных? — move-конструктор/move-оператор присваивания конкретного типа, выбранный благодаря касту std::move
- core | Что происходит с vector при перемещении и за какое время? — копируются 3 внутренних указателя (начало, конец данных, конец ёмкости) в новый объект, у источника они обнуляются — O(1), без копирования элементов
- deep | В каком состоянии находится объект после std::move(obj) по стандарту? — в валидном, но неопределённом состоянии — использование старых данных не UB, но логическая ошибка, не ловится санитайзерами
Почему дальше: логическая ошибка использования объекта после move не UB и не ловится санитайзерами — но есть отдельная категория ошибок, которая формально UB и которую санитайзеры как раз находят.
## 5. UB: конкретные случаи и что ловят ASAN/UBSAN
UB (undefined behavior) — поведение, для которого стандарт не накладывает вообще никаких
требований: компилятор вправе сгенерировать любой код, в том числе тот, что работает
по-разному в отладочной и релизной сборке, потому что оптимизатор строит код в предположении,
что UB не происходит.
- **Знаковое переполнение.** `INT_MAX + 1` для `int` (`INT_MAX = 2147483647` на типичной
32-битной `int`) — UB, не гарантированное переполнение по модулю, в отличие от `unsigned`,
для которого переполнение определено стандартом (арифметика по модулю 2^разрядность).
- **Некорректный сдвиг.** Сдвиг на число бит, большее или равное разрядности типа (`1 << 32`
для 32-битного `int`), либо сдвиг влево, затрагивающий знаковый бит отрицательного числа —
UB.
- **Нарушение выравнивания.** Приведение указателя к типу с более строгим выравниванием и
разыменование (например, `char*`, не кратный 4, приведённый к `int*` и разыменованный) — UB,
даже если конкретная архитектура физически позволяет такое чтение.
- **Разыменование null.** `*(int*)nullptr` — UB; на практике обычно даёт `SIGSEGV`, потому что
ОС намеренно оставляет страницу по адресу 0 непримапленной именно для того, чтобы такие
обращения падали предсказуемо, а не читали случайные данные.
**ASAN** (`-fsanitize=address`) проверяет ошибки **работы с памятью**: оборачивает выделения
«красными зонами», обращение к которым — сразу ошибка, и ловит выход за границы,
use-after-free, double-free; встроенный LeakSanitizer ловит утечки. **UBSAN**
(`-fsanitize=undefined`) вставляет проверки прямо в код в местах, являющихся UB по стандарту —
переполнение знаковых типов, некорректный сдвиг, нарушение выравнивания при разыменовании —
и печатает точную строку исходного кода при срабатывании. Они проверяют разные категории
(ASAN — адреса памяти, UBSAN — отдельные операции языка) и не заменяют друг друга, поэтому их
включают вместе:
```
g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer file.cpp -o file
```
Оба замедляют программу и требуют пересборки с флагом `-fsanitize=...`, поэтому используются
в отладочных/тестовых сборках, а не в проде.
**Ловушки**
- Полагаться на то, что `int` переполняется предсказуемо «как unsigned» (по модулю) → UB даёт компилятору право отбросить проверку переполнения при оптимизации, если решит, что переполнения «не бывает» → код, работающий в `-O0`, ломается в `-O2`.
- Включить только ASAN и решить, что этого достаточно против всех UB → ASAN не ловит знаковое переполнение и некорректные сдвиги — это зона UBSAN, нужны оба флага вместе.
**Факты для карточек**
- base | Что проверяет ASAN, а что — UBSAN? — ASAN: ошибки работы с памятью (границы, use-after-free, double-free, утечки); UBSAN: операции, являющиеся UB по стандарту (переполнение, сдвиг, выравнивание)
- base | Значение INT_MAX для 32-битного int? — 2147483647
- core | Почему UB опасен именно тем, что код может работать в отладочной сборке и падать в релизной? — оптимизатор релизной сборки строит код в предположении, что UB не происходит, и может убрать проверки, которые, по мнению программиста, должны были сработать
- core | Какой флаг компилятора включает сразу оба санитайзера? — -fsanitize=address,undefined
- deep | Почему разыменование nullptr на практике обычно даёт SIGSEGV, а не тихо читает мусор? — ОС намеренно не отображает страницу по адресу 0 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает
## Проверь себя
<details>
<summary>1. Почему sizeof(struct { char a; int b; char c; }) равен 12, а не 6 или 9?</summary>
6 — это сумма размеров полей без паддинга, физически недостижима из-за требования
выравнивания int на 4 байта: между a (смещение 0) и b нужно 3 байта паддинга, b занимает
смещения 4–7, c — смещение 8. 9 байт — это конец полезных данных после c, но итоговый размер
структуры округляется вверх до кратного выравниванию самого строгого поля (int, 4) — 9
округляется до 12, добавляя ещё 3 байта хвостового паддинга.
</details>
<details>
<summary>2. Почему копирование объекта с необъявленными спецфункциями и владеющим указателем даёт double-free?</summary>
Компилятор генерирует конструктор копирования по умолчанию, если ни одну из пяти спецфункций
не объявили сами. Он делает побитовое копирование полей — указатель копируется как значение
адреса, оба объекта получают один и тот же адрес. При уничтожении обоих объектов оба
деструктора вызывают delete на этом адресе — второй вызов на уже освобождённой памяти.
</details>
<details>
<summary>3. Почему delete через Base* без virtual-деструктора не роняет программу сразу, а просто течёт?</summary>
Компилятор жёстко привязывает вызов delete к статическому типу указателя (Base*) на этапе
компиляции, потому что деструктор не virtual — вызывается только ~Base(). Память под объект
освобождается корректно (адрес правильный), падения не происходит, но ~Derived() не
выполняется, и ресурсы, которыми управлял именно Derived (например, отдельный new[]), никогда
не освобождаются — это утечка, а не крах.
</details>
<details>
<summary>4. Что конкретно делает std::move и кто выполняет реальное перемещение?</summary>
std::move — это static_cast к rvalue-ссылке, никакого действия во время выполнения не
происходит. Реальную работу — например, перенос трёх внутренних указателей vector в новый
объект и обнуление их у источника — делает move-конструктор/move-оператор присваивания
конкретного типа, который компилятор выбирает благодаря этому касту.
</details>
<details>
<summary>5. Почему сдвиг 1 << 32 для 32-битного int — это UB, а не просто 0 или неожиданный результат?</summary>
Стандарт определяет поведение сдвига только для сдвига на число бит меньше разрядности типа.
Сдвиг на количество бит, равное или большее разрядности (32 для 32-битного int), — UB:
компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при
разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение.
</details>
## Материалы
- cppreference: правило трёх (rule of three) — https://en.cppreference.com/w/cpp/language/rule_of_three
- cppreference: конструктор перемещения — https://en.cppreference.com/w/cpp/language/move_constructor
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html
+299
View File
@@ -0,0 +1,299 @@
# D2, часть 2. Файловый ввод-вывод и мультиплексирование (урок)
Один и тот же файловый дескриптор можно читать тремя разными способами: обычными
`read`/`write` (данные копируются между буфером ядра и буфером пользователя на каждый
вызов), через `mmap` (данные читаются напрямую из страничного кэша по факту обращения), или
не читать самому, а ждать готовности сразу многих дескрипторов через `select`/`poll`/`epoll`.
Разбор идёт в этом порядке: базовые вызовы → почему `write` не гарантирует запись всего
буфера → `mmap` как альтернатива → мультиплексирование и его цена → неблокирующие сокеты и
retry-цикл, без которого ET-режим `epoll` не работает.
## 1. open/read/write/lseek/close и флаги
`int open(const char *path, int flags, mode_t mode)` возвращает файловый дескриптор — целое
число, индекс в таблице открытых файлов процесса. Флаги комбинируются побитовым ИЛИ:
`O_RDONLY`, `O_WRONLY`, `O_RDWR` (режим доступа, взаимоисключающие), `O_APPEND` (каждая
запись атомарно смещается в конец файла перед записью), `O_CREAT` (создать файл, если не
существует, требует третий аргумент `mode`), `O_TRUNC` (обрезать существующий файл до нуля
при открытии), `O_NONBLOCK` (не блокироваться на операциях, для которых обычно ждут — актуально
для FIFO, сокетов, терминалов).
`ssize_t read(int fd, void *buf, size_t count)` и `ssize_t write(int fd, const void *buf,
size_t count)` возвращают число реально прочитанных/записанных байт, `0` от `read` означает
EOF, `-1` — ошибку с кодом в `errno`. `off_t lseek(int fd, off_t offset, int whence)` двигает
позицию чтения/записи файла без самого I/O (`SEEK_SET`/`SEEK_CUR`/`SEEK_END`) — именно эта
позиция определяет, откуда начнёт читать следующий `read`. `close(fd)` освобождает
дескриптор; незакрытые дескрипторы копятся до лимита `ulimit -n` и дают `EMFILE`.
**Факты для карточек**
- base | Какие флаги комбинируются битовым ИЛИ при open()? — O_RDONLY/O_WRONLY/O_RDWR, O_APPEND, O_CREAT, O_TRUNC, O_NONBLOCK
- base | Что возвращает read() при достижении конца файла? — 0
- core | Что делает lseek() и меняет ли он содержимое файла? — двигает позицию чтения/записи файла, содержимое не трогает
- core | Чем O_APPEND отличается от ручного lseek(fd, 0, SEEK_END) перед каждой записью? — O_APPEND атомарно смещает позицию в конец непосредственно перед самой записью на уровне ядра, ручной lseek+write у двух процессов может гонку: оба сделают lseek, потом оба write, и один перезапишет данные другого
Почему дальше: `read`/`write` возвращают число реально обработанных байт, а не гарантированное — отсюда прямое следствие про буферизацию и частичную запись.
## 2. Буферизация: почему write не обязан записать всё
`write(fd, buf, count)` может вернуть значение меньше `count` — это не ошибка. Причины:
буфер ядра для канала/сокета заполнен и вмещает меньше, чем просят записать; вызов был
прерван сигналом до завершения (`EINTR`); для сокетов и pipe частичная запись — штатное
поведение при большом объёме данных. Правильный код пишет весь буфер циклом:
```c++
size_t written = 0;
while (written < count) {
ssize_t n = write(fd, buf + written, count - written);
if (n < 0) {
if (errno == EINTR) continue; // прервано сигналом — повторить тот же вызов
break; // настоящая ошибка
}
written += (size_t)n;
}
```
То же верно для `read`: он может вернуть меньше байт, чем запрошено, даже если EOF ещё не
достигнут (например, из pipe пришла только часть данных) — цикл чтения нужен так же, как и
цикл записи.
**`fsync`/`fdatasync`.** `write` пишет в буфер страничного кэша ядра, а не сразу на физический
носитель — данные могут какое-то время лежать только в памяти. `fsync(fd)` блокирует вызов до
тех пор, пока и данные, и все метаданные файла (время модификации, размер и прочее) не будут
сброшены на диск. `fdatasync(fd)` делает то же самое для данных, но пропускает метаданные, не
влияющие на последующее чтение файла (например, время последнего доступа) — дешевле по числу
операций записи на диск, если метаданные важны только там, где влияют на сами данные
(например, размер файла).
**Ловушки**
- Считать, что `write(fd, buf, count)` всегда пишет ровно `count` байт → без цикла часть данных теряется молча, особенно на pipe и сокетах под нагрузкой → видно как обрезанные данные на приёмной стороне без единой ошибки в логе.
- Не проверять `errno == EINTR` отдельно от настоящих ошибок в цикле записи/чтения → сигнал, пришедший во время `write`, интерпретируется как фатальная ошибка и обрывает передачу.
- Полагаться на `write` без `fsync` там, где данные должны пережить аварийное отключение питания → данные остаются только в буфере страничного кэша и теряются при падении до сброса на диск.
**Факты для карточек**
- base | Может ли write() записать меньше байт, чем попросили, без ошибки? — да, это не ошибка, нужен цикл дозаписи
- core | Чем fdatasync отличается от fsync? — fsync сбрасывает на диск данные и все метаданные файла, fdatasync — только данные и те метаданные, что нужны для последующего чтения (например, размер), пропуская остальные (время доступа)
- core | Какой errno означает «вызов прерван сигналом, нужно просто повторить»? — EINTR
Почему дальше: `write`/`read` всегда копируют данные между буфером ядра и буфером пользователя — `mmap` даёт способ работать с файлом вообще без этого копирования на каждый вызов.
## 3. mmap: отображение файла в память
`mmap` резервирует диапазон виртуальных адресов процесса и связывает его со страницами файла
в странично́м кэше ядра — дальше работа с содержимым файла становится обычным разыменованием
указателя, а не парой вызовов `read`/`write`.
Механизм ленивый: при самом вызове `mmap` физически с диска ничего не читается, только
резервируется адресный диапазон. При первом обращении к странице внутри этого диапазона
происходит **page fault**: если страница уже есть в странично́м кэше — это **minor fault**
(дешёвая операция, просто добавить отображение), если нет — **major fault** (ядро реально
читает страницу с диска). Дальнейшие обращения к уже загруженной странице идут без page fault
вообще — прямое чтение по адресу.
**`MAP_SHARED` vs `MAP_PRIVATE`.** `MAP_SHARED` — изменения видны всем процессам, отобразившим
тот же файл, и в конечном счёте попадают обратно в файл на диске. `MAP_PRIVATE` — copy-on-write:
страницы изначально общие (как при `fork`), но при первой записи процесс получает собственную
копию изменённой страницы, а исходный файл на диске не меняется — тот же механизм COW, что и
у `fork()`. `msync(addr, len, flags)` принудительно сбрасывает изменения `MAP_SHARED`-области
на диск, не дожидаясь, пока это сделает ядро само — без него при аварийном завершении процесса
последние записанные страницы можно потерять. `munmap(addr, len)` снимает отображение.
**Когда mmap быстрее read.** Повторный или случайный доступ к большому файлу: не нужно копировать
данные между буфером ядра и буфером пользователя на каждый вызов (та самая копия, которую
всегда делает `read`), и не нужен системный вызов на каждое обращение — только на первое
касание страницы. Также удобен для разделения памяти между процессами через один и тот же
файл, отображённый `MAP_SHARED`.
**Когда mmap хуже.** Однократное последовательное чтение небольшого файла: накладные расходы
на настройку отображения и page fault на каждую новую страницу не амортизируются, обычный
`read` одним вызовом может оказаться дешевле. При доступе к огромным областям растёт давление
на TLB (аппаратный кэш трансляции виртуальных адресов в физические, ограничен по числу записей)
— трансляция адреса вне TLB требует обхода таблицы страниц, что медленнее прямого попадания.
На системах с ограниченным адресным пространством (32-бит, порядка нескольких гигабайт) размер
файла, который вообще можно отобразить целиком, ограничен размером адресного пространства —
на 64-битных системах это практически не проблема.
**Факты для карточек**
- base | В чём разница MAP_SHARED и MAP_PRIVATE? — MAP_SHARED: изменения видны другим процессам и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны и не попадают в файл на диске
- base | Что делает msync? — принудительно сбрасывает изменения MAP_SHARED-области на диск, не дожидаясь ядра
- core | Чем minor page fault отличается от major? — minor: страница уже в страничном кэше, только добавляется отображение (дёшево); major: страница реально читается с диска (дорого)
- core | Когда mmap проигрывает read по скорости? — при однократном последовательном чтении небольшого файла — накладные расходы на отображение и page fault не амортизируются
- deep | Что ограничивает mmap на 32-битных системах? — размер доступного виртуального адресного пространства (порядка нескольких гигабайт) — файл целиком отобразить может не получиться
Почему дальше: и read/write, и mmap работают с одним дескриптором за раз — сервер, обслуживающий тысячи соединений одним потоком, должен уметь ждать готовности сразу многих дескрипторов, отсюда select/poll/epoll.
## 4. select/poll/epoll: сигнатуры и стоимость
`int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval
*timeout)` — `nfds` это максимальный номер дескриптора в наборах плюс один, `fd_set` —
битовая маска дескрипторов. Жёсткий лимит — `FD_SETSIZE`, **1024** дескриптора: номер
дескриптора больше этого значения `select` обработать не может. При каждом вызове ядро
проходит весь набор целиком, чтобы определить, какие дескрипторы готовы, — сложность `O(n)`
от общего числа отслеживаемых дескрипторов на каждый вызов, независимо от того, сколько из
них реально готовы. `fd_set` к тому же модифицируется вызовом на месте — перед следующим
вызовом набор нужно пересобирать заново.
`int poll(struct pollfd *fds, nfds_t nfds, int timeout)` убирает лимит `FD_SETSIZE` — набор
это обычный массив структур `pollfd` произвольной длины, — но сложность та же: ядро всё равно
проходит по всем `nfds` элементам массива на каждый вызов, `O(n)`.
`epoll` разделяет операции регистрации и ожидания на разные вызовы:
- `int epoll_create1(int flags)` создаёт инстанс epoll (возвращает дескриптор самого epoll);
- `int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)` с `op` равным
`EPOLL_CTL_ADD`/`EPOLL_CTL_MOD`/`EPOLL_CTL_DEL` добавляет, изменяет или убирает дескриптор
из **списка интереса**, который ядро хранит между вызовами в красно-чёрном дереве;
- `int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)` просто
возвращает содержимое отдельного **списка готовых** — ядро само добавляет туда дескриптор,
когда его состояние меняется, без участия вызывающего кода.
Отсюда сложность `epoll_wait` — `O(1)` на каждое готовое событие: работа пропорциональна
числу реально готовых дескрипторов, а не общему числу зарегистрированных, потому что
фильтрация уже произошла в ядре асинхронно, а не в момент вызова.
**Факты для карточек**
- base | Жёсткий лимит числа дескрипторов у select и его значение? — FD_SETSIZE, 1024
- base | Сложность select и poll на один вызов? — O(n) от общего числа отслеживаемых дескрипторов, независимо от того, сколько готовы
- core | Три функции epoll и их роль? — epoll_create1 (создать инстанс), epoll_ctl (ADD/MOD/DEL в списке интереса), epoll_wait (забрать готовые из списка готовых)
- core | На чём построен список интереса epoll внутри ядра? — на красно-чёрном дереве
- core | Сложность epoll_wait на одно готовое событие? — O(1) — ядро уже отфильтровало готовые дескрипторы заранее, работа не зависит от общего числа зарегистрированных
Почему дальше: epoll сообщает, что дескриптор готов, но не гарантирует, что напомнит об этом снова, если не вычитать данные полностью — отсюда разница между level-triggered и edge-triggered режимами.
## 5. Edge-triggered vs level-triggered
**Level-triggered (LT)** — поведение по умолчанию у `epoll`, единственный режим у `select`
и `poll`: событие сообщается, **пока условие остаётся истинным**. Если в буфере сокета есть
непрочитанные данные, каждый следующий `epoll_wait` снова покажет этот дескриптор готовым,
даже если в прошлый раз данные вычитали не полностью.
**Edge-triggered (ET)**, флаг `EPOLLET` при `epoll_ctl` — событие сообщается **один раз**, в
момент перехода состояния из неготового в готовое. Если после этого не вычитать все
доступные данные (не дойти до `EAGAIN`), а прерваться раньше, оставшиеся данные никак не
будут сигнализированы повторно — дескриптор может «зависнуть» с непрочитанными данными до
следующего изменения состояния (например, до прихода новых данных).
Из этого прямо следует требование: **ET обязателен на неблокирующих дескрипторах** и требует
цикла чтения/записи до `EAGAIN`. Причина — на блокирующем дескрипторе цикл «читать, пока не
кончатся данные» на последнем вызове заблокировался бы навсегда в ожидании новых данных
вместо того, чтобы сразу вернуть `EAGAIN` и позволить перейти к другому дескриптору.
**Факты для карточек**
- base | В чём разница level-triggered и edge-triggered? — LT: событие повторяется, пока условие истинно; ET: событие сообщается один раз, в момент перехода в готовое состояние
- core | Почему ET требует неблокирующих дескрипторов? — на блокирующем дескрипторе цикл чтения до исчерпания данных на последнем вызове заблокируется навсегда вместо возврата EAGAIN
- core | Что произойдёт, если в ET-режиме не дочитать данные до EAGAIN? — оставшиеся данные не будут сигнализированы повторно, пока состояние дескриптора не изменится снова (например, не придут новые данные)
- deep | Какой флаг epoll_ctl включает edge-triggered режим? — EPOLLET
Почему дальше: раз ET требует вычитывать всё до конца, нужен точный протокол — что означает EAGAIN, чем он отличается от настоящей ошибки, и как устроен retry.
## 6. EAGAIN/EWOULDBLOCK/EINTR и retry-цикл
Неблокирующий сокет (`O_NONBLOCK`) не ждёт готовности: `read`/`recv`/`write`/`send`
немедленно возвращают управление, даже если данных нет или буфер записи полон. В этом случае
вызов возвращает `-1`, а `errno` выставляется в `EAGAIN` (на Linux синоним `EWOULDBLOCK`,
то же числовое значение) — это не ошибка в смысле сбоя, а сигнал «сейчас нечего делать,
попробуй позже». `EINTR` — другой случай: вызов был прерван доставкой сигнала до завершения,
и его нужно просто повторить теми же аргументами, а не считать ошибкой.
```c++
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) { /* обработать n байт */ continue; }
if (n == 0) { /* EOF, закрыть соединение */ break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // данных больше нет сейчас — выходим из цикла
if (errno == EINTR) continue; // прервано сигналом — повторить read
/* иначе настоящая ошибка */ break;
}
```
Ошибочная трактовка `EAGAIN` как сбоя (например, закрытие соединения при его получении) рвёт
рабочие соединения просто потому, что в момент проверки данные ещё не пришли — типичный
симптом под нагрузкой: случайные обрывы соединений, которых не должно быть.
**Ловушки**
- Закрыть соединение при получении EAGAIN вместо того, чтобы просто выйти из цикла чтения → рабочие соединения обрываются под нагрузкой без реальной причины.
- Использовать ET без цикла до EAGAIN → часть данных остаётся невычитанной и не сигнализируется повторно → дескриптор «зависает» до следующего изменения состояния.
- Не обработать EINTR отдельно от прочих ошибок → сигнал, пришедший во время вызова, обрывает обработку соединения вместо простого повтора.
**Факты для карточек**
- base | Что означает EAGAIN/EWOULDBLOCK на неблокирующем дескрипторе? — данных для чтения нет (или буфер записи полон) прямо сейчас — не ошибка, повторить позже
- base | Что означает EINTR и как на него реагировать? — вызов прерван сигналом — повторить тот же вызов немедленно
- core | Совпадают ли числовые значения EAGAIN и EWOULDBLOCK на Linux? — да, это синонимы с одним и тем же значением
Почему дальше: retry-цикл нужен на уровне отдельного сокета, но сами сокеты сервер создаёт и закрывает постоянно — здесь важно, что происходит с портом сразу после закрытия соединения.
## 7. SO_REUSEADDR и TIME_WAIT
При закрытии TCP-соединения сторона, отправившая последний ACK (первая начавшая закрытие),
переходит в состояние **TIME_WAIT** и ждёт там время, равное удвоенному MSL (Maximum Segment
Lifetime), прежде чем окончательно освободить сокет и порт. Ожидание нужно, чтобы поймать
задержавшиеся в сети дубликаты сегментов старого соединения: если бы порт освобождался сразу
и тут же переиспользовался новым соединением, устаревший сегмент из старого соединения мог бы
быть по ошибке принят как часть новой сессии.
Практическое следствие — сервер, часто пересоздающий слушающий сокет на одном и том же порту
(например, при рестарте), может получить ошибку `bind`: «Address already in use», пока
предыдущие сокеты не выйдут из TIME_WAIT. Опция `SO_REUSEADDR`, выставляемая на сокете перед
`bind`, разрешает повторно привязаться к адресу и порту, у которого есть сокеты в TIME_WAIT —
это стандартная практика для серверов, которые должны уметь быстро перезапускаться.
**Факты для карточек**
- base | Сколько времени сокет проводит в TIME_WAIT? — 2×MSL (удвоенное время жизни сегмента в сети)
- core | Зачем нужно состояние TIME_WAIT? — чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения и не спутать их с новым, если порт переиспользуют слишком быстро
- core | Что даёт SO_REUSEADDR? — разрешает bind на адрес/порт, у которого уже есть сокеты в состоянии TIME_WAIT — иначе bind вернёт «Address already in use»
## Проверь себя
<details>
<summary>1. Почему write() может записать меньше байт, чем передали, и как с этим работать правильно?</summary>
Буфер ядра для сокета/pipe может быть заполнен, вызов может быть прерван сигналом, или для
сокетов частичная запись — штатное поведение при большом объёме данных. Правильный код
дописывает оставшиеся байты в цикле, обрабатывая EINTR отдельно от настоящих ошибок.
</details>
<details>
<summary>2. Почему select ограничен 1024 дескрипторами, а epoll — нет?</summary>
select передаёт в ядро набор дескрипторов как битовую маску fd_set фиксированного размера
FD_SETSIZE (1024); номер дескриптора больше этого значения физически не помещается в маску.
epoll хранит список интереса внутри ядра между вызовами (в красно-чёрном дереве), а не
передаёт весь набор при каждом вызове, поэтому ограничения по числу дескрипторов на уровне
самого API нет.
</details>
<details>
<summary>3. Почему epoll_wait даёт O(1) на событие, а select и poll — O(n) на вызов?</summary>
select и poll на каждом вызове заново проходят весь переданный набор дескрипторов, чтобы
определить готовые — работа пропорциональна общему числу отслеживаемых дескрипторов. epoll
поддерживает отдельный список готовых, который ядро заполняет асинхронно по мере изменения
состояния дескрипторов; epoll_wait просто отдаёт содержимое этого списка — работа
пропорциональна числу реально готовых событий.
</details>
<details>
<summary>4. Почему edge-triggered режим epoll требует неблокирующих дескрипторов?</summary>
ET сообщает о готовности один раз за переход состояния, поэтому нужно вычитывать данные в
цикле до EAGAIN, чтобы не пропустить оставшиеся данные. На блокирующем дескрипторе последний
вызов такого цикла заблокировался бы навсегда в ожидании новых данных вместо немедленного
возврата EAGAIN.
</details>
<details>
<summary>5. Зачем нужен SO_REUSEADDR и с чем он связан?</summary>
Сервер после закрытия соединения оставляет сокет в TIME_WAIT на 2×MSL, чтобы поймать
задержавшиеся дубликаты сегментов. Без SO_REUSEADDR повторный bind на тот же адрес/порт,
пока есть сокеты в TIME_WAIT, вернёт ошибку «Address already in use» — опция явно разрешает
это игнорировать.
</details>
## Задачи дня
- `tasks/07_epoll` — TCP-эхо-сервер на epoll: неблокирующие сокеты, `EAGAIN`/`EINTR`,
частичные чтения и записи, `SO_REUSEADDR`, до 64 одновременных соединений, аккуратное
завершение по SIGTERM/SIGINT.
## Материалы
- man 2 open — https://man7.org/linux/man-pages/man2/open.2.html
- man 2 mmap — https://man7.org/linux/man-pages/man2/mmap.2.html
- man 7 epoll — https://man7.org/linux/man-pages/man7/epoll.7.html
- man 2 select — https://man7.org/linux/man-pages/man2/select.2.html
- man 7 tcp (TIME_WAIT, SO_REUSEADDR) — https://man7.org/linux/man-pages/man7/tcp.7.html
- man 7 signal (SIGTERM/SIGINT) — https://man7.org/linux/man-pages/man7/signal.7.html
+307
View File
@@ -0,0 +1,307 @@
# D3, часть 1. Списки, стек, очередь, монотонный стек (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Односвязный и двусвязный список: сложности
```c
struct Node { int value; Node* next; }; // односвязный
struct DNode { int value; DNode* prev, *next; }; // двусвязный
```
Вставка и удаление узла, когда указатель на нужное место уже есть, — O(1): операция это
перелинковка 2–3 указателей соседей, без сдвига остальных элементов. Поиск по значению или
по номеру — всегда O(n): у списка нет арифметики адреса, только последовательный проход по
`next` от головы.
Двусвязный список хранит ещё и `prev`, поэтому удаление узла по указателю на него самого не
требует отдельно знать предыдущий узел — это цена дополнительного указателя (8 байт на 64-бит
системе) в каждом узле. Узлы списка разбросаны по куче отдельными аллокациями — отсюда плохая
локальность памяти и частые промахи кэша процессора по сравнению с массивом, где элементы
лежат подряд.
**Факты для карточек**
- base | Сложность вставки в список, если указатель на место уже есть? — O(1)
- base | Сложность поиска элемента в связном списке? — O(n)
- core | Почему список медленнее массива на практике, хотя вставка O(1) на бумаге? — узлы разбросаны по куче, промахи кэша при обходе
- core | Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего? — лишним указателем `prev` в каждом узле
Почему дальше: раз вставка/удаление — это перелинковка указателей, логично разобрать
классическую операцию на этих указателях — разворот списка.
## 2. Разворот списка тремя указателями
```c
Node* reverse_list(Node* head) {
Node* prev = nullptr;
Node* curr = head;
while (curr) {
Node* next = curr->next; // сохранить хвост ДО перезаписи
curr->next = prev;
prev = curr;
curr = next;
}
return prev; // новая голова
}
```
Три указателя нужны потому, что `curr->next` перезаписывается на `prev`, а без сохранённого
`next` связь с остальной частью списка потеряется безвозвратно. Один проход, O(n) по времени,
O(1) дополнительной памяти — ни одной аллокации, только перестановка указателей.
**Ловушки**
- Забыть сохранить `next` до перезаписи `curr->next` → потеря хвоста списка → утечка памяти,
видна по ASAN/LeakSanitizer.
- Вернуть `curr` вместо `prev` как новую голову → `curr` в конце цикла равен `nullptr`,
функция вернёт пустой список.
**Факты для карточек**
- base | Сложность разворота списка тремя указателями? — O(n) по времени, O(1) по памяти
- core | Зачем нужен третий указатель `next`? — сохранить связь с остатком списка до перезаписи `curr->next`
Почему дальше: раз можно пройти список одним указателем, следующий вопрос — как найти
элемент на заданном расстоянии от конца за один проход, не зная длины заранее.
## 3. k-й элемент с конца
Идея: два указателя с фиксированным сдвигом в k узлов. Сначала продвигаем «ведущий» указатель
на k шагов вперёд от головы, затем двигаем оба указателя одновременно по одному шагу, пока
ведущий не дойдёт до конца — «отстающий» указатель в этот момент стоит на k-м узле с конца.
Один проход, O(n) по времени (ведущий указатель проходит список один раз, с задержкой в k
шагов для отстающего), O(1) дополнительной памяти — длину списка знать заранее не нужно.
**Факты для карточек**
- base | Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины? — один
- core | На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения? — на k шагов
Почему дальше: тот же приём с двумя указателями разной скорости, а не разного стартового
сдвига, даёт середину списка и обнаружение цикла — логично разобрать оба.
## 4. Середина списка: `find_middle` через slow/fast
```c
Node* find_middle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) { // fast не может продвинуться на 2 — стоп
slow = slow->next;
fast = fast->next->next;
}
return slow; // для чётной длины — второй из двух средних
}
```
`fast` движется на 2 узла за шаг, `slow` — на 1: когда `fast` доходит до конца, `slow`
прошёл ровно половину пути. Для списка из 4 узлов (1→2→3→4) цикл даёт `slow` на узле 3 — это
второй из двух средних (2 и 3), что совпадает с требованием задачи `02_list`. Условие цикла
`fast && fast->next` — единственно верное: без проверки `fast->next` обращение
`fast->next->next` на последнем узле разыменует `nullptr`.
**Факты для карточек**
- base | На сколько узлов за шаг двигается `fast` в поиске середины? — на 2, `slow` — на 1
- core | Что вернёт `find_middle` для списка из 4 узлов (1→2→3→4)? — узел 3 (второй из двух средних)
- deep | Почему условие цикла — `fast && fast->next`, а не только `fast`? — без `fast->next` обращение `fast->next->next` на последнем узле разыменует `nullptr`
Почему дальше: та же пара slow/fast, если список зацикленный, не выходит из цикла вовсе —
это и есть способ обнаружить цикл в списке.
## 5. Цикл Флойда: обнаружение и вход в цикл
```c
bool has_cycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // встретились внутри цикла
}
return false;
}
```
Почему указатели вообще встречаются: если в списке есть цикл, `fast` внутри цикла обгоняет
`slow` с относительной скоростью 1 узел за шаг (двигаясь на 2, а `slow` — на 1), поэтому
расстояние между ними в цикле сокращается на 1 каждый шаг и обязательно дойдёт до 0 — не
позже чем за `c` шагов, где `c` — длина цикла. Без цикла `fast` просто первым дойдёт до
`nullptr` и цикл остановится по условию `fast && fast->next`.
Поиск входа в цикл — отдельный шаг после обнаружения встречи. Пусть `a` — расстояние от
головы до входа в цикл, `b` — от входа до точки встречи, `c` — длина цикла. В момент встречи
`slow` прошёл `a+b`, а `fast` — вдвое больше шагов, чем `slow`, то есть `2(a+b) = a+b + n·c`
для какого-то целого `n` (лишние `n` полных обхода цикла). Отсюда `a = (n-1)·c + (c-b)` — то
есть путь длины `a` от головы совпадает по конечной точке с путём длины `c-b` от точки
встречи (остаток цикла до входа). Практический вывод: после обнаружения встречи ставим один
указатель обратно на голову, второй оставляем в точке встречи, двигаем оба по одному узлу за
шаг — они встретятся ровно на входе в цикл.
**Факты для карточек**
- base | С какой относительной скоростью `fast` догоняет `slow` внутри цикла? — 1 узел за шаг
- core | Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи? — один, второй остаётся в точке встречи, оба идут дальше по 1 шагу
- deep | Почему после первой встречи путь от головы длиной `a` и путь от точки встречи длиной `c-b` сходятся в одной точке? — из равенства `2(a+b) = a+b+n·c`, откуда `a = (n-1)c + (c-b)`
**Ловушки**
- Сравнивать значения узлов вместо указателей (`slow->value == fast->value`) → ложное
срабатывание на списке с повторяющимися значениями без реального цикла.
- Забыть проверку `fast && fast->next` перед `fast->next->next` → падение по `nullptr` на
списке без цикла нечётной/чётной длины на границе.
Почему дальше: и разворот, и середина, и обнаружение цикла работают без единой аллокации —
логично разобрать приём, который избавляет от лишних проверок на границах списка ещё до
самого алгоритма — dummy-узел.
## 6. Зачем dummy-узел
Dummy (sentinel) — фиктивный узел перед настоящей головой списка, который никогда не несёт
полезных данных. Механизм: без dummy операции вставки/удаления в начало списка требуют
отдельной ветки кода («если это голова — обнови указатель head отдельно»), а с dummy у любого
настоящего узла всегда есть предшественник, и вставка/удаление в начало становится тем же
кодом, что и вставка/удаление в середину — специальный случай исчезает.
Типичное применение — задачи слияния двух списков и удаления узлов по условию: результат
собирают, привязывая новые узлы к `dummy->next`, а в конце возвращают `dummy->next` как
настоящую голову.
**Факты для карточек**
- base | Что решает dummy-узел? — убирает отдельную ветку кода для вставки/удаления в начало списка
- core | Что возвращают в конце вместо dummy? — `dummy->next` как настоящую голову результата
Почему дальше: список — это структура с O(1) вставкой по указателю, но что если нужен доступ
только с одного (или двух) концов — это уже стек и очередь.
## 7. Стек и очередь на массиве; очередь на двух стеках
Стек на массиве — индекс вершины `top` плюс сам массив: `push` пишет по `top` и увеличивает
индекс, `pop` уменьшает индекс и отдаёт значение — O(1), без аллокаций, LIFO (last in, first
out).
Очередь на массиве наивно требует сдвига элементов при каждом `pop` с начала — O(n). Решение
без сдвигов — два индекса `head`/`tail` с заворотом по модулю ёмкости (разбор в следующем
разделе, кольцевой буфер).
Альтернативная конструкция без ёмкости заранее — **очередь на двух стеках** (`in`, `out`):
`push` всегда кладёт в `in` — O(1). `pop`: если `out` пуст, целиком переливаем `in` в `out`
(это разворачивает порядок — самый старый элемент `in` окажется на вершине `out`), затем
берём с вершины `out`; если `out` не пуст — берём сразу. Каждый элемент физически
перекладывается из `in` в `out` не более одного раза за всё время своей жизни в очереди,
поэтому суммарная стоимость `n` операций — O(n), то есть **амортизированная O(1)** на
операцию, хотя отдельный вызов `pop` с переливом стоит O(n).
**Факты для карточек**
- base | Сложность push/pop у стека на массиве? — O(1)
- core | Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между `in` и `out`? — не более одного раза
- core | Средняя (амортизированная) сложность pop в очереди на двух стеках? — O(1), несмотря на то что отдельный вызов с переливом стоит O(n)
Почему дальше: очередь на массиве без сдвигов элементов — это ровно то, что нужно для
сетевых буферов, где сдвигать байты на каждый пакет непозволительно дорого. Это кольцевой
буфер.
## 8. Кольцевой буфер: head/tail, wrap-around, полный/пустой
Массив фиксированного размера `capacity` плюс два индекса: `head` — куда пишем следующим,
`tail` — откуда читаем следующим. Когда индекс доходит до конца массива, он возвращается в
начало: `idx = (idx + 1) % capacity`, либо быстрее без деления — `if (++idx == capacity) idx = 0;`.
Сдвигать элементы не нужно никогда — в этом весь смысл структуры: O(1) на `push`/`pop`.
Ключевая ловушка, из-за которой структура — частый вопрос на собеседовании: если `head` и
`tail` совпали, буфер пуст или полон? Оба состояния дают одинаковое совпадение индексов.
Решения — либо отдельный счётчик `size` (тогда `empty()` — это `size == 0`, `full()` — это
`size == capacity`), либо сознательно держать одну ячейку всегда свободной и не использовать
её для данных.
Именно эта структура лежит в основе буферов приёма/передачи пакетов и буферов DMA в
драйверах: аппаратура пишет в буфер по одному индексу, программа читает по другому, оба
двигаются по кругу независимо, без перемещения самих данных в памяти. В однопоточном
варианте `head`/`tail` — обычные переменные; если буфер общий между потоком и прерыванием
или между двумя потоками, индексы нужно защищать (атомарными операциями или мьютексом) —
это разбирается в уроке про многопоточность.
**Факты для карточек**
- base | Формула перехода индекса на начало массива в кольцевом буфере? — `idx = (idx + 1) % capacity`
- base | Сложность push/pop в кольцевом буфере? — O(1), без сдвига элементов
- core | Как отличить полный кольцевой буфер от пустого, если `head == tail`? — счётчик `size`, либо держать одну ячейку всегда свободной
- core | Более быстрая замена `% capacity` без деления? — `if (++idx == capacity) idx = 0;`
- deep | Где кольцевой буфер встречается в драйверах? — буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных
**Ловушки**
- Сдвиг элементов вместо движения индексов → теряется весь смысл O(1), фактически O(n) на
операцию.
- Путаница «пусто/полно» при совпавших `head`/`tail` без отдельного счётчика → неверный
ответ `empty()`/`full()` на границе.
- Инкремент индекса до записи/чтения вместо после → значения идут в неправильном порядке
после первого же заворота.
Почему дальше: если элементов много и нужно быстро находить «следующий больший» для каждого
из них за один проход, наивный перебор даёт O(n²) — монотонный стек снижает это до O(n).
## 9. Монотонный стек: Next Greater Element, Daily Temperatures
Задача Next Greater Element: для каждого элемента массива найти первый элемент справа от
него, который больше. Наивно — вложенный цикл, O(n²). Монотонный стек решает за O(n): идём
слева направо, в стеке храним индексы элементов, для которых ответ ещё не найден (значения в
стеке убывают снизу вверх). Для каждого нового элемента `a[i]`: пока стек не пуст и
`a[i] > a[top]` — это и есть «следующий больший» для индекса на вершине, снимаем его со
стека и записываем ответ; затем кладём индекс `i` в стек.
Daily Temperatures — та же схема, только вместо значения элемента в ответ пишут расстояние
(число дней) до дня с более высокой температурой.
Почему это O(n), хотя внутри есть вложенный `while`: каждый индекс попадает в стек ровно один
раз (`push` вызывается n раз суммарно) и снимается со стека не более одного раза (`pop`
вызывается максимум n раз суммарно за весь проход) — суммарно операций со стеком не больше
2n, поэтому общая стоимость всех итераций внешнего цикла вместе с внутренним `while` — O(n).
Это классический пример **амортизированного анализа**: отдельная итерация внешнего цикла
может выполнить несколько `pop`, но в сумме по всему проходу лишних операций не набегает.
**Факты для карточек**
- base | Наивная сложность Next Greater Element вложенным циклом? — O(n²)
- base | Сложность Next Greater Element через монотонный стек? — O(n)
- core | Почему монотонный стек даёт O(n), если внутри есть вложенный `while`? — каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход
- core | Что хранит монотонный стек в задаче Next Greater Element — значения или индексы? — индексы (значения по ним убывают снизу вверх)
- deep | Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма? — тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение
Почему дальше: списки и кольцевые буферы из этого урока становятся общими структурами между
несколькими потоками в задаче `06_threads` — там начинается вопрос синхронизации доступа к
ним.
Ссылки на задачи этого дня: `tasks/02_list` (разворот, середина, цикл), `tasks/03_ring`
(кольцевой буфер, ступенями).
<details>
<summary>Проверь себя</summary>
1. Список из 5 узлов (1→2→3→4→5). Что вернёт `find_middle` по алгоритму slow/fast из этого
урока?
<details><summary>Ответ</summary>Узел 3 — ровно середина при нечётной длине 5.</details>
2. Почему `pop` в очереди на двух стеках всё равно считается амортизированной O(1), если
конкретный вызов с переливом стоит O(n)?
<details><summary>Ответ</summary>Каждый элемент переливается из `in` в `out` не более
одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n),
то есть в среднем O(1) на операцию.</details>
3. В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз
`head` за это время перешёл через границу массива (сделал wrap-around) при последовательной
индексации с 0?
<details><summary>Ответ</summary>Один раз: после 4 записей `head` дошёл до конца и вернулся
в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно
проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`).</details>
4. Почему монотонный стек для Next Greater Element хранит убывающую последовательность
значений, а не произвольную?
<details><summary>Ответ</summary>Как только встречается элемент больше вершины стека, это
и есть ответ для всех подходящих элементов на вершине — их снимают со стека; то, что
остаётся в стеке, по построению всегда убывает сверху вниз, иначе элемент уже был бы снят
раньше.</details>
</details>
## Материалы
- Clang: документация AddressSanitizer (утечки при потере хвоста списка) — https://clang.llvm.org/docs/AddressSanitizer.html
- cppreference: `std::mutex` — https://en.cppreference.com/w/cpp/thread/mutex
- cppreference: `std::atomic` и `memory_order` — https://en.cppreference.com/w/cpp/atomic/memory_order
- Linux Kernel Module Programming Guide (кольцевые буферы в драйверах) — https://sysprog21.github.io/lkmpg/
- Linux kernel: Driver APIs (DMA-буферы) — https://docs.kernel.org/driver-api/
+254
View File
@@ -0,0 +1,254 @@
# D3, часть 3. Отладка: gdb, core dump, санитайзеры (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/09_gdb`. Задача
дня — не переписать падающую программу с нуля, а найти дефекты отладчиком и починить
минимально.
## 1. Что нужно для отладки: `-g -O0`, DWARF
Флаг `-g` встраивает в бинарник отладочную информацию в формате **DWARF** — соответствие
машинных адресов номерам строк исходника, именам переменных и их типам. Без `-g` gdb видит
только адреса и ассемблер: `bt` покажет голые адреса вместо имён функций и номеров строк.
`-O0` отключает оптимизации компилятора и обязателен вместе с `-g` для комфортной отладки:
оптимизатор переставляет и удаляет инструкции, инлайнит функции и переиспользует регистры под
разные переменные, из-за чего отладочная информация перестаёт однозначно соответствовать
исходному коду — строки «прыгают», переменные показывают не то значение. Типичная ошибка —
попытаться отладить прод-бинарник, собранный с `-O2`: получится рассинхронизация строк и
пропущенные шаги.
**Факты для карточек**
- base | Какие два флага компиляции нужны для комфортной отладки в gdb? — `-g -O0`
- base | Как называется формат отладочной информации, который встраивает `-g`? — DWARF
- core | Почему `-O2` мешает отладке даже при наличии `-g`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, отладочная информация перестаёт однозначно совпадать с исходником
Почему дальше: раз бинарник собран правильно, следующий шаг — реально запустить его под
отладчиком и разобраться с базовыми командами.
## 2. Запуск и базовые команды
`gdb ./prog` запускает отладчик с указанным бинарником, `run args` внутри gdb передаёт
управление процессу с аргументами командной строки, останавливаясь на точках останова или
при сигнале.
- `bt` — печатает стек вызовов (backtrace): цепочку кадров от текущей функции до `main`.
- `frame N` — переключает контекст `print`/`list` на конкретный кадр этого стека.
- `info locals` / `info args` — печатают локальные переменные и аргументы текущего кадра.
- `print x` (или `p x`) — печатает текущее значение переменной по её типу из отладочной
информации.
- `x/16xb ptr` — команда examine memory: печатает 16 (`N`) единиц в формате hex (`x`),
единица — байт (`b`), начиная с адреса `ptr`; формат `x/NFU addr`.
**Факты для карточек**
- base | Какая команда печатает стек вызовов в gdb? — `bt`
- base | Что означает `x/16xb ptr`? — 16 байт в hex начиная с адреса `ptr` (формат `x/NFU addr`)
- core | Чем `frame N` отличается от `bt`? — `bt` печатает весь стек, `frame N` переключает текущий контекст `print`/`info locals` на конкретный кадр из этого стека
Почему дальше: чтобы остановиться в нужном месте, а не просто дойти до конца программы,
нужны точки останова.
## 3. Точки останова
`break file:line` или `break func` ставит точку останова — адрес, на котором выполнение
приостанавливается при каждом попадании. `tbreak` — то же самое, но точка автоматически
удаляется после первого срабатывания, удобно для одноразовой остановки без ручной очистки.
`watch var` — точка наблюдения (watchpoint): останавливает выполнение при каждом изменении
значения переменной, а не в конкретной строке кода. `rwatch var` — остановка при чтении,
`awatch var` — при чтении или записи (access). Механизм — аппаратные регистры отладки
процессора: на x86 их 4 (`DR0`–`DR3`), поэтому одновременно можно держать лишь ограниченное
число аппаратных watchpoint; gdb использует их, если может, иначе откатывается на медленный
программный watchpoint (построчное выполнение с проверкой значения после каждой инструкции).
`watch` незаменим, когда переменная меняется как будто сама по себе из другого места кода —
без него пришлось бы вручную расставлять точки останова по подозрению.
**Факты для карточек**
- base | Чем `tbreak` отличается от `break`? — `tbreak` удаляется автоматически после первого срабатывания
- core | Чем `rwatch` отличается от `watch`? — `watch` реагирует на изменение значения, `rwatch` — на чтение переменной
- deep | Сколько аппаратных регистров отладки на x86 ограничивают число одновременных аппаратных watchpoint? — 4 (`DR0`–`DR3`)
Почему дальше: точки останова дают место остановки, но дальше нужно управлять именно ходом
выполнения — построчно или до конца функции.
## 4. Степание: `next`/`step`/`finish`/`until`
- `next` — выполняет текущую строку целиком, включая вызовы функций внутри неё, не заходя
внутрь них.
- `step` — заходит внутрь вызываемой функции, если для неё есть отладочная информация.
- `finish` — выполняет до возврата из текущей функции, печатает возвращаемое значение.
- `until` (без аргумента) — продолжает выполнение до строки с номером больше текущей в
текущем кадре, удобно чтобы выйти из цикла, не проходя его пошагово итерацию за итерацией.
Если бы `step` всегда заходил внутрь, отладка кода с вызовами библиотечных функций без
отладочной информации была бы мучительной — `next` даёт способ пропустить неинтересную
функцию, не теряя контроль над остальным ходом программы.
**Факты для карточек**
- base | Чем `next` отличается от `step`? — `next` не заходит внутрь вызываемых функций, `step` заходит
- core | Что делает `finish`? — выполняет до возврата из текущей функции и печатает возвращаемое значение
- core | Зачем нужен `until` внутри цикла? — продолжить до строки с номером больше текущей, не проходя цикл пошагово
Почему дальше: все эти команды одинаково работают и при разборе уже случившегося падения —
после срабатывания сигнала или по сохранённому снимку памяти (core dump).
## 5. Разбор падения: core dump, `ulimit -c`, `bt` по кадрам
Первый путь — запустить программу прямо под gdb и дождаться сигнала (обычно `SIGSEGV`, код
139 = 128+11): gdb сам остановится на инструкции, вызвавшей сбой, `bt` покажет полный стек
вызовов, а `print` значений указателей и переменных в нужных кадрах обычно сразу показывает,
например, что указатель равен `nullptr` или мусорному значению.
Второй путь — по **core dump**: файл-снимок памяти процесса, который ядро ОС сохраняет в
момент сигнала, приводящего к аварийному завершению, — все сегменты адресного пространства
(стек, куча, регистры процессора, список загруженных библиотек). Открывают его отдельно:
`gdb prog core` — и получают тот же `bt`/`print`, но постфактум, без необходимости
воспроизводить падение заново. Core dump — единственный способ разобрать баг, который
воспроизводится редко или только под нагрузкой в проде, куда заранее интерактивный отладчик
не прицепишь.
По умолчанию система часто отключает сохранение core-файлов — перед тем как ждать падение,
выставляют `ulimit -c unlimited`. Типичная ошибка — забыть это сделать и потерять
единственный шанс поймать редкий баг; вторая типичная ошибка — пересобрать бинарник (даже
без изменения логики) перед анализом core — адреса не совпадут с записанными в core, и gdb
покажет несогласованный стек вместо реального.
**Факты для карточек**
- base | Каким кодом завершается процесс при SIGSEGV? — 139 (128+11)
- base | Какой командой разрешить сохранение core-файлов перед ожиданием падения? — `ulimit -c unlimited`
- core | Как открыть core dump вместе с бинарником в gdb? — `gdb prog core`
- core | Почему пересборка бинарника перед анализом core ломает разбор? — адреса в новом бинарнике не совпадают с адресами, записанными в core, gdb покажет несогласованный стек
Почему дальше: падение может быть не одиночным потоком — если программа многопоточная, нужно
отдельно смотреть, какой именно поток и в каком состоянии упал или завис.
## 6. Отладка многопоточности
`info threads` показывает все потоки процесса и их текущее состояние (на какой строке/в
какой функции остановлен каждый). `thread N` переключает текущий контекст отладчика (для
`bt`, `frame`, `print`) на поток с номером `N`.
Это основной способ диагностировать зависший дедлок: программа не падает и не пишет ничего в
лог, просто стоит — `info threads` и просмотр стека (`bt`) каждого потока по очереди
показывают, какой поток на каком мьютексе застрял и кого он, в свою очередь, ждёт.
**Факты для карточек**
- base | Какая команда gdb показывает все потоки процесса разом? — `info threads`
- core | Как переключиться на конкретный поток по номеру для `bt`/`print`? — `thread N`
- core | Как gdb помогает диагностировать дедлок, если программа просто зависла без вывода? — `info threads` + `bt` по каждому потоку показывают, кто на каком мьютексе застрял
Почему дальше: не каждый краш находится ровно там, где gdb его показывает, — иногда точка
остановки и точка реальной ошибки в коде далеко друг от друга.
## 7. Почему краш «далеко от причины»: ASAN против gdb
Переполнение буфера или запись по неверному указателю портит чужую память, но крах может
случиться значительно позже — например, когда программа попытается использовать уже
испорченный указатель совсем в другом месте кода. gdb в момент самого краша покажет именно
**точку симптома** (где программа реально упала), а не точку, где память была испорчена
изначально.
Санитайзеры (ASAN — `-fsanitize=address`, UBSAN — `-fsanitize=undefined`) инструментируют
каждое обращение к памяти на этапе компиляции и останавливают программу с диагностикой в
момент **нарушения**, а не когда испорченные данные позже вызовут крах в неожиданном месте —
то есть показывают точку причины напрямую. ASAN дополнительно ловит выход за границы,
use-after-free и двойное освобождение через «красные зоны» вокруг выделенных блоков и
теневую карту памяти; в связке с LeakSanitizer он же в конце работы программы репортит
утечки. Санитайзеры — это перекомпиляция с проверками, а не отдельная программа поверх
готового бинарника, и включаются одним флагом на этапе сборки.
**Факты для карточек**
- core | В чём разница между точкой, которую покажет gdb при краше, и точкой, которую покажет ASAN? — gdb показывает точку симптома (где реально упало), ASAN — точку причины (момент нарушения)
- base | Каким флагом компиляции включается ASAN? — `-fsanitize=address`
- base | Каким флагом компиляции включается UBSAN? — `-fsanitize=undefined`
Почему дальше: санитайзеры требуют пересборки с флагами — если такой возможности нет
(например, готовый чужой бинарник или библиотека), есть альтернатива без пересборки.
## 8. `valgrind --tool=memcheck` как альтернатива
Valgrind ищет ошибки работы с памятью и утечки без пересборки программы с особыми флагами.
Модуль **memcheck** запускает бинарник внутри собственной виртуальной машины — эмулирует
каждую машинную инструкцию на лету, отслеживая состояние памяти (инициализирована/не
инициализирована, выделена/освобождена), и на каждое подозрительное обращение выводит
диагностику со стеком вызовов.
Эмуляция каждой инструкции в софтверной VM принципиально тяжелее, чем компиляторная
инструментация ASAN (которая добавляет проверки только вокруг реальных обращений к памяти
уже в нативном коде) — valgrind ощутимо медленнее санитайзеров, счёт идёт на кратное
замедление, а не на проценты накладных расходов. Главное преимущество — не нужен доступ к
исходникам и пересборка, поэтому его применяют на готовых бинарниках или сторонних
библиотеках, где ASAN не подключить; в собственном проекте с доступом к сборке обычно
предпочитают санитайзеры именно из-за скорости на CI.
**Факты для карточек**
- base | Какой модуль valgrind ищет ошибки памяти? — `memcheck` (`valgrind --tool=memcheck`)
- core | Почему valgrind медленнее ASAN? — эмулирует каждую машинную инструкцию в софтверной VM, а не добавляет проверки только вокруг обращений к памяти в нативном коде
- core | Когда valgrind предпочтительнее санитайзеров? — когда нет доступа к исходникам/пересборке (готовый бинарник, сторонняя библиотека)
## 9. Задача дня: `crash.c` (`tasks/09_gdb`)
`crash.c` — программа, которая падает на части входов и портит память; задача — найти
дефекты отладчиком и починить минимально, не переписывая с нуля. Ожидаемое поведение после
починки:
- `./crash` (без аргумента) печатает `len=5`, код возврата 0;
- `./crash <слово>` печатает `len=<длина слова>`, код возврата 0;
- `./crash ""` печатает `len=0`, код возврата 0;
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
Порядок работы: собрать `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`; под
`gdb ./crash` командами `run`, `bt`, `frame`, `info locals`, `watch` разобраться, что именно
портит память, а что приводит к падению при выходе — это разные дефекты, и падение при
выходе из программы обычно означает испорченный служебный указатель (например, порчу стека
или метаданных кучи), который проявляется только в момент разрушения объекта или выхода из
функции. Проверка — `python3 grade.py 09`: компилирует `crash.c` компилятором C (gcc) с
ASAN/UBSAN и прогоняет 5 входов; `answer.txt` проверяется ревью — важен ход разбора
командами отладчика, а не только итоговый результат.
**Факты для карточек**
- base | Какой командой собирают `crash.c` для отладки с санитайзерами? — `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`
- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0
- core | Почему падение может произойти не в строке с самой ошибкой, а при выходе из программы? — испорченный служебный указатель (стек/метаданные кучи) проявляется только в момент разрушения объекта, а не в момент самой порчи
Ссылка на задачу этого дня: `tasks/09_gdb` — проверка `python3 grade.py 09`, 5 входов, ASAN/UBSAN
чистые.
<details>
<summary>Проверь себя</summary>
1. Собрал бинарник с `-g -O2` и заметил, что `next` в gdb «перепрыгивает» через строки не по
порядку. В чём причина и что нужно изменить в сборке?
<details><summary>Ответ</summary>Оптимизатор при `-O2` переставляет и инлайнит инструкции,
отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с
`-O0` (оставив `-g`).</details>
2. Программа падает по `SIGSEGV` только раз в несколько дней под нагрузкой в проде. Какие две
вещи нужно сделать заранее, чтобы разобрать этот краш постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited`, чтобы ядро сохранило core
dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в
core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек.</details>
3. ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом
же баге падал бы совсем в другом месте кода. Почему так?
<details><summary>Ответ</summary>Порча памяти повреждает чужие данные, но видимый крash
может случиться значительно позже, когда программа использует уже испорченные данные в
другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN
инструментирует каждое обращение и останавливает программу прямо в момент нарушения,
то есть в точке причины.</details>
4. Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности
пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер?
<details><summary>Ответ</summary>`valgrind --tool=memcheck` — он эмулирует уже готовый
бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от
санитайзеров, которые требуют перекомпиляции исходного кода.</details>
</details>
## Материалы
- man 1 gdb — https://man7.org/linux/man-pages/man1/gdb.1.html
- Документация GDB (sourceware) — https://sourceware.org/gdb/current/onlinedocs/gdb.html/
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html
- man 2 setrlimit (ulimit -c / RLIMIT_CORE) — https://man7.org/linux/man-pages/man2/setrlimit.2.html
- man 7 signal (SIGSEGV) — https://man7.org/linux/man-pages/man7/signal.7.html
+348
View File
@@ -0,0 +1,348 @@
# D3, часть 2. Многопоточность (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/06_threads`. На
собеседовании тут спрашивают не «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
счётчик под нагрузкой» — то есть понимание синхронизации, а не перечисление API.
## 1. `std::thread`: создание, join/detach, стек
`std::thread t(f, args...)` запускает переданную функцию в новом потоке операционной системы
немедленно, в момент вызова конструктора, а не при отдельной команде «старт». Создание —
это системный вызов (на Linux — `clone`), ядру нужно выделить новому потоку собственный стек
(по умолчанию порядка 8 МБ на поток на типичной Linux-системе) и завести отдельный набор
регистров; адресное пространство, куча и таблица файловых дескрипторов остаются общими с
процессом.
С объектом `std::thread` после создания есть ровно два законных пути: `t.join()` — дождаться
завершения потока, блокируя вызывающий код до его окончания, или `t.detach()` — отсоединить
поток, чтобы он жил и завершался независимо от объекта. Если объект `std::thread`,
представляющий ещё не завершённый и не присоединённый поток, уничтожается (выходит из
области видимости) — стандарт требует вызвать `std::terminate`: поток — ресурс ОС, и его
нельзя «тихо потерять». При `detach()` нужно отдельно следить за временем жизни всего, что
поток использует по ссылке/указателю — если основной поток уничтожит эти объекты раньше, чем
завершится отсоединённый поток, это use-after-free.
**Факты для карточек**
- base | Когда именно стартует новый поток при `std::thread t(f)`? — сразу, в конструкторе
- base | Примерный размер стека потока на типичной Linux-системе? — ~8 МБ
- core | Что произойдёт, если объект `std::thread` с незавершённым и не присоединённым потоком уничтожится? — `std::terminate`, программа аварийно завершится
- core | Каким системным вызовом Linux создаёт новый поток? — `clone`
Почему дальше: раз несколько потоков делят одно адресное пространство, следующий вопрос —
что происходит, если они одновременно трогают одни и те же данные без защиты.
## 2. Data race и почему это UB уже для `int`
Гонка данных (data race) — одновременный доступ двух и более потоков к одной и той же ячейке
памяти без синхронизации, где хотя бы один из доступов — запись. По стандарту C++ это
неопределённое поведение (UB) — причём для **любого** типа, включая обычный `int`, а не
только для сложных структур.
Причина строгости правила не в том, что чтение/запись `int` физически не атомарны на
конкретном железе (выровненный `int` большинство процессоров читает и пишет одной шиной за
раз) — причина в том, что компилятор без синхронизации вправе кэшировать значение переменной
в регистре, переупорядочивать обращения к памяти и предполагать отсутствие гонки при
оптимизациях. UB здесь означает, что компилятор формально может сгенерировать любой код,
включая код, ломающий программу способом, не связанным напрямую с «неправильным числом» —
поэтому полагаться на «на моём железе `int` всё равно атомарен» неверно и небезопасно.
**Факты для карточек**
- base | Что такое data race? — одновременный доступ к одной памяти минимум с одной записью без синхронизации
- core | Является ли гонка на обычном `int` без атомиков UB по стандарту C++? — да, даже если на конкретном железе чтение/запись `int` физически атомарны
- core | Почему компилятору мало того, что чтение `int` атомарно на железе? — без синхронизации он вправе кэшировать значение в регистре и переупорядочивать обращения к памяти
Почему дальше: раз гонка данных — это UB, нужен механизм, который гарантирует, что в
критическую секцию кода одновременно входит только один поток, — мьютекс.
## 3. `std::mutex` и RAII-обёртки
`std::mutex` гарантирует взаимное исключение: только один поток одновременно может держать
его захваченным. На практике мьютекс почти никогда не захватывают вручную через
`lock()`/`unlock()` — их оборачивают в RAII-объект, потому что ручной `unlock()` легко забыть
на пути исключения или раннего `return`, и тогда мьютекс останется захваченным навсегда.
- `std::lock_guard` — простая блокировка на время текущей области видимости, без возможности
разблокировать раньше.
- `std::unique_lock` — то же самое, но с ручной разблокировкой/повторным захватом,
возможностью передать владение и обязательный тип для работы с `condition_variable::wait`.
- `std::scoped_lock` (C++17) — захватывает **несколько** мьютексов одной атомарной операцией,
без риска, что между захватом первого и второго вклинится другой поток с обратным порядком
захвата (это прямая защита от дедлока при захвате нескольких мьютексов в одном месте).
Дедлок из-за неверного порядка захвата компилятор не ловит — это не синтаксическая, а
динамическая ошибка, которая проявляется только в рантайме на конкретной раскладке потоков.
Обнаруживают его ThreadSanitizer (детектирует инверсию порядка захвата, даже если фактического
зависания в конкретном прогоне не случилось) либо наблюдением зависшей программы через
`gdb`/`info threads`.
**Факты для карточек**
- base | Какая RAII-обёртка нужна для работы с `condition_variable::wait`? — `std::unique_lock`
- base | Что делает `std::scoped_lock`? — атомарно захватывает несколько мьютексов сразу
- core | Почему дедлок ловится ThreadSanitizer, а не компилятором? — это динамическая ошибка, зависящая от конкретной раскладки потоков в рантайме, а не от статической структуры кода
Почему дальше: мьютекс защищает данные, но поток часто должен ещё и **ждать** какое-то
событие (данные появились, место освободилось) — для этого нужен `condition_variable`.
## 4. `condition_variable` и predicate-цикл
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток одной
неделимой операцией. Атомарность здесь принципиальна: если бы освобождение мьютекса и уход в
сон были двумя раздельными шагами, между ними мог бы вклиниться другой поток, изменить
состояние и вызвать `notify` — тогда это уведомление потерялось бы (lost wakeup), потому что
ждущий поток ещё не успел реально зайти в состояние ожидания. При пробуждении `wait`
повторно захватывает тот же мьютекс перед тем, как вернуть управление — весь код после
`wait` уже выполняется под защитой.
Стандарт C++ прямо разрешает **ложные пробуждения** (spurious wakeup) — `wait` может
вернуться без единого вызова `notify_one`/`notify_all`, просто по решению ОС (на Linux
`condition_variable` реализован поверх `futex`, который иногда пробуждается по внутренним
причинам платформы). Из-за этого одиночный `if (!ready) cv.wait(lock);` недостаточен —
правильный паттерн:
```c++
std::unique_lock<std::mutex> lock(mtx);
while (!ready) // predicate-цикл, не if
cv.wait(lock);
```
Изменение состояния (`ready = true;`) обязательно делают под тем же мьютексом, которым
защищена сама проверка предиката, — иначе между проверкой предиката и входом в `wait` может
вклиниться другой поток. А вот сам вызов `notify_one`/`notify_all` можно делать как под
мьютексом, так и сразу после `unlock()` — на корректность это не влияет, потому что проверка
предиката и уход в `wait` у ждущего потока в любом случае происходят атомарно под общим
мьютексом. На производительность разница есть: `notify` под захваченным мьютексом иногда
будит поток, который тут же снова блокируется на этом же мьютексе в ожидании его
освобождения — вызов `notify` после `unlock()` избавляет от этого лишнего пробуждения-и-сна.
**Ловушки**
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
- Изменение состояния (`size_++`, `ready = true`) вне захваченного мьютекса → гонка данных →
ловится ThreadSanitizer как data race.
- Один `condition_variable` на два разных предиката (например «не пусто» и «не полно») вместе
с `notify_one` → можно разбудить не тот поток, а нужный останется ждать → зависание.
**Факты для карточек**
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
- core | Почему `while`, а не `if`, вокруг `wait`? — из-за spurious wakeup: `wait` может вернуться без единого вызова `notify`
- core | Обязательно ли вызывать `notify` под захваченным мьютексом? — нет, обязательно лишь менять состояние под мьютексом; `notify` после `unlock()` — оптимизация, не требование корректности
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — `futex`
Почему дальше: мьютекс и `condition_variable` могут заблокировать программу навсегда, если
их захватывать в разном порядке в разных местах кода, — это дедлок.
## 5. Deadlock: 4 условия и порядок захвата
Дедлок — взаимная блокировка, когда каждый из нескольких потоков ждёт ресурс, захваченный
другим, и никто не может продолжить. Классический сценарий на двух мьютексах: поток A держит
мьютекс 1 и ждёт мьютекс 2, поток B держит мьютекс 2 и ждёт мьютекс 1 — оба висят навсегда.
Четыре условия Коффмана, необходимые одновременно для дедлока:
1. Взаимное исключение — ресурс занят не более чем одним потоком.
2. Удержание и ожидание — поток держит один ресурс и ждёт другой.
3. Невозможность принудительного отбора — ресурс нельзя отобрать у держащего потока.
4. Круговое ожидание — цикл потоков, каждый ждёт ресурс следующего.
Разрушить достаточно одно условие. На практике проще всего разрушить круговое ожидание:
всегда захватывать несколько мьютексов в едином порядке во всей программе (например, по
адресу объекта или по заранее заданному номеру) — тогда цикл ожидания просто не может
образоваться. Там, где несколько мьютексов захватываются в одном месте кода, для этого есть
`std::lock` или `std::scoped_lock` — атомарный захват сразу нескольких без риска, что между
захватом первого и второго вклинится поток с обратным порядком.
Дедлок обычно не падает с ошибкой — это зависшая программа без вывода в лог; диагностируют
через `gdb`, `info threads` и просмотр стеков всех потоков, чтобы увидеть, кто на каком
мьютексе застрял.
**Факты для карточек**
- core | Назови 4 условия Коффмана для дедлока? — взаимное исключение, удержание-и-ожидание, невозможность отбора, круговое ожидание
- core | Какое условие обычно разрушают на практике фиксированным порядком захвата мьютексов? — круговое ожидание
- base | Какой командой gdb смотрят, на каком мьютексе застрял каждый поток при зависании? — `info threads` (и стек каждого потока)
Почему дальше: помимо мьютекса, для простых операций вроде счётчика есть более дешёвая
альтернатива без перехода в ядро — атомарные типы.
## 6. `std::atomic` и `memory_order`
`std::atomic<T>` для простых типов (счётчики, флаги, указатели) дешевле мьютекса: операции
выполняются одной аппаратной атомарной инструкцией процессора (например compare-and-swap),
без системного вызова и без усыпления потока — тогда как мьютекс при конкуренции может
потребовать перехода в ядро и контекстного переключения.
`memory_order` управляет тем, какие перестановки чтений/записей вокруг атомарной операции
разрешены компилятору и процессору:
- `relaxed` — гарантирует только атомарность самой операции, никакого порядка относительно
других обращений к памяти не задаёт.
- `acquire` (на загрузке/чтении) — запрещает переносить более поздние по коду обращения к
памяти ДО этой операции.
- `release` (на сохранении/записи) — запрещает переносить более ранние по коду обращения к
памяти ПОСЛЕ этой операции; `release`-запись синхронизируется-с последующим `acquire`-чтением
того же атомика в другом потоке.
- `seq_cst` (значение по умолчанию для всех операций `std::atomic`) — то же, что `acquire`
и `release` вместе, плюс единый глобальный порядок для всех `seq_cst`-операций во всей
программе — самый строгий и самый дорогой по производительности вариант.
Видимость изменений между ядрами процессора на аппаратном уровне обеспечивает протокол
когерентности кэша **MESI** (Modified / Exclusive / Shared / Invalid) — каждая кэш-линия в
каждом ядре находится в одном из этих 4 состояний, и запись в линию на одном ядре инвалидирует
копии этой же линии в кэшах других ядер, заставляя их перечитать актуальное значение при
следующем обращении. `memory_order` — это про то, какие перестановки инструкций разрешены
компилятору и ядру процессора вокруг атомарной операции; MESI — это про то, как аппаратно
гарантируется, что после разрешённого порядка операций другое ядро увидит актуальное значение
кэш-линии, а не устаревшую локальную копию.
**Факты для карточек**
- base | Чем `std::atomic` дешевле мьютекса для простого счётчика? — одна аппаратная инструкция (например CAS), без перехода в ядро и усыпления потока
- core | Что гарантирует `memory_order_relaxed`? — только атомарность операции, без ограничений порядка с другими обращениями к памяти
- core | Что запрещает `acquire`, а что — `release`? — acquire запрещает переносить более поздние обращения ДО себя; release запрещает переносить более ранние обращения ПОСЛЕ себя
- deep | Сколько состояний у кэш-линии в протоколе MESI? — 4 (Modified, Exclusive, Shared, Invalid)
- deep | Какой memory_order используется по умолчанию у операций `std::atomic`? — `seq_cst`
Почему дальше: у синхронизации через мьютекс и `condition_variable` есть готовый типовой
паттерн, где всё это применяется вместе, — producer/consumer.
## 7. Producer/consumer и потокобезопасная очередь (`tasks/06_threads`)
Задача `06_threads` — ограниченная блокирующая очередь:
```c++
class BlockingQueue {
public:
explicit BlockingQueue(size_t capacity);
~BlockingQueue();
void push(int v); // блокируется, пока очередь полна
bool pop(int& out); // блокируется, пока пуста; false — если закрыта и пуста
void close(); // после close: pop() опустошает остаток и отдаёт false
size_t size() const;
};
```
Требования: `push` после `close()` бросает `std::runtime_error`; закрытие разблокирует все
ждущие потоки (никакого вечного ожидания и busy-wait); размер очереди никогда не превышает
`capacity`; ни одной гонки, включая `size()`, который тоже вызывается из другого потока.
Механизм — общее состояние (буфер, счётчик, флаг `closed`) защищено одним `std::mutex`.
Для `push` и `pop` нужны разные условия ожидания («не полна» и «не пуста или закрыта») —
такое возможно с одним `condition_variable`, только если использовать `notify_all` (каждый
разбуженный поток сам перепроверяет свой предикат в цикле и снова засыпает, если условие не
его), либо завести два раздельных `condition_variable`.
Почему `size()` тоже требует мьютекса: инкремент/декремент счётчика — это не одна
процессорная операция, а последовательность «прочитать — изменить — записать»
(read-modify-write); без синхронизации с `push`/`pop`, которые пишут в тот же счётчик, это
гонка данных даже для «безобидного» чтения одного числа — UB по стандарту, а не просто
«иногда неверное число».
**Ловушки**
- Забыть разбудить всех потоков при `close()` → часть потоков навсегда висит в `wait` →
зависание процесса, тест не завершается за отведённое время.
- Защищать `size_` отдельным от данных мьютексом → `size()` может вернуть значение, уже не
соответствующее реальному состоянию буфера.
**Факты для карточек**
- base | Каким исключением отвечает `push` после `close()`? — `std::runtime_error`
- core | Почему `size()` в этой задаче требует того же мьютекса, что и данные очереди? — инкремент/декремент — не атомарная операция read-modify-write, без синхронизации это гонка данных
Почему дальше: если под каждую входящую задачу создавать отдельный `std::thread`, накладные
расходы на создание потока (системный вызов, выделение стека) быстро перевешивают полезную
работу — логично перейти к переиспользуемому пулу потоков.
## 8. Пул потоков
Пул — заранее созданный набор из N рабочих потоков (обычно порядка числа ядер процессора),
которые постоянно забирают задачи из общей очереди (той же природы, что producer/consumer
выше) вместо создания нового `std::thread` под каждую задачу. Причина — цена создания потока:
системный вызов, выделение стека, регистрация в планировщике ОС; при коротких и частых
задачах эти накладные расходы легко превышают время самой полезной работы. Пул амортизирует
эту цену — потоки создаются один раз при старте и переиспользуются для множества задач, а
фиксированное их число не даёт программе бесконтрольно наплодить тысячи потоков под наплывом
запросов и не утопить систему в переключениях контекста.
**Факты для карточек**
- base | Примерно сколько потоков создают в пуле относительно ядер CPU? — порядка числа ядер процессора
- core | Какую конкретно цену амортизирует пул потоков? — стоимость создания потока (системный вызов, стек, регистрация в планировщике) на каждую отдельную короткую задачу
Почему дальше: не всякую параллельную задачу удобно оформлять вручную через
`std::thread`/очередь — для запуска функции и получения её результата есть более короткий
интерфейс, `std::async`/`std::future`.
## 9. `std::async` и `std::future`
`std::async(policy, f, args...)` запускает функцию `f` и сразу возвращает `std::future` —
объект-обещание будущего результата. Политика запуска (`std::launch::async` — обязательно в
новом потоке, `std::launch::deferred` — отложенный вызов при первом обращении к результату,
или их комбинация по умолчанию — реализация вправе выбрать любой вариант) определяет, когда
и где реально выполнится `f`.
`future.get()` блокирует вызывающий поток до готовности результата и возвращает его. `get()`
можно вызвать только один раз: после первого вызова `future` становится невалидным
(`valid() == false`), и повторный вызов `get()` — неопределённое поведение по стандарту.
`std::async` избавляет от ручного управления мьютексом/`condition_variable`, когда нужен
именно единичный результат одной асинхронной операции, а не постоянный поток задач через
очередь.
**Факты для карточек**
- base | Что возвращает `std::async` сразу после вызова? — `std::future` с будущим результатом
- core | Сколько раз можно вызвать `future.get()` для одного результата? — один; второй вызов — неопределённое поведение (`valid() == false`)
Почему дальше: всё, что описано в этом уроке, — мьютексы, `condition_variable`, атомики,
дедлоки — проверяемо инструментом, который ловит гонки по факту исполнения, а не на глаз.
## 10. ThreadSanitizer
ThreadSanitizer (`-fsanitize=thread`, флаг компиляции — то есть перекомпиляция с
инструментацией, а не отдельная программа поверх готового бинарника) инструментирует каждое
обращение к памяти и синхронизирующие примитивы, отслеживая порядок happens-before между
потоками во время конкретного исполнения — то есть ловит гонку по факту того, что реально
произошло в этом запуске.
Из этого следует практическое ограничение: «прогнать разок и не увидеть ошибки» не равно
«гонки в коде нет» — конкретная раскладка потоков в конкретном запуске могла просто не
проявить проблему, которая есть в коде и выстрелит на другой машине или под другой нагрузкой.
**Факты для карточек**
- base | Каким флагом компиляции включается ThreadSanitizer? — `-fsanitize=thread`
- core | Почему чистый прогон под TSan не гарантирует отсутствие гонки в коде вообще? — TSan ловит гонку по факту конкретной раскладки потоков в конкретном запуске, а не статическим анализом всех возможных раскладок
Ссылка на задачу этого дня: `tasks/06_threads` — потокобезопасная ограниченная очередь,
проверяется `python3 grade.py 06` (все `ok`, TSan чистый, нет зависания).
<details>
<summary>Проверь себя</summary>
1. Почему `cv.wait(lock)` обязан принимать `unique_lock`, уже захвативший тот же мьютекс, что
защищает разделяемое состояние, а не произвольный лок?
<details><summary>Ответ</summary>`wait` должен атомарно освободить именно этот мьютекс
перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат
под разными блокировками, и защита состояния перестала бы работать.</details>
2. Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно
изменить, чтобы устранить дедлок, не трогая логику самой критической секции?
<details><summary>Ответ</summary>Привести захват к единому порядку во всей программе
(например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать
оба мьютекса разом через `std::scoped_lock`.</details>
3. Почему data race на обычном `int` без `std::atomic` считается UB, даже если на конкретном
процессоре чтение/запись `int` физически выполняются одной инструкцией?
<details><summary>Ответ</summary>Потому что стандарт C++ формально не гарантирует
атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в
регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо
действительно рвёт запись на части.</details>
4. Чем `memory_order_acquire` отличается от `memory_order_relaxed` по факту разрешённых
перестановок кода?
<details><summary>Ответ</summary>`relaxed` гарантирует только атомарность самой операции;
`acquire` дополнительно запрещает переносить более поздние по коду обращения к памяти до
этой операции — то есть даёт порядок, а не только неделимость.</details>
</details>
## Материалы
- cppreference: `std::mutex` — https://en.cppreference.com/w/cpp/thread/mutex
- cppreference: `std::condition_variable` — https://en.cppreference.com/w/cpp/thread/condition_variable
- cppreference: `std::atomic` и `memory_order` — https://en.cppreference.com/w/cpp/atomic/memory_order
- Clang: документация ThreadSanitizer — https://clang.llvm.org/docs/ThreadSanitizer.html
- cppreference: undefined behavior (data race) — https://en.cppreference.com/w/cpp/language/ub
- Linux kernel: "volatile considered harmful" — https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html
+410
View File
@@ -0,0 +1,410 @@
# D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/12_dijkstra`. Граф —
это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки,
процессы и их зависимости, файлы и их include-цепочки.
## 1. Представления графа: матрица смежности vs список смежности
Граф — это набор вершин `V` и рёбер `E`. Два способа хранить связи в памяти:
```c++
// матрица смежности: adj[u][v] = вес ребра (или 0/inf, если ребра нет)
std::vector<std::vector<int>> adj(n, std::vector<int>(n, 0));
// список смежности: adj[u] = список (сосед, вес)
std::vector<std::vector<std::pair<int,int>>> adj(n);
```
**Матрица смежности:** память O(V²) независимо от числа рёбер, проверка «есть ли ребро u→v» —
O(1) прямым обращением по индексу, но перебор всех соседей вершины — всегда O(V), даже если
соседей всего два. Подходит для **плотных** графов (E близко к V²) и когда нужна частая
проверка «есть ли ребро».
**Список смежности:** память O(V+E) — платишь только за реально существующие рёбра, перебор
соседей вершины — O(deg(v)), то есть ровно столько операций, сколько соседей. Подходит для
**разреженных** графов (типичный случай: сети, графы зависимостей), где E ≈ V, а не V².
`tasks/12_dijkstra` — 100 000 вершин и 200 000 рёбер: матрица заняла бы 10¹⁰ ячеек и не влезла
бы в память, поэтому единственный вариант — список смежности.
**Факты для карточек**
- base | Память матрицы смежности для V вершин? — O(V²)
- base | Память списка смежности? — O(V+E)
- core | Сложность перебора всех соседей вершины в списке смежности? — O(deg(v)), в матрице — всегда O(V)
- core | Почему для графа 100 000 вершин нужен список смежности, а не матрица? — матрица заняла бы 10¹⁰ ячеек, не влезает в память
Почему дальше: раз связи хранятся как список соседей, естественный первый вопрос — как обойти
весь граф, начиная с одной вершины.
## 2. BFS: обход в ширину и кратчайший путь по числу рёбер
```c++
std::vector<int> bfs(int src, const std::vector<std::vector<int>>& adj) {
std::vector<int> dist(adj.size(), -1);
std::queue<int> q;
dist[src] = 0;
q.push(src);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u])
if (dist[v] == -1) { // ещё не посещена
dist[v] = dist[u] + 1;
q.push(v);
}
}
return dist;
}
```
BFS раскрывает граф слоями: сначала все вершины на расстоянии 1 ребро от источника, потом на
расстоянии 2, и так далее — это следствие того, что структура данных — **очередь** (FIFO):
вершина, добавленная раньше, обрабатывается раньше, поэтому к моменту, когда очередь дошла до
вершин расстояния k, все вершины расстояния <k уже обработаны. Отсюда ключевое свойство: **в
невзвешенном графе BFS даёт кратчайший путь по числу рёбер** — первый раз, когда вершина
помечена посещённой, это гарантированно по кратчайшему пути.
Каждая вершина кладётся в очередь ровно один раз (проверка `dist[v] == -1` это гарантирует), и
для каждой вершины перебираются все её соседи один раз — суммарно **O(V+E)**: O(V) на
инициализацию и постановку/снятие каждой вершины из очереди, O(E) на суммарный перебор всех
рёбер по всем вершинам (каждое ребро просматривается один или два раза, в зависимости от
ориентированности).
**Факты для карточек**
- base | Сложность BFS на списке смежности? — O(V+E)
- base | Какую структуру данных использует BFS? — очередь (FIFO)
- core | Что гарантированно находит BFS в невзвешенном графе? — кратчайший путь по числу рёбер от источника
- core | Почему BFS даёт кратчайший путь именно из-за очереди, а не из-за чего-то ещё? — FIFO-порядок раскрывает граф строго по слоям расстояния, вершина расстояния k не может обработаться раньше всех вершин расстояния <k
Почему дальше: если вместо очереди использовать стек (или рекурсию), обход идёт не слоями, а
«вглубь» одной ветки до конца — это DFS, и он даёт другие гарантии.
## 3. DFS: обход в глубину
```c++
void dfs(int u, const std::vector<std::vector<int>>& adj, std::vector<bool>& visited) {
visited[u] = true;
for (int v : adj[u])
if (!visited[v]) dfs(v, adj, visited); // рекурсия = неявный стек вызовов
}
```
DFS идёт максимально глубоко по одной ветке, прежде чем откатиться и попробовать соседнюю —
это следствие того, что структура данных — **стек** (LIFO), явный или неявный (стек вызовов
рекурсии). Сложность та же, что у BFS — **O(V+E)**: те же рассуждения про однократное
посещение каждой вершины и однократный перебор рёбер, разница только в порядке обхода, не в
асимптотике.
DFS не гарантирует кратчайший путь (может найти путь в 10 рёбер, когда есть путь в 2), зато
даёт естественный механизм для задач, где важен порядок «сначала потомки, потом сам узел» —
топологическая сортировка через постфиксный обход, обнаружение циклов, поиск компонент
связности.
**Ловушки**
- Рекурсивный DFS на графе с длинной цепочкой (например, список из 100 000 вершин,
выстроенных в цепь) → глубина рекурсии 100 000 → переполнение стека вызовов (stack
overflow), не ловится компилятором, падает в рантайме. Решение — итеративный DFS с явным
`std::stack`.
- Забыть пометить вершину посещённой до рекурсивного спуска, а не после → на графе с циклом
уйдёт в бесконечную рекурсию.
**Факты для карточек**
- base | Сложность DFS на списке смежности? — O(V+E)
- base | Какую структуру данных использует DFS? — стек (явный или стек вызовов рекурсии)
- core | Гарантирует ли DFS кратчайший путь? — нет, в отличие от BFS
- deep | Почему рекурсивный DFS опасен на графе-цепочке из 100 000 вершин? — глубина рекурсии равна длине цепочки, стек вызовов переполняется
Почему дальше: раз BFS/DFS помечают вершины посещёнными за один проход, естественно
использовать это для подсчёта изолированных друг от друга частей графа — компонент
связности.
## 4. Связные компоненты
Механизм: перебираем все вершины `0..n-1`; если вершина ещё не посещена — запускаем от неё
BFS или DFS, это помечает посещёнными всю компоненту, к которой она принадлежит, и
увеличиваем счётчик компонент на 1. Суммарная сложность по всем запускам всё равно
**O(V+E)** — каждая вершина и каждое ребро обрабатываются ровно один раз за весь перебор,
несмотря на то что BFS/DFS запускается несколько раз (по числу компонент), а не один.
Задача **Number of Islands** — это ровно связные компоненты на сетке: клетка `'1'` —
вершина, соседние по 4 направлениям (вверх/вниз/влево/вправо) клетки `'1'` — рёбра. BFS/DFS
(flood fill) от каждой ещё не посещённой клетки `'1'` закрашивает весь остров, число запусков
= число островов. Граф здесь не хранится явно списком смежности — соседи вычисляются на лету
по координатам, но асимптотика та же: O(rows·cols).
**Факты для карточек**
- base | Сложность подсчёта связных компонент через BFS/DFS от каждой непосещённой вершины? — O(V+E), суммарно по всем запускам
- core | Что в задаче Number of Islands является «вершиной» и «ребром»? — вершина — клетка `'1'`, ребро — соседство по 4 направлениям
Почему дальше: связные компоненты не различают направление рёбер. В ориентированном графе
зависимостей («B зависит от A») важен порядок — какую вершину обработать раньше другой. Это
топологическая сортировка.
## 5. Топологическая сортировка: алгоритм Кана
Топологический порядок существует только для **направленного ациклического графа (DAG)** —
если есть цикл (A зависит от B, B зависит от A), непротиворечивого порядка «раньше/позже»
построить нельзя в принципе.
```c++
std::vector<int> kahn_toposort(int n, const std::vector<std::vector<int>>& adj) {
std::vector<int> indeg(n, 0);
for (int u = 0; u < n; ++u)
for (int v : adj[u]) indeg[v]++; // степень входа: сколько рёбер входит в v
std::queue<int> q;
for (int u = 0; u < n; ++u)
if (indeg[u] == 0) q.push(u); // вершины без зависимостей — стартовые
std::vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u])
if (--indeg[v] == 0) q.push(v); // все зависимости v уже обработаны
}
return order; // order.size() < n -> в графе есть цикл
}
```
Механизм: **степень входа** вершины — число рёбер, входящих в неё, то есть число
нерассмотренных зависимостей. Вершина с `indeg == 0` не зависит ни от кого необработанного —
её можно поставить в порядок прямо сейчас. Когда вершина обработана, она «снимает»
зависимость со всех своих соседей (`--indeg[v]`); как только у соседа не осталось
необработанных зависимостей, он тоже готов — кладём его в очередь. Сложность **O(V+E)**: та
же логика, что у BFS — каждая вершина обрабатывается один раз, каждое ребро уменьшает
`indeg` один раз.
**Как проявляется цикл:** если в графе есть цикл, все вершины цикла имеют `indeg > 0` до
конца работы алгоритма (каждая ждёт кого-то из цикла, а тот ждёт её) — они никогда не попадут
в очередь. Проверка: **`order.size() < n`** после завершения — значит, часть вершин не
обработана, в графе цикл. Это и есть способ решить задачу **Course Schedule**: курсы —
вершины, пререквизит «A перед B» — ребро A→B; если топологический порядок покрывает все
курсы, все курсы можно пройти, иначе где-то циклическая зависимость.
**Факты для карточек**
- base | Для какого типа графа определена топологическая сортировка? — направленный ациклический граф (DAG)
- base | Сложность алгоритма Кана? — O(V+E)
- core | Что такое степень входа вершины в алгоритме Кана? — число входящих в неё рёбер (нерассмотренных зависимостей)
- core | Как алгоритм Кана обнаруживает цикл? — счётчик обработанных вершин `order.size()` меньше `n` после завершения
- core | Как задача Course Schedule сводится к топологической сортировке? — курсы — вершины, пререквизит — направленное ребро, цикл = невозможно пройти все курсы
**Ловушки**
- Забыть, что `order.size() < n` — единственный надёжный признак цикла (пустая очередь сама
по себе не значит успех) → код считает граф с циклом корректно отсортированным.
- Спутать степень входа со степенью выхода при инициализации очереди → в очередь попадут не
те вершины, порядок будет неверным с первого шага.
Почему дальше: топологическая сортировка отвечает на вопрос «в каком порядке», но не «на
каком расстоянии». Если рёбрам приписать веса, следующий естественный вопрос — кратчайший
путь по сумме весов, а не по числу рёбер.
## 6. Дейкстра: кратчайшие пути с неотрицательными весами
Условие применимости — **все веса рёбер ≥ 0** (`tasks/12_dijkstra` явно требует `w >= 0`,
при этом вес 0 допустим). Причина ограничения — алгоритм жадный: как только вершина извлечена
с минимальным текущим расстоянием, она считается **окончательно решённой** и больше не
пересматривается. Это верно только если из невыбранных вершин путь до неё не может стать
короче — а если есть отрицательное ребро, путь через ещё не рассмотренную вершину теоретически
мог бы уменьшить уже «зафиксированное» расстояние, и жадность ломается: алгоритм даст неверный
ответ, а не просто отработает медленнее.
**Наивная реализация** (без очереди с приоритетом): на каждом из V шагов ищем среди
непосещённых вершину с минимальным `dist` линейным перебором — O(V) на шаг, всего **O(V²)**.
Годится для плотных графов, где E ≈ V².
**С `priority_queue`** (мин-куча по `dist`): каждое расслабление ребра — это `push` в кучу
O(log V), таких расслаблений всего O(E), плюс O(V) извлечений минимума — итого **O((V+E) log V)**.
Для `tasks/12_dijkstra` (100 000 вершин, 200 000 рёбер) это единственный вариант, укладывающийся
в 2 секунды: V² здесь — 10¹⁰ операций, а (V+E)·log V — около 300 000 · 17 ≈ 5·10⁶.
```c++
long long shortest_path(int n,
const std::vector<std::tuple<int,int,int>>& edges, // (u, v, w)
int src, int dst) {
if (src == dst) return 0;
std::vector<std::vector<std::pair<int,long long>>> adj(n);
for (auto& [u, v, w] : edges) adj[u].push_back({v, w}); // ориентированное ребро
std::vector<long long> dist(n, -1);
using P = std::pair<long long,int>; // (расстояние, вершина)
std::priority_queue<P, std::vector<P>, std::greater<P>> pq; // мин-куча
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (dist[u] != -1 && d != dist[u]) continue; // устаревшая запись — "ленивое" удаление
if (u == dst) return d;
for (auto& [v, w] : adj[u]) {
long long nd = d + w;
if (dist[v] == -1 || nd < dist[v]) {
dist[v] = nd;
pq.push({nd, v});
}
}
}
return -1; // недостижима
}
```
**«Ленивое» удаление устаревших записей** — `std::priority_queue` не умеет напрямую
уменьшать ключ уже лежащего элемента (нет операции decrease-key), поэтому вместо обновления
кладут в кучу новую пару `(nd, v)` при каждом улучшении расстояния — старая запись остаётся в
куче. Когда до неё доходит очередь на извлечение, проверка `if (d != dist[v]) continue;`
находит, что для `v` уже известно расстояние лучше, чем в этой записи, и просто пропускает
её. Куча может временно раздуться до O(E) записей вместо O(V), но асимптотика O((V+E) log V)
не меняется, потому что каждая запись — это одна операция `push` в ответ на одно расслабление
ребра.
**Факты для карточек**
- base | Обязательное условие применимости Дейкстры? — все веса рёбер неотрицательны (w ≥ 0)
- base | Сложность наивной Дейкстры (без кучи)? — O(V²)
- core | Сложность Дейкстры с `priority_queue`? — O((V+E) log V)
- core | Почему отрицательное ребро ломает Дейкстру? — вершина считается решённой сразу после извлечения минимума, а отрицательное ребро из ещё не рассмотренной вершины теоретически могло бы уменьшить это «зафиксированное» расстояние
- core | Что проверяет `if (d != dist[v]) continue;` в реализации на куче? — что извлечённая запись не устарела (для v уже нашли путь короче)
- deep | Почему куча может держать до O(E) записей вместо O(V)? — при каждом улучшении расстояния в кучу кладётся новая запись вместо обновления старой (нет decrease-key)
**Ловушки**
- Забыть проверку устаревшей записи (`if (d != dist[v]) continue;`) → для одной вершины
расслабление соседей выполняется несколько раз лишний раз, результат остаётся верным, но
сложность деградирует к худшему случаю.
- Применить Дейкстру к графу с отрицательным ребром без проверки условия → неверный
(заниженный или завышенный, в зависимости от структуры) ответ без явной ошибки —
тестами не всегда ловится, если отрицательное ребро не лежит на кратчайшем пути.
Почему дальше: если в графе всё же есть отрицательные веса, нужен алгоритм без жадного
«фиксирования» результата — Беллман-Форд.
## 7. Беллман-Форд: отрицательные веса
Механизм: вместо жадного выбора минимума **расслабляем все E рёбер `V-1` раз подряд**. После
k-й полной итерации гарантированно найдены все кратчайшие пути, использующие не более k
рёбер; кратчайший путь без отрицательных циклов не может состоять больше чем из `V-1` ребра
(иначе он повторял бы вершину), поэтому `V-1` итераций достаточно, чтобы расстояния
стабилизировались. Сложность — **O(V·E)**: `V-1` проходов по всем E рёбрам.
Дополнительная V-я итерация по всем рёбрам служит детектором **отрицательного цикла**: если
после `V-1` итераций расстояние для какого-то ребра всё ещё можно уменьшить, значит в графе
есть цикл с отрицательной суммой весов — кратчайший путь в принципе не определён (можно
крутиться по циклу бесконечно, уменьшая сумму). Дейкстра такой цикл не заметит и просто даст
неверный ответ; Беллман-Форд явно сигнализирует о его существовании.
**Факты для карточек**
- base | Сложность Беллман-Форда? — O(V·E)
- base | Сколько раз алгоритм проходит по всем рёбрам в основном цикле? — V-1 раз
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если расстояние ещё можно уменьшить на дополнительной V-й итерации, в графе есть отрицательный цикл
- core | Почему V-1 итераций достаточно? — кратчайший путь без отрицательных циклов не может содержать больше V-1 ребра (иначе повторяет вершину)
Почему дальше: и Дейкстра, и Беллман-Форд работают на ориентированных рёбрах с весами. Для
неориентированных рёбер есть отдельная задача — построить минимальный связывающий набор
рёбер (остовное дерево) без циклов; для неё нужна структура, которая быстро отвечает «эти две
вершины уже соединены?» — union-find.
## 8. Union-Find (Disjoint Set Union): ранг, сжатие пути, Kruskal
Структура хранит разбиение вершин на непересекающиеся множества (компоненты) и поддерживает
две операции: `find(x)` — найти представителя множества, `union(x, y)` — объединить два
множества.
```c++
struct DSU {
std::vector<int> parent, rank_;
DSU(int n) : parent(n), rank_(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // сжатие пути (path compression)
return parent[x];
}
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false; // уже в одном множестве — цикл
if (rank_[x] < rank_[y]) std::swap(x, y);
parent[y] = x;
if (rank_[x] == rank_[y]) rank_[x]++; // объединение по рангу
return true;
}
};
```
**Сжатие пути:** при каждом `find` все вершины на пути до корня перепривязываются напрямую к
корню — следующий `find` для любой из них займёт один шаг вместо повторного прохода всей
цепочки. **Объединение по рангу:** при слиянии корень с меньшим рангом (примерной оценкой
высоты дерева) подвешивается под корень с большим — это не даёт деревьям расти в глубину без
необходимости. По отдельности каждый приём даёт логарифмическое улучшение, вместе — амортизи-
рованная сложность операции становится **O(α(n))**, где α — обратная функция Аккермана: она
растёт настолько медленно, что для любого практически представимого n (меньше числа атомов во
вселенной) α(n) ≤ 4. На практике это и называют «почти O(1)».
**Kruskal** (минимальное остовное дерево): сортируем все рёбра по весу — O(E log E); идём по
рёбрам от меньшего веса к большему, для каждого ребра `(u,v)` проверяем `find(u) != find(v)` —
если вершины ещё не в одной компоненте, добавляем ребро в остов и делаем `unite(u,v)` (иначе
ребро создало бы цикл — пропускаем). Итоговая сложность — **O(E log E)** (сортировка
доминирует над почти O(1) операциями union-find).
Задача **Redundant Connection**: дан граф-дерево из n вершин и n рёбер (то есть ровно одно
лишнее ребро, замыкающее единственный цикл) — нужно найти это лишнее ребро. Решение —
union-find: идём по рёбрам по порядку, для каждого делаем `unite`; первое ребро, для которого
`unite` вернул `false` (обе вершины уже в одной компоненте до объединения), и есть искомое —
оно замыкает цикл.
Задача **Network Delay Time**: рёбра с весами (время передачи сигнала), нужно время, за
которое сигнал от вершины k дойдёт до всех остальных. Это ровно Дейкстра от источника k;
ответ — максимум из всех найденных кратчайших расстояний (если какая-то вершина осталась
недостижима — ответ -1).
**Факты для карточек**
- base | Какие два приёма дают почти-константную сложность union-find? — сжатие пути и объединение по рангу
- base | Амортизированная сложность операции union-find с обоими приёмами? — O(α(n)), обратная функция Аккермана, практически O(1)
- core | Сложность алгоритма Kruskal и что в ней доминирует? — O(E log E), доминирует сортировка рёбер
- core | Как union-find решает Redundant Connection? — первое ребро, для которого `unite` вернул false (вершины уже в одной компоненте), и есть лишнее
- core | Как Network Delay Time сводится к Дейкстре? — вершина k — источник, ответ — максимум из всех кратчайших расстояний, -1 если что-то недостижимо
- deep | Каково верхнее ограничение обратной функции Аккермана для практических n? — α(n) ≤ 4 для любого n, меньшего числа атомов во вселенной
**Ловушки**
- Забыть сжатие пути в `find` → дерево может выродиться в цепочку, `find` деградирует к O(n)
на несбалансированных входных данных.
- В Kruskal забыть проверку `find(u) != find(v)` перед добавлением ребра → в остов попадёт
ребро, замыкающее цикл, результат перестанет быть деревом.
Ссылки на задачи этого дня: `tasks/12_dijkstra` (Дейкстра на `priority_queue`, ленивое
удаление, условие неотрицательных весов).
<details>
<summary>Проверь себя</summary>
1. Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не
матрицу, и во сколько раз (порядок величины) список экономнее по памяти?
<details><summary>Ответ</summary>Матрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000
ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память.</details>
2. Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе?
<details><summary>Ответ</summary>BFS раскрывает граф слоями через очередь (FIFO) — все
вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение
вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой
гарантии не даёт.</details>
3. В алгоритме Кана после обработки все вершины кроме трёх остались с `indeg > 0`. Что это
означает и как это связано с количеством обработанных вершин?
<details><summary>Ответ</summary>Эти три (и, возможно, другие зависимые от них) вершины
образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть
признак цикла в графе.</details>
4. Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто
«запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»?
<details><summary>Ответ</summary>Нельзя гарантировать заранее, какие вершины уже
«зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра —
если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но
Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие
w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине.</details>
5. Почему сложность Kruskal — O(E log E), если union-find работает почти за O(1)?
<details><summary>Ответ</summary>Потому что перед объединением рёбра нужно отсортировать по
весу — это O(E log E) и доминирует над суммарной почти константной стоимостью всех
union-find операций O(E·α(V)).</details>
</details>
## Материалы
- Документация `std::priority_queue` (мин-куча в реализации Дейкстры) — https://en.cppreference.com/w/cpp/container/priority_queue
+483
View File
@@ -0,0 +1,483 @@
# D4, часть 2. L2/L3: Ethernet, IP, подсети (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/04_ipv4` и
`tasks/13_subnet`. Тема дня — то, что физически лежит в байтах пакета от момента, когда
приложение вызвало `send`, до момента, когда кадр ушёл в провод.
## 1. Модель OSI и модель TCP/IP
OSI — эталонная модель из **семи** независимых уровней: физический, канальный, сетевой,
транспортный, сеансовый, представления, прикладной. Каждый уровень решает свою задачу и
разговаривает только с соседними уровнями через фиксированный интерфейс, не зная деталей их
реализации — физическую среду можно сменить с меди на оптику, ничего не трогая в IP и TCP,
потому что канальный уровень скрывает эту деталь от сетевого. На практике верхние три уровня
(сеансовый, представления, прикладной) отдельно не реализуют — приложение само решает вопросы
сессии и кодирования данных.
**TCP/IP** — практическая **четырёхуровневая** модель, которой реально пользуется стек Linux:
канальный, интернет, транспортный, прикладной. Соответствие: канальный уровень TCP/IP = L1+L2
OSI (доставка кадра внутри сегмента), интернет-уровень = L3 OSI (IP, маршрутизация между
сетями), транспортный = L4 OSI (TCP/UDP, порты), прикладной уровень TCP/IP поглощает сразу
L5–L7 OSI. Сокет создаётся на транспортном уровне — отдельного программного «уровня сессии»
в реальном стеке не существует.
**Факты для карточек**
- base | Сколько уровней в модели OSI? — 7
- base | Сколько уровней в модели TCP/IP? — 4
- core | Какие три уровня OSI объединяет прикладной уровень TCP/IP? — сеансовый, представления, прикладной
- core | На каком уровне создаётся сокет? — транспортном (L4 OSI / транспортный TCP/IP)
Почему дальше: раз данные проходят через уровни сверху вниз при отправке, нужно понять, что
физически происходит с байтами на каждом уровне — это инкапсуляция.
## 2. Инкапсуляция: во что заворачиваются данные
Каждый нижний уровень оборачивает данные верхнего уровня в свой заголовок (а канальный —
ещё и в трейлер) при передаче:
```
данные приложения
→ сегмент TCP (заголовок 20 байт)
→ пакет IP (заголовок 20 байт)
→ кадр Ethernet (заголовок 14 байт + трейлер FCS 4 байта)
```
На приёмной стороне процесс идёт в обратном порядке: каждый уровень снимает свой заголовок и
передаёт содержимое выше, ориентируясь на поле типа протокола (EtherType в Ethernet-кадре
говорит, что внутри IP; поле `protocol` в IP-заголовке говорит, что внутри TCP/UDP/ICMP).
Так устроено потому, что каждый уровень должен работать независимо от содержимого —
коммутатору не нужно знать про TCP, чтобы передать кадр дальше, ему хватает MAC-адреса в
заголовке L2. В дампе `tcpdump` эта вложенность видна буквально как последовательность байт:
Ethernet, затем IP, затем TCP, затем данные.
**Факты для карточек**
- base | Размер заголовка TCP-сегмента (без опций)? — 20 байт
- base | Размер заголовка IP-пакета (без опций)? — 20 байт
- base | Размер заголовка Ethernet-кадра и трейлера FCS? — 14 байт заголовок + 4 байта FCS
- core | По какому полю верхний уровень при приёме узнаёт, что лежит внутри нижнего? — по полю типа протокола (EtherType в Ethernet, `protocol` в IP)
Почему дальше: раз данные заворачиваются в Ethernet-кадр последними перед проводом, логично
разобрать этот заголовок по байтам первым.
## 3. Ethernet и MAC-адрес
Ethernet-заголовок — **14 байт**: 6 байт MAC-адрес получателя, 6 байт MAC-адрес отправителя,
2 байта EtherType (тип содержимого кадра — например, IPv4 или ARP). После полезной нагрузки
кадр завершается 4-байтовым трейлером **FCS** (frame check sequence) — контрольной суммой для
проверки целостности при приёме; трейлер частью заголовка не считается. Фиксированная и
компактная структура нужна для того, чтобы коммутатор мог разобрать заголовок на аппаратной
скорости без анализа содержимого выше — все поля имеют строго фиксированную длину и позицию.
**MAC-адрес** — 48-битный (6-байтный) физический адрес интерфейса, действующий только внутри
одного сегмента (широковещательного домена). Первые **3 байта (OUI)** назначаются
производителю оборудования организацией IEEE, оставшиеся 3 байта производитель присваивает
конкретному интерфейсу. Когда пакет покидает сегмент через маршрутизатор, MAC-адрес
получателя в заголовке меняется на MAC следующего узла на пути, а IP-адрес остаётся прежним —
L2 отвечает за доставку «из рук в руки» внутри сегмента, L3 — за доставку через множество
сегментов.
**Факты для карточек**
- base | Длина Ethernet-заголовка? — 14 байт
- base | Длина трейлера FCS? — 4 байта
- base | Длина MAC-адреса в битах и байтах? — 48 бит, 6 байт
- core | Из скольки байт состоит OUI в MAC-адресе и кто его назначает? — 3 байта, назначает IEEE производителю
- core | Что меняется в заголовках при переходе пакета через маршрутизатор — MAC или IP получателя? — MAC-адрес получателя, IP остаётся прежним
Почему дальше: чтобы отправить кадр внутри сегмента, нужен MAC получателя, а известен обычно
только его IP — протокол, который их связывает, это ARP.
## 4. ARP: разрешение IP в MAC
ARP (Address Resolution Protocol) по известному IP-адресу узла в локальном сегменте находит
его MAC-адрес. Механизм: отправитель рассылает **широковещательный (broadcast)** кадр «кто
владеет IP X, сообщите свой MAC»; получают его все узлы сегмента, но отвечает только владелец
адреса — уже адресным **unicast**-пакетом со своим MAC. Полученную пару IP-MAC отправитель
кладёт в локальный **ARP-кэш (таблицу)**, чтобы не повторять broadcast на каждый пакет —
именно поэтому первый пакет к новому соседу в сети чуть медленнее последующих, он ждёт
ARP-ответа.
**Gratuitous ARP** — ARP-запрос или ответ, который узел рассылает не в ответ на чужой запрос,
а сам, без повода, про собственный IP-адрес (запрашивает MAC для своего же IP). Назначение —
объявить остальным узлам сегмента свою пару IP-MAC заранее (обновить их кэши до первого
реального обмена) или обнаружить конфликт IP-адресов: если кто-то в сегменте уже отвечает за
тот же IP, это сразу видно. Используется при старте интерфейса и при переключении на резервный
узел (failover), чтобы соседи сразу обновили устаревшую запись в ARP-кэше на MAC нового узла.
Если ARP не проходит (узел выключен, неверная подсеть, фильтрация), это видно в `tcpdump` как
повторяющиеся «who has X» без ответа, а приложение получит таймаут соединения без объяснения
причины.
**Факты для карточек**
- base | Как рассылается ARP-запрос — broadcast или unicast? — broadcast
- base | Как отправляется ARP-ответ? — unicast, от владельца адреса
- core | Зачем нужен ARP-кэш? — не повторять broadcast-запрос для каждого исходящего пакета к уже известному узлу
- core | Что такое gratuitous ARP и для чего он нужен? — незапрошенный ARP про собственный IP; обновление чужих кэшей заранее и обнаружение конфликта адресов
Почему дальше: ARP работает внутри одного L2-сегмента. Если в сети настроены виртуальные
сегменты поверх одной физической — VLAN, — это меняет сам Ethernet-заголовок.
## 5. VLAN 802.1Q
VLAN (Virtual LAN) по стандарту **802.1Q** делит один физический сегмент на несколько
логически изолированных широковещательных доменов — кадры из разных VLAN не видят
широковещательный трафик друг друга, хотя идут по одному кабелю и через один коммутатор.
Механизм — тег из **4 дополнительных байт**, вставляемый в Ethernet-заголовок между
MAC-адресом отправителя и полем EtherType:
- **TPID** (Tag Protocol Identifier) — 2 байта, фиксированное значение **0x8100**, сигнализирует
коммутатору, что дальше идёт VLAN-тег, а не обычный EtherType;
- **TCI** (Tag Control Information) — 2 байта, из них: **PCP** (Priority Code Point, 3 бита) —
приоритет кадра для QoS, **DEI** (Drop Eligible Indicator, 1 бит) — кандидат на отбрасывание
первым при перегрузке, **VID** (VLAN ID, **12 бит**) — номер VLAN, диапазон 0–4095 (0 и
4095 зарезервированы, реально используется 1–4094).
Из-за тега заголовок Ethernet-кадра с VLAN вырастает с 14 до **18 байт**. Если это не
учесть при расчёте MTU или максимального размера кадра, получится ошибка ровно на 4 байта —
типично ловится сравнением дампа `tcpdump` с ожидаемой длиной.
**Факты для карточек**
- base | Сколько байт добавляет VLAN-тег 802.1Q к Ethernet-заголовку? — 4 байта (14 → 18)
- base | Значение поля TPID для VLAN-тега? — 0x8100
- core | Из каких трёх полей состоит TCI и сколько бит занимает VID? — PCP (3 бита), DEI (1 бит), VID (12 бит)
- core | Диапазон реально используемых VLAN ID? — 1–4094 (0 и 4095 зарезервированы)
Почему дальше: заголовок Ethernet ограничивает не только формат, но и максимальный размер
полезной нагрузки в одном кадре — MTU.
## 6. MTU и фрагментация
MTU (Maximum Transmission Unit) — максимальный размер полезной нагрузки, который канальный
уровень передаёт в одном кадре; для стандартного Ethernet это **1500 байт**. Если IP-пакет
крупнее MTU исходящего интерфейса, происходит одно из двух:
- **фрагментация** — пакет режется на несколько IP-пакетов меньшего размера, каждый со своим
IP-заголовком, собираются они уже на узле-получателе;
- если в заголовке выставлен флаг **DF** (Don't Fragment) — узел на пути отбрасывает пакет и
отправляет источнику ICMP-сообщение о необходимости фрагментации (см. раздел про ICMP).
Ограничение в 1500 байт исторически идёт из спецификации Ethernet и балансирует накладные
расходы заголовка против задержки и вероятности ошибки на длинном кадре: чем крупнее кадр,
тем дороже обходится его повторная передача при ошибке. Фрагментация — дорогая операция:
она нагружает маршрутизаторы на пути и делает передачу уязвимой к потере одного фрагмента, из-
за которого теряется весь исходный пакет целиком. Поэтому современные стеки заранее подбирают
размер пакета под MTU всего пути — механизм **PMTUD** (Path MTU Discovery), разобранный
дальше вместе с ICMP.
**Факты для карточек**
- base | Значение MTU для стандартного Ethernet? — 1500 байт
- core | Что происходит с IP-пакетом крупнее MTU, если флаг DF не выставлен? — фрагментируется на несколько IP-пакетов меньшего размера
- core | Что происходит, если пакет крупнее MTU и выставлен флаг DF? — пакет отбрасывается, отправителю летит ICMP о необходимости фрагментации
- deep | Почему фрагментация считается дорогой операцией? — нагружает маршрутизаторы на пути, и потеря одного фрагмента роняет весь исходный пакет
Почему дальше: и флаг DF, и размер, и адреса — это конкретные поля одной структуры,
IP-заголовка. Разберём его целиком по байтам.
## 7. IPv4-заголовок по полям
IPv4-заголовок — минимум **20 байт** (без опций), опции могут увеличить его до 60 байт.
Поля в порядке следования:
| Поле | Размер | Смысл |
|---|---|---|
| Version | 4 бита | версия протокола, для IPv4 всегда 4 |
| IHL | 4 бита | длина заголовка в 32-битных словах (минимум 5 → 20 байт) |
| TOS (Type of Service) | 8 бит | приоритет/качество обслуживания пакета |
| Total Length | 16 бит | общая длина пакета (заголовок + данные), байты |
| Identification | 16 бит | идентификатор для сборки фрагментов одного исходного пакета |
| Flags | 3 бита | бит DF (не фрагментировать), бит MF (есть ещё фрагменты) |
| Fragment Offset | 13 бит | смещение этого фрагмента в исходном пакете |
| TTL | 8 бит | время жизни пакета в хопах |
| Protocol | 8 бит | протокол следующего уровня: 6 = TCP, 17 = UDP, 1 = ICMP |
| Header Checksum | 16 бит | контрольная сумма заголовка (не данных) |
| Source / Destination IP | по 32 бита | адреса отправителя и получателя |
Это ровно поля структуры `Ipv4Header` из `tasks/04_ipv4`. Задача требует парсить их из сырого
буфера побайтово (через сдвиги и маски), а не приведением указателя `reinterpret_cast` — на
невыровненных адресах и при другом порядке байт это UB. Обязательные проверки при разборе:
буфер короче 20 байт, `version != 4`, `ihl < 5`, `ihl*4 > len`, `total_length < ihl*4` или
`total_length > len` — пакет с любым из этих условий отбрасывается как некорректный, ещё до
попытки читать поля выше заголовка.
**Факты для карточек**
- base | Минимальный размер IPv4-заголовка? — 20 байт
- base | Значение поля Protocol для TCP / UDP / ICMP? — 6 / 17 / 1
- core | Сколько бит занимает поле IHL и что оно означает? — 4 бита, длина заголовка в 32-битных словах
- core | Какие условия делают IPv4-пакет некорректным при разборе (по `tasks/04_ipv4`)? — буфер <20 байт, version≠4, ihl<5, ihl·4>len, total_length<ihl·4 или >len
- deep | Почему в `tasks/04_ipv4` запрещён `reinterpret_cast` на буфер? — невыровненный адрес и другой порядок байт на проводе дают UB при чтении полей как структуры напрямую
Почему дальше: одно из полей заголовка — TTL — существует специально для защиты от
зацикленной маршрутизации, и с ним напрямую связан отдельный протокол ошибок, ICMP.
## 8. TTL и ICMP Time Exceeded: как работает traceroute
**TTL** (Time To Live) уменьшается на единицу **на каждом маршрутизаторе**, через который
проходит пакет. Это сделано специально: чтобы зацикленный по ошибке маршрут не гонял пакет по
сети бесконечно. Когда TTL достигает нуля, пакет отбрасывается, а отправителю отправляется
**ICMP Time Exceeded** (тип 11).
Именно на этом механизме построен **traceroute**: он последовательно отправляет пакеты с
TTL 1, 2, 3, ... Пакет с TTL 1 гарантированно "умирает" на первом же маршрутизаторе — тот
шлёт ICMP Time Exceeded с собственным адресом, это и есть первая строка вывода traceroute.
Пакет с TTL 2 доходит до второго маршрутизатора и умирает там, и так далее, пока пакет не
дойдёт до конечного получателя. Так по цепочке ICMP-ответов восстанавливается список всех
промежуточных маршрутизаторов на пути, без какого-либо специального протокола обнаружения
маршрута — только манипуляция TTL и стандартный побочный эффект его истечения.
**Факты для карточек**
- base | На сколько уменьшается TTL на каждом маршрутизаторе? — на 1
- base | Какой ICMP-тип отправляется при обнулении TTL? — Time Exceeded, тип 11
- core | Как traceroute находит промежуточные маршрутизаторы, не имея отдельного протокола обнаружения пути? — последовательно шлёт пакеты с TTL=1,2,3..., каждый умирает на очередном хопе и присылает ICMP Time Exceeded с адресом этого хопа
Почему дальше: Time Exceeded — лишь один из типов ICMP-сообщений. Остальные закрывают другие
сценарии ошибок доставки и обнаружения MTU пути.
## 9. ICMP: echo, destination unreachable, PMTUD
ICMP (Internet Control Message Protocol) — протокол сетевого уровня для диагностических
сообщений; у него нет портов, он не переносит пользовательские данные приложений. Ключевые
типы:
- **Echo Request / Echo Reply** (тип 8 / тип 0) — основа команды `ping`;
- **Time Exceeded** (тип 11) — TTL обнулился (раздел выше, основа traceroute);
- **Destination Unreachable** (тип 3) — пакет физически не может быть доставлен; код внутри
этого типа уточняет причину, в частности код **"fragmentation needed and DF set"** —
посылается, когда пакет крупнее MTU промежуточного линка, а флаг DF запрещает
фрагментацию.
Именно код "fragmentation needed" лежит в основе **PMTUD** (Path MTU Discovery): отправитель
шлёт пакеты с выставленным DF, начиная с MTU своего интерфейса; если по пути встречается
линк с меньшим MTU, тот роутер отбрасывает пакет и присылает ICMP Destination Unreachable с
этим кодом (в современных реализациях — вместе со значением MTU узкого места); отправитель
уменьшает размер пакета и повторяет попытку. Так стек заранее подбирает размер, не полагаясь
на фрагментацию по пути.
ICMP существует отдельно от TCP/UDP потому, что диагностика нужна на уровне, где ещё нет
понятия соединения или порта — маршрутизатор должен уметь сообщить об ошибке доставки, даже не
зная, что за протокол был внутри. Именно поэтому `ping` и `traceroute` работают даже к узлу,
на котором не открыт ни один сервис поверх TCP/UDP. Если ICMP заблокирован файрволом
(частая практика), `ping` не пройдёт, хотя TCP-соединение на конкретный порт может работать
нормально — это видно, если сравнить `ping host` и `curl host` с разным результатом.
**Факты для карточек**
- base | Номера типов Echo Request и Echo Reply? — 8 и 0
- base | Тип ICMP-сообщения Destination Unreachable? — тип 3
- core | Какой код Destination Unreachable запускает PMTUD? — "fragmentation needed and DF set"
- core | Почему ICMP не имеет портов? — диагностика работает на сетевом уровне, где ещё нет понятия транспортного соединения
- deep | Почему `ping` может не проходить, а `curl` на тот же хост — работать? — ICMP заблокирован файрволом отдельно от TCP-порта, это два разных уровня фильтрации
**Ловушки**
- Судить о доступности хоста только по `ping` → файрвол может глушить ICMP, не трогая
реальный TCP-сервис — вывод «хост недоступен» будет ложным.
- Не учитывать PMTUD при жёстко заданном MTU в туннеле (VPN, GRE) → пакеты с DF молча
теряются на узком месте, если ICMP-ответы блокируются по пути — проявляется как
«маленькие пакеты проходят, большие — зависают».
Почему дальше: контрольная сумма заголовка из раздела про IPv4-поля устроена нетривиально —
разберём отдельно, как она считается и почему покрывает только заголовок.
## 10. Контрольная сумма IPv4-заголовка
Алгоритм — сумма в **дополнительном коде (one's complement)** по 16-битным словам заголовка,
затем **инверсия** результата (побитовое НЕ) — это и есть значение, которое пишут в поле
`header_checksum`. При сложении в дополнительном коде перенос из старшего бита не отбрасывается,
а прибавляется обратно к младшему биту суммы (end-around carry).
`tasks/04_ipv4` требует ровно эту схему в двух функциях:
```c++
// сумма в дополнительном коде по len байтам, затем инверсия — значение для записи в поле
// (вызывается на буфере, где поле контрольной суммы уже обнулено)
uint16_t compute_checksum(const uint8_t* buf, size_t len);
// true, если контрольная сумма заголовка верна: сумма в дополнительном коде по ihl*4 байтам
// заголовка (вместе с полем контрольной суммы) равна 0xFFFF; нагрузка не участвует
bool checksum_valid(const uint8_t* buf, size_t len);
```
Асимметрия проверки и вычисления не случайна: при **вычислении** поле суммы ещё не заполнено
(обнулено), поэтому оно не участвует своим значением; при **проверке** поле уже содержит
записанную сумму, и если она верна, повторное суммирование всех 16-битных слов заголовка
(включая это поле) в дополнительном коде обязано дать **все единицы — 0xFFFF**. Это свойство
дополнительного кода: сумма X и инверсии X всегда даёт все единицы.
**Почему сумма считается только по заголовку, а не по всему пакету с данными**: заголовок
меняется на каждом хопе — как минимум TTL уменьшается на 1 на каждом маршрутизаторе, а значит
контрольную сумму заголовка пришлось бы пересчитывать на каждом хопе заново. Пересчитывать её
ещё и по всей полезной нагрузке на каждом маршрутизаторе было бы избыточно дорого и не нужно:
целостность самих данных уже проверяется отдельно контрольными суммами более высокого уровня
(TCP/UDP-заголовок содержит свою контрольную сумму, покрывающую данные) и трейлером FCS на
канальном уровне. Если контрольная сумма заголовка не сходится, пакет молча отбрасывается —
ошибка ловится счётчиками ошибок интерфейса или отсутствием ожидаемого ответа в `tcpdump`.
**Факты для карточек**
- base | Алгоритм контрольной суммы IPv4-заголовка? — сумма в дополнительном коде по 16-битным словам, затем инверсия
- core | Какое значение должна давать сумма при проверке валидности (с учётом поля суммы)? — 0xFFFF
- core | Почему контрольная сумма покрывает только заголовок, а не данные? — заголовок (минимум TTL) меняется на каждом хопе и пересчитывается заново; целостность данных проверяют TCP/UDP-checksum и FCS канального уровня
- deep | Что такое end-around carry в one's complement сложении? — перенос из старшего бита не отбрасывается, а прибавляется обратно к младшему биту суммы
**Ловушки**
- Считать контрольную сумму по буферу с уже заполненным полем `header_checksum` вместо
обнулённого → результат `compute_checksum` окажется неверным, потому что старое значение
поля участвует в сумме.
- Включить в сумму данные после заголовка (payload) → сумма не сойдётся ни у отправителя, ни
при проверке — контрольная сумма IPv4 считается строго по `ihl*4` байтам, не по всей длине
пакета.
Почему дальше: сами IP-адреса в заголовке не существуют сами по себе — узел должен понимать,
какая их часть определяет сеть, а какая — конкретный хост. Это маска и подсеть.
## 11. Маски, подсети и CIDR
Маска подсети делит 32-битный IPv4-адрес на две части: номер сети и номер узла внутри неё —
это определяет, какие адреса «свои» для локальной доставки внутри сегмента, а какие требуют
выхода через шлюз. Запись `/N` (**CIDR**, Classless Inter-Domain Routing) — это и есть длина
префикса сети в битах вместо устаревшей классовой адресации (A/B/C), что позволяет выделять
подсети произвольного размера, а не только фиксированных 8/16/24 бит.
**Широковещательный адрес** подсети — адрес, где все биты хостовой части выставлены в 1; он
зарезервирован для рассылки всем узлам сегмента и не выдаётся конкретному устройству. Первый
адрес подсети (все хостовые биты — 0) зарезервирован как адрес самой сети.
Число доступных хостов по маске (для обычных подсетей — минус 2 служебных адреса, сеть и
broadcast):
| Маска | Хостовых бит | Всего адресов | Доступно хостам |
|---|---|---|---|
| /24 | 8 | 256 | **254** |
| /26 | 6 | 64 | **62** |
| /30 | 2 | 4 | **2** |
| /31 | 1 | 2 | **2** (RFC 3021, оба адреса — хосты, без сети/broadcast) |
| /32 | 0 | 1 | **1** (сеть = broadcast = сам адрес) |
`/31` и `/32` — исключения из общего правила «минус 2»: `/31` (RFC 3021) используется на
линках точка-точка между двумя маршрутизаторами, где резервировать сеть и broadcast из
всего двух адресов расточительно — оба адреса становятся хостовыми; `/32` — адрес единственного
узла, маршрут на конкретный хост.
По `tasks/13_subnet`: для `prefix <= 30` — `host_count = 2^(32-prefix) - 2`, `first_host =
network + 1`, `last_host = broadcast - 1`. Эталонный пример: `10.0.1.130/26` → сеть
`10.0.1.128`, broadcast `10.0.1.191`, диапазон хостов `129..190`. Обратная задача —
`prefix_for_hosts(h)`: найти **самую узкую** (наибольший `prefix`) подсеть, вмещающую `h`
хостов — считается как `32 - ceil(log2(h+2))` за O(1), без перебора. Примеры: `62` → `/26`,
`63` → `/25`, `254` → `/24`, `1` → `/30` (`/31` и `/32` в подборе не участвуют — они не для
произвольного числа хостов, а под конкретные сценарии линка и одиночного адреса).
**Факты для карточек**
- base | Сколько хостов доступно в подсети /24? — 254
- base | Сколько хостов доступно в подсети /26? — 62
- base | Сколько хостов доступно в подсети /30? — 2
- core | Чем /31 отличается от остальных масок по числу служебных адресов? — оба адреса хостовые (RFC 3021), нет отдельного сетевого/broadcast адреса
- core | Что означает запись `/N` в CIDR? — длина префикса сети в битах вместо классовой адресации A/B/C
- deep | Формула подбора самой узкой подсети под h хостов за O(1)? — `32 - ceil(log2(h+2))`
**Ловушки**
- Забыть исключить /31 и /32 из общего расчёта `2^(32-prefix) - 2` → для /31 формула даст 0
хостов вместо верных 2, для /32 — отрицательное число.
- Спутать адрес сети (все хостовые биты 0) с первым доступным хостом (`network + 1`) → off-by-one
в диапазоне выдаваемых адресов.
Почему дальше: маска определяет, доставлять ли пакет напрямую внутри сегмента или через
устройство более высокого уровня. Логично развести устройства L1/L2/L3 по тому, что каждое
из них реально делает с кадром/пакетом.
## 12. Хаб, коммутатор, маршрутизатор; таблица MAC-адресов (FDB)
Три устройства работают на разных уровнях и с разным объёмом понимания трафика:
- **Хаб (L1)** — просто повторитель: любой бит, пришедший на один порт, электрически
копируется на все остальные порты без какого-либо разбора кадра. Все порты хаба — один
общий домен коллизий; трафик одной пары узлов виден всем остальным. Практически вытеснен
коммутаторами.
- **Коммутатор (L2)** — разбирает Ethernet-заголовок и пересылает кадр только на порт, где
находится MAC-адрес получателя, а не на все порты сразу. Каждый порт — отдельный домен
коллизий, но все порты (без VLAN) остаются одним широковещательным доменом.
- **Маршрутизатор (L3)** — разбирает IP-заголовок и пересылает пакет между разными подсетями
по таблице маршрутов, уменьшая TTL на 1 на каждом пересланном пакете. Разделяет
широковещательные домены — broadcast из одной подсети не проходит через маршрутизатор в
другую.
Коммутатор знает, на какой порт слать кадр, благодаря **таблице MAC-адресов** (FDB, Forwarding
Database, также называют CAM-таблицей) — по сути хеш-таблице, где ключ — MAC-адрес, значение —
номер порта. Заполняется она самообучением: коммутатор смотрит **source MAC** каждого
входящего кадра и запоминает пару (MAC, порт, на который кадр пришёл) — так через обычный
трафик таблица заполняется без отдельного протокола объявления адресов. Если адреса
получателя нет в таблице (адрес ещё не встречался как источник), коммутатор пересылает кадр
на все порты, кроме входного (flooding) — точно так же, как хаб, но только для этого одного
кадра, пока адрес не станет известен.
Записи в FDB не хранятся вечно — у каждой запись есть **aging**: таймер, который сбрасывается
при каждом новом кадре с этим source MAC, и если запись не обновлялась дольше таймаута
(типичное значение в реализациях — 300 секунд, 5 минут), она удаляется из таблицы. Это нужно,
чтобы таблица не разрасталась записями отключённых или перемещённых на другой порт устройств
— переключить сетевой кабель с одного порта на другой без aging означало бы, что коммутатор
продолжал бы слать кадры на старый, уже неверный порт.
**Факты для карточек**
- base | На каком уровне работает хаб / коммутатор / маршрутизатор? — L1 / L2 / L3
- base | Что такое FDB коммутатора по структуре данных? — хеш-таблица MAC-адрес → порт
- core | Как коммутатор заполняет FDB без отдельного протокола объявления? — самообучением по source MAC каждого входящего кадра
- core | Что делает коммутатор с кадром, чей MAC получателя не найден в FDB? — рассылает на все порты кроме входного (flooding)
- core | Зачем нужен aging записей FDB? — удалять устаревшие пары MAC-порт, если устройство отключилось или переехало на другой порт
- deep | Что разделяет маршрутизатор, чего не делает коммутатор? — широковещательные домены (домены коллизий разделяет уже коммутатор)
**Ловушки**
- Ожидать, что коммутатор ограничивает broadcast-трафик → он передаёт broadcast-кадры на все
порты одного широковещательного домена так же, как хаб; ограничивает его только
маршрутизатор (или разбиение на VLAN).
- Перепутать основание пересылки: коммутатор решает по MAC (L2), маршрутизатор — по IP (L3) →
попытка «настроить маршрутизацию на коммутаторе» без функции L3 физически невозможна.
Ссылки на задачи этого дня: `tasks/04_ipv4` (разбор IPv4-заголовка и контрольная сумма),
`tasks/13_subnet` (арифметика подсетей).
<details>
<summary>Проверь себя</summary>
1. Кадр Ethernet с VLAN-тегом занимает 18 байт заголовка вместо 14. Откуда взялись эти лишние
4 байта и что конкретно в них закодировано?
<details><summary>Ответ</summary>VLAN-тег 802.1Q: 2 байта TPID (фиксированное значение
0x8100) + 2 байта TCI, где TCI = PCP (3 бита приоритета) + DEI (1 бит) + VID (12 бит номер
VLAN, диапазон 1–4094 реально используемых значений).</details>
2. Почему PMTUD зависит от ICMP, и что происходит, если файрвол на пути блокирует ICMP
целиком?
<details><summary>Ответ</summary>PMTUD узнаёт об узком месте по ICMP Destination
Unreachable с кодом «fragmentation needed and DF set» от промежуточного маршрутизатора;
если ICMP заблокирован, отправитель никогда не получит этот сигнал и будет слать пакеты,
которые молча теряются на узком месте — классическая причина «маленькие пакеты проходят,
большие зависают» через VPN/туннель.</details>
3. При проверке контрольной суммы IPv4-заголовка сумма всех 16-битных слов (включая само
поле суммы) даёт 0xFFFF. Почему именно это число, а не 0?
<details><summary>Ответ</summary>Поле контрольной суммы — это инверсия суммы остальных
слов; сумма числа и его инверсии в дополнительном коде всегда даёт все единицы (0xFFFF),
это математическое свойство one's complement, а не произвольный выбор.</details>
4. Подсеть `10.0.1.130/26`. Назови адрес сети, broadcast и диапазон хостов, не считая заново
с нуля — по правилу из этого урока.
<details><summary>Ответ</summary>Сеть `10.0.1.128`, broadcast `10.0.1.191`, хосты
`10.0.1.129`–`10.0.1.190` (62 адреса, /26 = 64 адреса минус 2 служебных).</details>
5. Почему `/31` — единственная маска (кроме /32) с `host_count`, который не считается по
формуле `2^(32-prefix) - 2`?
<details><summary>Ответ</summary>RFC 3021: на линке точка-точка из всего двух адресов
резервировать отдельно сеть и broadcast бессмысленно — оба адреса используют как хостовые,
поэтому `host_count = 2`, а не `2^1 - 2 = 0`.</details>
6. Коммутатор получил кадр с MAC получателя, которого нет в его FDB. Что он сделает и чем
это временно похоже на поведение хаба?
<details><summary>Ответ</summary>Разошлёт кадр на все порты, кроме входного (flooding) —
как и хаб, который всегда рассылает на все порты; разница в том, что у коммутатора это
разовое поведение для конкретного неизвестного адреса, а не постоянный режим работы для
всего трафика.</details>
</details>
## Материалы
- RFC 791, Internet Protocol (IPv4) — https://datatracker.ietf.org/doc/html/rfc791
- RFC 826, An Ethernet Address Resolution Protocol (ARP) — https://datatracker.ietf.org/doc/html/rfc826
- RFC 792, Internet Control Message Protocol (ICMP) — https://datatracker.ietf.org/doc/html/rfc792
- RFC 4632, Classless Inter-domain Routing (CIDR) — https://datatracker.ietf.org/doc/html/rfc4632
- RFC 3021, Using 31-Bit Prefixes on IPv4 Point-to-Point Links — https://datatracker.ietf.org/doc/html/rfc3021
- man7.org, `ip(7)` — https://man7.org/linux/man-pages/man7/ip.7.html
+391
View File
@@ -0,0 +1,391 @@
# D5, часть 1. Динамическое программирование (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Когда ДП вообще применимо
ДП применимо, если задача одновременно даёт два свойства:
- **Оптимальная подструктура** — оптимальное решение всей задачи собирается из оптимальных
решений подзадач (для LCS: если последние символы совпали, LCS всей строки — это LCS без
последних символов плюс 1; оптимум подзадачи не пересчитывается заново).
- **Перекрывающиеся подзадачи** — одни и те же подзадачи встречаются многократно при наивной
рекурсии (для чисел Фибоначчи `fib(5)` вызывает `fib(3)` дважды, `fib(2)` трижды и так
далее — без запоминания результат пересчитывается экспоненциально много раз).
Если подзадачи не пересекаются (например, обычное бинарное дерево решений без общих
поддеревьев), рекурсия остаётся рекурсией — мемоизация не ускоряет её, кэшировать нечего.
Если нет оптимальной подструктуры (жадный локальный выбор не гарантирует глобальный оптимум),
ДП даёт неверный ответ — тогда нужен либо перебор, либо доказанный жадный алгоритм.
**Факты для карточек**
- base | Два условия применимости ДП? — оптимальная подструктура + перекрывающиеся подзадачи
- core | Почему наивный рекурсивный `fib(n)` работает за экспоненциальное время? — одни и те же подзадачи (`fib(k)` для одного и того же k) пересчитываются заново много раз
- core | Что ломается, если применить ДП-переход к задаче без оптимальной подструктуры? — переход не отражает реальную зависимость оптимумов, ответ будет неверным независимо от таблицы
Почему дальше: раз задача сводится к подзадачам, нужно определить, что именно является
«состоянием» подзадачи и как один переход выражается через предыдущие.
## 2. Состояние, переход, мемоизация vs табуляция
**Состояние** — минимальный набор параметров, однозначно определяющий подзадачу (для рюкзака:
номер предмета + оставшийся вес; для LCS: позиции в обеих строках). **Переход** — формула,
выражающая ответ для состояния через ответы уже решённых состояний.
Два способа посчитать одно и то же:
- **Мемоизация (top-down)** — обычная рекурсия по формуле перехода, но перед вычислением
проверяем кэш (`unordered_map` или массив), а после вычисления кладём результат в кэш.
Считает только реально нужные состояния, но каждый вызов — это кадр стека.
- **Табуляция (bottom-up)** — заполняем таблицу итеративно от базовых случаев к целевому,
без рекурсии вообще. Требует явно определить порядок заполнения (что должно быть посчитано
раньше, чем понадобится).
```c++
// мемоизация
long long fib_memo(int n, std::vector<long long>& cache) {
if (n <= 1) return n;
if (cache[n] != -1) return cache[n];
return cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache);
}
// табуляция
long long fib_tab(int n) {
std::vector<long long> dp(n + 1);
dp[0] = 0; if (n >= 1) dp[1] = 1;
for (int i = 2; i <= n; ++i) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
```
Оба варианта — O(число состояний) по времени (каждое состояние считается ровно один раз, а
не экспоненциально много) и O(число состояний) по памяти (нужно где-то хранить ответ для
каждого состояния). У мемоизации есть дополнительный риск, которого нет у табуляции:
рекурсия на входе с большим n (например, `fib_memo(1'000'000, ...)`) кладёт по кадру на
каждый уровень — стек ограничен (типично несколько МБ), и глубокая рекурсия падает с
переполнением стека (SIGSEGV) там, где итеративная табуляция отработает без проблем.
**Ловушки**
- Забыть проверить кэш перед вычислением в мемоизации → рекурсия становится обычной наивной
→ возврат к экспоненциальному времени, видно по таймауту на больших n.
- Взять слишком глубокую рекурсию для мемоизации (n порядка 10⁵–10⁶) → переполнение стека →
падение по SIGSEGV, а не по логической ошибке.
**Факты для карточек**
- base | Сложность по времени мемоизации/табуляции? — O(число состояний)
- core | Чем мемоизация рискует, а табуляция — нет? — переполнением стека при большой глубине рекурсии
- core | Что нужно определить в ДП-задаче до написания кода? — состояние (параметры подзадачи) и переход (формула через уже решённые состояния)
Почему дальше: раз табуляция явно хранит таблицу, логично спросить, всегда ли нужна вся
таблица целиком, или память можно ужать.
## 3. Уменьшение памяти: O(n) → O(1) или O(n)
Если переход использует только последние 1–2 строки/значения таблицы, всю таблицу хранить не
нужно — достаточно «скользящего окна» из нужного числа последних значений.
Лестница (Climbing Stairs: сколько способов подняться на n ступеней шагами по 1 или 2):
переход `dp[i] = dp[i-1] + dp[i-2]` использует только два предыдущих значения — O(1) памяти.
```c++
int climb_stairs(int n) { // способов дойти до ступени n
if (n <= 2) return n;
int prev2 = 1, prev1 = 2;
for (int i = 3; i <= n; ++i) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
House Robber (нельзя грабить два соседних дома подряд, максимизировать сумму): тот же приём.
`dp[i] = max(dp[i-1], dp[i-2] + nums[i])` — не ограбить дом i (взять лучший результат без
него) или ограбить (взять лучший результат через один плюс текущий дом).
```c++
int rob(const std::vector<int>& nums) {
int prev2 = 0, prev1 = 0;
for (int x : nums) {
int curr = std::max(prev1, prev2 + x);
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
Рюкзак 0/1 ужимается не до O(1), а до O(W) (одна строка по весу вместо таблицы n×W) — переход
там зависит от целой предыдущей строки, а не от 1–2 чисел; подробно в следующем разделе.
**Факты для карточек**
- base | Сложность по памяти Climbing Stairs при развёрнутых `prev1`/`prev2`? — O(1)
- core | Почему рюкзак 0/1 нельзя ужать до O(1), только до O(W)? — переход `dp[i][w]` зависит от целой предыдущей строки по весу, а не от 1–2 соседних чисел
Почему дальше: рюкзак — задача, где переход по весу нельзя писать «вперёд» без потери
корректности; разберём, почему именно назад.
## 4. Рюкзак 0/1: почему обратный проход по весу
Задача: n предметов с весом `weight[i]` и ценностью `value[i]`, вместимость W, каждый предмет
берётся не более одного раза, максимизировать суммарную ценность.
```c++
int knapsack01(const std::vector<int>& weight, const std::vector<int>& value, int W) {
std::vector<int> dp(W + 1, 0);
for (size_t i = 0; i < weight.size(); ++i)
for (int w = W; w >= weight[i]; --w) // обратный проход!
dp[w] = std::max(dp[w], dp[w - weight[i]] + value[i]);
return dp[W]; // O(n·W) время, O(W) память
}
```
Если одномерный массив `dp[w]` переиспользуется для всех предметов подряд (без второго
измерения по номеру предмета), то при проходе весов **вперёд** (`w` от `weight[i]` до `W`)
значение `dp[w - weight[i]]`, использованное в переходе, могло уже быть обновлено этим же
предметом i на текущей итерации — то есть предмет i фактически используется дважды, и задача
незаметно превращается в рюкзак с неограниченным числом копий предмета (unbounded knapsack).
Проход **назад** гарантирует, что `dp[w - weight[i]]` берётся из состояния «до предмета i» —
старое значение ещё не тронуто текущей итерацией внешнего цикла.
**Ловушки**
- Пройти веса вперёд в одномерном 0/1-рюкзаке → предмет учитывается несколько раз → ответ
завышен относительно эталона, видно на тесте с одним дорогим предметом.
- Перепутать размер массива (`W` вместо `W+1`) → индекс `dp[W]` вне границ → UB/ASAN heap-buffer-overflow.
**Факты для карточек**
- base | Временная сложность 0/1-рюкзака с одномерным массивом? — O(n·W)
- core | Почему в одномерном 0/1-рюкзаке веса обходят от W к weight[i], а не наоборот? — иначе `dp[w-weight[i]]` уже обновлён текущим предметом в этой же итерации, предмет посчитается дважды
- deep | Как называется вариант рюкзака, в который случайно превращается 0/1-рюкзак при прямом проходе весов? — unbounded knapsack (неограниченное число копий предмета)
Почему дальше: та же ловушка с порядком циклов — не по направлению, а по тому, что снаружи, а
что внутри — по-другому проявляется в задаче про размен монет.
## 5. Монеты: порядок циклов меняет смысл ответа
Coin Change (минимальное число монет для суммы `amount`, каждая монета берётся неограниченное
число раз — это уже unbounded knapsack):
```c++
int coin_change(const std::vector<int>& coins, int amount) {
std::vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int a = 1; a <= amount; ++a)
for (int c : coins)
if (c <= a && dp[a - c] != INT_MAX)
dp[a] = std::min(dp[a], dp[a - c] + 1);
return dp[amount] == INT_MAX ? -1 : dp[amount]; // O(amount · coins.size())
}
```
Для минимума порядок циклов не влияет на корректность — минимум не зависит от того, в каком
порядке предметы разрешено переиспользовать. Но для **подсчёта числа способов** (Coin Change
II) порядок циклов меняет сам смысл ответа:
```c++
// количество КОМБИНАЦИЙ: {1,2} и {2,1} — один и тот же способ
long long change_combinations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int c : coins) // внешний цикл — монета
for (int a = c; a <= amount; ++a)
dp[a] += dp[a - c];
return dp[amount];
}
// количество ПЕРЕСТАНОВОК: {1,2} и {2,1} — разные способы
long long change_permutations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int a = 1; a <= amount; ++a) // внешний цикл — сумма
for (int c : coins)
if (c <= a) dp[a] += dp[a - c];
return dp[amount];
}
```
Монета снаружи цикла фиксирует «в каком порядке монеты рассматриваются» раз и навсегда для
всех сумм — эквивалентные по составу, но переставленные последовательности монет схлопываются
в один и тот же результат, отсюда комбинации. Сумма снаружи цикла для каждого `a` заново
перебирает все монеты как «последнюю добавленную» — одна и та же комбинация монет, добавленная
в разном порядке, считается несколько раз, отсюда перестановки.
**Ловушки**
- Перепутать порядок циклов в задаче «сколько способов» → вместо количества комбинаций
получается количество перестановок (число сильно больше ожидаемого) → расходится с
эталонным ответом на тесте с 2+ разными монетами.
**Факты для карточек**
- base | Сложность Coin Change (минимум монет) по времени? — O(amount · число_номиналов)
- core | Какой порядок циклов в Coin Change II даёт число комбинаций, а какой — перестановок? — монета снаружи/сумма внутри → комбинации; сумма снаружи/монета внутри → перестановки
Почему дальше: рюкзак и монеты — одномерные ДП по числу. Следующий класс задач — ДП по двум
строкам сразу, с двумерной таблицей.
## 6. LCS и Edit Distance: таблица (n+1)×(m+1)
**LCS** (длиннейшая общая подпоследовательность двух строк, не обязательно непрерывная):
```c++
int lcs_length(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
dp[i][j] = (a[i - 1] == b[j - 1])
? dp[i - 1][j - 1] + 1
: std::max(dp[i - 1][j], dp[i][j - 1]);
return dp[n][m]; // O(n·m) время и память
}
```
Строка 0 и столбец 0 — базовый случай «одна из строк пустая», отсюда размер `(n+1)×(m+1)`, а
не `n×m`. Если последние символы совпадают, они точно входят в оптимальную LCS — переход к
`dp[i-1][j-1]+1`. Если нет — общая подпоследовательность не может использовать оба последних
символа одновременно, берём лучшее из «отбросить последний символ a» и «отбросить последний
символ b».
**Edit Distance** (минимум вставок/удалений/замен, чтобы превратить строку a в b):
```c++
int edit_distance(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1));
for (int i = 0; i <= n; ++i) dp[i][0] = i; // удалить все i символов
for (int j = 0; j <= m; ++j) dp[0][j] = j; // вставить все j символов
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
dp[i][j] = (a[i - 1] == b[j - 1])
? dp[i - 1][j - 1]
: 1 + std::min({dp[i - 1][j - 1], // замена
dp[i - 1][j], // удаление
dp[i][j - 1]}); // вставка
return dp[n][m]; // O(n·m) время и память
}
```
Тот же размер таблицы `(n+1)×(m+1)` и та же причина: строка/столбец 0 — превращение пустой
строки в префикс другой строки чисто вставками или удалениями.
**Ловушки**
- Завести таблицу `n×m` вместо `(n+1)×(m+1)` → нет места для базового случая «пустой префикс»
→ неверные значения на границе или выход за границы массива.
- В Edit Distance забыть инициализировать нулевую строку/столбец → сравнение с мусорными
значениями → неверный ответ без падения программы (тихая ошибка).
**Факты для карточек**
- base | Размер таблицы LCS/Edit Distance для строк длины n и m? — (n+1)×(m+1)
- base | Временная сложность LCS и Edit Distance? — O(n·m)
- core | Почему при несовпадении последних символов в LCS берут max(dp[i-1][j], dp[i][j-1])? — оба последних символа одновременно в общую подпоследовательность войти не могут, значит хотя бы один из них можно отбросить без потери оптимальности
Почему дальше: LCS/Edit Distance решают за O(n·m) через явную таблицу. Следующая классическая
задача — LIS — решается за O(n²) той же схемой, но улучшается до O(n log n) совсем другим
приёмом.
## 7. LIS (НВП): O(n²) и O(n log n)
Длиннейшая строго возрастающая подпоследовательность массива (не обязательно непрерывная).
Сигнатура из `tasks/05_lis`: `int lis_length(const std::vector<int>& a)`.
**Наивное O(n²)**: `dp[i]` — длина LIS, заканчивающейся ровно на элементе `a[i]`.
```c++
int lis_naive(const std::vector<int>& a) {
int n = a.size();
std::vector<int> dp(n, 1);
int best = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j)
if (a[j] < a[i]) dp[i] = std::max(dp[i], dp[j] + 1);
best = std::max(best, dp[i]);
}
return best; // O(n²)
}
```
На 100 000 элементах (ограничение задачи `05_lis`) это 10¹⁰ операций — не укладывается в 2
секунды внутреннего теста, нужен другой алгоритм.
**O(n log n) через массив «хвостов»**:
```c++
int lis_length(const std::vector<int>& a) {
std::vector<int> tails; // tails[k] = минимальный возможный последний
// элемент возрастающей подпоследовательности длины k+1
for (int x : a) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x); // x больше всех хвостов — новая длина
else *it = x; // заменить первый хвост ≥ x на x
}
return tails.size(); // O(n log n)
}
```
`tails` не хранит саму подпоследовательность — только минимально возможное значение
последнего элемента для каждой достижимой длины. `tails` всегда отсортирован по построению:
если `x` заменяет элемент на позиции `it`, новое значение `x` меньше старого (иначе
`lower_bound` нашёл бы другую позицию) и больше всех элементов слева от `it` (иначе
`lower_bound` остановился бы раньше) — порядок не нарушается ни при замене, ни при добавлении
в конец. `lower_bound` ищет первый элемент ≥ x, что даёт строго возрастающую LIS (`task.md`
требует именно строгую); для нестрогой (неубывающей) подпоследовательности нужен
`upper_bound`. Длина `tails` в конце равна длине LIS, хотя сам массив LIS обычно не является.
**Факты для карточек**
- base | Наивная сложность LIS через dp[i]? — O(n²)
- base | Сложность LIS через массив хвостов и бинарный поиск? — O(n log n)
- core | Что хранится в `tails[k]`? — минимально возможный последний элемент возрастающей подпоследовательности длины k+1
- core | Почему `tails` остаётся отсортированным после каждой замены/добавления? — новое значение всегда меньше заменяемого и больше всех элементов левее позиции, найденной `lower_bound`
- core | `lower_bound` или `upper_bound` нужен для строго возрастающей LIS? — `lower_bound`
- deep | Сколько операций у наивного O(n²) LIS на 100 000 элементах и почему это не укладывается в тест? — порядка 10¹⁰, тест роняет прогон при времени > 2 с
Почему дальше: LIS через хвосты — пример, где ДП-таблица заменяется одним отсортированным
массивом и бинарным поиском; та же идея (жертвовать явной таблицей ради log-фактора)
встречается и в других задачах на подпоследовательности.
Ссылки на задачи этого дня: `tasks/05_lis` — реализовать `lis_length` за O(n log n); наивная
O(n²) версия не пройдёт по времени на 100 000 элементах.
<details>
<summary>Проверь себя</summary>
1. Массив весов `[1, 3, 4]`, ценностей `[15, 20, 30]`, вместимость `W=4`. Какой ответ даст
0/1-рюкзак и почему это не «взять предметы 1 и 2» (вес 1+3=4)?
<details><summary>Ответ</summary>35 — рюкзак действительно может выбрать предметы с весами
1 и 3 (суммарный вес 4, ценность 15+20=35); предмет весом 4 отдельно даёт только 30, что
меньше. Ответ 35, а не 30 — если получилось 30, значит в переборе не сравнили оба варианта
заполнения веса 4.</details>
2. Почему для Coin Change II (подсчёт числа способов, не минимума) важно, какой цикл
снаружи — монета или сумма, — а для Coin Change (минимум монет) не важно?
<details><summary>Ответ</summary>Минимум не зависит от порядка, в котором монеты
рассматриваются — это просто оптимум по всем комбинациям. Подсчёт способов чувствителен к
порядку: монета снаружи фиксирует относительный порядок монет и схлопывает перестановки
одной комбинации в один результат (комбинации); сумма снаружи пересчитывает каждую монету
как потенциально «последнюю» для каждой суммы заново, из-за чего одна комбинация
монет считается один раз на каждую перестановку (перестановки).</details>
3. Для массива `[3, 1, 4, 1, 5, 9, 2, 6]` пройдите LIS через `tails` вручную. Чему равен
`tails` после обработки первых пяти элементов (`3, 1, 4, 1, 5`)?
<details><summary>Ответ</summary>`[1, 4, 5]`: 3 → `[3]`; 1 заменяет 3 → `[1]`; 4 больше
всех → `[1,4]`; 1 заменяет первый элемент ≥1 (сам 1) → `[1,4]` без изменений; 5 больше
всех → `[1,4,5]`.</details>
4. Мемоизация `fib_memo(n, cache)` вызвана с n = 200 000. Почему это скорее упадёт по
SIGSEGV, чем отработает медленно?
<details><summary>Ответ</summary>Каждый рекурсивный вызов держит кадр стека, пока не
вернётся его результат; глубина рекурсии здесь равна n, а размер стека потока ограничен
(типично несколько МБ) — при n=200 000 кадров стек переполняется раньше, чем вычисление
успевает завершиться. Табуляция (`fib_tab`) того же n отработает без проблем — там нет
рекурсии, только цикл.</details>
</details>
## Материалы
- cppreference, `std::vector` — https://en.cppreference.com/w/cpp/container/vector
- cppreference, `std::lower_bound` — https://en.cppreference.com/w/cpp/algorithm/lower_bound
- cppreference, `std::max` — https://en.cppreference.com/w/cpp/algorithm/max
- cppreference, `std::min` — https://en.cppreference.com/w/cpp/algorithm/min
- cppreference, лимиты `<climits>` (`INT_MAX`) — https://en.cppreference.com/w/cpp/types/climits
+387
View File
@@ -0,0 +1,387 @@
# D5, часть 3. Bash, awk/sed, /proc (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Пайплайны и код возврата
`cmd1 | cmd2 | cmd3` запускает три отдельных процесса одновременно, соединённых анонимными
каналами (pipe) — `cmd2` начинает читать данные, ещё пока `cmd1` их пишет, ничего не
буферизуется целиком в памяти между шагами. `$?` после пайплайна — это код возврата
**последней** команды (`cmd3`), а не всего конвейера: если `cmd1` упал с ошибкой, а `cmd3`
завершилась успешно, `$?` покажет 0 — ошибка `cmd1` потеряется молча.
Чтобы увидеть код возврата каждой команды пайплайна отдельно, есть массив `PIPESTATUS`:
`echo "${PIPESTATUS[@]}"` после `cmd1 | cmd2` печатает, например, `1 0` — `cmd1` упал,
`cmd2` отработал нормально, хотя `$?` был бы 0.
**Факты для карточек**
- base | Чей код возврата хранит `$?` после `cmd1 | cmd2 | cmd3`? — только последней команды (`cmd3`)
- core | Как узнать код возврата каждой команды пайплайна отдельно? — массив `PIPESTATUS` (`${PIPESTATUS[@]}`)
Почему дальше: раз `$?` по умолчанию скрывает ошибки середины пайплайна, логично разобрать
режимы `set`, которые меняют это поведение по умолчанию.
## 2. `set -euo pipefail`: что каждая буква включает и где ломается
- **`-e`** — скрипт немедленно завершается, если любая команда вернула ненулевой код.
- **`-u`** — обращение к неопределённой переменной — ошибка, а не пустая строка.
- **`-o pipefail`** — код возврата пайплайна становится кодом **первой** упавшей команды в
нём, а не только последней (закрывает дыру из раздела 1).
`-e` не так надёжен, как кажется, — есть места, где он **не срабатывает**:
- Команда внутри условия (`if cmd; then`, `while cmd; do`, после `&&`/`||`) — код возврата там
ожидаем и проверяется явно, `-e` эту команду не трогает даже при ошибке.
- Команда в подстановке `$(cmd)`, присвоенной переменной без немедленного использования
результата в условии — ошибка внутри подстановки сама по себе не всегда останавливает
внешний скрипт, поведение зависит от контекста использования результата.
- Любая команда как часть пайплайна, кроме последней, — без `pipefail` `-e` реагирует только
на код последней команды пайплайна (та же дыра, что и в разделе 1).
**Ловушки**
- Полагаться на `-e` внутри `if some_check; then ... fi` для остановки скрипта при ошибке
`some_check` → скрипт не остановится, потому что команда — часть условия → ошибка
проглатывается и обнаруживается заметно позже, в неожиданном месте.
- Забыть `pipefail` → `grep pattern file | sort` при отсутствующем `file` вернёт 0 (потому что
`sort` пустого потока отрабатывает успешно) → скрипт продолжит работу, как будто файл найден.
**Факты для карточек**
- base | Что делает `-e` в `set -euo pipefail`? — скрипт завершается сразу при ненулевом коде возврата любой команды
- base | Что делает `-u`? — обращение к неопределённой переменной — ошибка вместо пустой строки
- core | Что делает `pipefail` и какую проблему из раздела 1 это закрывает? — код возврата пайплайна = код первой упавшей команды, а не только последней; закрывает потерю ошибок середины пайплайна
- core | В каком месте `-e` не остановит скрипт при ошибке команды? — если команда — часть условия (`if`, `while`, после `&&`/`||`) или не последняя команда пайплайна без `pipefail`
Почему дальше: `set -u` ловит неопределённые переменные, но не спасает от другой частой
причины сюрпризов — как bash разбивает строку на слова при подстановке без кавычек.
## 3. Кавычки, word splitting, IFS, массивы
Без двойных кавычек bash после подстановки переменной делает **word splitting** — разбивает
результат на отдельные слова по символам из `IFS` (по умолчанию пробел, таб, перевод строки) и
затем **globbing** — раскрывает символы `*`, `?`, `[...]` как маски файлов. `"$var"` в двойных
кавычках отключает оба шага — переменная передаётся как одна строка целиком, что почти всегда
и нужно.
```bash
file="my file.txt"
rm $file # word splitting: rm воспринимает это как rm "my" "file.txt" — два аргумента
rm "$file" # правильно: один аргумент "my file.txt"
```
`IFS` можно временно переопределить, чтобы разбить строку по нужному разделителю:
```bash
IFS=',' read -ra parts <<< "a,b,c" # parts=(a b c)
```
Массивы: `arr=(a b c)`, обращение по индексу `${arr[1]}`, все элементы — `${arr[@]}` (каждый
элемент — отдельное слово при развёртывании в кавычках, `"${arr[@]}"`), длина — `${#arr[@]}`.
**Ловушки**
- Забыть кавычки вокруг переменной с путём, содержащим пробел, → word splitting режет путь на
несколько аргументов → «No such file or directory» на файле, который на самом деле есть.
- `${arr[@]}` без кавычек при переборе `for x in ${arr[@]}` → элементы с пробелами внутри сами
разбиваются на несколько слов → цикл обрабатывает не те элементы, что были в массиве.
**Факты для карточек**
- base | Что по умолчанию входит в IFS? — пробел, таб, перевод строки
- core | Что отключают двойные кавычки вокруг `"$var"`? — word splitting и globbing (`*`/`?`/`[...]` не раскрываются)
- core | Как правильно перебрать массив с элементами, содержащими пробелы? — `for x in "${arr[@]}"` (с кавычками)
Почему дальше: помимо кода возврата команды и переменных окружения, у самого запущенного
скрипта и его процессов есть ещё несколько специальных переменных и способ отреагировать на
сигнал — `trap`.
## 4. `$?`, `$!`, `$$`, `trap`
- **`$?`** — код возврата последней выполненной команды (см. раздел 1 про пайплайны).
- **`$!`** — PID последнего запущенного в фоне процесса (`cmd &` затем `pid=$!`), нужен,
чтобы потом сделать `wait $pid` или `kill $pid`.
- **`$$`** — PID самого текущего скрипта/шелла, часто используется для уникальных временных
файлов: `tmpfile="/tmp/out.$$"`.
- **`trap`** — регистрирует обработчик на сигнал или на выход из скрипта:
`trap 'rm -f "$tmpfile"' EXIT` гарантированно удалит временный файл при любом завершении
скрипта — обычном, по ошибке (`-e`) или по сигналу (если сигнал перечислен), без ручного
дублирования `rm` в каждой точке выхода.
**Факты для карточек**
- base | Что хранит `$!`? — PID последнего фонового процесса
- base | Что хранит `$$`? — PID текущего скрипта/шелла
- core | Зачем `trap ... EXIT` используют для временных файлов? — гарантирует очистку при любом пути завершения скрипта (успех, ошибка, сигнал), без дублирования кода в каждой точке выхода
Почему дальше: `$$` в имени временного файла — это про один процесс; когда данные для команды
нужно передать через аргументы, а не через имя файла, и данных много (список файлов, список
PID), на сцену выходит `xargs`.
## 5. `xargs -0`
`xargs` берёт поток строк со стандартного ввода и подставляет их как аргументы в конец
указанной команды, разбивая по частям, если список слишком длинный для одного вызова
(ограничение ОС на длину аргументов). По умолчанию `xargs`, как и bash, разбивает вход по
пробельным символам — имена файлов с пробелами или переводами строк внутри (редко, но
бывает) ломают такой разбор.
`-0` — вход разделён не пробелами/переводами строк, а нулевыми байтами (`\0`), которые не
могут встретиться внутри легального имени файла в Unix. Источник таких нулевых разделителей —
`find ... -print0`:
```bash
find . -name '*.tmp' -print0 | xargs -0 rm -f
```
Это единственная безопасная пара для случая, когда имена файлов заранее не гарантированы
«простыми» (без пробелов, кавычек, переводов строк).
**Ловушки**
- `find ... | xargs rm` без `-print0`/`-0` на именах файлов с пробелами → имя разбивается на
несколько «аргументов» → `rm` пытается удалить несуществующие файлы или удаляет не то.
**Факты для карточек**
- base | Чем `-0` в `xargs -0` отличается от поведения по умолчанию? — вход разделяется нулевыми байтами `\0` вместо пробелов/переводов строк
- core | С какой опцией `find` обычно комбинируют `xargs -0`? — `-print0`
Почему дальше: `xargs` передаёт список аргументов другой команде; сами команды для поиска и
агрегации данных в потоке — отдельный набор утилит с конкретными флагами.
## 6. Поиск и агрегация: grep, sort, uniq
- **`grep -c PATTERN`** — печатает не совпавшие строки, а их количество.
- **`grep -o PATTERN`** — печатает только совпавшую часть строки, а не строку целиком (удобно
вместе с `wc -l`, чтобы посчитать число вхождений, а не строк с хотя бы одним вхождением).
- **`grep -v PATTERN`** — инвертирует фильтр, печатает строки, которые **не** совпали.
- **`sort -k N`** — сортирует по N-му полю (по умолчанию разделитель — пробел), а не по всей
строке целиком.
- **`sort -n`** — числовая сортировка (`10` идёт после `9`, а не перед `2`, как при
лексикографической сортировке строк по умолчанию).
- **`sort -u`** — сортировка с одновременным удалением дубликатов (эквивалент `sort | uniq`
в один проход).
- **`uniq -c`** — схлопывает соседние повторяющиеся строки, добавляя счётчик повторов перед
каждой строкой; работает только на **уже отсортированном** входе — несмежные повторы
`uniq` не видит.
```bash
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -3 # топ-3 IP по числу запросов
```
**Ловушки**
- Вызвать `uniq -c` без предварительного `sort` → повторы, не идущие подряд в исходном файле,
не схлопнутся → счётчики занижены относительно реального числа повторов.
- `sort` без `-n` на числовых полях (порты, счётчики) → `10 < 9` лексикографически → строки
идут не в том порядке, который ожидался.
**Факты для карточек**
- base | Что печатает `grep -c`? — количество совпавших строк, а не сами строки
- base | Почему `uniq -c` нужно применять после `sort`? — `uniq` схлопывает только соседние одинаковые строки, несмежные повторы не видит
- core | Чем `sort -u` эквивалентен по результату? — `sort | uniq`, но за один проход
Почему дальше: `grep`/`sort`/`uniq` работают со строками целиком или по одному полю через
внешний ключ; когда нужна произвольная логика по колонкам с накоплением состояния (суммы,
счётчики по ключу), нужен `awk`.
## 7. awk: поля, NR/NF, ассоциативные массивы
`awk` читает вход построчно и автоматически разбивает каждую строку на поля по разделителю
(`FS`, по умолчанию — пробел/таб): `$1`, `$2`, … — поля, `$0` — строка целиком, `NR` — номер
текущей строки от начала потока, `NF` — число полей в текущей строке.
```bash
awk '{sum[$1] += $2} END {for (k in sum) print k, sum[k]}' data.txt
```
`sum` здесь — ассоциативный массив: ключ `$1` (первое поле), значение — накапливаемая сумма
второго поля. `awk` — полноценный язык с переменными, циклами и такими массивами, а не просто
фильтр, поэтому в нём можно агрегировать данные по ключу за один проход, без промежуточных
файлов.
Почему `awk` на файле в 10⁶ строк быстрее bash-цикла построчного чтения с внешними командами
внутри: `awk` — один процесс, который читает поток и делает линейный проход по строкам целиком
внутри себя. Bash-цикл вида `while read -r line; do grep ... <<< "$line"; done < file`
порождает (форкает) отдельный новый процесс **на каждую строку** — на миллионе строк это
миллион `fork`+`exec`, каждый из которых на порядки дороже одной итерации внутреннего цикла
`awk`. Разница — не в разы, а на порядки по времени выполнения.
**Ловушки**
- Обрабатывать большой лог циклом `for`/`while` с построчным чтением и вызовом `grep`/`awk`
внутри цикла → минуты вместо секунд на файле в миллионы строк → видно по `time` на запуске
скрипта; правильный подход — один проход `awk`/`sort` на весь поток целиком.
**Факты для карточек**
- base | Что означает `$1` в awk? — первое поле текущей строки
- base | Что содержит `NR`? — номер текущей строки от начала потока
- core | Почему awk на 1e6 строк быстрее bash-цикла с grep внутри? — awk — один процесс с линейным проходом по строкам, bash-цикл форкает новый процесс на каждую итерацию (миллион fork+exec против одного процесса)
Почему дальше: `awk` агрегирует и печатает; когда нужно не посчитать, а точечно
отредактировать текст по шаблону прямо в потоке, для этого — `sed`.
## 8. sed
`sed` применяет команды редактирования к каждой строке потока и печатает результат — потоковый
редактор, не интерактивный. Самая частая команда — замена по шаблону:
`sed 's/OLD/NEW/'` (заменяет первое вхождение в строке), `sed 's/OLD/NEW/g'` (все вхождения в
строке, флаг `g` = global). Без флага `-i` `sed` не меняет исходный файл, а печатает результат
в stdout — `sed -i 's/OLD/NEW/g' file` редактирует файл на месте.
```bash
sed -n '5,10p' file # напечатать только строки с 5 по 10 (-n подавляет вывод по умолчанию)
sed '/^#/d' file # удалить строки, начинающиеся с #
```
**Факты для карточек**
- base | Что делает флаг `g` в `s/OLD/NEW/g`? — заменяет все вхождения в строке, а не только первое
- core | Чем `sed -i` отличается от `sed` без флага? — редактирует файл на месте вместо печати результата в stdout
Почему дальше: `grep`/`awk`/`sed` работают с текстовыми потоками из файлов и команд; отдельный
источник текстовых данных в Linux — это `/proc`, где ядро публикует состояние процессов и
ресурсов в виде текстовых файлов.
## 9. `/proc`: что там реально лежит
`/proc` — виртуальная файловая система: файлы в ней не лежат на диске, ядро генерирует их
содержимое в реальном времени по запросу чтения. Ключевые пути:
- **`/proc/<pid>/status`** — читаемая человеком сводка о процессе: состояние, использование
памяти (VmRSS и другие), UID/GID, число потоков.
- **`/proc/<pid>/cmdline`** — команда запуска процесса с аргументами, разделёнными нулевыми
байтами `\0` (не пробелами — тот же принцип, что у `find -print0`, аргумент с пробелом
внутри не спутать с границей между аргументами).
- **`/proc/<pid>/fd/`** — каталог, где каждый файл — символическая ссылка на открытый этим
процессом файловый дескриптор (обычный файл, сокет, pipe); число ссылок в этом каталоге —
реальное число открытых дескрипторов процесса прямо сейчас.
- **`/proc/<pid>/maps`** — карта отображений памяти процесса: диапазоны адресов, права доступа
(r/w/x), какому файлу или сегменту (куча, стек, конкретная библиотека) принадлежит каждый
диапазон.
- **`/proc/cpuinfo`** — параметры процессора (модель, число ядер, флаги поддерживаемых
инструкций).
- **`/proc/meminfo`** — распределение оперативной памяти: занято, свободно, в кэше, в буферах
(источник данных для `free -h`).
- **`/proc/<pid>/stat`** — машинно-читаемая (не для человека) строка с числовыми полями
состояния процесса, включая, например, время в user/kernel-режиме; источник данных для
`ps`/`top`.
Утилиты вроде `ps`, `top`, `free`, `iostat` — это не отдельный механизм сбора данных, а тонкая
обёртка над чтением этих же файлов `/proc`; ядро уже держит эти данные готовыми, специально
«собирать» их не нужно.
**Ловушки**
- Читать `/proc/<pid>/cmdline` как обычную строку с пробелами в качестве разделителей
аргументов → аргумент вида `"two words"` неотличим от двух отдельных аргументов → нужен
именно разбор по `\0`.
**Факты для карточек**
- base | Что такое /proc с точки зрения хранения данных? — виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске
- base | Чем разделены аргументы в /proc/<pid>/cmdline? — нулевыми байтами `\0`
- core | Что можно узнать из /proc/<pid>/maps? — диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон
- core | Откуда `free -h` берёт данные о памяти? — из /proc/meminfo
Почему дальше: `/proc/<pid>/fd/` показывает текущее число открытых дескрипторов процесса;
верхнюю границу на это число задаёт `ulimit`.
## 10. `ulimit`: дескрипторы, core, стек
`ulimit` задаёт лимиты ресурсов для текущего шелла и процессов, запущенных из него:
- **`ulimit -n`** — максимальное число одновременно открытых файловых дескрипторов на
процесс; упереться в этот лимит на нагруженном сервере — типичная причина ошибки
«Too many open files».
- **`ulimit -c`** — максимальный размер core dump файла; по умолчанию часто `0` (core dump
отключён), поэтому перед тем как ловить редкий краш, выставляют `ulimit -c unlimited` —
иначе программа упадёт, а файла с состоянием памяти на момент падения просто не будет.
- **`ulimit -s`** — размер стека потока; именно этот лимит определяет, на какой глубине
рекурсии процесс упадёт с переполнением стека (см. риск глубокой рекурсии при мемоизации в
теме ДП).
**Ловушки**
- Ждать редкий краш под нагрузкой без `ulimit -c unlimited` заранее → программа падает, core
dump не создаётся (лимит 0 по умолчанию) → единственный шанс поймать причину падения
упущен, воспроизвести заново может быть нечем.
**Факты для карточек**
- base | Что ограничивает `ulimit -n`? — максимальное число открытых файловых дескрипторов на процесс
- core | Почему перед отладкой редкого краша выставляют `ulimit -c unlimited`? — по умолчанию размер core dump часто ограничен нулём, без этого файл с состоянием памяти на момент падения не создастся
Почему дальше: лимиты `ulimit` — это про ресурсы процесса; отдельная система ограничений — про
то, кому вообще разрешено читать, писать и исполнять конкретный файл.
## 11. Права: `chmod`/`chown`, `umask`
Права файла — три триады бит (владелец/группа/остальные) × (чтение r / запись w / выполнение
x), каждая триада кодируется одной восьмеричной цифрой: r=4, w=2, x=1, сумма даёт цифру для
триады. `755` = `rwxr-xr-x`: владелец — 7 (4+2+1, полный доступ), группа — 5 (4+1, чтение и
выполнение без записи), остальные — 5 (то же самое). `chmod 755 file` выставляет эти права
явно; `chown user:group file` меняет владельца и группу файла.
`umask` — маска, которая **вычитается** из прав по умолчанию при создании нового файла/каталога
(обычно каталоги создаются с базовым 777, файлы — с 666, дальше действует `umask`): при
`umask 022` новый файл получает `666 & ~022 = 644` (`rw-r--r--`), новый каталог — `777 & ~022 =
755`. `umask` не меняет права существующих файлов — только права по умолчанию для новых.
**Факты для карточек**
- base | Расшифровка прав 755? — rwxr-xr-x (владелец: полный доступ, группа и остальные: чтение+выполнение)
- base | Какие числа кодируют r, w, x? — r=4, w=2, x=1
- core | Что делает `umask 022` с правами нового файла по умолчанию (666)? — вычитает 022, получается 644 (rw-r--r--)
Почему дальше: права управляют доступом к файлам статически; когда нужно понять, что процесс
делает с файлами/сетью прямо сейчас, в динамике, нужны отдельные диагностические утилиты.
## 12. `strace`, `lsof`, `ss` как инструменты
- **`strace -p PID`** (или `strace command`) — трассирует системные вызовы процесса построчно:
какой syscall, с какими аргументами, что вернул. Способ увидеть, например, что процесс
зависает именно на `read()` конкретного файлового дескриптора, а не где-то в логике
приложения.
- **`lsof -p PID`** — список файлов (в широком unix-смысле — включая сокеты, pipe), открытых
процессом; тот же смысл, что и `/proc/<pid>/fd/`, но в удобном для чтения формате с
дополнительной информацией.
- **`ss -tan`** — состояние TCP-сокетов (замена устаревшему `netstat`): адреса, порты,
состояние соединения (`ESTABLISHED`, `TIME_WAIT` и так далее — см. состояния TCP).
**Факты для карточек**
- base | Что показывает `strace`? — системные вызовы процесса с аргументами и результатом, построчно
- core | Чем `lsof -p PID` и `/proc/<pid>/fd/` пересекаются по смыслу? — оба показывают список файловых дескрипторов, открытых процессом
Ссылки на задачи этого дня: `tasks/08_bash` — разбор access-лога (`TOTAL`/`TOP`/`5XX`) на
файле до миллиона строк; построчный bash-цикл не проходит по времени (раздел 7), нужен
`awk`/`sort` за один-два прохода.
<details>
<summary>Проверь себя</summary>
1. Скрипт с `set -e` содержит `if grep -q pattern file; then echo found; fi`, и `grep` не
находит `pattern` (возвращает 1). Остановится ли скрипт на этой строке?
<details><summary>Ответ</summary>Нет — команда внутри условия `if` не подчиняется `-e`,
даже если она вернула ненулевой код; это одно из исключений `-e`, скрипт продолжит
выполнение дальше.</details>
2. `grep pattern missing_file.txt | wc -l` при отсутствующем файле напечатает `0`, а `$?`
пайплайна без `pipefail` будет `0`. Как обнаружить, что реальная проблема — отсутствующий
файл, а не отсутствие совпадений?
<details><summary>Ответ</summary>Включить `set -o pipefail` — тогда код возврата пайплайна
станет кодом первой упавшей команды (`grep` на несуществующем файле), а не только `wc -l`;
либо проверить `${PIPESTATUS[0]}` явно после пайплайна.</details>
3. На файле в миллион строк нужно посчитать число запросов на каждый IP-адрес. Почему решение
через `while read -r line; do ...; done < access.log` с вызовом `awk`/`grep` внутри цикла
будет на порядки медленнее, чем `awk '{print $1}' access.log | sort | uniq -c`?
<details><summary>Ответ</summary>Цикл на bash с внешней командой внутри порождает (fork+exec)
новый процесс на каждую из миллиона строк; связка `awk | sort | uniq -c` — это фиксированное
малое число процессов (по одному на каждый элемент конвейера), каждый из которых делает
один линейный проход по всему потоку целиком.</details>
4. Процесс с `ulimit -c 0` неожиданно падает по SIGSEGV под нагрузкой, воспроизвести падение
по требованию не получается. Что нужно было сделать заранее, чтобы разобрать причину
постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited` до запуска процесса —
тогда при падении ядро сохранит core dump со снимком памяти на момент сбоя, и его можно
будет открыть позже (`gdb prog core`), не дожидаясь повторного воспроизведения
краша.</details>
</details>
## Материалы
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html
- man7.org, `getrlimit(2)` — https://man7.org/linux/man-pages/man2/getrlimit.2.html
- man7.org, `ptrace(2)` — https://man7.org/linux/man-pages/man2/ptrace.2.html
- man7.org, `chmod(2)` — https://man7.org/linux/man-pages/man2/chmod.2.html
- man7.org, `umask(2)` — https://man7.org/linux/man-pages/man2/umask.2.html
- man7.org, `signal(7)` — https://man7.org/linux/man-pages/man7/signal.7.html
+446
View File
@@ -0,0 +1,446 @@
# D5, часть 2. TCP глубоко (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Разбор идёт от
байтов заголовка к состояниям соединения, затем к механизмам надёжности и скорости, и в конце
к соседним протоколам (UDP, DNS, DHCP, NAT), которые решают то, что TCP не решает.
## 1. Заголовок TCP: 20 байт, поле за полем
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Offset| Rsvd|C E U A P R S F| Window |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```
Фиксированная часть — ровно **20 байт**, это минимум заголовка TCP-сегмента (тот же минимум
20 байт, что и у IP-заголовка, вложенного на уровень ниже). Поля:
- **Source Port / Destination Port** — по 16 бит каждый (порты 0–65535).
- **Sequence Number** — 32 бита, номер первого байта данных в этом сегменте.
- **Acknowledgment Number** — 32 бита, номер следующего ожидаемого байта от собеседника.
- **Data Offset** — 4 бита, длина заголовка в 32-битных словах (максимум 15×4 = 60 байт,
значит опции — максимум 60 − 20 = 40 байт).
- **Флаги** — по одному биту: **SYN** (установка соединения), **ACK** (подтверждение), **FIN**
(корректное закрытие направления), **RST** (аварийный сброс), **PSH** (передать данные
приложению немедленно, не буферизуя), **URG** (часть данных помечена срочной через Urgent
Pointer); плюс **ECE**/**CWR** — сигнализация перегрузки сети (ECN, RFC 3168), делит
6 «резервных» бит исходного RFC 793 на 4 действительно резервных и 2 флаговых.
- **Window** — 16 бит, объявляемый размер приёмного окна (flow control, раздел 6).
- **Checksum** — 16 бит, контрольная сумма заголовка+данных+псевдозаголовка IP.
- **Urgent Pointer** — 16 бит, действителен только при выставленном URG.
- **Options** — переменная длина, до 40 байт; сюда входит **MSS** (Maximum Segment Size) —
опция, которой стороны обмениваются только в SYN-сегментах, сообщая максимальный размер
сегмента, который готовы принять.
**Факты для карточек**
- base | Минимальный размер заголовка TCP? — 20 байт
- base | Сколько бит занимает порт в заголовке TCP? — 16 бит (диапазон 0–65535)
- core | Максимальный размер опций TCP-заголовка и почему именно столько? — 40 байт, потому что Data Offset (4 бита) кодирует длину заголовка в 32-битных словах максимум 15×4=60 байт, минус 20 байт фиксированной части
- core | В каких сегментах передаётся опция MSS? — только в сегментах с флагом SYN, при установке соединения
- deep | Какие два флага TCP появились позже исходного RFC 793 и для чего? — ECE и CWR (RFC 3168), сигнализация перегрузки сети (ECN) вместо/вместе с потерей пакета
Почему дальше: поля Sequence/ACK Number и флаг SYN используются в первую очередь при
установке соединения — разберём этот обмен по шагам.
## 2. Установка соединения: three-way handshake
1. Клиент → серверу: сегмент с флагом **SYN** и собственным начальным порядковым номером
(**ISN**, Initial Sequence Number).
2. Сервер → клиенту: сегмент с флагами **SYN+ACK** — подтверждает ISN клиента (Ack = ISN_клиента + 1)
и присылает собственный ISN.
3. Клиент → серверу: сегмент с флагом **ACK**, подтверждающим ISN сервера.
Три шага, а не два, нужны потому, что соединение TCP полнодуплексное — у каждого направления
свой независимый ISN, и каждая сторона должна не только сообщить свой ISN, но и получить
подтверждение, что собеседник его получил. Два шага (SYN → SYN-ACK) недостаточно: сервер не
может быть уверен, что его SYN-ACK дошёл до клиента, пока не получит финальный ACK. После
рукопожатия обе стороны знают начальные номера друг друга и могут независимо отслеживать
доставку и порядок байт в каждом направлении.
**Ловушки**
- Файрвол блокирует ответный ACK клиента → сервер зависает в состоянии `SYN_RECEIVED`
(полуоткрытое соединение) → видно как одинокий `SYN` без завершающего `ACK` в tcpdump и
запись в `ss -tan`.
**Факты для карточек**
- base | Сколько сегментов в three-way handshake? — 3 (SYN, SYN+ACK, ACK)
- core | Почему для установки TCP-соединения недостаточно двух сегментов? — соединение полнодуплексное, серверу нужно подтверждение, что его SYN-ACK (и его ISN) реально дошёл до клиента
- core | Что означает ISN и синхронизируется ли он в одном экземпляре на оба направления? — начальный порядковый номер; нет, у каждого направления свой собственный ISN
Почему дальше: раз соединение открывается тремя сегментами и переходит через промежуточные
состояния (`SYN_SENT`, `SYN_RECEIVED`), логично разобрать полный набор состояний TCP как
конечный автомат.
## 3. Состояние-машина TCP
- **CLOSED** — соединения нет.
- **LISTEN** — сервер ждёт входящих SYN.
- **SYN_SENT** — клиент отправил SYN, ждёт SYN-ACK.
- **SYN_RECEIVED** — сервер получил SYN, отправил SYN-ACK, ждёт финальный ACK.
- **ESTABLISHED** — соединение открыто, идёт обмен данными.
- **FIN_WAIT_1** — эта сторона отправила FIN, ждёт ACK на него.
- **FIN_WAIT_2** — FIN подтверждён, эта сторона ждёт FIN от собеседника.
- **CLOSE_WAIT** — получен FIN от собеседника, эта сторона ещё может досылать данные.
- **LAST_ACK** — эта сторона отправила свой FIN (после CLOSE_WAIT), ждёт последний ACK.
- **TIME_WAIT** — сторона, отправившая финальный ACK, ждёт 2×MSL перед освобождением сокета.
- **CLOSING** — оба конца отправили FIN почти одновременно, редкий путь одновременного закрытия.
Автомат асимметричен по конструкции: клиент и сервер проходят разные пути (`SYN_SENT` только
у инициатора, `SYN_RECEIVED`/`LISTEN` только у принимающей стороны), потому что роли в
рукопожатии разные. `ss -tan` показывает текущее состояние сокета в столбце State — это прямое
отражение позиции в этом автомате, а не абстракция.
**Факты для карточек**
- base | В каком состоянии сервер ждёт входящие подключения? — LISTEN
- core | Чем отличаются пути клиента и сервера в конечном автомате TCP? — клиент проходит SYN_SENT, сервер — LISTEN и SYN_RECEIVED; роли в рукопожатии асимметричны
- core | Какой командой в Linux видно текущее состояние TCP-сокета? — `ss -tan` (столбец State)
Почему дальше: часть состояний (`FIN_WAIT_*`, `CLOSE_WAIT`, `LAST_ACK`, `TIME_WAIT`) относится
к закрытию соединения — разберём эту последовательность отдельно, она сложнее открытия.
## 4. Закрытие в четыре шага и TIME_WAIT
1. Сторона A, завершившая передачу, шлёт **FIN**.
2. Сторона B подтверждает его **ACK** (A уходит в `FIN_WAIT_2`, B — в `CLOSE_WAIT`).
3. Когда сторона B тоже готова закрыться, она шлёт свой **FIN**.
4. Сторона A подтверждает финальным **ACK** (A уходит в `TIME_WAIT`, B — в `CLOSED` сразу
после получения этого ACK).
Четыре сегмента, а не два, — потому что закрытие каждого направления независимо
(**полузакрытие**, half-close): получение FIN от B означает только «B больше не пришлёт
данных», но A может продолжать досылать данные в обратном направлении, прежде чем закрыть
свою половину. FIN идёт в одну сторону за раз именно поэтому — это закрытие конкретного
направления потока, а не всего соединения разом.
Сторона, отправившая последний ACK (то есть первой инициировавшая закрытие), уходит в
**TIME_WAIT** и ждёт там **2×MSL** (Maximum Segment Life) перед освобождением сокета. Зачем:
эта сторона должна поймать задержавшиеся в сети дубликаты старых сегментов (если порт
освободить сразу и тут же переиспользовать для нового соединения, устаревший сегмент может
быть по ошибке принят как часть новой сессии) и быть готовой повторно отправить последний ACK,
если он потерялся и партнёр повторяет свой FIN. RFC 793 определяет номинальный MSL = 2 минуты
(отсюда 2×MSL = 4 минуты), но в Linux TIME_WAIT реализован как фиксированный таймаут **60
секунд** (константа `TCP_TIMEWAIT_LEN` в ядре) независимо от настраиваемого MSL. Куча
накопившихся `TIME_WAIT`-сокетов на активном сервере — видимая проблема (`ss -tan state
time-wait`), решается через `SO_REUSEADDR` или снижением частоты пересоздания соединений.
**Ловушки**
- Считать TIME_WAIT «багом» и убирать его целиком (агрессивные настройки reuse) → сервер
начинает принимать дубликаты старых сегментов как часть новых соединений → редкие, трудно
воспроизводимые повреждения данных на высоконагруженных коротких соединениях.
**Факты для карточек**
- base | Сколько сегментов нужно для полного закрытия TCP-соединения? — 4 (FIN, ACK, FIN, ACK)
- base | Формула длительности TIME_WAIT? — 2×MSL
- core | Почему закрытие TCP асимметрично («полузакрытие»), а не мгновенное закрытие по первому FIN? — соединение дуплексное, получение FIN означает только «собеседник больше не пришлёт данные», но сама сторона может ещё дописывать данные в обратном направлении
- deep | Сколько секунд реально длится TIME_WAIT в Linux и совпадает ли это с 2×MSL по RFC 793? — 60 секунд, фиксированная константа ядра; не совпадает с номинальными 4 минутами (2×2 мин) по RFC 793
Почему дальше: FIN — это вежливое закрытие. Разберём флаг, который сигнализирует не закрытие
по согласию, а ошибку или невозможность продолжить, — RST.
## 5. RST: аварийный сброс, а не закрытие
**RST** сигнализирует ошибку или невозможность продолжить соединение — в отличие от вежливого
FIN, тишины не будет. Типичный случай: попытка подключиться к закрытому порту получает в ответ
именно RST, а не молчание — так клиент сразу узнаёт, что порт не слушает, вместо ожидания
таймаута. Если приложение получает RST там, где ожидало нормальное закрытие через FIN, это
обычно означает, что сокет на другой стороне был закрыт грубо (например, процесс убит) —
классический симптом «connection reset by peer» в логах, подтверждается флагом RST в
последнем сегменте дампа tcpdump.
**Факты для карточек**
- base | Что получает клиент в ответ на попытку подключиться к закрытому порту? — RST
- core | Чем RST принципиально отличается от FIN по смыслу? — RST — аварийный немедленный сброс (ошибка/невозможность продолжить), FIN — согласованное закрытие направления
Почему дальше: SYN, FIN, RST — это управление соединением. Отдельный вопрос — как TCP поверх
этого управления гарантирует, что данные точно дойдут и в правильном порядке.
## 6. Надёжность: ACK, окно, flow control против congestion control
TCP строит надёжный упорядоченный байтовый поток поверх ненадёжной доставки IP:
- Каждый байт данных нумеруется порядковым номером (Sequence Number).
- Получатель подтверждает принятые данные **кумулятивным ACK** — Ack Number означает «я
получил всё непрерывно вплоть до этого байта», а не «я получил именно этот сегмент».
- Если подтверждение не пришло за таймаут (RTO, раздел 7) — отправитель ретранслирует.
- Сегменты, пришедшие не по порядку, получатель буферизует и переупорядочивает перед тем, как
отдать данные приложению.
- **Окно скольжения** (sliding window) — отправитель держит в полёте сразу много
неподтверждённых байт, не дожидаясь ACK на каждый сегмент отдельно; иначе пришлось бы ждать
полный RTT на каждый пакет.
TCP регулирует скорость передачи **двумя разными** механизмами, которые легко перепутать:
- **Flow control** (управление получателем, поле Window) — сколько байт получатель готов
принять прямо сейчас. Защищает получателя от переполнения его приёмного буфера: если
приложение читает данные медленно, окно сужается, вплоть до нуля («TCP Zero Window» в
tcpdump — передача полностью останавливается).
- **Congestion control** (управление сетью, `cwnd` — congestion window) — сколько отправитель
может слать, не перегружая сеть между узлами, о состоянии которой напрямую ничего не
известно. Работает по схеме **AIMD** (Additive Increase, Multiplicative Decrease):
- **Slow start** — `cwnd` стартует с малого значения (исторически 1 MSS, современный RFC
6928 разрешает стартовое окно до 10 MSS) и удваивается каждый RTT, пока не достигнет
порога `ssthresh` или не случится потеря.
- **Congestion avoidance** — после `ssthresh` рост становится линейным: `cwnd` растёт
примерно на 1 MSS за RTT (аддитивное увеличение).
- **Fast retransmit** — 3 повторных (дублирующих) ACK на один и тот же номер сегмента
трактуются как сигнал потери без ожидания полного таймаута RTO — ретрансмиссия начинается
немедленно.
- При потере: `ssthresh = cwnd / 2`, `cwnd` тоже уменьшается (мультипликативное уменьшение).
Потеря вдвое режет `cwnd`, а не сбрасывает в ноль, потому что потеря одного сегмента —
сигнал «сеть перегружена сейчас», а не «сеть недоступна»; резкое падение до минимума
впустую потратило бы уже проверенную пропускную способность. Полный сброс `cwnd` к
минимуму происходит отдельно — при таймауте RTO (более серьёзный сигнал, чем
дублирующие ACK).
Действующий по умолчанию в Linux алгоритм congestion control — **CUBIC** (с ядра 2.6.19),
более сложная функция роста `cwnd` от времени, чем классический AIMD Reno, но сама идея
«расти, пока не потеряли, резко сократиться при потере» сохраняется.
**Ловушки**
- Перепутать flow control (Window) с congestion control (`cwnd`) → неверный ответ на вопрос
«почему передача остановилась»: Zero Window — проблема медленного читателя на приёмнике,
просевший `cwnd` — проблема сети между узлами, у них разная диагностика и разное решение.
**Факты для карточек**
- base | Чем измеряется flow control в заголовке TCP? — полем Window
- core | Чем отличается flow control от congestion control по цели? — flow control защищает получателя от переполнения буфера, congestion control защищает сеть от перегрузки
- core | Во сколько раз падает cwnd при обнаруженной потере? — вдвое (ssthresh = cwnd/2)
- core | Что такое fast retransmit? — ретрансмиссия по 3 дублирующим ACK без ожидания полного таймаута RTO
- deep | Какой алгоритм congestion control используется в Linux по умолчанию? — CUBIC
Почему дальше: и ретрансмиссия по таймауту, и fast retransmit опираются на измеренное время
кругового пути — разберём, как считается сам таймаут RTO.
## 7. Таймеры ретрансмиссии: RTO и RTTVAR
Таймаут ретрансмиссии (RTO) не фиксированное число — он адаптируется под измеренный round-trip
time (RTT) конкретного соединения. По алгоритму Джекобсона/Карелса (RFC 6298):
- `SRTT` (сглаженный RTT) обновляется как экспоненциальное скользящее среднее с коэффициентом
`α = 1/8` от нового измерения.
- `RTTVAR` (вариация RTT) обновляется как скользящее среднее отклонения `|SRTT − RTT_sample|`
с коэффициентом `β = 1/4`.
- `RTO = SRTT + 4 × RTTVAR`.
Множитель 4 у `RTTVAR` — запас на случай, если сеть внезапно станет менее стабильной (большой
разброс задержек), чтобы не срабатывать ложно на обычный джиттер, но и не ждать избыточно
долго при реальной потере. Фиксированный RTO (без адаптации под RTT) не работает — RTT
локальной сети и RTT через несколько континентов различаются на порядки, единое число либо
слишком долго ждёт в быстрой сети, либо слишком рано ретранслирует в медленной.
**Факты для карточек**
- core | Формула RTO по Джекобсону/Карелсу? — SRTT + 4×RTTVAR
- deep | Какие коэффициенты сглаживания используются для SRTT и RTTVAR? — α=1/8 для SRTT, β=1/4 для RTTVAR
Почему дальше: RTO касается решения «когда переслать заново». Отдельный, более локальный
таймер решает более мелкий вопрос — отправлять ли данные прямо сейчас маленьким куском или
подождать и накопить.
## 8. Nagle и TCP_NODELAY
Алгоритм Нейгла по умолчанию задерживает отправку маленьких сегментов, пока не придёт ACK на
предыдущие неподтверждённые данные или не накопится достаточно данных для полного сегмента —
цель в том, чтобы не засорять сеть множеством мелких пакетов (несколько байт полезной нагрузки
на 20 байт TCP-заголовка — плохое соотношение). Флаг сокета **`TCP_NODELAY`** отключает эту
задержку — данные уходят сразу, как только приложение вызвало `write`/`send`.
Nagle плохо сочетается с **delayed ACK** (получатель тоже не спешит слать ACK, ждёт немного —
вдруг появятся данные для отправки в обратную сторону, тогда ACK можно приклеить к ним) —
классический сценарий из статьи Кларка 1982 года даёт задержки порядка сотен миллисекунд:
отправитель ждёт ACK, чтобы послать следующий маленький кусок, получатель ждёт данные, чтобы
не слать ACK отдельно — оба ждут друг друга. Для интерактивных протоколов с мелкими,
чувствительными к задержке сообщениями (например, построчный ввод в интерактивном сеансе)
`TCP_NODELAY` — стандартная практика.
**Факты для карточек**
- base | Что делает флаг TCP_NODELAY? — отключает алгоритм Нейгла, данные отправляются сразу без задержки на накопление
- core | Почему Nagle + delayed ACK вместе дают заметные задержки? — обе стороны ждут друг друга: отправитель — ACK перед следующей мелкой отправкой, получатель — данные для отправки в обратную сторону, чтобы не слать ACK отдельно
Почему дальше: TCP — не единственный транспортный протокол. Разберём его прямую
противоположность по философии — UDP, у которого почти ничего из разобранного выше просто нет.
## 9. UDP: 8 байт, без гарантий
Заголовок UDP — всего **8 байт**: порт источника, порт назначения, длина, контрольная сумма —
и всё; никаких порядковых номеров, подтверждений или окна, как у TCP. Нет установки
соединения, нет гарантии доставки, порядка или отсутствия дублей — датаграмма либо доходит,
либо нет, молча. Такая простота осознанная: приложениям, которым важнее низкая задержка и
минимум накладных расходов, чем гарантия каждого байта, не нужен вес состояния соединения и
ретрансмиссий.
Где уместен UDP:
- **DNS** — короткий запрос-ответ, переспросить целиком дешевле, чем ждать TCP-ретрансмиссию.
- **RTP** (голос/видео реального времени) — устаревший потерянный кадр всё равно бесполезен,
ждать его повторной доставки хуже, чем пропустить.
- **QUIC** — строит собственную надёжность и порядок поверх UDP на прикладном уровне, обходя
то, что классический TCP жёстко зашивает в ядро (head-of-line blocking на уровне сегментов).
**Факты для карточек**
- base | Размер заголовка UDP? — 8 байт
- base | Какие поля есть в заголовке UDP? — порт источника, порт назначения, длина, контрольная сумма
- core | Почему DNS исторически использует UDP, а не TCP? — типичный запрос-ответ короткий и умещается в одну датаграмму, устанавливать TCP-соединение ради одного маленького обмена избыточно медленно
Почему дальше: TCP и UDP используют один и тот же числовой идентификатор приложения на узле —
порт. Разберём, как устроено адресное пространство портов.
## 10. Порты: три диапазона
- **0–1023** — Well-known / System Ports: закреплены за стандартными службами (22 SSH, 53
DNS, 80 HTTP, 443 HTTPS) — клиент заранее знает, куда стучаться.
- **1024–49151** — Registered Ports: регистрируются IANA за конкретными приложениями, но без
такой строгой резервации, как первый диапазон.
- **49152–65535** — Dynamic/Private Ports (ephemeral): ОС временно выделяет их клиентским
сокетам на время соединения, не привязывая ни к какой конкретной службе.
Два процесса не могут одновременно слушать один и тот же порт на одном интерфейсе — второй
вызов `bind` завершится ошибкой «Address already in use»; частая причина, по которой сервис
не поднимается после аварийного перезапуска — старый процесс ещё держит порт (либо сокет
всё ещё в `TIME_WAIT`).
**Факты для карточек**
- base | Диапазон well-known портов? — 0–1023
- base | В каком диапазоне ОС обычно выделяет эфемерные порты клиентским соединениям? — 49152–65535
- core | Что вернёт второй `bind` на уже занятый порт? — ошибку «Address already in use»
Почему дальше: порт определяет приложение на узле, но не решает проблему нехватки IPv4-адресов
для самих узлов — этим занимается NAT.
## 11. NAT: таблица трансляций и почему ломается P2P
Маршрутизатор на исходящем пакете подменяет внутренний адрес источника на свой внешний и
запоминает в **таблице трансляций** соответствие «внутренний IP:порт — внешний IP:порт»; на
входящий ответный пакет ищет по этой таблице нужного внутреннего получателя и подменяет адрес
обратно. NAT возник как практическое решение нехватки публичных IPv4-адресов: 32-битного
пространства не хватает на все устройства мира, а один внешний адрес может обслуживать целую
локальную сеть, различая внутренние узлы по номеру порта (**PAT**, Port Address Translation).
Почему ломается P2P: узел за NAT не имеет собственного публичного адреса и не может принимать
входящие соединения без явной настройки (проброс портов, `-p` в Docker — тот же принцип) —
входящий пакет от нового, незнакомого узла просто не с чем сопоставить в таблице трансляций,
её запись создаётся только исходящим трафиком. Диагностируется тем, что снаружи виден только
внешний адрес роутера, а не внутренний узел.
**Факты для карточек**
- base | Что делает NAT с исходящим пакетом? — подменяет внутренний IP:порт источника на внешний IP:порт, запоминая соответствие в таблице трансляций
- core | Почему NAT ломает входящие P2P-соединения без проброса портов? — запись в таблице трансляций создаётся только исходящим трафиком, входящему от незнакомого узла не с чем сопоставиться
Почему дальше: NAT решает адресацию узлов, но перед этим узел нужно ещё найти по имени —
разберём DNS.
## 12. DNS: порт 53, рекурсия, типы записей, TTL
Клиент отправляет запрос резолверу через **UDP на порт 53** (типичный ответ умещается в один
пакет, соединение не нужно); резолвер либо отвечает из кэша, либо рекурсивно опрашивает
корневые, затем доменные (TLD), затем авторитативные серверы, пока не получит финальный ответ.
Если ответ не помещается в стандартный размер UDP-датаграммы (передача зоны, большие
DNSSEC-записи), DNS переключается на **TCP/53**, где нет ограничения на размер одного пакета.
Основные типы записей: **A** (имя → IPv4-адрес), **AAAA** (имя → IPv6-адрес), **MX** (почтовый
сервер домена, с приоритетом). Каждая запись несёт **TTL** — время в секундах, на которое
резолверам разрешено кэшировать запись без повторного запроса к авторитативному серверу;
меньший TTL — быстрее распространяются изменения (например, при смене IP сервиса), но больше
нагрузка на DNS-инфраструктуру повторными запросами.
**Факты для карточек**
- base | Порт DNS по умолчанию и протокол? — 53, UDP (переключение на TCP/53 для больших ответов)
- base | Что хранит запись типа A? — соответствие имени домена IPv4-адресу
- core | Зачем у DNS-записи есть TTL? — ограничивает время кэширования резолверами; компромисс между скоростью распространения изменений и нагрузкой повторными запросами
Почему дальше: DNS резолвит имя в адрес, но сам адрес узлу тоже нужно откуда-то получить при
подключении к сети — этим занимается DHCP.
## 13. DHCP: порты 67/68, схема DORA
DHCP-сервер слушает **UDP-порт 67**, клиент — **UDP-порт 68**. Обмен идёт по схеме **DORA**:
**D**iscover (узел широковещательно ищет сервер) → **O**ffer (сервер предлагает адрес) →
**R**equest (узел подтверждает выбор) → **A**ck (сервер закрепляет адрес на ограниченный срок
аренды — lease, который нужно периодически продлевать). Автоматизация нужна потому, что вручную
прописывать уникальный IP на каждое устройство в сети из сотен узлов неуправляемо и чревато
конфликтами адресов при ошибке администратора.
**Факты для карточек**
- base | Порты DHCP-сервера и клиента? — сервер 67/UDP, клиент 68/UDP
- core | Из каких четырёх шагов состоит DORA? — Discover, Offer, Request, Ack
Почему дальше: всё разобранное выше — это то, что реально видно в байтах на проводе; разберём
инструмент, которым эти байты читают напрямую.
## 14. tcpdump и Wireshark: как читать дамп
`tcpdump` захватывает пакеты на интерфейсе и печатает их построчно (или пишет в файл для
Wireshark). Базовые приёмы:
- Фильтр по порту: `tcpdump tcp port 80` — только TCP-трафик на порту 80 в любую сторону.
- Сохранение в файл для последующего анализа в Wireshark: `tcpdump -w capture.pcap`.
- Чтение handshake в выводе: строка с флагом `[S]` (SYN) от клиента, `[S.]` (SYN-ACK) от
сервера, `[.]` (ACK) от клиента — три строки подряд с растущими seq/ack номерами это и есть
three-way handshake, ровно как в разделе 2.
- Закрытие видно как пара `[F.]` (FIN+ACK) с обеих сторон, каждый подтверждён отдельным `[.]`.
- Одинокий `[S]` без ответа — недоступный порт или заблокированный файрволом ACK (раздел 2,
ловушка `SYN_RECEIVED`).
- Флаг `[R]` — RST, аварийный сброс (раздел 5).
**Факты для карточек**
- base | Команда для захвата TCP-трафика на 80 порту? — `tcpdump tcp port 80`
- base | Флаг tcpdump для сохранения дампа в файл? — `-w`
- core | Как в выводе tcpdump выглядит three-way handshake? — три строки подряд: `[S]` от клиента, `[S.]` от сервера, `[.]` от клиента
Ссылки на задачи этого дня: `tasks/04_ipv4` — разбор IPv4-заголовка и контрольной суммы
(нижний уровень относительно TCP, инкапсулирующий его); задачи чтения дампов — применение
раздела 14 на реальных `.pcap`.
<details>
<summary>Проверь себя</summary>
1. В tcpdump видно: `[S]` от клиента, `[S.]` от сервера, дальше тишина — ACK от клиента не
приходит. В каком состоянии завис сервер и почему?
<details><summary>Ответ</summary>`SYN_RECEIVED` — сервер получил SYN, отправил SYN-ACK, но
финальный ACK не дошёл (например, заблокирован файрволом), поэтому рукопожатие не
завершилось и сервер ждёт третий сегмент.</details>
2. Почему TIME_WAIT длится именно 2×MSL, а не произвольное короткое время вроде 1 секунды?
<details><summary>Ответ</summary>Сторона, закрывшая соединение последней, должна успеть
поймать задержавшиеся в сети дубликаты старых сегментов (которым отводится время жизни
MSL на путь туда и обратно — отсюда удвоение) и быть готовой повторно отправить последний
ACK, если его потеря заставит партнёра повторить FIN. Слишком короткий таймаут рискует
освободить порт раньше, чем дубликат добежит и будет ошибочно принят новым
соединением.</details>
3. Клиент шлёт данные маленькими кусками через `write()` без `TCP_NODELAY`, сервер использует
delayed ACK. Почему передача может ощутимо тормозить, хотя пропускной способности канала
достаточно?
<details><summary>Ответ</summary>Алгоритм Нейгла на клиенте задерживает отправку
следующего маленького куска, пока не придёт ACK на предыдущий; сервер с delayed ACK не
спешит слать этот ACK отдельно, ожидая данных для отправки в обратную сторону — обе
стороны ждут друг друга, и задержка растёт не от нехватки полосы, а от этого
взаимного ожидания.</details>
4. При передаче по сети с потерями каждые несколько RTT срабатывает fast retransmit. Что
происходит с `cwnd` при этом и почему не сбрасывается до минимального значения, как при
таймауте RTO?
<details><summary>Ответ</summary>`ssthresh` и `cwnd` уменьшаются вдвое (`cwnd/2`), а не до
минимума — 3 дублирующих ACK означают, что сеть в целом жива и часть сегментов всё же
доходит, это более мягкий сигнал перегрузки, чем полное отсутствие ответа при таймауте
RTO, поэтому реакция мягче.</details>
</details>
## Материалы
- RFC 793, Transmission Control Protocol — https://datatracker.ietf.org/doc/html/rfc793
- RFC 6298, Computing TCP's Retransmission Timer — https://datatracker.ietf.org/doc/html/rfc6298
- RFC 3168, The Addition of Explicit Congestion Notification (ECN) to IP — https://datatracker.ietf.org/doc/html/rfc3168
- RFC 768, User Datagram Protocol (UDP) — https://datatracker.ietf.org/doc/html/rfc768
- RFC 1035, Domain Names — Implementation and Specification (DNS) — https://datatracker.ietf.org/doc/html/rfc1035
- RFC 2131, Dynamic Host Configuration Protocol (DHCP) — https://datatracker.ietf.org/doc/html/rfc2131
- man7.org, `tcp(7)` — https://man7.org/linux/man-pages/man7/tcp.7.html
+325
View File
@@ -0,0 +1,325 @@
# D6, часть 2. Docker минимум под сборку и тесты (урок)
Это не про эксплуатацию продакшн-кластеров, а про Docker как инструмент воспроизводимой
сборки и тестового окружения для C++: одна и та же среда сборки — компилятор, тулчейн,
библиотеки — у каждого разработчика и на CI, вместо «у меня собирается, у тебя нет».
## 1. Контейнер — это процесс, а не виртуальная машина
Контейнер — обычный процесс хостовой ОС, изолированный двумя независимыми механизмами ядра
Linux:
- **namespaces** — отдельное пространство имён для каждого вида ресурса: **pid** (свой список
процессов, процесс внутри контейнера видит себя как PID 1), **mnt** (своя точка монтирования
файловой системы), **net** (свой сетевой стек — интерфейсы, адреса, таблица маршрутизации),
**uts** (свой hostname), **ipc** (изолированные механизмы межпроцессного взаимодействия —
очереди сообщений, семафоры), **user** (отображение UID/GID контейнера на другие UID/GID
хоста).
- **cgroups** (control groups) — ограничение и учёт потребления ресурсов: сколько CPU, памяти,
дискового ввода-вывода разрешено процессу и его потомкам.
Ключевое отличие от виртуальной машины: контейнеру не нужно собственное ядро — он использует
ядро хоста напрямую, тогда как VM через гипервизор эмулирует виртуальное железо, поверх
которого грузится отдельное ядро гостевой ОС. Отсюда напрямую следуют два практических
эффекта: старт контейнера — это по сути `fork`/`exec` с применёнными namespaces, то есть
доли секунды и накладные расходы, близкие к нулю (VM грузит собственное ядро — секунды и
проценты CPU/памяти на гипервизор); и системные вызовы контейнера выполняет то же самое
ядро хоста без эмуляции, то есть без потери производительности на виртуализацию — но и без
изоляции на уровне ядра: уязвимость ядра или неверно настроенные capabilities способны дать
выход из контейнера на хост, чего с отдельным ядром VM добиться сложнее.
**Факты для карточек**
- base | Из каких двух механизмов ядра Linux состоит изоляция контейнера? — namespaces (изоляция видимости ресурсов) и cgroups (ограничение потребления ресурсов)
- base | Перечисли namespaces, разбираемые в этом разделе? — pid, mnt, net, uts, ipc, user
- core | Почему контейнер стартует за доли секунды, а VM — за секунды? — контейнер не грузит собственное ядро, старт — это fork/exec с применёнными namespaces; VM грузит через гипервизор целое гостевое ядро
- core | Почему изоляция контейнера слабее, чем у VM, на уровне безопасности? — контейнер и хост используют одно и то же ядро; уязвимость ядра или неверные capabilities могут дать выход на хост, а у VM с отдельным ядром такой прямой путь отсутствует
Почему дальше: контейнер запускается из образа — разберём, из чего состоит сам образ и почему
порядок команд при его сборке влияет на скорость пересборки.
## 2. Слои образа и кэш сборки
Образ Docker — неизменяемый набор **слоёв**: каждая инструкция Dockerfile (`RUN`, `COPY`)
порождает отдельный слой — diff файловой системы относительно предыдущего слоя, слои
read-only и кэшируются по хешу содержимого. При сборке Docker идёт по инструкциям сверху вниз
и для каждой проверяет кэш: если инструкция и её входные данные не изменились с прошлой
сборки (для `COPY` — содержимое копируемых файлов), слой берётся из кэша без выполнения; как
только один слой не совпал с кэшем, **все последующие слои пересобираются заново**, даже если
сами по себе не менялись — кэш линеен и рвётся в первой же точке расхождения.
Отсюда практическое правило порядка инструкций: сначала копировать и устанавливать
зависимости (меняются редко), и только потом копировать исходный код (меняется на каждом
коммите). Если сделать наоборот — любая правка одной строки кода инвалидирует кэш
зависимостей, и сборка каждый раз заново качает пакеты из сети, что на CI ощутимо по времени.
**Ловушки**
- `COPY . .` перед установкой зависимостей → любое изменение кода инвалидирует и слой с
зависимостями → пересборка образа качает все пакеты заново на каждый коммит, видно по
резко выросшему времени сборки в CI.
**Факты для карточек**
- base | Что порождает каждая инструкция `RUN`/`COPY` в Dockerfile? — отдельный слой (diff файловой системы)
- core | Что происходит с последующими слоями, если один слой не совпал с кэшем? — все последующие слои пересобираются заново, даже если сами по себе не менялись
- core | Какой порядок инструкций Dockerfile правильный для скорости пересборки? — сначала зависимости (меняются редко), потом исходный код (меняется часто)
Почему дальше: применим это правило к конкретному Dockerfile для сборки C++-проекта.
## 3. Dockerfile для C++ сборки
```dockerfile
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY CMakeLists.txt .
COPY src/ src/
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
```
Установку пакетов делают **одной инструкцией `RUN`** (`apt-get update && apt-get install ...
&& rm -rf /var/lib/apt/lists/*`), а не отдельными командами — потому что слой фиксирует
файловую систему на момент завершения именно этой инструкции. Если `rm -rf
/var/lib/apt/lists/*` вынести в отдельный `RUN` после установки, скачанный кеш списков
пакетов уже необратимо запечён в предыдущем слое и продолжает занимать место в итоговом
образе — слой нельзя «похудеть» задним числом последующим слоем, можно только скрыть файл в
новом слое поверх старого.
`WORKDIR` задаёт и фиксирует рабочую директорию для всех последующих инструкций; `COPY`
переносит файлы с хоста в образ отдельным слоем — именно поэтому в примере сначала копируется
`CMakeLists.txt`, а исходники — вторым `COPY`: правка кода не трогает слой с конфигурацией
сборки. Каталог `build/`, уже собранный на хосте разработчика, копировать в образ **нельзя**:
он собран под окружение хоста (другая версия компилятора, другие пути, возможно другая
архитектура) и не гарантированно совместим с окружением внутри контейнера — сборка должна
проходить внутри самого образа, чтобы результат был воспроизводим одинаково у всех.
**Ловушки**
- `apt-get install` и `rm -rf /var/lib/apt/lists/*` в разных `RUN` → кеш списков пакетов
необратимо остаётся в промежуточном слое → итоговый образ ощутимо больше, чем при
объединении в одну инструкцию, видно по `docker history`.
- Копирование готового `build/` с хоста в образ вместо сборки внутри контейнера → бинарник
собран под окружение хоста, а не образа → несовместимость версий библиотек или архитектуры,
«works on my machine» переносится прямо в контейнер.
**Факты для карточек**
- base | Почему `apt-get install` и `rm -rf /var/lib/apt/lists/*` объединяют в одну инструкцию `RUN`? — слой фиксирует файловую систему на момент завершения инструкции; в отдельном RUN кеш пакетов уже необратимо запечён в предыдущем слое
- core | Почему нельзя копировать в образ каталог `build/`, собранный на хосте? — он собран под окружение хоста (версия компилятора, пути, архитектура), не гарантированно совместим с окружением контейнера; сборка должна идти внутри образа
Почему дальше: если собирать всё в одном образе, финальный образ несёт в себе весь тулчейн
сборки (компилятор, cmake, заголовки) — для рантайма это лишний вес и лишняя поверхность
атаки. Разберём, как это разделить.
## 4. Multi-stage build
```dockerfile
FROM debian:bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
FROM debian:bookworm-slim
COPY --from=builder /src/build/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
```
Первый `FROM ... AS builder` — образ со всем тулчейном сборки; второй `FROM` начинает
**новый, независимый** образ, в который командой `COPY --from=builder` переносится только
готовый результат — скомпилированный бинарник. Слои со всем тулчейном сборки в финальный
образ не попадают вовсе. Итоговый рантайм-образ для этого выбирают минимальным:
`debian:bookworm-slim` (урезанный Debian с базовыми библиотеками) — если бинарник собран
динамически и нужна libc; либо **`scratch`** (полностью пустой образ, без единого файла) —
подходит только для полностью статически слинкованного бинарника, которому вообще не нужна
никакая библиотека окружения.
**Факты для карточек**
- base | Что переносит `COPY --from=builder` во второй `FROM`? — только указанные готовые файлы (например бинарник) из первого этапа, без слоёв тулчейна
- core | Когда для финального этапа multi-stage можно использовать `FROM scratch`? — когда бинарник собран полностью статически и не нуждается ни в одной библиотеке окружения
- core | Чем `debian:bookworm-slim` в качестве финального образа лучше полного `debian:bookworm` для рантайма? — не несёт тулчейн сборки и лишние пакеты, меньше размер и меньше поверхность атаки
Почему дальше: образ — это шаблон файловой системы, но данные, которые должны пережить
пересоздание контейнера (например, база данных теста), нельзя держать в самом
read-write-слое контейнера — для этого есть volume и bind mount.
## 5. Volume и bind mount
Оба механизма монтируют в контейнер хранилище, которое живёт отдельно от read-write-слоя
контейнера (тот слой стирается командой `docker rm`).
- **Volume** — область, которой управляет сам Docker, физически хранится в его служебной
директории на хосте, не привязана к конкретному пути на диске разработчика — переносима
между машинами, подходит для персистентных данных вроде базы данных теста.
- **Bind mount** — прямое монтирование конкретного каталога хоста внутрь контейнера по
заданному пути: контейнер и хост видят один и тот же каталог одновременно и правки на хосте
сразу видны внутри без пересборки образа — используется при разработке, когда исходники
редактируются на хосте, а собираются/тестируются внутри контейнера.
**Ловушки**
- Держать данные в обычном read-write-слое контейнера без volume → `docker rm` или
пересоздание контейнера безвозвратно стирает данные → «тесты вчера прошли, база сегодня
пустая» без единой ошибки при удалении.
**Факты для карточек**
- base | Кто физически управляет расположением volume на диске? — сам Docker, служебная директория, не путь, выбранный вручную
- core | Почему bind mount, а не volume, используют для разработки с редактированием кода на хосте? — bind mount даёт прямой одновременный доступ к каталогу хоста, правки на хосте сразу видны в контейнере без пересборки образа
Почему дальше: то, как контейнер запускается и как к нему обращаются после старта, задаётся
флагами `docker run` и парой соседних команд — разберём их.
## 6. `docker run`, `docker exec`, `docker logs`
Частые флаги `docker run`:
- **`--rm`** — автоматически удалить контейнер (его read-write-слой) сразу после завершения
процесса; без него остановленные контейнеры копятся и занимают место на диске.
- **`-v host_path:container_path`** (или `-v volume_name:container_path`) — bind mount или
named volume (раздел 5).
- **`-p host_port:container_port`** — пробросить порт с хоста на порт внутри контейнера
(контейнер по умолчанию в изолированной bridge-сети не виден снаружи без явного проброса).
- **`--network=host`** — контейнер использует сетевой стек хоста напрямую, без собственного
сетевого namespace и без проброса портов, но и без сетевой изоляции.
**`docker exec <container> <cmd>`** запускает дополнительную команду внутри уже работающего
контейнера — типичный случай: открыть интерактивный shell (`docker exec -it <container>
bash`) для отладки живого процесса без его остановки. **`docker logs <container>`** показывает
вывод stdout/stderr, который написал процесс с PID 1 внутри контейнера, — то, что видно в
терминале при `docker run` без `-d`, доступно так же и после отсоединения.
**Факты для карточек**
- base | Что делает флаг `--rm` у `docker run`? — автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса
- base | Какая команда даёт интерактивный shell в уже запущенном контейнере? — `docker exec -it <container> bash`
- core | Чем `--network=host` отличается от обычного режима с `-p`? — контейнер напрямую использует сетевой стек хоста без собственного network namespace и без проброса портов, но и без сетевой изоляции
Почему дальше: реальная задача редко исчерпывается одним контейнером — обычно нужен ещё
тестовый брокер, база данных или несколько сервисов сразу; управлять их сетью и порядком
запуска вручную неудобно — для этого docker compose.
## 7. `docker compose`
`docker-compose.yml` — декларативное описание нескольких связанных контейнеров как одного
приложения: сервисы с указанием образа или пути к Dockerfile, портов, volume, переменных
окружения и зависимостей между сервисами. `docker compose up -d` поднимает все сервисы разом
и автоматически создаёт для них общую сеть, где сервисы видят друг друга по имени из YAML как
по hostname — не нужно вручную создавать сеть и связывать контейнеры по IP-адресам, которые
могут меняться при пересоздании.
`docker compose down` без флага `-v` останавливает и удаляет контейнеры, но **сохраняет
именованные volume**; `-v` удаляет и их. Типичная ошибка — предположить, что обычный `down`
стирает данные тестовой базы (нет, если она в именованном volume), либо наоборот случайно
потерять данные, добавив `-v` не задумавшись.
**Факты для карточек**
- base | Какая команда поднимает все сервисы из `docker-compose.yml` разом? — `docker compose up -d`
- core | Что удаляет `docker compose down -v`, чего не удаляет `docker compose down` без флага? — именованные volume и данные в них
Почему дальше: сервисы в compose ссылаются на образы по имени и тегу — разберём, что означает
тег и почему один из них считается плохой практикой.
## 8. Реестры и теги
Образ идентифицируется именем и тегом (`myapp:1.4.0`); реестр (например Docker Hub или
приватный registry) хранит образы, `docker pull`/`docker push` их скачивают/загружают.
Тег **`:latest`** — не «самая новая версия» в смысле гарантии, а обычный мутируемый тег,
который каждый `docker push` без явного тега перезаписывает: `myapp:latest`, скачанный сегодня
и через месяц, может указывать на совершенно разное содержимое образа. Это ломает
воспроизводимость сборки и тестов — CI, зафиксировавший `myapp:latest`, через месяц может
неожиданно тянуть другой код без единой изменённой строчки в собственном конфиге. Практика —
фиксировать конкретную версию тега или дайджест образа (`myapp@sha256:...`), который
неизменяем по определению хеша.
**Факты для карточек**
- base | Что физически происходит с тегом `:latest` при каждом `docker push` без явного тега? — он перезаписывается на новый образ, становится мутируемым указателем, а не фиксированной версией
- core | Почему фиксация `myapp@sha256:...` вместо тега `:latest` важна для воспроизводимости CI? — дайджест неизменяем по определению хеша, а тег `:latest` может незаметно указывать на другое содержимое образа в разное время
Почему дальше: помимо версии образа, есть ещё вопрос — от чьего имени процесс исполняется
внутри контейнера, и почему это не всё равно.
## 9. Права: `--user` и почему root в контейнере опасен
По умолчанию процесс внутри контейнера, если не указано иное, запускается от **root**
(UID 0) — того же UID 0, что и root на хосте, если не настроен user namespace с ремаппингом
UID. Раз у контейнера нет отдельного ядра (раздел 1), эскалация из контейнерного root до
root на хосте — через уязвимость ядра, неверно выданные capabilities или смонтированный внутрь
`docker.sock` — гораздо ближе и реальнее, чем аналогичный побег из виртуальной машины с
отдельным ядром.
Флаг **`--user uid:gid`** запускает процесс контейнера с непривилегированным UID/GID вместо
root — снижает ущерб от компрометации процесса внутри контейнера: даже получив контроль над
процессом, атакующий не имеет привилегий root ни внутри контейнера, ни тем более на хосте.
**Факты для карточек**
- base | От какого пользователя запускается процесс в контейнере по умолчанию, если не указано иное? — root (UID 0)
- core | Почему root в контейнере опаснее, чем root в отдельной VM? — контейнер не имеет отдельного ядра; эскалация до root хоста возможна через уязвимость общего ядра, неверные capabilities или смонтированный docker.sock, чего с отдельным ядром VM добиться сложнее
- core | Что делает флаг `--user uid:gid`? — запускает процесс контейнера от непривилегированного UID/GID вместо root
Почему дальше: помимо прав, отдельный вопрос — сколько ресурсов хоста контейнеру вообще
разрешено потреблять, чтобы один тестовый контейнер не положил всю машину.
## 10. Ограничения ресурсов: `--memory`, `--cpus`
- **`--memory=512m`** — жёсткий лимит памяти через cgroups; при превышении лимита OOM-killer
убивает процесс контейнера сигналом `SIGKILL` — тот же механизм и тот же код завершения
**137 = 128 + 9** (128 — соглашение shell/wait о сигнальном завершении, 9 — номер SIGKILL),
что и при обычном `kill -9` вне контейнера, но здесь его вызывает превышение
cgroup-лимита, а не человек.
- **`--cpus=1.5`** — ограничение через CPU-контроллер cgroups: контейнер не может использовать
больше эквивалента 1.5 ядра процессорного времени, даже если на хосте простаивают
дополнительные ядра.
Ограничения задаются той же cgroups-инфраструктурой, что обеспечивает саму изоляцию
контейнера (раздел 1) — это не отдельный механизм Docker, а применение уже существующего
механизма ядра с конкретными числами.
**Факты для карточек**
- base | Каким сигналом и с каким кодом завершения убивает процесс превышение лимита `--memory`? — SIGKILL, код завершения 137 (128 + 9)
- core | Что ограничивает `--cpus=1.5` технически? — квоту CPU-контроллера cgroups, эквивалент 1.5 ядра процессорного времени вне зависимости от простаивающих ядер хоста
Ссылки на задачи этого набора: `tasks/06_threads` — типичный кандидат на тестирование внутри
контейнера с ограничением `--cpus`, чтобы гонки данных проявлялись стабильнее под реальным
давлением на планировщик; `tasks/09_gdb` — отладка бинарника внутри контейнера требует флага
`--cap-add=SYS_PTRACE` (по умолчанию Docker урезает capabilities, и `ptrace`, на котором
работает `gdb`/`strace`, без этого флага запрещён).
<details>
<summary>Проверь себя</summary>
1. В Dockerfile сначала `COPY . .` копирует весь исходный код, а уже потом идёт установка
зависимостей через `apt-get`. Что произойдёт со временем пересборки при правке одной
строки кода и почему?
<details><summary>Ответ</summary>Слой с `COPY . .` изменится при любой правке кода, и по
правилу линейного кэша все последующие слои — включая установку зависимостей —
пересоберутся заново, заново скачивая пакеты из сети. Правильный порядок — сначала
зависимости (меняются редко), потом код.</details>
2. Финальный образ после multi-stage build весит существенно меньше, чем промежуточный
`builder`-образ, хотя бинарник тот же самый. За счёт чего?
<details><summary>Ответ</summary>Второй `FROM` начинает независимый образ, в который
`COPY --from=builder` переносит только сам готовый бинарник — весь тулчейн сборки
(компилятор, cmake, заголовки и их слои) остаётся только в промежуточном `builder`-образе
и в финальный не попадает.</details>
3. Тестовый контейнер убит с кодом завершения 137 после превышения лимита `--memory=256m`.
Что произошло механически и с чем ещё встречается тот же код завершения вне контейнеров?
<details><summary>Ответ</summary>cgroups-контроллер памяти зафиксировал превышение лимита
и OOM-killer прислал процессу `SIGKILL` (сигнал 9); shell/wait-конвенция кодирует
завершение по сигналу как 128 + номер сигнала = 137. Тот же код 137 виден при обычном
`kill -9` процесса вне всякого контейнера — механизм кодирования тот же.</details>
4. Почему root внутри контейнера — больший риск, чем root внутри виртуальной машины,
изолирующей тот же процесс?
<details><summary>Ответ</summary>Контейнер не имеет собственного ядра — он использует ядро
хоста напрямую, поэтому уязвимость в этом общем ядре, неверно выданные capabilities или
смонтированный `docker.sock` могут дать выход из контейнерного root прямо в root хоста.
VM с отдельным гостевым ядром не даёт такого прямого пути.</details>
</details>
## Материалы
- docs.docker.com, Dockerfile reference — https://docs.docker.com/engine/reference/builder/
- docs.docker.com, Multi-stage builds — https://docs.docker.com/build/building/multi-stage/
- docs.docker.com, `docker run` CLI reference — https://docs.docker.com/engine/reference/commandline/run/
- docs.docker.com, Volumes — https://docs.docker.com/storage/volumes/
- docs.docker.com, Compose file reference — https://docs.docker.com/compose/compose-file/
- man7.org, `namespaces(7)` — https://man7.org/linux/man-pages/man7/namespaces.7.html
- man7.org, `cgroups(7)` — https://man7.org/linux/man-pages/man7/cgroups.7.html
+380
View File
@@ -0,0 +1,380 @@
# D6, часть 1. Ядро и embedded обзорно (урок)
Уровень этого урока — «уверенно отвечать словами на собеседовании», а не «написать драйвер
с нуля». Идём от границы user space/kernel space к модулю, от модуля к драйверу символьного
устройства, от драйвера к железу (device tree, cross-compile, загрузка платы). Опорные
источники для самостоятельного углубления: docs.kernel.org, The Linux Kernel Module
Programming Guide (LKMPG), `man 2`/`man 3`/`man 7` (системные вызовы, библиотечные функции,
конвенции ядра).
## 1. User space vs kernel space и системный вызов как переход границы
Процессор x86 поддерживает уровни привилегий (кольца защиты): **кольцо 0** — режим ядра,
полный доступ к железу и памяти; **кольцо 3** — режим пользователя, обычные программы, без
прямого доступа к физической памяти чужих процессов или портам ввода-вывода. Ядро работает в
кольце 0, все обычные процессы — в кольце 3. Разделение существует ради защиты и стабильности:
если бы любая программа могла напрямую писать в память другого процесса или в регистры
диска, ошибка или злой умысел в одной программе обрушивали бы всю систему.
Чтобы попросить ядро что-то сделать (открыть файл, выделить память, создать процесс),
пользовательская программа не может просто вызвать функцию ядра — она делает **системный
вызов**: специальную инструкцию процессора, которая переключает CPU в привилегированный режим
и передаёт управление фиксированному обработчику в ядре, а после выполнения запроса управление
возвращается программе обратно в непривилегированном режиме. На x86-64 эту инструкцию зовут
**`syscall`**, на ARM — **`svc`** (supervisor call). Оба случая — не обычный вызов функции
(`call`), а специальная инструкция именно потому, что она обязана сменить уровень привилегий
процессора, а не просто передать управление по адресу.
**Факты для карточек**
- base | В каком кольце защиты x86 работает ядро Linux? — в кольце 0 (пользовательские процессы — в кольце 3)
- base | Какая инструкция делает системный вызов на x86-64? — `syscall` (на ARM — `svc`)
- core | Почему системный вызов — отдельная инструкция процессора, а не обычный `call`? — обычный `call` не меняет уровень привилегий CPU, а переход в кольцо 0 требует именно смены режима процессора
Почему дальше: код, работающий в кольце 0, можно добавлять в ядро двумя разными способами —
разберём, чем модуль ядра отличается от кода, встроенного в ядро на этапе сборки.
## 2. Модуль ядра vs встроенный в ядро
Функциональность можно либо **встроить в ядро** на этапе сборки (код компилируется прямо в
образ ядра, доступен сразу при загрузке, но требует пересборки и перезагрузки при любом
изменении), либо оформить как **загружаемый модуль ядра** (`.ko`-файл, подключается и
отключается в работающей системе без перезагрузки). Встроенным делают то, что нужно с самого
первого момента загрузки (например, драйвер корневой файловой системы); модулем — то, что
может понадобиться позже или не понадобиться вовсе (драйвер конкретного периферийного
устройства), чтобы не раздувать образ ядра и не грузить лишний код.
Инструменты управления модулями:
- **`insmod <path.ko>`** — загружает модуль по прямому пути к файлу, без разрешения
зависимостей от других модулей.
- **`rmmod <name>`** — выгружает модуль по имени (если счётчик использования равен нулю).
- **`modprobe <name>`** — загружает модуль по имени, сам находит файл в
`/lib/modules/$(uname -r)/` и подгружает зависимости по карте `modules.dep` (строится
утилитой `depmod`) — на практике используется чаще `insmod`, именно из-за автоматических
зависимостей.
- **`lsmod`** — список загруженных сейчас модулей (по сути читает `/proc/modules`), с
размером и счётчиком использования.
- **`modinfo <name|path>`** — метаданные модуля (лицензия, описание, параметры, зависимости)
без его загрузки в ядро.
**Факты для карточек**
- base | Чем `insmod` отличается от `modprobe`? — `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости
- base | Какая команда показывает метаданные модуля, не загружая его? — `modinfo`
- core | Откуда `modprobe` берёт карту зависимостей модулей? — из файла `modules.dep`, который строит утилита `depmod`
Почему дальше: раз модуль — это отдельно загружаемый код, у него должна быть точка входа и
точка выхода из ядра — разберём структуру простейшего модуля.
## 3. Структура простейшего модуля
```c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "hello: module loaded\n");
return 0; // 0 — успех; ненулевой код отменяет загрузку модуля
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "hello: module unloaded\n");
}
module_init(hello_init); // вызывается ядром при insmod/modprobe
module_exit(hello_exit); // вызывается ядром при rmmod
MODULE_LICENSE("GPL");
```
`module_init`/`module_exit` — не обычные вызовы функций, а макросы, которые регистрируют
функции как точки входа/выхода: сам код внутри них ядро вызывает автоматически в момент
`insmod`/`rmmod`, программист их напрямую не зовёт. `MODULE_LICENSE("GPL")` обязателен: без
него или с иной лицензией ядро помечает себя как **tainted** (загрязнённое) и закрывает
модулю доступ к символам, экспортированным только для GPL-кода (`EXPORT_SYMBOL_GPL`).
`printk` — это `printf` уровня ядра, пишет не в консоль процесса, а в кольцевой буфер ядра.
Первым аргументом обычно идёт уровень важности — восемь уровней от `KERN_EMERG` (0,
критично) до `KERN_DEBUG` (7, отладочная информация); сообщения с уровнем выше порога
консоли попадают только в буфер, но не печатаются на экран сразу. Прочитать буфер целиком —
команда **`dmesg`**.
**Факты для карточек**
- base | Какой макрос ядра — аналог `printf`? — `printk`
- base | Команда для чтения буфера сообщений ядра? — `dmesg`
- core | Сколько уровней важности у `printk` и какие крайние? — 8 уровней, от `KERN_EMERG` (0) до `KERN_DEBUG` (7)
- core | Что произойдёт с модулем без `MODULE_LICENSE("GPL")`? — ядро станет tainted и закроет модулю доступ к символам `EXPORT_SYMBOL_GPL`
- core | Кто вызывает функции, зарегистрированные `module_init`/`module_exit`? — ядро автоматически, при `insmod`/`modprobe` и `rmmod` соответственно, а не сам программист
Почему дальше: модулю часто нужно принимать настройки снаружи ещё до его собственной логики —
разберём, как модуль объявляет параметры запуска.
## 4. Параметры модуля: `module_param`
```c
static int count = 1;
module_param(count, int, S_IRUGO); // третий аргумент — права доступа в sysfs
```
`module_param(имя, тип, права)` (заголовок `<linux/moduleparam.h>`) делает переменную
настраиваемой при загрузке модуля — значение можно передать прямо в `insmod modname.ko
count=5`. Третий аргумент — режим доступа файла в sysfs: `0` означает, что параметр доступен
только при загрузке и не публикуется отдельным файлом, ненулевое значение (например
`S_IRUGO` — чтение всем) создаёт файл `/sys/module/<имя_модуля>/parameters/<count>`, из
которого параметр можно прочитать (а при подходящих правах — и переписать) уже после загрузки.
**Факты для карточек**
- base | Как передать параметр модулю при загрузке? — `insmod modname.ko имя_параметра=значение`
- core | Что означает третий аргумент `module_param`, если он ненулевой? — параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа
Почему дальше: файл параметра в sysfs — частный случай общего механизма, которым ядро вообще
разговаривает с пользовательским пространством через файловую систему, — `/proc` и `/sys`.
## 5. `/proc` и `/sys` как интерфейс к ядру
**`/proc`** (procfs) — виртуальная файловая система, изначально созданная для информации о
процессах (`/proc/<pid>/...`), позже расширенная общей информацией о ядре (`/proc/cpuinfo`,
`/proc/meminfo`, `/proc/modules`); файлы внутри часто содержат несколько значений свободным
текстом. **`/sys`** (sysfs, с ядра 2.6) — более новый и структурированный интерфейс,
отражающий модель устройств и драйверов ядра (шины, устройства, классы), с соглашением «один
файл — одно значение», что упрощает и чтение скриптами, и программную запись настроек. Оба
это не диски, а генерируются ядром на лету при каждом обращении — `cat /proc/meminfo`
формирует ответ в момент чтения, а не читает файл с диска.
**Факты для карточек**
- base | Чем отличается соглашение о содержимом файлов `/sys` от `/proc`? — в `/sys` одно значение на файл, в `/proc` файл может содержать несколько значений свободным текстом
- core | Откуда `cat /proc/meminfo` берёт данные? — ядро формирует ответ на лету в момент чтения, это не файл на диске
Почему дальше: `/proc` и `/sys` — интерфейсы общего назначения; когда нужно управлять
конкретным устройством операциями `open`/`read`/`write`, пишут символьный драйвер.
## 6. Символьный драйвер: `file_operations`, major/minor, барьер user/kernel
Драйвер символьного устройства регистрирует набор функций-обработчиков в структуре
`struct file_operations` — как минимум `.open`, `.read`, `.write`, `.release` (плюс,
например, `.unlocked_ioctl`); ядро вызывает нужный обработчик, когда пользовательский процесс
делает соответствующий системный вызов над файлом устройства в `/dev`. Регистрация —
`register_chrdev(major, name, &fops)`: если `major` передан как `0`, ядро само подбирает
свободный старший номер.
Устройство идентифицируется парой **major:minor**: **major** (старший номер) определяет
драйвер, обслуживающий устройство, **minor** (младший) — конкретный экземпляр внутри этого
драйвера (например, второй последовательный порт того же типа). `dev_t` в современном ядре —
32-битное число: 12 бит под major (до 4095) и 20 бит под minor (до 1 048 575).
Внутри `.read`/`.write` драйвер **не может напрямую разыменовать указатель, пришедший из
пользовательского пространства** — это чужое адресное пространство, страница может быть не
загружена в память или указатель вообще некорректен, а прямое разыменование либо уронит
ядро (kernel oops), либо (на CPU с SMAP/SMEP) вызовет аппаратный запрет. Вместо этого
обязательны **`copy_to_user`**/**`copy_from_user`** — они безопасно копируют данные через
границу, сами обрабатывают отсутствующую страницу и возвращают число байт, которые
**не** удалось скопировать (0 — полный успех).
**Ловушки**
- Разыменовать пользовательский указатель напрямую в `.read`/`.write` → крах ядра (oops) или
аппаратный запрет на SMAP/SMEP-системах → видно как немедленный крэш при обращении к
устройству, а не как «иногда неверные данные».
- Забыть проверить возвращаемое значение `copy_to_user`/`copy_from_user` → часть данных не
скопирована, а код считает операцию успешной → приложение получает частично мусорный буфер
без явной ошибки.
**Факты для карточек**
- base | Какие 4 обработчика минимально нужны в `file_operations` символьного драйвера? — `.open`, `.read`, `.write`, `.release`
- core | Что означают major и minor номера устройства? — major определяет драйвер, minor — конкретный экземпляр устройства внутри этого драйвера
- core | Сколько бит под major и minor в `dev_t`? — 12 бит major, 20 бит minor (32-битное число целиком)
- core | Почему в драйвере нельзя напрямую разыменовать указатель из user space? — это чужое адресное пространство, страница может быть не загружена или указатель некорректен; прямое разыменование роняет ядро или блокируется SMAP/SMEP
Почему дальше: `read`/`write` подходят для потока байт, но не для команд, которые не
укладываются в чтение/запись (настроить режим устройства, запросить статус) — для этого есть
`ioctl`.
## 7. `ioctl`: команды, которые не являются чтением или записью
`ioctl` — обработчик `.unlocked_ioctl` в `file_operations`, принимающий числовой код команды
и один аргумент (`unsigned long`, часто указатель на структуру в user space). Используется,
когда операция над устройством не укладывается в модель «поток байт» — например, «сообщи
текущую скорость порта» или «переведи устройство в другой режим». Коды команд собирают
макросами **`_IO`**, **`_IOR`**, **`_IOW`**, **`_IOWR`** (кодируют направление передачи данных,
«магическое число» драйвера и номер команды) — это соглашение снижает риск, что два разных
драйвера случайно используют одинаковый числовой код команды.
**Факты для карточек**
- base | В какой функции `file_operations` реализуется `ioctl`? — `.unlocked_ioctl`
- core | Зачем коды ioctl-команд собирают через `_IO`/`_IOR`/`_IOW`/`_IOWR`, а не пишут произвольным числом? — макросы кодируют направление передачи, магическое число драйвера и номер команды, снижая риск коллизии кодов между разными драйверами
Почему дальше: и `file_operations`, и `ioctl` работают с уже существующим, известным
устройством — а откуда ядро вообще узнаёт, какое железо есть на конкретной плате, особенно в
embedded, где плат много и они разные?
## 8. Device tree: описание железа без хардкода
**Device tree** — текстовое описание аппаратной конфигурации платы (`.dts`, компилируется
утилитой `dtc` в бинарный `.dtb`): адреса регистров периферии, линии прерываний, доступные
шины, строки `compatible`, по которым ядро сопоставляет узел дерева с подходящим драйвером.
Загрузчик передаёт `.dtb` ядру вместе с образом ядра при старте.
Смысл — один и тот же бинарник ядра должен уметь работать на разных платах с разной
периферией и разными адресами регистров без перекомпиляции под каждую плату: без device tree
адреса и конфигурация железа были бы зашиты прямо в код ядра (так называемые board files),
и под каждую новую плату требовалась бы правка и пересборка самого ядра. Device tree выносит
это описание из кода в данные, которые загрузчик просто подкладывает рядом с ядром.
**Факты для карточек**
- base | Во что компилируется `.dts` и какой утилитой? — в бинарный `.dtb`, утилитой `dtc`
- core | Зачем device tree вообще нужен, если можно было бы прописать адреса регистров прямо в коде драйвера? — один и тот же бинарник ядра работает на разных платах без пересборки под каждую; хардкод адресов требовал бы правки и компиляции ядра под каждую конкретную плату
Почему дальше: чтобы вообще собрать ядро (и модуль) под плату, у которой процессор отличается
от машины разработчика, обычную сборку компилятором хоста использовать нельзя — нужен
кросс-компилятор.
## 9. Cross-compile: тулчейн под целевую архитектуру
Сборка ведётся на машине разработчика (обычно x86-64), а результат должен исполняться на
целевом процессоре платы (например, ARM) — обычный компилятор хоста генерирует машинный код
под архитектуру хоста, целевая плата такой код исполнить не сможет. Нужен **кросс-компилятор**
— тулчейн, генерирующий код именно под целевую архитектуру: например `arm-linux-gnueabihf-gcc`.
- **`CROSS_COMPILE`** — переменная окружения/аргумент сборки (используется, например, в
Makefile ядра и Buildroot), задающая префикс имени инструментов тулчейна
(`CROSS_COMPILE=arm-linux-gnueabihf-`), чтобы вызывался `arm-linux-gnueabihf-gcc`, а не
системный `gcc`.
- **`-march`** — флаг компилятора, задающий конкретный набор инструкций целевого процессора
(например `-march=armv7-a`); несовпадение с реальным железом даёт крах «illegal
instruction» на плате или отказ собраться, если код использует расширения, которых у
целевого процессора нет.
- **Статическая сборка** — линковка всех библиотек прямо в бинарник вместо динамических
`.so`; в embedded это снимает риск несовпадения версии библиотек (например, glibc) в
минимальном корневом ФС платы с версией, под которую собирался бинарник, ценой большего
размера самого файла.
**Факты для карточек**
- base | Что задаёт переменная `CROSS_COMPILE`? — префикс имени инструментов тулчейна (например `arm-linux-gnueabihf-`)
- core | Что произойдёт при запуске бинарника, собранного с неверным `-march`, на реальной плате? — крах «illegal instruction» (процессор не поддерживает часть использованных инструкций) или отказ сборки
- core | Какую проблему в embedded снимает статическая линковка? — несовпадение версии динамических библиотек (например glibc) на целевой плате с версией сборки
Почему дальше: тулчейн даёт бинарники ядра и модулей под плату, но сама плата должна ещё
дойти от включения питания до работающей системы — разберём цепочку загрузки.
## 10. Загрузка платы: u-boot → ядро+dtb → initramfs → init
Типичная последовательность:
1. Boot ROM процессора (зашит в кристалл, неизменяем) загружает первый этап загрузчика.
2. **U-Boot** — распространённый загрузчик embedded-плат — инициализирует минимально
необходимое железо (память, консоль) и находит образ ядра и `.dtb` (раздел 8) на носителе.
3. U-Boot загружает **ядро** и **`.dtb`** в память и передаёт управление точке входа ядра.
4. Ядро инициализируется и монтирует **initramfs** — временную корневую файловую систему,
целиком находящуюся в оперативной памяти, — и запускает из неё `/init`.
5. `/init` в initramfs делает раннюю настройку (загружает нужные модули, находит настоящий
диск), затем переключается на настоящую корневую файловую систему (`pivot_root`/
`switch_root`) и запускает уже настоящий **init** (PID 1 — например `systemd` или
BusyBox init) на постоянном разделе.
initramfs нужен потому, что к моменту, когда ядро только загрузилось, оно ещё может не знать,
как смонтировать настоящий диск (нужный драйвер файловой системы или контроллера диска может
сам быть модулем, который ещё не загружен) — временная ФС в памяти даёт минимальную среду,
достаточную, чтобы загрузить эти модули и уже потом перейти на постоянное хранилище.
**Факты для карточек**
- base | В каком порядке идёт цепочка загрузки платы? — Boot ROM → U-Boot → ядро+dtb → initramfs → переключение на настоящий rootfs → init (PID 1)
- core | Зачем нужен initramfs, если ядро уже загружено и работает? — ядру для монтирования настоящего диска может понадобиться драйвер, который сам является модулем и ещё не загружен; initramfs даёт минимальную среду в памяти, чтобы его подгрузить перед переходом на постоянный rootfs
Почему дальше: после того как система загрузилась, embedded-разработчик работает с реальным
железом напрямую через память — а компилятор по умолчанию считает, что содержимое памяти
меняется только его собственным кодом. Разберём, что с этим не так.
## 11. `volatile`, `mmap` регистров и барьеры памяти
Обычная оптимизация компилятора — закэшировать значение переменной в регистре процессора и не
перечитывать его из памяти повторно, если по коду программы оно «не могло измениться».
Регистр памяти-отображённого устройства (MMIO) может измениться сам, независимо от кода
программы (например, статус-регистр меняется самим железом) — без `volatile` компилятор
законно уберёт «повторное» чтение как избыточное, и драйвер будет вечно видеть устаревшее
значение. `volatile` заставляет компилятор реально выполнять каждое обращение к памяти по
этому адресу, не кэшируя и не убирая «дублирующиеся» чтения/записи.
Важная ловушка: `volatile` **не даёт атомарности и не расставляет барьеры памяти** — он
касается только компилятора и только одной переменной, а не порядка операций между
несколькими адресами и не защищает от гонки между несколькими ядрами процессора (для этого в
пользовательском C++ есть `std::atomic`, раздел про многопоточность).
Доступ к регистрам устройства из драйвера обычно идёт не через прямой физический адрес, а
через **`ioremap()`** — функция ядра, отображающая физический адрес MMIO-региона в виртуальное
адресное пространство ядра, после чего к регистру обращаются как к обычному указателю.
**Барьеры памяти** (`mb()`, `rmb()`, `wmb()`) принудительно фиксируют порядок операций чтения
и записи в памяти, как его видит железо — нужны потому, что и компилятор, и сам процессор
вправе переставлять инструкции местами, если это не меняет результат с точки зрения
однопоточной программы, а DMA-контроллер или другое устройство читает память независимо от
этого порядка. Типичный случай: драйвер записывает данные в дескриптор DMA-кольца
(`tasks/03_ring`, задача этого набора про кольцевой буфер — та же структура head/tail лежит
в основе DMA-колец), а затем «звонит в дверной звонок» — пишет в регистр, запускающий
устройство; без `wmb()` между этими двумя записями устройство может увидеть сигнал запуска
раньше, чем реально записанные данные дескриптора, и прочитать старое содержимое.
**Ловушки**
- Забыть `volatile` на указателе на регистр устройства → компилятор кэширует значение в
регистре CPU и не видит изменений железа → драйвер «зависает», читая одно и то же старое
значение, хотя устройство давно сменило статус.
- Считать, что `volatile` защищает от гонки между несколькими ядрами процессора → в
многоядерном коде это не так, нужны барьеры памяти или атомики → гонка, не ловится
однопоточным тестированием.
**Факты для карточек**
- base | Что делает `volatile` с точки зрения компилятора? — заставляет реально выполнять каждое обращение к памяти по адресу, не кэшируя значение в регистре и не убирая повторные чтения/записи
- core | Даёт ли `volatile` атомарность или барьер памяти между несколькими ядрами CPU? — нет, только запрещает компилятору кэшировать/убирать обращения к конкретной переменной
- core | Какая функция ядра отображает физический адрес MMIO-региона в виртуальный адрес для доступа как к указателю? — `ioremap()`
- deep | Зачем нужен `wmb()` между записью DMA-дескриптора и записью в регистр запуска устройства? — без барьера порядок этих двух записей, как его видит железо, не гарантирован, и устройство может прочитать старое содержимое дескриптора раньше, чем увидит новые данные
Ссылки на задачи этого набора: `tasks/07_epoll` — то, как ядро уведомляет процесс о готовых
событиях на файловых дескрипторах (тот же принцип «пользовательский процесс не опрашивает
железо/сеть напрямую, а получает уведомление от ядра», что и в syscall-границе раздела 1);
`tasks/03_ring` — кольцевой буфер head/tail, структурно совпадающий с DMA-кольцами в
драйверах (раздел 11).
<details>
<summary>Проверь себя</summary>
1. Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы,
прямо внутри `.read` драйвера?
<details><summary>Ответ</summary>Это указатель в чужом (пользовательском) адресном
пространстве: соответствующая страница памяти может быть не загружена или указатель
вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется
аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые
безопасно обрабатывают этот переход.</details>
2. На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без
пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это
возможно и что бы сломалось без него?
<details><summary>Ответ</summary>Device tree: адреса регистров и конфигурация железа
вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без
device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение
конфигурации платы пришлось бы переписывать и пересобирать сам код ядра.</details>
3. Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но
переменная объявлена без `volatile` — цикл не завершается, хотя железо статус давно
сменило. В чём причина и как это чинится?
<details><summary>Ответ</summary>Компилятор решил, что раз в теле цикла переменная кодом
программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с
закэшированным в регистре значением — оптимизация, законная для обычной переменной, но
ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на
регистр как `volatile`, чтобы компилятор перечитывал память при каждой
итерации.</details>
4. Почему для системного вызова на x86-64 нужна специальная инструкция `syscall`, а не
обычный вызов функции ядра через `call` по известному адресу?
<details><summary>Ответ</summary>Обычный `call` не меняет уровень привилегий процессора —
пользовательская программа в кольце 3 так и осталась бы в кольце 3, не получив доступа к
привилегированным операциям ядра. `syscall` — специальная инструкция, которая одновременно
переключает CPU в кольцо 0 и передаёт управление фиксированному обработчику ядра, что
обычный переход по адресу сделать не может.</details>
</details>
## Материалы
- sysprog21.github.io, Linux Kernel Module Programming Guide (LKMPG) — https://sysprog21.github.io/lkmpg/
- docs.kernel.org, sysfs — https://docs.kernel.org/filesystems/sysfs.html
- docs.kernel.org, "volatile" Considered Harmful — https://docs.kernel.org/process/volatile-considered-harmful.html
- man7.org, `syscall(2)` — https://man7.org/linux/man-pages/man2/syscall.2.html
- man7.org, `ioctl(2)` — https://man7.org/linux/man-pages/man2/ioctl.2.html
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html
+648
View File
@@ -0,0 +1,648 @@
# D7 (30.09). Повтор и мок-интервью
Последний день — не новая тема, а тренировка в формате реального собеседования: 3 часа
алгоритмов вслух с таймером и 2 часа системных вопросов "объясни механизм за 30 секунд".
Ниже — карта, куда смотреть по слабым местам, и два мок-сценария с эталонными ответами.
## 1. Карта слабых мест
| Домен | Что спрашивают | Куда смотреть |
|---|---|---|
| O-нотация, хеш-таблица | сложности операций, устройство хеш-таблицы, коллизии, rehash | `D1_algo` §1–5 |
| fork/exec/wait/сигналы/errno | зомби, коды 137/139, `sigaction`, `EINTR`/`EAGAIN` | `D1_linux` |
| Деревья, BST, куча, top-K | обходы, инвариант BST, `sift-up`/`sift-down`, `nth_element` | `D2_algo` §1–6 |
| open/mmap/epoll/select/TIME_WAIT | буферизация, page fault, `FD_SETSIZE`, ET vs LT | `D2_linux` §1–7 |
| padding, правило 0/3/5, virtual-деструктор, move, UB | `sizeof`/`offsetof`, double-free, vtable, ASAN/UBSAN | `D2_cpp` §1–5 |
| Списки, стек/очередь, кольцевой буфер | разворот, цикл Флойда, dummy-узел, монотонный стек | `D3_algo` |
| Потоки, mutex, CV, deadlock, atomic | data race, 4 условия deadlock, `memory_order`, TSan | `D3_threads` |
| gdb, core dump, valgrind | точки останова, `bt`, ASAN vs gdb, `ulimit -c` | `D3_gdb` |
| Графы: BFS/DFS, топосортировка, Дейкстра, DSU | сложности `O(V+E)`, `O((V+E) log V)`, ранг+сжатие пути | `D4_algo` |
| Ethernet/ARP/VLAN/MTU/IPv4/TTL/ICMP/CIDR | заголовки по байтам, checksum, /24 vs /26 | `D4_net` |
| ДП: рюкзак, монеты, LCS, Edit Distance, LIS | таблица `(n+1)×(m+1)`, обратный проход по весу, `O(n log n)` LIS | `D5_algo` |
| TCP: заголовок, handshake, состояния, TIME_WAIT, UDP, NAT, DNS, DHCP | 20/8 байт заголовков, `2×MSL`, `tcpdump` | `D5_net` |
| bash: pipefail, xargs, awk/sed, `/proc`, ulimit, strace | коды возврата, `set -euo pipefail`, дескрипторы | `D5_bash` |
| Ядро обзорно: syscall, модуль, `/proc`/`/sys`, chardev, ioctl, device tree | user space vs kernel space, `copy_to_user` | `D6_kernel` |
Механизмы уровня "откуда это следует" (ООП, STL, Git/Docker/GDB детальнее) — в
`HR_BASE_deep.md`, если конкретный урок дня даёт ответ слишком коротко.
**Факты для карточек**
- base | Сколько уроков покрывает план перед D7? — 13 файлов (`D1_algo`…`D6_docker`) по алгоритмам, Linux, C++, сетям, потокам, отладке, bash и ядру
- core | Где искать формулировки механизмов, если в уроке дня их не хватает? — `HR_BASE_deep.md`
- base | Сколько часов длится каждый мок сегодня? — мок №1 (алгоритмы) 3 часа, мок №2 (системное) 2 часа
Почему дальше: карта показывает, где искать теорию; дальше — тренировка в формате реального
таймера, начиная с алгоритмов.
## 2. Мок №1. Алгоритмы (3 часа, 6 задач × 25 минут)
Формат: 25 минут на задачу — сначала вслух проговорить план и уточняющие вопросы (первые
3–5 минут), потом решение. На реальном скрининге читаемый план ценится не меньше кода: если
план верный, а код не дописан за 25 минут, это всё ещё сильный ответ. Ниже — не готовый код,
а эталонная последовательность шагов для каждой задачи; сам код — в соответствующем уроке дня.
### Задача 1. Хеш-таблица с нуля (`tasks/10_hash`, теория — `D1_algo` §3, §5)
**Что проверяет интервьюер:** понимание механизма, а не вызов готового `std::unordered_map` —
обоснование выбора разрешения коллизий и сложности, а не память формул.
**Критерии сильного ответа:** называет компоненты (массив корзин, хеш-функция, коллизии,
load factor) без подсказки; сразу разделяет среднюю сложность `O(1)` и худший случай `O(n)`;
осознанно выбирает chaining или open addressing и объясняет цену выбора; упоминает rehash при
превышении порога загрузки.
**Ловушки**
- Путают среднюю и худшую сложность операций → на вопрос "а что если все ключи попали в одну корзину" отвечают "всё равно O(1)" → сразу видно, что структура не понята, а заучена.
- При open addressing не знают про удаление через tombstone → предлагают просто очищать ячейку → следующий поиск обрывается раньше времени, не находя элемент дальше по цепочке зондирования.
- Не проговаривают степень двойки для capacity → используют деление по модулю вместо `hash & (cap - 1)`, не объясняя, зачем вообще нужна степень двойки.
**Эталонный план по шагам:**
1. Уточнить тип ключей (строки/числа), нужен ли `erase`, ожидаемый порядок `N`.
2. Взять `capacity` степенью двойки, задать порог load factor (например, 0.7).
3. Выбрать хеш-функцию: для строк — FNV-1a или `std::hash<std::string>`.
4. Выбрать разрешение коллизий: chaining — проще и безопаснее уложить в 25 минут.
5. `insert`: посчитать `idx = hash & (cap - 1)`, добавить в корзину; если load factor превышен — rehash в массив вдвое больше, перенести все элементы.
6. `get`/`erase`: пройти цепочку по индексу; для open addressing `erase` — пометить tombstone, не очищать ячейку.
7. Проговорить итоговые сложности и триггер rehash.
**Сложность:** амортизированно `O(1)` на вставку/поиск/удаление; худший случай (плохой хеш,
все ключи в одной корзине) — `O(n)`; сам rehash — `O(n)`, но происходит редко, поэтому не
портит амортизированную оценку.
**Уточняющие вопросы кандидата:** нужна ли потокобезопасность? Ключи известны заранее (тогда
возможен perfect hashing)? Важен ли порядок вставки при обходе?
**Факты для карточек**
- base | Средняя сложность операций хеш-таблицы? — амортизированное O(1)
- core | При каком load factor обычно триггерят rehash при open addressing? — около 0.7
- core | Зачем capacity берут степенью двойки? — `idx = hash & (cap - 1)` вместо дорогого деления по модулю
- deep | Каков стандартный max_load_factor по умолчанию у `std::unordered_map` в большинстве реализаций (libstdc++, MSVC)? — 1.0
Почему дальше: хеш-таблица даёт O(1) доступ по ключу без порядка; следующая задача — структура
с O(1) доступом к экстремуму и осмысленным порядком по величине.
### Задача 2. Куча / top-K (`tasks/11_heap`, теория — `D2_algo` §5–6)
**Что проверяет интервьюер:** различие между построением кучи и потоковым top-K; выбор между
полной кучей, min-heap размера k и `nth_element` по контексту задачи.
**Критерии сильного ответа:** первым делом спрашивает про размер данных (влезают ли в
память); знает сложности всех трёх подходов; не путает направление кучи для потокового
top-K (для top-K наибольших нужен именно min-heap размера k).
**Ловушки**
- Строят кучу через `push` в цикле вместо bottom-up `heapify` → результат корректный, но `O(n log n)` вместо `O(n)` → на миллионах элементов заметно медленнее, видно по времени выполнения.
- В потоковом top-K сравнивают новый элемент с максимумом кучи, а не с минимумом → кандидаты вытесняются в обратном порядке → итоговый top-K оказывается неверным набором.
- Используют `nth_element` там, где нужен отсортированный результат, и забывают досортировать k-элементный префикс → набор элементов верный, порядок — нет.
**Эталонный план по шагам:**
1. Уточнить `k`, `N`, влезают ли данные в память целиком, нужен ли отсортированный результат.
2. Если данные помещаются в память — `heapify` за `O(n)`, затем `k` раз `pop` по `O(log n)`.
3. Если поток / не влезает — держать min-heap размера `k`: сравнивать новый элемент с `top()` кучи (O(1)); если новый больше минимума — вытеснить минимум, добавить новый.
4. Альтернатива без построения кучи — `nth_element` (quickselect), в среднем `O(n)`, но без готового порядка внутри половин.
5. Если нужен отсортированный top-K через `nth_element` — досортировать k-элементный префикс отдельно.
6. Проговорить, почему выбран именно этот вариант.
**Сложность:** полная куча — `O(n + k log n)` время, `O(n)` память; потоковый min-heap —
`O(n log k)` время, `O(k)` память; `nth_element` — среднее `O(n)`, худшее `O(n²)`.
**Уточняющие вопросы кандидата:** данные статичны или стримятся? Нужен ли устойчивый порядок
при равных ключах? Есть ли жёсткое ограничение по памяти?
**Факты для карточек**
- base | Сложность top-K через полную кучу? — O(n + k log n)
- core | Какую кучу держат для потокового top-K наибольших элементов размера k? — min-heap размера k
- core | Средняя и худшая сложность nth_element? — среднее O(n), худшее O(n²)
- deep | Как получить top-K частых элементов за O(n) без log-множителя? — подсчёт хеш-таблицей O(n) + bucket sort по частоте (массив корзин размера n+1)
Почему дальше: куча и top-K работают со случайным доступом по значению; следующая задача —
структура, где доступ строго последовательный через указатели, и там есть собственный класс
ошибок — циклы.
### Задача 3. Связный список с циклом (`tasks/02_list`, теория — `D3_algo` §5)
**Что проверяет интервьюер:** понимание, почему указатели вообще встречаются (а не просто
память алгоритма Флойда), корректная обработка граничных случаев.
**Критерии сильного ответа:** объясняет механизм через относительную скорость (`fast`
догоняет `slow` на 1 узел за шаг внутри цикла), а не просто произносит "медленный и быстрый
указатель"; корректно достраивает поиск входа в цикл вторым проходом.
**Ловушки**
- Сравнивают значения узлов вместо указателей → на списке с повторяющимися значениями без реального цикла даёт ложное срабатывание.
- Забывают проверку `fast && fast->next` перед `fast->next->next` → падение на `nullptr` для списка без цикла чётной/нечётной длины на границе.
- Не могут объяснить вторую часть (поиск входа в цикл) без домашней заготовки → интервьюер спрашивает "почему это работает", а не "какой код писать".
**Эталонный план по шагам:**
1. Уточнить: список одно- или двусвязный, возможны ли пустой список и self-loop (узел ссылается сам на себя).
2. `slow = head`, `fast = head`.
3. Пока `fast && fast->next`: `slow` на 1 шаг, `fast` на 2 шага; если `slow == fast` — цикл найден, выйти из цикла проверки.
4. Если `fast` дошёл до `nullptr` — цикла нет.
5. Для входа в цикл: сбросить один указатель на `head`, второй оставить в точке встречи; двигать оба по 1 шагу — встретятся ровно на входе в цикл.
6. Кратко обосновать почему (из `2(a+b) = a+b+n·c` следует `a = (n-1)c + (c-b)`).
**Сложность:** время `O(n)`, память `O(1)` — без хеш-множества посещённых узлов.
**Уточняющие вопросы кандидата:** нужна ли длина цикла отдельно от факта его наличия? Список
гарантированно не пуст?
**Факты для карточек**
- base | Сложность обнаружения цикла алгоритмом Флойда по времени и памяти? — O(n) время, O(1) память
- core | С какой относительной скоростью fast догоняет slow внутри цикла? — 1 узел за шаг
- core | Как найти вход в цикл после первой встречи указателей? — сбросить один указатель на head, оба двигать по 1 шагу — встретятся на входе
Почему дальше: список — структура с одним "соседом" на узел; граф — структура с произвольным
числом соседей, и обход там даёт другие гарантии в зависимости от порядка обхода.
### Задача 4. BFS по матрице/графу (теория — `D4_algo` §2)
**Что проверяет интервьюер:** умение свести сетку к неявному графу соседей, правильный момент
пометки `visited`, вывод сложности через размеры входа.
**Критерии сильного ответа:** явно проговаривает, что кратчайший путь по рёбрам гарантирован
именно порядком FIFO очереди, а не самим фактом использования BFS "потому что так учили";
помечает узел посещённым в момент постановки в очередь, а не при извлечении.
**Ловушки**
- Помечают `visited` при извлечении из очереди, а не при добавлении → один и тот же узел кладётся в очередь несколько раз → деградация по времени, иногда неверный `dist`.
- Не проверяют границы сетки перед обращением к соседней клетке → выход за границы массива.
- Считают диагональных соседей, хотя задача просила 4-связность (или наоборот) → неверный граф с самого начала, весь дальнейший разбор бессмыслен.
**Эталонный план по шагам:**
1. Уточнить: сетка или произвольный граф; 4 или 8 соседей; есть ли препятствия или веса рёбер.
2. Представление: список смежности для явного графа; прямой обход соседних клеток для сетки.
3. `dist[] = -1` (или `visited[][] = false`), очередь, `dist[src] = 0`, `push(src)`.
4. Пока очередь не пуста: `pop u`; для каждого непосещённого соседа `v` — `dist[v] = dist[u] + 1`, `push(v)`, пометить как посещённый сразу здесь.
5. Ответ — `dist[target]` (или число посещённых клеток для задач на компоненты/острова).
**Сложность:** `O(V+E)` для графа; для сетки `R×C` — `O(R·C)`, так как `V = R·C`, а `E`
пропорционально `V` при фиксированном числе соседей на клетку (4 или 8).
**Уточняющие вопросы кандидата:** веса рёбер одинаковы? Граф ориентированный? Нужен сам путь
или только его длина?
**Факты для карточек**
- base | Сложность BFS на сетке R×C? — O(R·C)
- core | Почему BFS гарантирует кратчайший путь по числу рёбер? — FIFO-очередь раскрывает граф строго по слоям расстояния
- core | В какой момент нужно помечать узел посещённым, чтобы избежать повторных вставок в очередь? — в момент постановки в очередь, а не при извлечении
- deep | Как обойти граф со весами рёбер только 0 и 1 за O(V+E) без полноценной Дейкстры? — 0-1 BFS: deque, вес 0 — push_front, вес 1 — push_back
Почему дальше: BFS решает граф с одинаковой "ценой" каждого ребра; следующая задача — с
одномерным массивом, где решение каждого следующего элемента зависит от предыдущих
подрешений, а не от соседей в структуре — это динамическое программирование.
### Задача 5. ДП на строки: LCS / Edit Distance (теория — `D5_algo` §6)
**Что проверяет интервьюер:** умение построить таблицу `(n+1)×(m+1)`, объяснить переходы
словами, а не просто написать формулу по памяти.
**Критерии сильного ответа:** явно объясняет базовый случай (пустая строка/префикс) и почему
размер таблицы `(n+1)×(m+1)`, а не `n×m`; для Edit Distance проговаривает все три операции
(замена/удаление/вставка) и почему берётся минимум трёх соседних ячеек.
**Ловушки**
- Заводят таблицу `n×m` вместо `(n+1)×(m+1)` → нет места под базовый случай "один из префиксов пуст" → неверные значения на границе или выход за границы массива.
- В Edit Distance забывают инициализировать нулевую строку/столбец → сравнение с мусорными значениями → неверный ответ без падения программы (тихая ошибка).
- Путают направление переходов LCS (берут `min` вместо `max` при несовпадении символов) → результат заведомо меньше правильного.
**Эталонный план по шагам:**
1. Уточнить: нужна длина LCS, сама подпоследовательность (восстановление пути) или Edit Distance.
2. Завести таблицу `dp[(n+1)][(m+1)]`; строка/столбец 0 — базовый случай "один из префиксов пуст".
3. LCS: при совпадении символов — `dp[i-1][j-1] + 1`; иначе — `max(dp[i-1][j], dp[i][j-1])`.
4. Edit Distance: `dp[i][0] = i`, `dp[0][j] = j`; при совпадении — `dp[i-1][j-1]`; иначе — `1 + min(замена, удаление, вставка)`.
5. Ответ — `dp[n][m]`.
6. Если нужна экономия памяти и не нужно восстановление пути — держать только 2 строки таблицы.
**Сложность:** время и память `O(n·m)`; при экономии памяти (без восстановления пути) —
`O(min(n,m))` память.
**Уточняющие вопросы кандидата:** нужна ли сама подпоследовательность/путь операций, или
только число? Укладывается ли `n·m` в лимит по времени при заданных ограничениях на длину строк?
**Факты для карточек**
- base | Размер таблицы LCS/Edit Distance для строк длины n и m? — (n+1)×(m+1)
- base | Временная и пространственная сложность LCS/Edit Distance? — O(n·m) и по времени, и по памяти
- core | Три операции, между которыми выбирают минимум в Edit Distance? — замена, удаление, вставка
Почему дальше: LCS и Edit Distance решают задачу за O(n·m) явной таблицей; следующая
классическая задача решается той же схемой за O(n²), но улучшается до O(n log n) совсем
другим приёмом — это повод спросить про компромисс между простотой и асимптотикой.
### Задача 6. ДП/НВП — LIS (`tasks/05_lis`, теория — `D5_algo` §7)
**Что проверяет интервьюер:** знание наивного `O(n²)` и продвинутого `O(n log n)` подходов,
умение оценить ограничения по `N` и сразу выбрать нужный алгоритм.
**Критерии сильного ответа:** сразу считает, влезает ли наивный `O(n²)` в лимит времени при
данном `N` (например, на `N = 100 000` это `10¹⁰` операций — не укладывается в 2 секунды);
понимает, что массив `tails[]` — не сама LIS, а вспомогательная структура минимальных хвостов.
**Ловушки**
- Путают длину массива `tails[]` (результат) с самой LIS-последовательностью → на просьбу "выведите саму подпоследовательность" отвечают неверно, потому что `tails[]` не хранит реальный путь.
- Делают линейный поиск позиции вместо `lower_bound` в `tails[]` → теряют весь выигрыш от `O(n log n)`, получают снова `O(n²)`.
- Не уточняют строгое или нестрогое возрастание → готовое решение может не соответствовать формулировке задачи.
**Эталонный план по шагам:**
1. Уточнить `N` и лимит по времени/памяти, строгое или нестрогое возрастание.
2. Если `N` мало — наивный `O(n²)`: `dp[i]` — длина LIS, заканчивающейся на `a[i]`, перебор `j < i` с `a[j] < a[i]`.
3. Если `N` большое — массив `tails[]`: для каждого нового элемента бинарным поиском (`lower_bound`) найти позицию замены или расширения `tails`.
4. Ответ — текущая длина `tails[]`.
5. Проговорить: для восстановления самой подпоследовательности нужен отдельный массив индексов-предков, `tails[]` для этого не подходит.
**Сложность:** наивный подход — `O(n²)` время, `O(n)` память; продвинутый — `O(n log n)`
время, `O(n)` память.
**Уточняющие вопросы кандидата:** нужна ли сама подпоследовательность или только длина?
Строгое возрастание или нестрогое (допускаются равные соседние значения)?
**Факты для карточек**
- base | Сложность наивного LIS и продвинутого через tails[]? — O(n²) и O(n log n) соответственно
- core | Почему наивный O(n²) не проходит на N=100 000 за 2 секунды? — 10¹⁰ операций, не укладывается в типичный лимит времени внутреннего теста
- core | Что хранит tails[k]? — минимальный возможный последний элемент возрастающей подпоследовательности длины k+1
- deep | Как называется классический приём построения tails[] с заменой через бинарный поиск? — patience sorting (раскладка карт по кучкам)
Почему дальше: алгоритмическая часть проверяет чистые структуры данных с однозначным
эталонным ответом; системная часть мока проверяет то же самое качество понимания, но в формате
устного объяснения без кода — и там интервьюер чаще всего задаёт один и тот же вопрос дважды,
разными словами, чтобы проверить глубину.
## 3. Мок №2. Системное (2 часа, 12 вопросов)
Формат: интервьюер задаёт короткий вопрос, ждёт ответ на 20–40 секунд, затем "углубляет" —
задаёт уточняющий вопрос, который проверяет, понята ли причина, а не просто выучен факт.
Ниже — по 4 вопроса на C/C++, Linux и сети.
### C/C++
**Вопрос 1. Посчитайте `sizeof(struct { char a; int b; char c; })` на x86-64 и объясните откуда взялось число.**
Чего ждёт интервьюер: конкретное число (12) и раскладку по смещениям, а не "наверное, 6".
Заготовка ответа (20–40 сек): "Поля дают 6 байт, но `sizeof` будет 12. `a` на смещении 0;
дальше 3 байта паддинга, потому что `int b` требует выравнивания на 4 байта; `b` занимает
смещения 4–7; `c` на смещении 8. Полезные данные кончаются на 9 байте, но размер всей
структуры округляется вверх до кратного выравниванию самого строгого поля — здесь `int`,
выравнивание 4 — отсюда ещё 3 байта хвостового паддинга и итог 12."
Углубление: "А если переставить поля — `char a; char c; int b`?" → 8 байт: два `char` подряд
без паддинга между ними, потом `int` с выравниванием 4, конец уже кратен 4 — экономия 4
байта на объект. "Что делает `#pragma pack(1)`?" → убирает паддинг (`sizeof` станет 6), но
доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим
выравниванием.
**Факты для карточек**
- base | sizeof(struct { char a; int b; char c; }) на x86-64? — 12 байт
- core | sizeof той же структуры с полями в порядке char a; char c; int b? — 8 байт
**Вопрос 2. Что такое правило 0/3/5 и когда его нужно применять?**
Чего ждёт интервьюер: связь между "класс владеет ресурсом" и необходимостью явно писать
копирование/перемещение.
Заготовка ответа: "Если класс сам управляет ресурсом (владеющий указатель, файловый
дескриптор) и поэтому определяет деструктор, он почти наверняка должен явно определить и
конструктор/оператор копирования (правило трёх), а с C++11 — ещё и перемещения (правило
пяти). Если ни одну из пяти функций не объявить, компилятор сгенерирует все сам, и
сгенерированное копирование — побитовое: для владеющего указателя это значит, что обе копии
получат один и тот же адрес, и оба деструктора вызовут `delete` на нём — double-free."
Углубление: "А правило нуля?" → не писать ни одну из пяти вручную, доверив владение готовой
RAII-обёртке (`unique_ptr`, `vector`, `string`) — её сгенерированные копирование/перемещение
уже корректны.
**Факты для карточек**
- base | Какие 5 функций входят в правило пяти? — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением
- core | Что делает сгенерированный компилятором конструктор копирования по умолчанию? — побитовое (memberwise) копирование каждого поля
**Вопрос 3. Что физически делает `std::move`?**
Чего ждёт интервьюер: развенчание мифа "move перемещает данные во время выполнения".
Заготовка ответа: "Ничего во время выполнения — это `static_cast<T&&>(x)`, явный каст к
rvalue-ссылке. Единственный эффект — при выборе перегрузки компилятор теперь предпочитает
move-конструктор, а не копирующий. Реальную работу делает move-конструктор конкретного типа:
для `vector` это перенос трёх указателей (начало, конец данных, конец ёмкости) и обнуление их
у источника — O(1), без копирования элементов."
Углубление: "В каком состоянии объект после `std::move`?" → в валидном, но неопределённом
состоянии по стандарту; использование старых данных — не UB, но логическая ошибка, не ловится
санитайзерами.
**Факты для карточек**
- base | Что физически делает std::move во время выполнения? — ничего: это static_cast к rvalue-ссылке
- core | За какое время перемещается std::vector и что именно переносится? — O(1): три внутренних указателя (начало, конец данных, конец ёмкости)
**Вопрос 4. Назовите 2–3 примера UB в C++ и объясните разницу ASAN/UBSAN.**
Чего ждёт интервьюер: конкретные примеры (не общее "неопределённое поведение — это плохо") и
чёткое разделение зон ответственности двух санитайзеров.
Заготовка ответа: "Знаковое переполнение `int` (`INT_MAX + 1`), сдвиг на число бит больше или
равное разрядности типа (`1 << 32` для 32-битного `int`), разыменование `nullptr` — всё UB.
ASAN проверяет ошибки работы с памятью: границы, use-after-free, double-free. UBSAN проверяет
сами операции языка, являющиеся UB по стандарту: переполнение, сдвиг, выравнивание. Они не
заменяют друг друга, включают вместе: `-fsanitize=address,undefined`."
Углубление: "Почему код может работать в `-O0` и падать в `-O2`?" → оптимизатор релизной
сборки строит код в предположении, что UB не происходит, и может убрать проверки, на которые
программист рассчитывал.
**Факты для карточек**
- base | Значение INT_MAX для 32-битного int? — 2147483647
- core | Флаг компилятора, включающий сразу ASAN и UBSAN? — -fsanitize=address,undefined
Почему дальше: C++ отвечает за корректность в пределах одного процесса; следующий блок
вопросов — про то, что происходит на границе процесса и ядра.
### Linux
**Вопрос 5. Процесс упал, echo $? показывает 139. Что произошло?**
Чего ждёт интервьюер: связь оболочечного кода возврата с сигналом, а не просто "процесс
упал".
Заготовка ответа: "Оболочка кодирует код возврата как 128 + номер сигнала. 139 = 128 + 11 —
`SIGSEGV`, падение по памяти. 137 = 128 + 9 — `SIGKILL`, часто это OOM-killer. 143 = 128 + 15
— `SIGTERM`, корректный запрос на завершение. В коде это разбирается макросами
`WIFSIGNALED`/`WTERMSIG` над `status` из `waitpid`, а не вручную арифметикой."
Углубление: "Можно ли перехватить SIGKILL обработчиком?" → нет, `SIGKILL` (9) и `SIGSTOP` (19)
нельзя ни перехватить, ни заблокировать — ядро обрабатывает их безусловно, это единственный
гарантированный способ остановить процесс, который сам игнорирует остальные сигналы.
**Факты для карточек**
- base | Откуда взялись коды возврата 137 и 139? — 128 + номер сигнала: 137 = SIGKILL(9), 139 = SIGSEGV(11)
- core | Какие два сигнала нельзя перехватить или заблокировать? — SIGKILL (9) и SIGSTOP (19)
**Вопрос 6. Что такое зомби-процесс?**
Чего ждёт интервьюер: точное понимание, что зомби не расходует память, а занимает запись в
таблице процессов.
Заготовка ответа: "Завершившийся ребёнок не исчезает полностью — ядро держит его запись (код
возврата) в таблице процессов, пока родитель не заберёт её через `wait`/`waitpid`. Такой
процесс — зомби, состояние `Z` в `ps`. Памяти он не занимает, но занимает слот в таблице
процессов; накопление зомби — это утечка слотов, а не утечка памяти."
Углубление: "Как избавиться от зомби, если родитель не вызывает wait?" → либо родитель обязан
вызывать `waitpid` (часто в обработчике `SIGCHLD`), либо если родитель сам умирает раньше
ребёнка — ребёнка усыновляет init/systemd (PID 1), который делает `wait` за него, и ребёнок
зомби не остаётся.
**Факты для карточек**
- base | Состояние зомби-процесса в выводе ps? — Z
- core | Что именно занимает зомби-процесс — память или что-то другое? — слот в таблице процессов, не память
**Вопрос 7. Чем epoll лучше select для сервера с тысячами соединений?**
Чего ждёт интервьюер: количественное сравнение сложности и лимитов, а не общую фразу "epoll
быстрее".
Заготовка ответа: "select ограничен `FD_SETSIZE` = 1024 дескриптора и на каждый вызов
проходит весь набор целиком — `O(n)` независимо от того, сколько реально готово, плюс
`fd_set` надо пересобирать перед каждым вызовом. epoll разносит регистрацию (`epoll_ctl`,
список интереса на красно-чёрном дереве в ядре) и ожидание (`epoll_wait`, который просто
возвращает уже готовые дескрипторы) — `O(1)` на каждое готовое событие, без лимита 1024."
Углубление: "Level-triggered или edge-triggered по умолчанию?" → level-triggered — у epoll по
умолчанию и всегда у select/poll: событие сообщается, пока условие истинно. Edge-triggered —
явный флаг `EPOLLET`, требует вычитывать данные до `EAGAIN` на неблокирующем дескрипторе,
иначе остаток данных не будет сигнализирован повторно.
**Факты для карточек**
- base | Жёсткий лимит select и его значение? — FD_SETSIZE, 1024
- core | Сложность epoll_wait на одно готовое событие против select/poll на весь набор? — epoll_wait O(1) на событие; select/poll O(n) на весь набор при каждом вызове
**Вопрос 8. Что делает mmap и чем MAP_SHARED отличается от MAP_PRIVATE?**
Чего ждёт интервьюер: механизм ленивого отображения через page fault, а не "mmap читает файл
в память".
Заготовка ответа: "mmap резервирует диапазон виртуальных адресов и связывает его со
страницами файла в страничном кэше ядра; физического чтения при самом вызове нет. При первом
обращении к странице — page fault: minor, если страница уже в кэше (дёшево), major, если её
нужно реально прочитать с диска (дорого). `MAP_SHARED` — изменения видны всем процессам и
попадают в файл на диске; `MAP_PRIVATE` — copy-on-write, как при `fork`: страницы общие, пока
не начинается запись, тогда процесс получает свою копию, а файл на диске не меняется."
Углубление: "Когда read быстрее mmap?" → при однократном последовательном чтении небольшого
файла — накладные расходы на настройку отображения и page fault на каждую новую страницу не
амортизируются, обычный `read` одним вызовом дешевле.
**Факты для карточек**
- base | Разница MAP_SHARED и MAP_PRIVATE? — MAP_SHARED: изменения видны другим и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны
- core | Чем minor page fault отличается от major? — minor: страница уже в кэше (дёшево); major: страница читается с диска (дорого)
Почему дальше: Linux управляет одним узлом; следующий блок — про то, как узлы вообще находят
друг друга и обмениваются байтами по проводу.
### Сети
**Вопрос 9. Из чего состоит Ethernet-заголовок и что меняет VLAN?**
Чего ждёт интервьюер: точные числа байт заголовка и понимание, зачем и куда вставляется тег.
Заготовка ответа: "Ethernet-заголовок — 14 байт: 6 байт MAC получателя, 6 байт MAC
отправителя, 2 байта EtherType. После полезной нагрузки — 4-байтовый трейлер FCS, в заголовок
не входит. VLAN-тег 802.1Q по стандарту вставляет 4 дополнительных байта между MAC
отправителя и EtherType: TPID (2 байта, фиксировано 0x8100) и TCI (2 байта: PCP 3 бита, DEI 1
бит, VID 12 бит, диапазон реально используемых значений 1–4094). Заголовок с тегом растёт с
14 до 18 байт."
Углубление: "Что будет, если не учесть эти 4 байта при расчёте MTU?" → ошибка ровно на 4
байта — типично ловится сравнением дампа `tcpdump` с ожидаемой длиной кадра.
**Факты для карточек**
- base | Длина Ethernet-заголовка и длина заголовка с VLAN-тегом? — 14 байт без тега, 18 байт с тегом 802.1Q
- core | Значение TPID и число бит поля VID? — TPID = 0x8100, VID = 12 бит (диапазон 1–4094)
**Вопрос 10. Дана подсеть 10.0.1.130/26. Назовите сеть, broadcast и диапазон хостов.**
Чего ждёт интервьюер: расчёт на лету, а не заученный пример из урока.
Заготовка ответа: "/26 — 6 бит под хосты, 64 адреса всего, 62 доступно хостам (минус адрес
сети и broadcast). Маска в десятичном виде — 255.255.255.192. Для 10.0.1.130/26: сеть —
10.0.1.128, broadcast — 10.0.1.191, диапазон хостов — 129..190."
Углубление: "Как быстро найти самую узкую подсеть под 100 хостов?" → формула `32 -
ceil(log2(h+2))`: для `h=100` это `32 - ceil(log2(102)) = 32 - 7 = /25` (126 хостов — ближайшая
подходящая степень двойки сверху, `/26` с 62 хостами уже не хватит).
**Факты для карточек**
- base | Сколько хостов доступно в подсети /26 и как выглядит маска в десятичном виде? — 62 хоста, 255.255.255.192
- core | Сеть, broadcast и диапазон хостов для 10.0.1.130/26? — сеть 10.0.1.128, broadcast 10.0.1.191, хосты 129–190
**Вопрос 11. Как работает traceroute и при чём тут TTL?**
Чего ждёт интервьюер: понимание, что traceroute не использует отдельный протокол обнаружения
маршрута, а эксплуатирует побочный эффект TTL.
Заготовка ответа: "TTL уменьшается на 1 на каждом маршрутизаторе на пути пакета; при
обнулении пакет отбрасывается, а отправителю уходит ICMP Time Exceeded (тип 11). traceroute
последовательно шлёт пакеты с TTL 1, 2, 3... — пакет с TTL 1 умирает на первом
маршрутизаторе, тот присылает Time Exceeded со своим адресом; пакет с TTL 2 умирает на
втором, и так далее, пока пакет не дойдёт до получателя. Список хопов восстанавливается по
цепочке ICMP-ответов без специального протокола обнаружения маршрута."
Углубление: "Почему ping иногда не проходит, а обычное TCP-соединение на тот же хост
работает?" → ICMP может быть заблокирован файрволом отдельно от TCP-порта — это два разных
уровня фильтрации, `ping host` и `curl host` в таком случае дают разный результат.
**Факты для карточек**
- base | На сколько уменьшается TTL на каждом маршрутизаторе и какой ICMP-тип шлётся при обнулении? — на 1; ICMP Time Exceeded, тип 11
- core | С какого TTL traceroute начинает зондирование и почему именно так восстанавливает маршрут? — с TTL=1, наращивая на 1 — каждый пакет умирает на очередном хопе и раскрывает его адрес
**Вопрос 12. Три сегмента TCP-рукопожатия и зачем нужен TIME_WAIT?**
Чего ждёт интервьюер: точная последовательность флагов и механическая причина TIME_WAIT, а не
"так положено по стандарту".
Заготовка ответа: "SYN от клиента со своим ISN → SYN+ACK от сервера (Ack = ISN клиента + 1, и
собственный ISN сервера) → ACK от клиента, подтверждающий ISN сервера. Двух шагов
недостаточно, потому что соединение полнодуплексное: сервер не может быть уверен, что его
SYN-ACK дошёл, пока не получит финальный ACK. Закрытие — уже 4 сегмента (FIN, ACK, FIN, ACK)
из-за полузакрытия каждого направления по отдельности. Сторона, отправившая финальный ACK,
уходит в TIME_WAIT на 2×MSL — она должна поймать задержавшиеся в сети дубликаты старых
сегментов и быть готова повторно отправить последний ACK, если партнёр повторит FIN."
Углубление: "Сколько реально длится TIME_WAIT в Linux?" → 60 секунд, фиксированная константа
ядра `TCP_TIMEWAIT_LEN`, а не номинальные 4 минуты (2×2 мин) по RFC 793.
**Факты для карточек**
- base | Сколько сегментов в three-way handshake и в полном закрытии соединения? — 3 (SYN, SYN+ACK, ACK) и 4 (FIN, ACK, FIN, ACK)
- deep | Сколько секунд реально длится TIME_WAIT в Linux? — 60 секунд (константа TCP_TIMEWAIT_LEN), не совпадает с номинальными 4 минутами по RFC 793
Почему дальше: 12 вопросов закрывают C/C++, Linux и сети по отдельности; дальше — не техника,
а формат самого разговора: что спросить у работодателя и как рассказать про себя, чтобы не
потерять баллы на ровном месте.
## 4. Вопросы работодателю
Задавать под конец интервью, когда спросят "есть ли у вас вопросы" — не сам факт вопроса
важен, а конкретность.
1. На каком стандарте C++ пишут в проекте (C++11/14/17/20), какой компилятор и целевая
архитектура (x86, ARM, embedded-таргет)?
2. Как устроено код-ревью: обязательно ли оно для джуна перед мерджем, кто ревьюит, сколько
итераций в среднем проходит один PR?
3. Что гоняет CI на каждый коммит — юнит-тесты, статический анализ, санитайзеры? Обязательно
ли покрытие тестами для мерджа?
4. Какие задачи обычно дают джуну в первый месяц — багфиксы, мелкие фичи, документация,
разбор legacy-кода?
5. Есть ли доступ к реальному стенду/железу для отладки, или разработка сначала идёт на
эмуляторе/симуляторе?
6. Кто ментор на испытательный срок и как часто идёт синхронизация — ежедневный созвон,
раз в неделю, по запросу?
7. Что для команды считается успехом джуна через год — конкретные вехи или метрики, а не
общие слова?
8. Какая доля времени уходит на поддержку и доработку существующего кода против новых задач
с нуля?
**Факты для карточек**
- base | Сколько вопросов работодателю стоит подготовить заранее на такое интервью? — 8, каждый — конкретный, не риторический
- core | Когда обычно задают вопросы работодателю на техническом скрининге? — в конце интервью, по приглашению интервьюера
Почему дальше: вопросы работодателю закрывают техническую часть разговора; последний пункт —
не техника вообще, а то, как честно и без потери баллов рассказать про собственный опыт.
## 5. Легенда по опыту
Задача — не приукрашивать, а структурировать то, что уже сделано руками, так, чтобы
интервьюер за 60–90 секунд понял уровень, не тратя время на наводящие вопросы. Рабочий каркас
для таких ответов — STAR (Situation, Task, Action, Result): сначала контекст в одной фразе,
потом конкретное действие, потом измеримый результат.
**Что говорить как есть, потому что это правда и это ценно:**
- Практика с C/C++ и Linux не в вакууме, а руками: написанный код, работа в терминале,
скрипты.
- Опыт сборки и CI на собственном проекте (например, сборка APK и настройка пайплайна) —
конкретный пример того, что такое "довести до работающего результата", даже если стек не
совпадает с C++/embedded напрямую: понимание пайплайна сборки, зависимостей, автоматизации
переносится.
- Текущая подготовка по плану — не "готовился неделю перед собеседованием для галочки", а
системный разбор конкретных тем с самопроверкой (карточки, мок-интервью) — это тоже сигнал
об умении учиться быстро и целенаправленно.
**Как объяснять пробелы, не оправдываясь:**
- "Алгоритмы — тема, которую я целенаправленно добираю последние недели: понимаю формальные
свойства структур (сложность, инварианты, границы применимости), но production-опыта
оптимизации под high-load нет — это зона роста, а не то, что я скрываю."
- Если спросят про конкретный API, которого не касался — честно "не работал с этим напрямую,
но по механизму ожидаю Y, потому что Z" — интервьюер оценивает способность рассуждать, а не
только базу знаний.
**Чего не говорить:**
- Не заявлять претензию на роль лида или архитектурные решения, если такого опыта не было —
на джуновском скрининге это резко расходится с реальным уровнём ответов на технические
вопросы и подрывает доверие ко всему остальному сказанному.
- Не преувеличивать глубину embedded/kernel-опыта: тема ядра в этом плане закрыта на уровне
"отвечать словами" (`D6_kernel`), а не "писал драйверы в проде" — если спросят "а сами
писали?", честный ответ "нет, разбирал теоретически и на учебных примерах" безопаснее любой
неправды, которая всплывёт на первом же уточняющем вопросе.
**Факты для карточек**
- base | Из каких 4 частей состоит структура STAR для ответа про опыт? — Situation, Task, Action, Result
- core | Как правильно называть пробел в алгоритмах на собеседовании — скрывать или называть прямо? — называть прямо: конкретная тема добирается сейчас, с указанием, что уже понятно (сложность, инварианты)
## Проверь себя
<details>
<summary>1. Почему для top-K наибольших элементов в потоковом режиме нужен именно min-heap размера k, а не max-heap?</summary>
Min-heap хранит k текущих кандидатов, и на вершине (O(1) доступ) всегда самый маленький из
них — именно с ним сравнивают каждый новый элемент: если новый больше минимума кучи, минимум
вытесняется, новый занимает его место. Max-heap размера k показывал бы на вершине самый
большой из текущих k кандидатов, что не даёт дешёвого способа проверить "стоит ли вытеснять
кого-то" — пришлось бы искать минимум отдельно, теряя весь выигрыш от кучи.
</details>
<details>
<summary>2. Процесс завершился с кодом 139. Что произошло и какой командой в коде это разбирают?</summary>
139 = 128 + 11, то есть процесс убит сигналом SIGSEGV — падение по памяти (например,
разыменование невалидного указателя). В коде это разбирается макросами WIFSIGNALED(status) и
WTERMSIG(status) над status, полученным из wait/waitpid, а не вычитанием 128 вручную.
</details>
<details>
<summary>3. Почему epoll_wait даёт O(1) на готовое событие, а select — O(n) на весь набор при каждом вызове?</summary>
select при каждом вызове проходит весь переданный набор дескрипторов целиком, чтобы понять,
какие из них готовы — работа пропорциональна общему числу отслеживаемых дескрипторов
независимо от того, сколько реально готовы. epoll разносит регистрацию (epoll_ctl, список
интереса на красно-чёрном дереве в ядре) и ожидание (epoll_wait): ядро само добавляет
дескриптор в список готовых асинхронно, когда его состояние меняется, и epoll_wait просто
отдаёт содержимое этого списка — работа пропорциональна числу реально готовых дескрипторов.
</details>
<details>
<summary>4. Для 10.0.1.130/26 назовите адрес сети и диапазон доступных хостов, объяснив расчёт.</summary>
/26 оставляет 6 бит под хосты (32-26=6), это 64 адреса всего, из них 62 доступны хостам
(минус адрес сети и broadcast). Маска — 255.255.255.192, последний октет сети получается
округлением 130 вниз до кратного 64: сеть 10.0.1.128, broadcast 10.0.1.191 (128+63), диапазон
хостов — 129..190.
</details>
<details>
<summary>5. Почему TCP-рукопожатие требует три сегмента, а не два?</summary>
Соединение TCP полнодуплексное — у каждого направления свой независимый ISN (начальный
порядковый номер), и обе стороны должны не только сообщить свой ISN, но и получить
подтверждение, что собеседник его получил. После SYN и SYN-ACK сервер ещё не знает, дошёл ли
его SYN-ACK до клиента — только финальный ACK клиента даёт это подтверждение и завершает
синхронизацию обоих направлений.
</details>
<details>
<summary>6. Чем зомби-процесс отличается от процесса-сироты и что физически занимает зомби?</summary>
Зомби — завершившийся процесс, чья запись (код возврата) ещё не забрана родителем через
wait/waitpid; он не занимает память, но занимает слот в таблице процессов. Сирота — процесс,
чей родитель умер раньше него; его усыновляет init/systemd (PID 1), и это состояние не
связано с зомби напрямую — сирота продолжает работать, зомби уже завершился и ждёт только
уборки записи.
</details>
## Материалы
- cppreference, `std::unordered_map` — https://en.cppreference.com/w/cpp/container/unordered_map
- cppreference, `std::move` — https://en.cppreference.com/w/cpp/utility/move
- man7.org, `epoll(7)` — https://man7.org/linux/man-pages/man7/epoll.7.html
- man7.org, `mmap(2)` — https://man7.org/linux/man-pages/man2/mmap.2.html
- man7.org, `wait(2)` — https://man7.org/linux/man-pages/man2/wait.2.html
- RFC 793, Transmission Control Protocol — https://datatracker.ietf.org/doc/html/rfc793