diff --git a/README.md b/README.md index 712f0fe..dc86af5 100644 --- a/README.md +++ b/README.md @@ -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 # вывести вопросы с ответами и пояснениями ``` diff --git a/WEEK7.md b/WEEK7.md index 4eb9626..aefd764 100644 --- a/WEEK7.md +++ b/WEEK7.md @@ -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 + сети, включая разбор дампа. diff --git a/diag/cards_src.tsv b/diag/cards_src.tsv index 1595ba3..ce000b5 100644 --- a/diag/cards_src.tsv +++ b/diag/cards_src.tsv @@ -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 не может обработаться раньше всех вершин расстояния len, total_lengthlen урок 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//cmdline нулевыми байтами `\0` урок D5_bash +core linux Что можно узнать из /proc//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//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 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 diff --git a/lessons/D2_algo.md b/lessons/D2_algo.md new file mode 100644 index 0000000..19c2111 --- /dev/null +++ b/lessons/D2_algo.md @@ -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`, та же асимптотика `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. + +## Проверь себя + +
+1. Почему нельзя проверить BST, сравнивая каждый узел только с прямым родителем? +Потому что нарушение инварианта может произойти через поколение: узел может быть больше +своего прямого родителя, но при этом лежать в поддереве более раннего предка, для которого +он слишком велик (например, правый потомок левого ребёнка корня оказывается больше самого +корня). Правильная проверка передаёт вниз границы (min, max), актуальные для всего пути от +корня, а не только для одного родителя. +
+ +
+2. Почему bottom-up heapify — O(n), хотя каждый sift-down в отдельности — O(log n)? +Потому что работа sift-down в конкретном узле пропорциональна высоте именно этого узла, а не +высоте всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел +высоты log n, листьев высоты 0 — около n/2, для них sift-down почти ничего не делает). Сумма +Σ h/2^h по всем высотам сходится к константе, поэтому суммарная работа растёт линейно с n. +
+ +
+3. Чем LCA в BST отличается по сложности от LCA в обычном бинарном дереве и почему? +В BST — O(h): порядок значений позволяет на каждом шаге однозначно решить, идти влево, вправо +или остановиться. В обычном дереве такого порядка нет, поэтому приходится рекурсивно +обходить дерево (postorder) и в худшем случае посетить все n узлов — O(n). +
+ +
+4. Почему nth_element в среднем O(n), но не гарантирует худший случай? +Потому что рекурсия спускается только в ту часть, где лежит искомый ранг, отбрасывая +остальное после каждого партиционирования — в среднем это даёт T(n) = T(n/2) + O(n) → O(n). +Но при устойчиво плохом выборе опорного элемента (та же причина, что и у худшего случая +quicksort) партиционирование каждый раз отбрасывает лишь один элемент, и сложность +деградирует до O(n²). +
+ +
+5. Почему массив — плохое представление для сильно несбалансированного (не полного) дерева? +Потому что индексы 2i+1/2i+2 предполагают позицию узла в полном дереве соответствующей +глубины; если дерево заполнено не полностью (например, вырожденная цепочка), индексы, +которые физически используются, разбросаны на диапазон до 2^h, и большая часть массива +такого размера останется пустой. +
+ +## Задачи дня + +- `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 diff --git a/lessons/D2_cpp.md b/lessons/D2_cpp.md new file mode 100644 index 0000000..03d0476 --- /dev/null +++ b/lessons/D2_cpp.md @@ -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(x)`, явное приведение объекта к rvalue-ссылке. Единственный +эффект — при выборе перегрузки компилятор теперь предпочитает конструктор/оператор +присваивания **перемещением**, а не копированием, если такой у типа определён. + +```c++ +std::vector a = {1, 2, 3}; +std::vector b = std::move(a); +// std::move(a) сам по себе ничего не делает — просто приводит a к vector&& +// реальную работу выполняет 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 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает + +## Проверь себя + +
+1. Почему sizeof(struct { char a; int b; char c; }) равен 12, а не 6 или 9? +6 — это сумма размеров полей без паддинга, физически недостижима из-за требования +выравнивания int на 4 байта: между a (смещение 0) и b нужно 3 байта паддинга, b занимает +смещения 4–7, c — смещение 8. 9 байт — это конец полезных данных после c, но итоговый размер +структуры округляется вверх до кратного выравниванию самого строгого поля (int, 4) — 9 +округляется до 12, добавляя ещё 3 байта хвостового паддинга. +
+ +
+2. Почему копирование объекта с необъявленными спецфункциями и владеющим указателем даёт double-free? +Компилятор генерирует конструктор копирования по умолчанию, если ни одну из пяти спецфункций +не объявили сами. Он делает побитовое копирование полей — указатель копируется как значение +адреса, оба объекта получают один и тот же адрес. При уничтожении обоих объектов оба +деструктора вызывают delete на этом адресе — второй вызов на уже освобождённой памяти. +
+ +
+3. Почему delete через Base* без virtual-деструктора не роняет программу сразу, а просто течёт? +Компилятор жёстко привязывает вызов delete к статическому типу указателя (Base*) на этапе +компиляции, потому что деструктор не virtual — вызывается только ~Base(). Память под объект +освобождается корректно (адрес правильный), падения не происходит, но ~Derived() не +выполняется, и ресурсы, которыми управлял именно Derived (например, отдельный new[]), никогда +не освобождаются — это утечка, а не крах. +
+ +
+4. Что конкретно делает std::move и кто выполняет реальное перемещение? +std::move — это static_cast к rvalue-ссылке, никакого действия во время выполнения не +происходит. Реальную работу — например, перенос трёх внутренних указателей vector в новый +объект и обнуление их у источника — делает move-конструктор/move-оператор присваивания +конкретного типа, который компилятор выбирает благодаря этому касту. +
+ +
+5. Почему сдвиг 1 << 32 для 32-битного int — это UB, а не просто 0 или неожиданный результат? +Стандарт определяет поведение сдвига только для сдвига на число бит меньше разрядности типа. +Сдвиг на количество бит, равное или большее разрядности (32 для 32-битного int), — UB: +компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при +разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение. +
+ +## Материалы + +- 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 diff --git a/lessons/D2_linux.md b/lessons/D2_linux.md new file mode 100644 index 0000000..23feef3 --- /dev/null +++ b/lessons/D2_linux.md @@ -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» + +## Проверь себя + +
+1. Почему write() может записать меньше байт, чем передали, и как с этим работать правильно? +Буфер ядра для сокета/pipe может быть заполнен, вызов может быть прерван сигналом, или для +сокетов частичная запись — штатное поведение при большом объёме данных. Правильный код +дописывает оставшиеся байты в цикле, обрабатывая EINTR отдельно от настоящих ошибок. +
+ +
+2. Почему select ограничен 1024 дескрипторами, а epoll — нет? +select передаёт в ядро набор дескрипторов как битовую маску fd_set фиксированного размера +FD_SETSIZE (1024); номер дескриптора больше этого значения физически не помещается в маску. +epoll хранит список интереса внутри ядра между вызовами (в красно-чёрном дереве), а не +передаёт весь набор при каждом вызове, поэтому ограничения по числу дескрипторов на уровне +самого API нет. +
+ +
+3. Почему epoll_wait даёт O(1) на событие, а select и poll — O(n) на вызов? +select и poll на каждом вызове заново проходят весь переданный набор дескрипторов, чтобы +определить готовые — работа пропорциональна общему числу отслеживаемых дескрипторов. epoll +поддерживает отдельный список готовых, который ядро заполняет асинхронно по мере изменения +состояния дескрипторов; epoll_wait просто отдаёт содержимое этого списка — работа +пропорциональна числу реально готовых событий. +
+ +
+4. Почему edge-triggered режим epoll требует неблокирующих дескрипторов? +ET сообщает о готовности один раз за переход состояния, поэтому нужно вычитывать данные в +цикле до EAGAIN, чтобы не пропустить оставшиеся данные. На блокирующем дескрипторе последний +вызов такого цикла заблокировался бы навсегда в ожидании новых данных вместо немедленного +возврата EAGAIN. +
+ +
+5. Зачем нужен SO_REUSEADDR и с чем он связан? +Сервер после закрытия соединения оставляет сокет в TIME_WAIT на 2×MSL, чтобы поймать +задержавшиеся дубликаты сегментов. Без SO_REUSEADDR повторный bind на тот же адрес/порт, +пока есть сокеты в TIME_WAIT, вернёт ошибку «Address already in use» — опция явно разрешает +это игнорировать. +
+ +## Задачи дня + +- `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 diff --git a/lessons/D3_algo.md b/lessons/D3_algo.md new file mode 100644 index 0000000..d420ee8 --- /dev/null +++ b/lessons/D3_algo.md @@ -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` +(кольцевой буфер, ступенями). + +
+Проверь себя + +1. Список из 5 узлов (1→2→3→4→5). Что вернёт `find_middle` по алгоритму slow/fast из этого + урока? +
ОтветУзел 3 — ровно середина при нечётной длине 5.
+ +2. Почему `pop` в очереди на двух стеках всё равно считается амортизированной O(1), если + конкретный вызов с переливом стоит O(n)? +
ОтветКаждый элемент переливается из `in` в `out` не более + одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n), + то есть в среднем O(1) на операцию.
+ +3. В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз + `head` за это время перешёл через границу массива (сделал wrap-around) при последовательной + индексации с 0? +
ОтветОдин раз: после 4 записей `head` дошёл до конца и вернулся + в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно + проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`).
+ +4. Почему монотонный стек для Next Greater Element хранит убывающую последовательность + значений, а не произвольную? +
ОтветКак только встречается элемент больше вершины стека, это + и есть ответ для всех подходящих элементов на вершине — их снимают со стека; то, что + остаётся в стеке, по построению всегда убывает сверху вниз, иначе элемент уже был бы снят + раньше.
+ +
+ +## Материалы + +- 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/ diff --git a/lessons/D3_gdb.md b/lessons/D3_gdb.md new file mode 100644 index 0000000..a339257 --- /dev/null +++ b/lessons/D3_gdb.md @@ -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 +чистые. + +
+Проверь себя + +1. Собрал бинарник с `-g -O2` и заметил, что `next` в gdb «перепрыгивает» через строки не по + порядку. В чём причина и что нужно изменить в сборке? +
ОтветОптимизатор при `-O2` переставляет и инлайнит инструкции, + отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с + `-O0` (оставив `-g`).
+ +2. Программа падает по `SIGSEGV` только раз в несколько дней под нагрузкой в проде. Какие две + вещи нужно сделать заранее, чтобы разобрать этот краш постфактум? +
ОтветВыставить `ulimit -c unlimited`, чтобы ядро сохранило core + dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в + core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек.
+ +3. ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом + же баге падал бы совсем в другом месте кода. Почему так? +
ОтветПорча памяти повреждает чужие данные, но видимый крash + может случиться значительно позже, когда программа использует уже испорченные данные в + другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN + инструментирует каждое обращение и останавливает программу прямо в момент нарушения, + то есть в точке причины.
+ +4. Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности + пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер? +
Ответ`valgrind --tool=memcheck` — он эмулирует уже готовый + бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от + санитайзеров, которые требуют перекомпиляции исходного кода.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D3_threads.md b/lessons/D3_threads.md new file mode 100644 index 0000000..f5fe429 --- /dev/null +++ b/lessons/D3_threads.md @@ -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 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` для простых типов (счётчики, флаги, указатели) дешевле мьютекса: операции +выполняются одной аппаратной атомарной инструкцией процессора (например 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 чистый, нет зависания). + +
+Проверь себя + +1. Почему `cv.wait(lock)` обязан принимать `unique_lock`, уже захвативший тот же мьютекс, что + защищает разделяемое состояние, а не произвольный лок? +
Ответ`wait` должен атомарно освободить именно этот мьютекс + перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат + под разными блокировками, и защита состояния перестала бы работать.
+ +2. Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно + изменить, чтобы устранить дедлок, не трогая логику самой критической секции? +
ОтветПривести захват к единому порядку во всей программе + (например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать + оба мьютекса разом через `std::scoped_lock`.
+ +3. Почему data race на обычном `int` без `std::atomic` считается UB, даже если на конкретном + процессоре чтение/запись `int` физически выполняются одной инструкцией? +
ОтветПотому что стандарт C++ формально не гарантирует + атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в + регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо + действительно рвёт запись на части.
+ +4. Чем `memory_order_acquire` отличается от `memory_order_relaxed` по факту разрешённых + перестановок кода? +
Ответ`relaxed` гарантирует только атомарность самой операции; + `acquire` дополнительно запрещает переносить более поздние по коду обращения к памяти до + этой операции — то есть даёт порядок, а не только неделимость.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D4_algo.md b/lessons/D4_algo.md new file mode 100644 index 0000000..a72d83d --- /dev/null +++ b/lessons/D4_algo.md @@ -0,0 +1,410 @@ +# D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок) + +Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/12_dijkstra`. Граф — +это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки, +процессы и их зависимости, файлы и их include-цепочки. + +## 1. Представления графа: матрица смежности vs список смежности + +Граф — это набор вершин `V` и рёбер `E`. Два способа хранить связи в памяти: + +```c++ +// матрица смежности: adj[u][v] = вес ребра (или 0/inf, если ребра нет) +std::vector> adj(n, std::vector(n, 0)); + +// список смежности: adj[u] = список (сосед, вес) +std::vector>> 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 bfs(int src, const std::vector>& adj) { + std::vector dist(adj.size(), -1); + std::queue 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, все вершины расстояния >& adj, std::vector& 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 kahn_toposort(int n, const std::vector>& adj) { + std::vector indeg(n, 0); + for (int u = 0; u < n; ++u) + for (int v : adj[u]) indeg[v]++; // степень входа: сколько рёбер входит в v + + std::queue q; + for (int u = 0; u < n; ++u) + if (indeg[u] == 0) q.push(u); // вершины без зависимостей — стартовые + + std::vector 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>& edges, // (u, v, w) + int src, int dst) { + if (src == dst) return 0; + std::vector>> adj(n); + for (auto& [u, v, w] : edges) adj[u].push_back({v, w}); // ориентированное ребро + + std::vector dist(n, -1); + using P = std::pair; // (расстояние, вершина) + std::priority_queue, std::greater

> 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 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`, ленивое +удаление, условие неотрицательных весов). + +

+Проверь себя + +1. Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не + матрицу, и во сколько раз (порядок величины) список экономнее по памяти? +
ОтветМатрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000 + ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память.
+ +2. Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе? +
ОтветBFS раскрывает граф слоями через очередь (FIFO) — все + вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение + вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой + гарантии не даёт.
+ +3. В алгоритме Кана после обработки все вершины кроме трёх остались с `indeg > 0`. Что это + означает и как это связано с количеством обработанных вершин? +
ОтветЭти три (и, возможно, другие зависимые от них) вершины + образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть + признак цикла в графе.
+ +4. Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто + «запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»? +
ОтветНельзя гарантировать заранее, какие вершины уже + «зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра — + если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но + Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие + w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине.
+ +5. Почему сложность Kruskal — O(E log E), если union-find работает почти за O(1)? +
ОтветПотому что перед объединением рёбра нужно отсортировать по + весу — это O(E log E) и доминирует над суммарной почти константной стоимостью всех + union-find операций O(E·α(V)).
+ +
+ +## Материалы + +- Документация `std::priority_queue` (мин-куча в реализации Дейкстры) — https://en.cppreference.com/w/cpp/container/priority_queue diff --git a/lessons/D4_net.md b/lessons/D4_net.md new file mode 100644 index 0000000..2382869 --- /dev/null +++ b/lessons/D4_net.md @@ -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_lengthlen +- 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` (арифметика подсетей). + +
+Проверь себя + +1. Кадр Ethernet с VLAN-тегом занимает 18 байт заголовка вместо 14. Откуда взялись эти лишние + 4 байта и что конкретно в них закодировано? +
ОтветVLAN-тег 802.1Q: 2 байта TPID (фиксированное значение + 0x8100) + 2 байта TCI, где TCI = PCP (3 бита приоритета) + DEI (1 бит) + VID (12 бит номер + VLAN, диапазон 1–4094 реально используемых значений).
+ +2. Почему PMTUD зависит от ICMP, и что происходит, если файрвол на пути блокирует ICMP + целиком? +
ОтветPMTUD узнаёт об узком месте по ICMP Destination + Unreachable с кодом «fragmentation needed and DF set» от промежуточного маршрутизатора; + если ICMP заблокирован, отправитель никогда не получит этот сигнал и будет слать пакеты, + которые молча теряются на узком месте — классическая причина «маленькие пакеты проходят, + большие зависают» через VPN/туннель.
+ +3. При проверке контрольной суммы IPv4-заголовка сумма всех 16-битных слов (включая само + поле суммы) даёт 0xFFFF. Почему именно это число, а не 0? +
ОтветПоле контрольной суммы — это инверсия суммы остальных + слов; сумма числа и его инверсии в дополнительном коде всегда даёт все единицы (0xFFFF), + это математическое свойство one's complement, а не произвольный выбор.
+ +4. Подсеть `10.0.1.130/26`. Назови адрес сети, broadcast и диапазон хостов, не считая заново + с нуля — по правилу из этого урока. +
ОтветСеть `10.0.1.128`, broadcast `10.0.1.191`, хосты + `10.0.1.129`–`10.0.1.190` (62 адреса, /26 = 64 адреса минус 2 служебных).
+ +5. Почему `/31` — единственная маска (кроме /32) с `host_count`, который не считается по + формуле `2^(32-prefix) - 2`? +
ОтветRFC 3021: на линке точка-точка из всего двух адресов + резервировать отдельно сеть и broadcast бессмысленно — оба адреса используют как хостовые, + поэтому `host_count = 2`, а не `2^1 - 2 = 0`.
+ +6. Коммутатор получил кадр с MAC получателя, которого нет в его FDB. Что он сделает и чем + это временно похоже на поведение хаба? +
ОтветРазошлёт кадр на все порты, кроме входного (flooding) — + как и хаб, который всегда рассылает на все порты; разница в том, что у коммутатора это + разовое поведение для конкретного неизвестного адреса, а не постоянный режим работы для + всего трафика.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D5_algo.md b/lessons/D5_algo.md new file mode 100644 index 0000000..cca7684 --- /dev/null +++ b/lessons/D5_algo.md @@ -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& 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 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& 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& weight, const std::vector& value, int W) { + std::vector 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& coins, int amount) { + std::vector 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& coins) { + std::vector 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& coins) { + std::vector 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> dp(n + 1, std::vector(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> dp(n + 1, std::vector(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& a)`. + +**Наивное O(n²)**: `dp[i]` — длина LIS, заканчивающейся ровно на элементе `a[i]`. + +```c++ +int lis_naive(const std::vector& a) { + int n = a.size(); + std::vector 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& a) { + std::vector 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 элементах. + +
+Проверь себя + +1. Массив весов `[1, 3, 4]`, ценностей `[15, 20, 30]`, вместимость `W=4`. Какой ответ даст + 0/1-рюкзак и почему это не «взять предметы 1 и 2» (вес 1+3=4)? +
Ответ35 — рюкзак действительно может выбрать предметы с весами + 1 и 3 (суммарный вес 4, ценность 15+20=35); предмет весом 4 отдельно даёт только 30, что + меньше. Ответ 35, а не 30 — если получилось 30, значит в переборе не сравнили оба варианта + заполнения веса 4.
+ +2. Почему для Coin Change II (подсчёт числа способов, не минимума) важно, какой цикл + снаружи — монета или сумма, — а для Coin Change (минимум монет) не важно? +
ОтветМинимум не зависит от порядка, в котором монеты + рассматриваются — это просто оптимум по всем комбинациям. Подсчёт способов чувствителен к + порядку: монета снаружи фиксирует относительный порядок монет и схлопывает перестановки + одной комбинации в один результат (комбинации); сумма снаружи пересчитывает каждую монету + как потенциально «последнюю» для каждой суммы заново, из-за чего одна комбинация + монет считается один раз на каждую перестановку (перестановки).
+ +3. Для массива `[3, 1, 4, 1, 5, 9, 2, 6]` пройдите LIS через `tails` вручную. Чему равен + `tails` после обработки первых пяти элементов (`3, 1, 4, 1, 5`)? +
Ответ`[1, 4, 5]`: 3 → `[3]`; 1 заменяет 3 → `[1]`; 4 больше + всех → `[1,4]`; 1 заменяет первый элемент ≥1 (сам 1) → `[1,4]` без изменений; 5 больше + всех → `[1,4,5]`.
+ +4. Мемоизация `fib_memo(n, cache)` вызвана с n = 200 000. Почему это скорее упадёт по + SIGSEGV, чем отработает медленно? +
ОтветКаждый рекурсивный вызов держит кадр стека, пока не + вернётся его результат; глубина рекурсии здесь равна n, а размер стека потока ограничен + (типично несколько МБ) — при n=200 000 кадров стек переполняется раньше, чем вычисление + успевает завершиться. Табуляция (`fib_tab`) того же n отработает без проблем — там нет + рекурсии, только цикл.
+ +
+ +## Материалы + +- 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, лимиты `` (`INT_MAX`) — https://en.cppreference.com/w/cpp/types/climits diff --git a/lessons/D5_bash.md b/lessons/D5_bash.md new file mode 100644 index 0000000..797d99a --- /dev/null +++ b/lessons/D5_bash.md @@ -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//status`** — читаемая человеком сводка о процессе: состояние, использование + памяти (VmRSS и другие), UID/GID, число потоков. +- **`/proc//cmdline`** — команда запуска процесса с аргументами, разделёнными нулевыми + байтами `\0` (не пробелами — тот же принцип, что у `find -print0`, аргумент с пробелом + внутри не спутать с границей между аргументами). +- **`/proc//fd/`** — каталог, где каждый файл — символическая ссылка на открытый этим + процессом файловый дескриптор (обычный файл, сокет, pipe); число ссылок в этом каталоге — + реальное число открытых дескрипторов процесса прямо сейчас. +- **`/proc//maps`** — карта отображений памяти процесса: диапазоны адресов, права доступа + (r/w/x), какому файлу или сегменту (куча, стек, конкретная библиотека) принадлежит каждый + диапазон. +- **`/proc/cpuinfo`** — параметры процессора (модель, число ядер, флаги поддерживаемых + инструкций). +- **`/proc/meminfo`** — распределение оперативной памяти: занято, свободно, в кэше, в буферах + (источник данных для `free -h`). +- **`/proc//stat`** — машинно-читаемая (не для человека) строка с числовыми полями + состояния процесса, включая, например, время в user/kernel-режиме; источник данных для + `ps`/`top`. + +Утилиты вроде `ps`, `top`, `free`, `iostat` — это не отдельный механизм сбора данных, а тонкая +обёртка над чтением этих же файлов `/proc`; ядро уже держит эти данные готовыми, специально +«собирать» их не нужно. + +**Ловушки** +- Читать `/proc//cmdline` как обычную строку с пробелами в качестве разделителей + аргументов → аргумент вида `"two words"` неотличим от двух отдельных аргументов → нужен + именно разбор по `\0`. + +**Факты для карточек** +- base | Что такое /proc с точки зрения хранения данных? — виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске +- base | Чем разделены аргументы в /proc//cmdline? — нулевыми байтами `\0` +- core | Что можно узнать из /proc//maps? — диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон +- core | Откуда `free -h` берёт данные о памяти? — из /proc/meminfo + +Почему дальше: `/proc//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//fd/`, но в удобном для чтения формате с + дополнительной информацией. +- **`ss -tan`** — состояние TCP-сокетов (замена устаревшему `netstat`): адреса, порты, + состояние соединения (`ESTABLISHED`, `TIME_WAIT` и так далее — см. состояния TCP). + +**Факты для карточек** +- base | Что показывает `strace`? — системные вызовы процесса с аргументами и результатом, построчно +- core | Чем `lsof -p PID` и `/proc//fd/` пересекаются по смыслу? — оба показывают список файловых дескрипторов, открытых процессом + +Ссылки на задачи этого дня: `tasks/08_bash` — разбор access-лога (`TOTAL`/`TOP`/`5XX`) на +файле до миллиона строк; построчный bash-цикл не проходит по времени (раздел 7), нужен +`awk`/`sort` за один-два прохода. + +
+Проверь себя + +1. Скрипт с `set -e` содержит `if grep -q pattern file; then echo found; fi`, и `grep` не + находит `pattern` (возвращает 1). Остановится ли скрипт на этой строке? +
ОтветНет — команда внутри условия `if` не подчиняется `-e`, + даже если она вернула ненулевой код; это одно из исключений `-e`, скрипт продолжит + выполнение дальше.
+ +2. `grep pattern missing_file.txt | wc -l` при отсутствующем файле напечатает `0`, а `$?` + пайплайна без `pipefail` будет `0`. Как обнаружить, что реальная проблема — отсутствующий + файл, а не отсутствие совпадений? +
ОтветВключить `set -o pipefail` — тогда код возврата пайплайна + станет кодом первой упавшей команды (`grep` на несуществующем файле), а не только `wc -l`; + либо проверить `${PIPESTATUS[0]}` явно после пайплайна.
+ +3. На файле в миллион строк нужно посчитать число запросов на каждый IP-адрес. Почему решение + через `while read -r line; do ...; done < access.log` с вызовом `awk`/`grep` внутри цикла + будет на порядки медленнее, чем `awk '{print $1}' access.log | sort | uniq -c`? +
ОтветЦикл на bash с внешней командой внутри порождает (fork+exec) + новый процесс на каждую из миллиона строк; связка `awk | sort | uniq -c` — это фиксированное + малое число процессов (по одному на каждый элемент конвейера), каждый из которых делает + один линейный проход по всему потоку целиком.
+ +4. Процесс с `ulimit -c 0` неожиданно падает по SIGSEGV под нагрузкой, воспроизвести падение + по требованию не получается. Что нужно было сделать заранее, чтобы разобрать причину + постфактум? +
ОтветВыставить `ulimit -c unlimited` до запуска процесса — + тогда при падении ядро сохранит core dump со снимком памяти на момент сбоя, и его можно + будет открыть позже (`gdb prog core`), не дожидаясь повторного воспроизведения + краша.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D5_net.md b/lessons/D5_net.md new file mode 100644 index 0000000..3466403 --- /dev/null +++ b/lessons/D5_net.md @@ -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`. + +
+Проверь себя + +1. В tcpdump видно: `[S]` от клиента, `[S.]` от сервера, дальше тишина — ACK от клиента не + приходит. В каком состоянии завис сервер и почему? +
Ответ`SYN_RECEIVED` — сервер получил SYN, отправил SYN-ACK, но + финальный ACK не дошёл (например, заблокирован файрволом), поэтому рукопожатие не + завершилось и сервер ждёт третий сегмент.
+ +2. Почему TIME_WAIT длится именно 2×MSL, а не произвольное короткое время вроде 1 секунды? +
ОтветСторона, закрывшая соединение последней, должна успеть + поймать задержавшиеся в сети дубликаты старых сегментов (которым отводится время жизни + MSL на путь туда и обратно — отсюда удвоение) и быть готовой повторно отправить последний + ACK, если его потеря заставит партнёра повторить FIN. Слишком короткий таймаут рискует + освободить порт раньше, чем дубликат добежит и будет ошибочно принят новым + соединением.
+ +3. Клиент шлёт данные маленькими кусками через `write()` без `TCP_NODELAY`, сервер использует + delayed ACK. Почему передача может ощутимо тормозить, хотя пропускной способности канала + достаточно? +
ОтветАлгоритм Нейгла на клиенте задерживает отправку + следующего маленького куска, пока не придёт ACK на предыдущий; сервер с delayed ACK не + спешит слать этот ACK отдельно, ожидая данных для отправки в обратную сторону — обе + стороны ждут друг друга, и задержка растёт не от нехватки полосы, а от этого + взаимного ожидания.
+ +4. При передаче по сети с потерями каждые несколько RTT срабатывает fast retransmit. Что + происходит с `cwnd` при этом и почему не сбрасывается до минимального значения, как при + таймауте RTO? +
Ответ`ssthresh` и `cwnd` уменьшаются вдвое (`cwnd/2`), а не до + минимума — 3 дублирующих ACK означают, что сеть в целом жива и часть сегментов всё же + доходит, это более мягкий сигнал перегрузки, чем полное отсутствие ответа при таймауте + RTO, поэтому реакция мягче.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D6_docker.md b/lessons/D6_docker.md new file mode 100644 index 0000000..59f75cf --- /dev/null +++ b/lessons/D6_docker.md @@ -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 `** запускает дополнительную команду внутри уже работающего +контейнера — типичный случай: открыть интерактивный shell (`docker exec -it +bash`) для отладки живого процесса без его остановки. **`docker logs `** показывает +вывод stdout/stderr, который написал процесс с PID 1 внутри контейнера, — то, что видно в +терминале при `docker run` без `-d`, доступно так же и после отсоединения. + +**Факты для карточек** +- base | Что делает флаг `--rm` у `docker run`? — автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса +- base | Какая команда даёт интерактивный shell в уже запущенном контейнере? — `docker exec -it 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`, без этого флага запрещён). + +
+Проверь себя + +1. В Dockerfile сначала `COPY . .` копирует весь исходный код, а уже потом идёт установка + зависимостей через `apt-get`. Что произойдёт со временем пересборки при правке одной + строки кода и почему? +
ОтветСлой с `COPY . .` изменится при любой правке кода, и по + правилу линейного кэша все последующие слои — включая установку зависимостей — + пересоберутся заново, заново скачивая пакеты из сети. Правильный порядок — сначала + зависимости (меняются редко), потом код.
+ +2. Финальный образ после multi-stage build весит существенно меньше, чем промежуточный + `builder`-образ, хотя бинарник тот же самый. За счёт чего? +
ОтветВторой `FROM` начинает независимый образ, в который + `COPY --from=builder` переносит только сам готовый бинарник — весь тулчейн сборки + (компилятор, cmake, заголовки и их слои) остаётся только в промежуточном `builder`-образе + и в финальный не попадает.
+ +3. Тестовый контейнер убит с кодом завершения 137 после превышения лимита `--memory=256m`. + Что произошло механически и с чем ещё встречается тот же код завершения вне контейнеров? +
Ответcgroups-контроллер памяти зафиксировал превышение лимита + и OOM-killer прислал процессу `SIGKILL` (сигнал 9); shell/wait-конвенция кодирует + завершение по сигналу как 128 + номер сигнала = 137. Тот же код 137 виден при обычном + `kill -9` процесса вне всякого контейнера — механизм кодирования тот же.
+ +4. Почему root внутри контейнера — больший риск, чем root внутри виртуальной машины, + изолирующей тот же процесс? +
ОтветКонтейнер не имеет собственного ядра — он использует ядро + хоста напрямую, поэтому уязвимость в этом общем ядре, неверно выданные capabilities или + смонтированный `docker.sock` могут дать выход из контейнерного root прямо в root хоста. + VM с отдельным гостевым ядром не даёт такого прямого пути.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D6_kernel.md b/lessons/D6_kernel.md new file mode 100644 index 0000000..53ef601 --- /dev/null +++ b/lessons/D6_kernel.md @@ -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 `** — загружает модуль по прямому пути к файлу, без разрешения + зависимостей от других модулей. +- **`rmmod `** — выгружает модуль по имени (если счётчик использования равен нулю). +- **`modprobe `** — загружает модуль по имени, сам находит файл в + `/lib/modules/$(uname -r)/` и подгружает зависимости по карте `modules.dep` (строится + утилитой `depmod`) — на практике используется чаще `insmod`, именно из-за автоматических + зависимостей. +- **`lsmod`** — список загруженных сейчас модулей (по сути читает `/proc/modules`), с + размером и счётчиком использования. +- **`modinfo `** — метаданные модуля (лицензия, описание, параметры, зависимости) + без его загрузки в ядро. + +**Факты для карточек** +- base | Чем `insmod` отличается от `modprobe`? — `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости +- base | Какая команда показывает метаданные модуля, не загружая его? — `modinfo` +- core | Откуда `modprobe` берёт карту зависимостей модулей? — из файла `modules.dep`, который строит утилита `depmod` + +Почему дальше: раз модуль — это отдельно загружаемый код, у него должна быть точка входа и +точка выхода из ядра — разберём структуру простейшего модуля. + +## 3. Структура простейшего модуля + +```c +#include +#include +#include + +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(имя, тип, права)` (заголовок ``) делает переменную +настраиваемой при загрузке модуля — значение можно передать прямо в `insmod modname.ko +count=5`. Третий аргумент — режим доступа файла в sysfs: `0` означает, что параметр доступен +только при загрузке и не публикуется отдельным файлом, ненулевое значение (например +`S_IRUGO` — чтение всем) создаёт файл `/sys/module/<имя_модуля>/parameters/`, из +которого параметр можно прочитать (а при подходящих правах — и переписать) уже после загрузки. + +**Факты для карточек** +- base | Как передать параметр модулю при загрузке? — `insmod modname.ko имя_параметра=значение` +- core | Что означает третий аргумент `module_param`, если он ненулевой? — параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа + +Почему дальше: файл параметра в sysfs — частный случай общего механизма, которым ядро вообще +разговаривает с пользовательским пространством через файловую систему, — `/proc` и `/sys`. + +## 5. `/proc` и `/sys` как интерфейс к ядру + +**`/proc`** (procfs) — виртуальная файловая система, изначально созданная для информации о +процессах (`/proc//...`), позже расширенная общей информацией о ядре (`/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). + +
+Проверь себя + +1. Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы, + прямо внутри `.read` драйвера? +
ОтветЭто указатель в чужом (пользовательском) адресном + пространстве: соответствующая страница памяти может быть не загружена или указатель + вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется + аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые + безопасно обрабатывают этот переход.
+ +2. На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без + пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это + возможно и что бы сломалось без него? +
ОтветDevice tree: адреса регистров и конфигурация железа + вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без + device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение + конфигурации платы пришлось бы переписывать и пересобирать сам код ядра.
+ +3. Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но + переменная объявлена без `volatile` — цикл не завершается, хотя железо статус давно + сменило. В чём причина и как это чинится? +
ОтветКомпилятор решил, что раз в теле цикла переменная кодом + программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с + закэшированным в регистре значением — оптимизация, законная для обычной переменной, но + ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на + регистр как `volatile`, чтобы компилятор перечитывал память при каждой + итерации.
+ +4. Почему для системного вызова на x86-64 нужна специальная инструкция `syscall`, а не + обычный вызов функции ядра через `call` по известному адресу? +
ОтветОбычный `call` не меняет уровень привилегий процессора — + пользовательская программа в кольце 3 так и осталась бы в кольце 3, не получив доступа к + привилегированным операциям ядра. `syscall` — специальная инструкция, которая одновременно + переключает CPU в кольцо 0 и передаёт управление фиксированному обработчику ядра, что + обычный переход по адресу сделать не может.
+ +
+ +## Материалы + +- 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 diff --git a/lessons/D7_mock.md b/lessons/D7_mock.md new file mode 100644 index 0000000..e544611 --- /dev/null +++ b/lessons/D7_mock.md @@ -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`. +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(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 | Как правильно называть пробел в алгоритмах на собеседовании — скрывать или называть прямо? — называть прямо: конкретная тема добирается сейчас, с указанием, что уже понятно (сложность, инварианты) + +## Проверь себя + +
+1. Почему для top-K наибольших элементов в потоковом режиме нужен именно min-heap размера k, а не max-heap? +Min-heap хранит k текущих кандидатов, и на вершине (O(1) доступ) всегда самый маленький из +них — именно с ним сравнивают каждый новый элемент: если новый больше минимума кучи, минимум +вытесняется, новый занимает его место. Max-heap размера k показывал бы на вершине самый +большой из текущих k кандидатов, что не даёт дешёвого способа проверить "стоит ли вытеснять +кого-то" — пришлось бы искать минимум отдельно, теряя весь выигрыш от кучи. +
+ +
+2. Процесс завершился с кодом 139. Что произошло и какой командой в коде это разбирают? +139 = 128 + 11, то есть процесс убит сигналом SIGSEGV — падение по памяти (например, +разыменование невалидного указателя). В коде это разбирается макросами WIFSIGNALED(status) и +WTERMSIG(status) над status, полученным из wait/waitpid, а не вычитанием 128 вручную. +
+ +
+3. Почему epoll_wait даёт O(1) на готовое событие, а select — O(n) на весь набор при каждом вызове? +select при каждом вызове проходит весь переданный набор дескрипторов целиком, чтобы понять, +какие из них готовы — работа пропорциональна общему числу отслеживаемых дескрипторов +независимо от того, сколько реально готовы. epoll разносит регистрацию (epoll_ctl, список +интереса на красно-чёрном дереве в ядре) и ожидание (epoll_wait): ядро само добавляет +дескриптор в список готовых асинхронно, когда его состояние меняется, и epoll_wait просто +отдаёт содержимое этого списка — работа пропорциональна числу реально готовых дескрипторов. +
+ +
+4. Для 10.0.1.130/26 назовите адрес сети и диапазон доступных хостов, объяснив расчёт. +/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. +
+ +
+5. Почему TCP-рукопожатие требует три сегмента, а не два? +Соединение TCP полнодуплексное — у каждого направления свой независимый ISN (начальный +порядковый номер), и обе стороны должны не только сообщить свой ISN, но и получить +подтверждение, что собеседник его получил. После SYN и SYN-ACK сервер ещё не знает, дошёл ли +его SYN-ACK до клиента — только финальный ACK клиента даёт это подтверждение и завершает +синхронизацию обоих направлений. +
+ +
+6. Чем зомби-процесс отличается от процесса-сироты и что физически занимает зомби? +Зомби — завершившийся процесс, чья запись (код возврата) ещё не забрана родителем через +wait/waitpid; он не занимает память, но занимает слот в таблице процессов. Сирота — процесс, +чей родитель умер раньше него; его усыновляет init/systemd (PID 1), и это состояние не +связано с зомби напрямую — сирота продолжает работать, зомби уже завершился и ждёт только +уборки записи. +
+ +## Материалы + +- 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