Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613
This commit is contained in:
@@ -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 # вывести вопросы с ответами и пояснениями
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user