Compare commits

..

1 Commits

Author SHA1 Message Date
tura 74dd44fbfc проверка права записи 2026-09-23 16:39:13 +07:00
50 changed files with 200 additions and 12336 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
diag/solutions/ diag/solutions/
diag/submissions/ diag/submissions/
diag/dist/ diag/dist/
diag/tasks/*/t_* diag/results_*.json
diag/tasks/*/t_0*
diag/tasks/07_epoll/solution diag/tasks/07_epoll/solution
diag/tasks/09_gdb/crash_checked diag/tasks/09_gdb/crash_checked
diag/tasks/08_bash/big.log diag/tasks/08_bash/big.log
-369
View File
@@ -1,369 +0,0 @@
# База перед тех-скринингом (HR, видеосвязь)
Собрано под вакансию Eltex «Разработчик C/C++» (Новосибирск, 1–3 года, ключевые навыки
C/C++, Linux, TCP/IP). Формат — вопрос → короткий ответ. Читать подряд, вслух проговаривая
ответы: на скрининге важно не «знаю», а «скажу связно за 20 секунд».
---
## 1. ООП (спрашивают почти всегда)
**Что такое ООП?** Парадигма, где программа — набор объектов, обменивающихся сообщениями; объект объединяет данные и поведение.
**Три кита?** Инкапсуляция, наследование, полиморфизм. (Иногда добавляют абстракцию.)
**Инкапсуляция?** Сокрытие внутреннего состояния за публичным интерфейсом: поля private, доступ через методы.
**Наследование?** Переиспользование и расширение: класс-наследник получает члены базового.
**Полиморфизм?** Один интерфейс — разные реализации. В C++ динамический полиморфизм — через `virtual` (решение принимается в рантайме по фактическому типу), статический — перегрузка и шаблоны (на компиляции).
**Класс и объект?** Класс — описание (чертёж), объект — экземпляр в памяти.
**struct и class в C++?** Разница только в доступе по умолчанию: у struct public, у class private.
**Что такое виртуальная функция?** Функция, вызываемая по фактическому типу объекта. Реализуется через таблицу виртуальных функций (vtable) — отсюда +указатель на объект.
**Зачем виртуальный деструктор?** Если удалять наследника через `Base*` без него — UB, деструктор наследника не вызовется, ресурсы утекут.
**Можно ли вызвать virtual из конструктора?** Можно, но вызовется версия текущего класса, а не наследника: объект ещё не достроен.
**Абстрактный класс?** Класс с хотя бы одной чисто виртуальной функцией (`= 0`), экземпляр создать нельзя. В C++ нет отдельного `interface` — его роль играет такой класс.
**Перегрузка и переопределение — в чём разница?** Перегрузка: несколько функций с одним именем и разными параметрами в одной области видимости. Переопределение: virtual-функция наследника с той же сигнатурой.
**Что такое static-член?** Общий для всех объектов класса, живёт вне объекта; у static-метода нет `this`.
**Что такое SOLID?** S — единственная ответственность, O — открыт для расширения/закрыт для изменения, L — подстановка Лисков, I — разделение интерфейсов, D — инверсия зависимостей. Достаточно назвать и объяснить одну-две.
**Композиция или наследование?** Предпочитают композицию: наследование жёстко связывает классы, композиция гибче.
---
## 2. C/C++ (ядро вакансии)
**Указатель и ссылка — разница?** Указатель — адрес, может быть null, его можно менять и арифметикой ходить. Ссылка — псевдоним существующего объекта, не бывает null, перепривязать нельзя.
**new/delete против malloc/free?** `new` вызывает конструктор и возвращает типизированный указатель, `malloc` даёт сырой блок; `new` бросает исключение, `malloc` возвращает NULL; `delete` вызывает деструктор.
**Стек и куча?** Стек — автоматическая память (локальные переменные, быстрая, ограничена). Куча — динамическая (`new`/`malloc`, больше, но освобождать вручную).
**Что такое утечка памяти?** Выделили и не освободили. Ищется ASAN (LeakSanitizer), valgrind.
**RAII?** Ресурс захватывается в конструкторе, освобождается в деструкторе — утечек нет даже при исключениях. Так работают `std::vector`, `std::ifstream`, умные указатели.
**unique_ptr и shared_ptr?** `unique_ptr` — единственный владелец, без счётчика, дешевле. `shared_ptr` — общий, считает ссылки, есть накладные расходы и проблема циклических ссылок (`weak_ptr`).
**Правило трёх/пяти?** Если класс владеет ресурсом и задан деструктор — надо определить копирование и перемещение (в C++11+: конструктор/присваивание копированием и перемещением).
**Что делает std::move?** Ничего сам по себе — это `static_cast` к rvalue-ссылке. Перемещение выполняет конструктор/присваивание перемещением.
**Что такое UB?** Undefined behavior — стандарт не накладывает требований. Примеры: выход за границы, знаковое переполнение, сдвиг в знаковый бит, разыменование null, гонка данных.
**Что проверяют ASAN и UBSAN?** ASAN — выходы за границы, use-after-free, двойное освобождение, утечки. UBSAN — неопределённое поведение (переполнения, сдвиги, выравнивание).
**sizeof(struct { char a; int b; char c; }) на x86-64?** 12: `a` в 0, padding 1–3, `b` в 4–7, `c` в 8, padding 9–11 до выравнивания структуры по 4.
**Порядок вычисления аргументов f(a(), b()) задан?** Нет, не определён — нельзя полагаться.
**Что делает const после сигнатуры метода?** Делает `this` указателем на константу: метод не меняет поля объекта, и его можно вызывать у const-объекта.
**Что такое перегрузка операторов и зачем?** Своя семантика для `+`, `==`, `[]` и т.п., чтобы тип вёл себя как встроенный. Нельзя перегружать `.`, `::`, `?:`, `sizeof`.
**Компиляция по шагам?** Препроцессор (`#include`, `#define`) → компиляция в объектные файлы → линковка в исполняемый. Ошибка «undefined reference» — от линковщика.
**Зачем header guards?** Чтобы заголовок не подключался дважды — иначе повторное определение типа.
**Что такое forward declaration?** Объявление класса/функции без определения, чтобы не тянуть `#include`; достаточно для указателей и ссылок.
**Что такое шаблон (template)?** Обобщённый код, тип подставляется при компиляции. Цена — ошибки и раздувание кода.
**Что такое лямбда?** Анонимная функция с захватом контекста (`[&]`, `[=]`). Синтаксический сахар над функтором.
**Что такое пространство имён?** Способ избежать конфликта имён; `std::` — пример.
**volatile — что это?** Говорит компилятору не кэшировать значение в регистре (регистры железа, переменные, меняемые извне). Это не про многопоточность — для неё `std::atomic`.
---
## 3. STL (обязательно, спрашивают про сложности)
**Какие контейнеры знаешь?** Последовательные: `vector`, `list`, `deque`, `array`. Ассоциативные: `map`, `set` (деревья, O(log n)). Хеш-таблицы: `unordered_map`, `unordered_set` (O(1) в среднем). Адаптеры: `stack`, `queue`, `priority_queue`.
**vector — сложности?** Доступ по индексу O(1), push_back амортизированное O(1), вставка/удаление в середине O(n), поиск O(n).
**Почему push_back амортизированное O(1)?** Ёмкость удваивается, реаллокация O(n) случается редко — в среднем одна операция O(1).
**list — сложности?** Доступ по номеру O(n), вставка/удаление при известном узле O(1), поиск O(n). Память дороже из-за указателей.
**map против unordered_map?** `map` — красно-чёрное дерево: O(log n), ключи упорядочены. `unordered_map` — хеш-таблица: O(1) в среднем, порядок не определён, нужен `std::hash`.
**Что быстрее и когда?** Поиск — обычно `unordered_map`. Если нужен порядок обхода или диапазонные запросы — `map`.
**Итераторы и их инвалидация?** Итератор — обобщённый указатель на элемент. У `vector` реаллокация инвалидирует все итераторы, у `list` — только итератор удалённого элемента.
**Как удалить элементы из vector по условию?** Идиома erase-remove: `v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());`.
**reserve и resize?** `reserve` меняет ёмкость (память), не размер. `resize` меняет размер (создаёт/удаляет элементы).
**emplace_back против push_back?** `emplace_back` конструирует объект на месте — без копирования/перемещения.
**Как работает priority_queue?** Бинарная куча поверх `vector`: вставка O(log n), извлечение максимума O(log n), доступ к максимуму O(1). По умолчанию max-куча.
**Какие алгоритмы STL знаешь?** `sort` (O(n log n), неустойчив), `stable_sort`, `find`, `count`, `lower_bound`/`upper_bound` (O(log n) на отсортированном), `accumulate`, `remove_if`, `unique`, `next_permutation`.
**Что такое функтор и предикат?** Объект с `operator()`. Предикат — функция/функтор, возвращающий bool, используется в алгоритмах.
**string — это контейнер?** Да, `std::basic_string<char>`: `size()`, `substr`, `find`, `append`; данные лежат непрерывно, `.c_str()` даёт C-строку.
---
## 4. Linux и ОС
**Процесс и поток?** Процесс — программа в исполнении со своим адресным пространством и PID. Поток — единица выполнения внутри процесса: общая память, свой стек и регистры.
**Что делает fork()?** Создаёт копию процесса. Возвращает дважды: в родителе — PID ребёнка, в ребёнке — 0, при ошибке −1.
**Что с памятью при fork()?** Copy-on-write: страницы общие, копируются при первой записи.
**exec?** Заменяет образ текущего процесса (код, данные, стек). При успехе не возвращается. `fork` + `exec` — запуск внешней программы.
**Зачем waitpid?** Забрать код возврата и не оставлять зомби.
**Зомби и сирота?** Зомби — завершившийся процесс, чья запись ждёт `wait`. Сирота — процесс, чей родитель умер; его усыновляет init (PID 1).
**Коды возврата 137 и 139?** 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV). Так кодирует оболочка.
**Сигналы, основные?** SIGINT (2, Ctrl+C), SIGKILL (9, не перехватить), SIGTERM (15, корректное завершение), SIGSEGV (11), SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17).
**SIGTERM против SIGKILL?** SIGTERM можно перехватить и завершиться аккуратно, SIGKILL нельзя ни перехватить, ни проигнорировать.
**Файловый дескриптор?** Число, по которому программа обращается к открытому файлу, сокету, pipe. 0/1/2 — stdin/stdout/stderr.
**Что такое epoll?** Механизм мультиплексирования: ядро сообщает о готовых дескрипторах, сложность O(1) на событие. `select`/`poll` перебирают все дескрипторы — O(n).
**mmap?** Отображает файл в память: страницы подгружаются по обращению, вместо read/write работаешь с указателем.
**Виртуальная память и страница?** Каждому процессу — своё адресное пространство; память делится на страницы (обычно 4 КБ), отображаемые на физическую.
**Что такое swap?** Выгрузка страниц на диск при нехватке памяти. Медленно, но спасает от OOM.
**Что такое inode?** Структура метаданных файла (права, размер, владелец, указатели на блоки). Имя файла — отдельно, в каталоге.
**Права доступа 755?** Владелец rwx, группа r-x, остальные r-x. `chmod`, `chown` — смена прав и владельца.
**Что такое pipe и перенаправление?** `|` соединяет вывод одной программы со входом другой; `>`, `>>`, `2>&1` перенаправляют потоки.
**Как посмотреть процессы и нагрузку?** `ps aux`, `top`/`htop`, `pidstat`; `free -h` — память, `df -h` — диски, `iostat` — ввод-вывод.
**Что такое демон?** Фоновый процесс без управляющего терминала; запускается systemd (unit-файлы, `systemctl start/status`).
**Что такое ядро и системный вызов?** Ядро управляет ресурсами; программа обращается к нему через системные вызовы (`open`, `read`, `write`, `fork`). `strace` показывает эти вызовы.
**Мьютекс, семафор, атомик?** Мьютекс — блокировка на одного владельца. Семафор — счётчик на N владельцев. Атомик — операция без блокировки, аппаратно.
**Что такое гонка данных и дедлок?** Гонка — несинхронизированный доступ к общей памяти (ловит ThreadSanitizer). Дедлок — потоки ждут ресурсы друг друга; лечится единым порядком захвата.
**Условие переменная (condition_variable)?** Способ ждать событие: ждать в цикле с предикатом (`while (!ready) cv.wait(lock);`) — защита от ложных пробуждений.
---
## 5. Сети: OSI, TCP/IP, TCP, UDP (ядро вакансии)
**Семь уровней OSI?** Физический, канальный, сетевой, транспортный, сеансовый, представления, прикладной. Практически работают с пятью.
**Что на каждом уровне?** L1 — биты и среды (витая пара, оптика). L2 — кадры, MAC, коммутатор. L3 — пакеты, IP, маршрутизатор. L4 — TCP/UDP, порты. L5–L7 — сессии, кодирование, прикладные протоколы (HTTP, DNS).
**TCP/IP модель?** Канальный, интернет, транспортный, прикладной. Соответствует OSI снизу.
**Инкапсуляция?** Каждый уровень добавляет свой заголовок: Ethernet (14 байт) → IP (20) → TCP (20) → данные. При передаче пакет кладут внутрь кадра.
**Сколько байт в Ethernet-заголовке?** 14 (MAC получателя 6, MAC отправителя 6, тип 2). VLAN 802.1Q добавляет 4.
**MAC-адрес?** 48 бит, физический адрес интерфейса, действует в пределах сегмента.
**ARP?** Находит MAC по IP в локальном сегменте: широковещательный запрос → ответ.
**MTU?** Максимальный размер полезной нагрузки кадра, для Ethernet 1500 байт. Больше — фрагментация или ошибка.
**IPv4-заголовок, что важно?** Минимум 20 байт: версия/IHL, длина, TTL, протокол, контрольная сумма заголовка, адреса источника и назначения. TTL уменьшается на 1 на каждом маршрутизаторе.
**Маски и подсети?** Маска делит адрес на сеть и узел. `/24` — 254 узла, `/26` — 62, `/30` — 2 (точка-точка). Широковещательный адрес — все единицы в хостовой части.
**Маршрутизация?** Узел сравнивает адрес со своей маской: своя подсеть — ARP и напрямую, чужая — на шлюз. Шлюз по умолчанию — выход в другие сети.
**ICMP?** Служебные сообщения: `ping` (echo request/reply), `traceroute` (истечение TTL), «destination unreachable».
**Порядок установки TCP-соединения?** SYN → SYN-ACK → ACK (трёхстороннее рукопожатие).
**Как закрывается TCP?** FIN → ACK → FIN → ACK; сторона, закрывшая первой, ждёт TIME_WAIT (2×MSL), чтобы добить потерянные сегменты.
**Флаги TCP?** SYN, ACK, FIN, RST, PSH, URG.
**Что даёт TCP?** Надёжность: нумерация, подтверждения, ретрансмиссии по таймауту, окно (управление потоком), контроль перегрузки, сохранение порядка.
**Что такое окно?** Сколько байт можно отправить без подтверждения. Управляет скоростью и защищает от переполнения получателя.
**UDP?** Без соединения и гарантий: заголовок 8 байт, порядок и доставка не гарантируются. Быстро и дёшево: DNS, DHCP, VoIP, игры, стриминг.
**TCP или UDP — когда что?** TCP — когда важна целостность (файлы, HTTP, SSH). UDP — когда важна задержка или данные самодостаточны (DNS, видео, телеметрия).
**Что такое порт?** Номер приложения на узле (16 бит). Известные: 22 SSH, 53 DNS, 80 HTTP, 443 HTTPS.
**DNS?** Превращает имя в IP. Обычно UDP/53, при больших ответах — TCP/53.
**DHCP?** Автоматическая выдача IP, маски, шлюза, DNS.
**NAT?** Подмена адресов при выходе в интернет: много внутренних устройств через один внешний IP.
**Сокеты: как устроен сервер?** `socket` → `bind` → `listen` → `accept` → `read`/`write` → `close`. Клиент: `socket` → `connect`. Для множества соединений — `epoll` и неблокирующие сокеты.
**Что такое неблокирующий сокет и EAGAIN?** Вызов не ждёт данных: если их нет, возвращает −1 и `errno = EAGAIN` — надо вернуться позже.
**Чем смотрят трафик?** `tcpdump`, Wireshark. Базово: `tcpdump -i eth0 -nn port 80`.
---
## 6. Многопоточность (в вакансии: многопоточные приложения)
**Как создать поток в C++?** `std::thread t(f, args); t.join();` (или `detach`, но тогда следи за временем жизни).
**Как защитить общие данные?** `std::mutex` + `std::lock_guard`/`std::unique_lock`; для счётчиков — `std::atomic`.
**Что такое ложное пробуждение?** `wait` может вернуться без сигнала — поэтому ждут в цикле с предикатом.
**Как избежать дедлока?** Единый порядок захвата мьютексов, `std::lock`/`std::scoped_lock` для нескольких сразу, не держать блокировку при вызове чужого кода.
**Producer/consumer — как?** Очередь + мьютекс + condition_variable: производитель кладёт и уведомляет, потребитель ждёт и забирает.
**Пул потоков зачем?** Создание потока дорого; пул переиспользует N потоков и очередь задач.
**Потокобезопасный size()?** Нужен либо атомарный счётчик, либо мьютекс: без этого чтение и запись — гонка данных.
---
## 7. Git
**Основной цикл работы?** `git clone` → `git checkout -b feature` → правки → `git add` → `git commit` → `git push` → pull request.
**Что такое коммит, ветка, HEAD?** Коммит — снимок состояния с родителями. Ветка — подвижный указатель на коммит. HEAD — где ты сейчас.
**merge и rebase?** merge создаёт коммит слияния, история ветвится. rebase переносит коммиты поверх другой ветки — история линейная, но переписываются хеши.
**Конфликт — что делать?** Git помечает файлы, правишь вручную, `git add`, `git merge --continue` (или `--abort`).
**reset, revert, checkout?** `reset` двигает ветку (может потерять коммиты), `revert` создаёт обратный коммит — безопасно для общей ветки, `checkout`/`switch` переключает ветки и файлы.
**stash?** Отложить незакоммиченные изменения: `git stash`, потом `git stash pop`.
**fetch и pull?** `fetch` скачивает без слияния, `pull` = fetch + merge (или rebase).
**Как посмотреть историю и что менялось?** `git log --oneline --graph`, `git diff`, `git show <commit>`, `git blame`.
**Что не коммитить?** Секреты, артефакты сборки, большие бинарники — через `.gitignore`.
---
## 8. Docker
**Образ и контейнер?** Образ — неизменяемый шаблон (слои файловой системы). Контейнер — запущенный экземпляр образа плюс своё изменяемое состояние.
**Чем отличается от виртуальной машины?** Контейнер использует ядро хоста и изолирует процессы (namespaces, cgroups) — легче и стартует за секунды; VM несёт своё ядро.
**Dockerfile — ключевые инструкции?** `FROM`, `RUN`, `COPY`, `WORKDIR`, `ENV`, `CMD`/`ENTRYPOINT`, `EXPOSE`.
**Зачем слои и порядок инструкций?** Каждая инструкция — слой, слои кэшируются; часто меняющееся (код) ставят после редко меняющегося (зависимости), чтобы кэш работал.
**Volume и bind mount?** Volume — управляемое Docker хранилище (данные переживают контейнер). Bind mount — каталог хоста внутри контейнера.
**Сети в Docker?** По умолчанию bridge с внутренними IP; порты публикуются через `-p 8080:80`. Есть host и overlay для swarm.
**docker-compose?** Описание нескольких сервисов в одном YAML: `docker compose up -d`.
**Где это в Eltex?** Воспроизводимая сборка и тесты: один образ у всех, «у меня работает» перестаёт быть аргументом.
---
## 9. GDB и отладка
**Как запустить?** `g++ -g -O0 prog.cpp -o prog` (обязательно `-g`), затем `gdb ./prog`, `run`.
**Основные команды?** `break main` / `break file:line`, `run`, `next` (по шагам, через вызовы), `step` (внутрь), `continue`, `print x`, `bt` (стек вызовов), `frame N`, `watch var`, `info threads`.
**Как отладить падение?** Запустить, получить `bt`, посмотреть кадр с падением, `print` указателей; либо core dump (`ulimit -c unlimited`, затем `gdb prog core`).
**Что такое core dump?** Снимок памяти процесса в момент падения; позволяет разобраться постфактум.
**Санитайзеры?** ASAN (память), UBSAN (UB), TSan (гонки), MSan (неинициализированная память) — включаются флагом `-fsanitize=...`.
**valgrind?** Инструмент поиска утечек и ошибок памяти без пересборки; медленнее санитайзеров.
---
## 10. Bash и инструменты
**Что такое скрипт и его шапка?** `#!/usr/bin/env bash` плюс `set -euo pipefail` — падать на ошибках, на необъявленных переменных и в конвейерах.
**Переменные и аргументы?** `name=value`, `$name`, `$1..$9`, `$#`, `$@`, `$?` (код возврата), `$$` (PID).
**grep, awk, sed — для чего?** grep — поиск по шаблону, awk — обработка по колонкам и подсчёты, sed — замена/правка потока. Для больших файлов — только они, а не bash-цикл по строкам.
**Пайплайны и перенаправления?** `cmd1 | cmd2`, `>`, `>>`, `2>`, `2>&1`, `tee`.
**Как найти файлы и выполнить команду?** `find /path -name '*.log' -mtime -1`, `find ... -exec ... {} +`, `xargs`.
**Полезное на каждый день?** `ps/top`, `ss -tulpn` (порты), `df -h`, `du -sh`, `tail -f`, `journalctl -u сервис`, `systemctl status`.
---
## 11. Embedded и SoC (в вакансии «плюс», знать обзорно)
**Что такое bringup?** Запуск новой платы: uboot → ядро → корневая ФС → периферия. Задача — довести устройство до стабильной работы.
**uboot?** Загрузчик: инициализирует железо, грузит ядро и передаёт ему параметры (device tree).
**Модуль ядра?** Код, загружаемый в ядро (`insmod`/`rmmod`), с `init`/`exit`; печатает через `printk`, видно в `dmesg`.
**Символьный драйвер?** Даёт доступ к устройству как к файлу через `file_operations` (`open`, `read`, `write`, `ioctl`).
**Device tree?** Описание железа (адреса, прерывания, шины) для ядра — отдельно от кода.
**Cross-compile?** Сборка под другую архитектуру (например ARM на x86) тулчейном `arm-linux-gnueabihf-gcc`.
**Что сказать честно?** Опыта продуктовой разработки драйверов нет, теорию знаю: модуль, драйвер, device tree, cross-compile; готов добирать на месте.
---
## 12. Вопросы, которые задаст HR (и хорошие ответы)
**Расскажи о себе.** 40 секунд: кто, сколько лет в разработке, на чём пишешь (C/C++, Linux), чем занимался последним, почему интересна встраиваемая разработка и сети.
**Почему Eltex?** Продукт настоящий и низкоуровневый: коммутаторы, маршрутизаторы, GPON. Хочу расти в C++/Linux/сетях на реальном железе, а не на абстрактных сервисах.
**Что знаешь о компании?** Более 2000 человек, Новосибирск, телекоммуникационное оборудование (Ethernet-коммутаторы, сервисные маршрутизаторы, Wi-Fi, GPON, IoT), свои SoC-устройства.
**Почему уходишь с текущего места?** Без негатива: хочу ближе к системной разработке и сетям, где больше глубины.
**Готов к офису/гибриду в Новосибирске?** Отвечай прямо, как есть; если релокация — скажи, что готов обсуждать сроки.
**Ожидания по зарплате?** Назови вилку с обоснованием по рынку и добавь «готов обсуждать по итогам интервью».
**Сильные и слабые стороны?** Сильная — довожу до работающего результата, разбираюсь в отладке. Слабая — называй реальную и что делаешь: например, «раньше писал по памяти, теперь веду заметки и проверяю сложности по коду».
**Готов к задачам на алгоритмы?** Да, тренируюсь: базовые структуры, сложности, типовые задачи на строки/массивы/графы.
**Есть вопросы к нам?** Обязательно спроси: какой продукт и команда, на каком стеке и железе, как устроен онбординг, как выглядит процесс разработки и ревью, что считается успехом в первые месяцы.
**Что почитать про нас перед интервью?** Сайт Eltex, раздел про продукты и вакансию; интервью и доклады сотрудников на конференциях.
---
## 13. Шпаргалка чисел (выучить наизусть)
- Ethernet-заголовок 14 байт, VLAN-тег +4, MTU 1500.
- IPv4-заголовок 20 байт, TCP-заголовок 20 байт, UDP-заголовок 8 байт.
- MAC 48 бит, IPv4 32 бита, порт 16 бит.
- Подсети: /24 → 254 узла, /26 → 62, /30 → 2.
- log₂(1000) ≈ 10, log₂(10⁶) ≈ 20.
- 137 = SIGKILL (128+9), 139 = SIGSEGV (128+11), 143 = SIGTERM (128+15).
- Порты: 22 SSH, 53 DNS, 80 HTTP, 443 HTTPS.
-514
View File
@@ -1,514 +0,0 @@
# База перед тех-скринингом — углублённая версия
Расширенная версия `HR_BASE.md`: те же 152 вопроса в том же порядке, но каждый ответ —
объяснение на 5–8 предложений по схеме **что это → как устроено (механизм, числа) →
почему именно так → что ломается на практике → какой вопрос логично следует дальше**.
Зачем: факт без механизма не запоминается. «137 = 128 + 9» держится в голове, а «padding
в структуре» — только когда понятно, откуда берётся выравнивание. Поэтому здесь не список
фактов, а причины, из которых факты следуют.
Как читать: сначала проговори ответ вслух своими словами, потом открывай спойлер и сверяй.
Где разошёлся — там и дыра.
Формат: `▪ **N.M Вопрос**`, под ним ответ в спойлере `||...||`.
---
**1. ООП**
▪ **1.1 Что такое ООП?**
||ООП — парадигма программирования, где программа моделируется как набор объектов, взаимодействующих через вызовы методов, а не как последовательность процедур над общими данными. Каждый объект инкапсулирует своё состояние (поля) и поведение (методы), а типы объектов организованы в классы, которые задают структуру и правила поведения; связи между объектами описываются через интерфейсы, а не через прямой доступ к чужим данным. Процедурный подход при росте программы приводит к тому, что данные и функции, которые их меняют, разбросаны по коду, и любое изменение структуры данных требует правки всех мест, которые её касаются — ООП закрепляет ответственность за состояние внутри объекта, который это состояние знает. Если нарушить инкапсуляцию и дать прямой доступ к полям, любой внешний код может привести объект в некорректное состояние, и такие ошибки трудно искать, потому что причина и симптом разнесены по разным файлам. Дальше логично спросить про три кита ООП, которые как раз описывают эти механизмы по отдельности.||
▪ **1.2 Три кита?**
||Три кита ООП — инкапсуляция, наследование, полиморфизм, каждый решает свою задачу: инкапсуляция скрывает состояние объекта и даёт доступ только через методы, наследование строит новый класс на основе существующего, переиспользуя и расширяя его код, полиморфизм даёт работать с разными типами через один интерфейс, не зная заранее конкретный тип. Иногда добавляют четвёртую — абстракцию, то есть выделение существенных свойств объекта и игнорирование деталей реализации; в C++ она выражается через абстрактные классы с чисто виртуальными функциями. Порядок, в котором их обычно называют, отражает порядок усложнения: инкапсуляция — про один объект, наследование — про отношения между классами, полиморфизм — про поведение во время выполнения программы. Полиморфизм на собеседовании спрашивают глубже всего, потому что за ним стоит конкретный механизм — vtable, — тогда как инкапсуляцию и наследование чаще проверяют на понимание, а не на реализацию. HR ждёт, что кандидат назовёт все три и объяснит каждую своими словами, а не процитирует определение. Дальше стоит разобрать каждый кит отдельно, начиная с инкапсуляции.||
▪ **1.3 Инкапсуляция?**
||Инкапсуляция — сокрытие внутреннего состояния объекта от внешнего кода и предоставление доступа к нему только через контролируемый публичный интерфейс. Поля класса объявляются private (или protected для наследников), а изменение и чтение состояния идёт через public-методы — геттеры, сеттеры или методы с бизнес-логикой, которые проверяют корректность значения перед тем, как его принять. Если поля публичные, любой внешний код может присвоить им произвольное значение в обход всех проверок, и класс перестаёт гарантировать, что его объект находится в корректном состоянии — инкапсуляция превращает эту гарантию в инвариант, поддерживаемый изнутри. Типичная ошибка — сделать поля public «для скорости» в маленьком проекте, а потом обнаружить, что код в другом файле обошёл проверку и записал в поле невалидное значение, например отрицательный размер буфера; баг ищется долго, потому что место записи и место падения разнесены. Логично продолжить наследованием — следующим механизмом, который тоже завязан на разделение public/private/protected.||
▪ **1.4 Наследование?**
||Наследование — механизм, при котором новый класс (наследник) получает поля и методы существующего класса (базового) и может добавлять свои или переопределять унаследованные. В C++ оно задаётся через двоеточие в объявлении класса (`class Derived : public Base`), уровень доступа определяет видимость унаследованных членов снаружи; объект наследника фактически содержит внутри подобъект базового класса, который строится первым при создании и разрушается последним при уничтожении. Наследование существует, чтобы не дублировать код — если два класса имеют общее поведение, его выносят в базовый класс один раз, и изменение в базовом классе распространяется на всех наследников сразу. Если наследование используют не для отношения «является», а ради переиспользования кода — например, `Stack` наследуют от `Vector`, чтобы не писать методы заново, — получается хрупкая иерархия: `Stack` получает лишние публичные методы `Vector`, которые ломают его инвариант (нельзя вставлять в середину стека). Раз наследование даёт общий интерфейс у разных классов, дальше встаёт вопрос — как за этим интерфейсом скрыть разное поведение, и это уже полиморфизм.||
▪ **1.5 Полиморфизм?**
||Полиморфизм — свойство кода работать с объектами разных типов через один и тот же интерфейс, не зная на этапе написания кода, какая именно реализация будет вызвана. В C++ есть два вида: динамический полиморфизм реализуется через `virtual`-функции — вызов через указатель или ссылку на базовый класс на этапе выполнения уходит по указателю на таблицу виртуальных функций (vtable) конкретного объекта и вызывает реализацию фактического типа; статический полиморфизм — это перегрузка функций и шаблоны, компилятор решает, какой код вызвать, ещё на этапе компиляции. Динамический полиморфизм платит за гибкость — каждый virtual-вызов идёт через дополнительное разыменование указателя на vtable, а объект с хотя бы одной virtual-функцией становится тяжелее на размер этого указателя; статический полиморфизм такой цены не имеет, потому что выбор кода зафиксирован при компиляции. Если забыть пометить функцию базового класса как `virtual` там, где ожидается переопределение, вызов через указатель на базовый класс всегда уйдёт в реализацию базового класса — баг проявляется как «вызывается не тот метод», хотя код выглядит правильно. Раз virtual привязан к таблице и указателю на объект, логично спросить, чем вообще класс отличается от объекта.||
▪ **1.6 Класс и объект?**
||Класс — описание типа: набор полей и методов, которые будут у каждого экземпляра, он существует только на уровне кода и не занимает память сам по себе (кроме static-членов). Объект — конкретный экземпляр класса, размещённый в памяти (стек, куча, static/global), со своим набором значений полей. Когда создаётся объект, компилятор выделяет под него память по размеру, определённому классом (сумма полей плюс выравнивание, плюс указатель на vtable при наличии virtual-функций), и вызывает конструктор, который инициализирует это место в памяти. Разделение класса и объекта — это разделение шаблона и данных: один класс `Point` описывает, что у точки есть `x` и `y`, а в памяти может быть сколько угодно объектов `Point` с разными значениями координат, использующих один и тот же код методов. Типичная путаница у новичков — ожидать, что изменение в одном объекте повлияет на другой; это симптом непонимания того, что у каждого объекта своя копия нестатических полей, тогда как static-поле действительно общее для всех. Раз в C++ есть два ключевых слова для описания класса — `struct` и `class` — стоит понять, чем они отличаются.||
▪ **1.7 struct и class в C++?**
||В C++ `struct` и `class` — одна и та же сущность на уровне языка, оба задают тип с полями и методами, единственная разница — уровень доступа по умолчанию. У `struct` члены по умолчанию `public`, у `class` — `private`; то же касается наследования по умолчанию (`public` для struct, `private` для class), если модификатор доступа не указан явно. Это наследие C, где `struct` уже существовал как простой контейнер данных без методов и без сокрытия — когда в C++ добавили классы с инкапсуляцией, `struct` расширили тем же функционалом, что и `class`, но сохранили прежнее поведение «всё видно» по умолчанию ради совместимости с кодом на C. По соглашению `struct` используют для простых типов-данных без инвариантов — например, координаты или конфигурация, а `class` — там, где есть инкапсуляция и поведение; это скорее вопрос читаемости кода, чем защиты от багов. Раз в class поля обычно private, а поведение выражается через методы, логично разобрать, как именно работает virtual-метод изнутри.||
▪ **1.8 Что такое виртуальная функция?**
||Виртуальная функция — метод класса, помеченный `virtual`, вызов которого разрешается не по типу указателя или ссылки, а по фактическому типу объекта во время выполнения программы. Если у класса есть хотя бы одна virtual-функция, компилятор добавляет в каждый объект этого класса скрытый указатель на vtable — таблицу указателей на реализации virtual-функций, специфичную для конкретного класса; при вызове `obj->f()` через указатель на базовый класс программа сначала читает указатель на vtable из объекта, затем берёт из таблицы нужный указатель на функцию и вызывает его в рантайме. Без такого механизма компилятор был бы вынужден решать, какую функцию вызвать, глядя только на тип указателя, известный на компиляции, — и полиморфизм (работа с разными наследниками через общий указатель на базовый класс) был бы невозможен. Наличие vtable увеличивает размер объекта на размер одного указателя (восемь байт на типичной 64-битной платформе) и добавляет одно дополнительное разыменование на каждый вызов — из-за этого в горячем коде иногда сознательно избегают virtual. Раз vtable завязана на то, что объект наследника уничтожается через указатель на базовый класс, отсюда прямо вытекает вопрос про виртуальный деструктор.||
▪ **1.9 Зачем виртуальный деструктор?**
||Виртуальный деструктор — деструктор базового класса, помеченный `virtual`, который гарантирует, что при удалении объекта через указатель на базовый класс вызовется деструктор фактического (производного) типа, а не только базового. Когда деструктор virtual, вызов `delete ptr` (где `ptr` типа `Base*`, а объект на самом деле `Derived`) идёт через vtable: сначала вызывается `~Derived()`, освобождающий ресурсы наследника, затем автоматически по цепочке — `~Base()`. Если деструктор не virtual, компилятор жёстко привязывает вызов `delete` к типу указателя на этапе компиляции — вызовется только `~Base()`, а `~Derived()` не вызовется вообще, хотя объект физически был типа `Derived`; это прямой источник утечки, если `Derived` владеет, например, динамическим массивом или файловым дескриптором. Такое удаление формально относится к неопределённому поведению; LeakSanitizer покажет утечку по стеку выделения в конструкторе `Derived`. Если класс задуман как базовый для полиморфного использования (у него уже есть другие virtual-методы), его деструктор почти всегда должен быть virtual. Раз virtual-вызовы завязаны на то, что объект уже полностью построен, логично спросить, что будет, если вызвать virtual прямо из конструктора.||
▪ **1.10 Можно ли вызвать virtual из конструктора?**
||Вызвать virtual-функцию из конструктора можно, это не ошибка компиляции, но вызовется реализация текущего строящегося класса, а не переопределённая версия в наследнике, даже если объект в итоге будет объектом наследника. Пока выполняется конструктор базового класса, подобъект наследника ещё не построен, и указатель на vtable в объекте на этом этапе указывает на vtable именно базового класса — он переключается на vtable наследника только когда начинает выполняться его собственный конструктор; поэтому virtual-вызов из тела конструктора базового класса разрешается в пределах текущего класса, как будто virtual не сработал. Это сделано намеренно, чтобы избежать вызова кода наследника над ещё не инициализированными полями наследника — если бы вызов ушёл в переопределённую версию в `Derived`, она могла бы обратиться к полям `Derived`, которые его конструктор ещё не успел инициализировать. Типичная ошибка — спроектировать базовый класс так, чтобы конструктор вызывал virtual-метод для «настройки» объекта, ожидая полиморфного поведения, и получить тихий баг: код компилируется и работает, но вызывается не та версия метода. Раз virtual-функция может быть не переопределена, а обязана быть переопределена — логично перейти к абстрактным классам.||
▪ **1.11 Абстрактный класс?**
||Абстрактный класс — класс, у которого есть хотя бы одна чисто виртуальная функция (объявленная с `= 0` вместо тела), и создать объект такого класса напрямую нельзя. Чисто виртуальная функция задаёт слот в vtable, для которого нет реализации по умолчанию у самого абстрактного класса — компилятор фиксирует это в самом типе, и попытка написать `AbstractClass obj;` или `new AbstractClass()` даёт ошибку компиляции; класс становится конкретным только когда наследник переопределяет все чисто виртуальные функции. Абстрактный класс — способ выразить в языке «интерфейс, обязательный к реализации»: базовый класс объявляет, что должно быть, а не как это делается, и заставляет каждого наследника предоставить своё «как». В C++ нет отдельного ключевого слова `interface` — его роль играет абстрактный класс, у которого все функции чисто виртуальные и нет собственного состояния. Если забыть переопределить хотя бы одну чисто виртуальную функцию в наследнике, он тоже останется абстрактным, и попытка создать его объект даст ошибку компиляции с указанием, какая функция не реализована. Раз речь зашла про переопределение функций, стоит чётко развести переопределение и перегрузку — их часто путают.||
▪ **1.12 Перегрузка и переопределение — в чём разница?**
||Перегрузка (overloading) — несколько функций с одинаковым именем, но разными параметрами в одной области видимости; переопределение (overriding) — когда наследник заново определяет virtual-функцию базового класса с точно такой же сигнатурой. Перегрузка разрешается компилятором статически, на этапе компиляции, по типам переданных аргументов; переопределение работает через vtable — при вызове через указатель или ссылку на базовый класс в рантайме находится и вызывается версия наследника. Перегрузка нужна, чтобы одно имя действия работало с разными типами данных (`print(int)` и `print(std::string)`), а переопределение нужно, чтобы одно действие вело себя по-разному в зависимости от типа объекта в иерархии, при этом вызывающий код не знает, с каким конкретно наследником имеет дело. Типичная ошибка — случайно поменять сигнатуру при попытке переопределить virtual-функцию (другой тип параметра, отсутствие `const`) — тогда компилятор молча создаёт перегрузку в наследнике вместо переопределения, и старая версия базового класса остаётся «видна» через указатель на базовый тип; спецификатор `override` в C++11 ловит эту ошибку компиляции, если сигнатура не совпадает ни с одной virtual-функцией базового класса. Раз перегрузка и переопределение относятся к методам объекта, отдельный вопрос — как устроены члены, не привязанные к конкретному объекту, то есть static.||
▪ **1.13 Что такое static-член?**
||Static-член класса — поле или метод, который принадлежит не отдельному объекту, а классу целиком, и существует в единственном экземпляре независимо от числа созданных объектов. Static-поле хранится не внутри объекта, а в отдельной области статических данных программы и должно быть определено один раз вне класса (в C++17 можно `inline static` прямо в классе); static-метод не получает скрытый параметр `this`, потому что не привязан к конкретному объекту, и может обращаться только к static-членам класса напрямую. Static-член существует для данных или поведения, логически общих для всех объектов класса — например, счётчик созданных объектов или фабричный метод, которому не нужен конкретный экземпляр. Типичная ошибка — попытаться обратиться из static-метода к нестатическому полю без объекта: компилятор откажет с ошибкой компиляции, потому что static-метод не знает, с каким объектом работать; другая проблема — порядок инициализации static-объектов в разных единицах трансляции не определён, и если один static-объект в конструкторе использует другой static-объект из другого файла, можно получить обращение к ещё не инициализированному объекту. Раз в собеседовании структуру класса проверяют по отдельности, дальше обычно спрашивают про более общие принципы проектирования — SOLID.||
▪ **1.14 Что такое SOLID?**
||SOLID — набор из пяти принципов объектно-ориентированного проектирования, описывающих, как строить классы и их отношения, чтобы код было проще расширять и поддерживать. S (Single Responsibility) — у класса должна быть одна причина для изменения; O (Open/Closed) — класс открыт для расширения, но закрыт для изменения уже написанного кода; L (Liskov Substitution) — объект наследника должен подставляться вместо объекта базового класса без нарушения корректности программы; I (Interface Segregation) — лучше несколько маленьких специализированных интерфейсов, чем один большой; D (Dependency Inversion) — модули верхнего уровня должны зависеть от абстракций, а не от конкретных реализаций модулей нижнего уровня. Все пять решают одну проблему с разных сторон — жёсткую связанность кода, из-за которой одно небольшое изменение требует правок в десятках мест. Классический пример нарушения L: если `Square` наследует от `Rectangle` и переопределяет `setWidth` так, что заодно меняет высоту, код, работающий с `Rectangle` и ожидающий независимого изменения ширины и высоты, сломается при подстановке `Square` — это логическая ошибка, проявляющаяся только в рантайме на конкретном сценарии, а не ошибка компиляции. Достаточно назвать и объяснить одну-две. Раз L и D затрагивают наследование напрямую, логично закончить раздел вопросом, что предпочитать — наследование или композицию.||
▪ **1.15 Композиция или наследование?**
||На практике композицию предпочитают наследованию — это устоявшийся принцип проектирования, а не абсолютный запрет наследования. Наследование — отношение «является» (is-a), оно жёстко связывает классы на этапе компиляции: наследник получает весь публичный и защищённый интерфейс базового класса целиком, и изменения в базовом классе сразу затрагивают всех наследников. Композиция — отношение «содержит» (has-a): один класс хранит объект другого как поле и вызывает у него нужные методы, выбирая, какую часть его поведения предоставить наружу через свой интерфейс. Наследование ломает инкапсуляцию сильнее, чем кажется — наследник иногда зависит от деталей реализации базового класса, а не только от его публичного интерфейса (проблема хрупкого базового класса), и изменение внутри базового класса может незаметно сломать поведение наследника; композиция такой связи не создаёт, потому что взаимодействие идёт строго через публичный интерфейс вложенного объекта. Пример со `Stack`, унаследованным от `Vector`, — наследник получает весь интерфейс `Vector`, включая вставку в середину, которая ломает инвариант стека; если сделать `Stack` классом, который хранит `Vector` как приватное поле и предоставляет наружу только `push`/`pop`/`top`, инвариант защищён на уровне интерфейса. Раздел ООП на этом закрыт — дальше идёт C/C++ как язык: указатели, память, RAII и то, как эти принципы реализуются на уровне байтов.||
---
**2. C/C++**
▪ **2.1 Указатель и ссылка — разница?**
||Указатель — переменная, которая хранит адрес другого объекта в памяти; ссылка — второе имя (псевдоним) для уже существующего объекта, а не самостоятельная переменная со своим адресом в привычном смысле. Указатель можно объявить без инициализации, присвоить `nullptr`, переприсвоить на другой адрес в любой момент и двигать арифметикой (`ptr + 1` — следующий элемент того же типа); ссылку обязательно инициализируют при объявлении, она не может быть null и не может быть «перепривязана» — `ref = other` присваивает значение, а не меняет, на что ссылка ссылается. Ссылка спроектирована как более безопасная и ограниченная альтернатива указателю там, где переприсваивание не нужно — параметр по ссылке (`void f(int&)`) гарантирует, что аргумент существует и не null, тогда как указательный параметр всегда требует проверки на null. Типичная ошибка — висящая ссылка или указатель: вернуть из функции ссылку на локальную переменную, уничтожаемую при выходе из функции — обращение к ней дальше UB, ASAN ловит это как use-after-return/use-after-scope, если объект был на стеке, или use-after-free, если в куче. Раз указатель работает с адресами в памяти, следующий логичный вопрос — как эту память вообще выделяют: `new`/`delete` против `malloc`/`free`.||
▪ **2.2 new/delete против malloc/free?**
||`new`/`delete` — операторы C++ для выделения памяти под объект с вызовом конструктора/деструктора; `malloc`/`free` — функции из C, которые выделяют/освобождают сырой блок байтов без понятия о типах и конструкторах. `new T(args)` выделяет память нужного размера, затем вызывает конструктор `T` на этой памяти и возвращает типизированный указатель `T*`; `delete ptr` сначала вызывает деструктор объекта, затем освобождает память. `malloc(size)` просто резервирует `size` байт и возвращает `void*` без инициализации содержимого, `free(ptr)` просто возвращает память, не вызывая деструкторов. Отличие существует, потому что в C++ объекты имеют жизненный цикл — если выделить память под объект класса через `malloc`, конструктор не вызовется, и объект останется в неинициализированном состоянии; кроме того, при нехватке памяти `new` бросает исключение `std::bad_alloc`, а `malloc` возвращает `NULL`, который нужно проверять вручную. Смешивать пары — выделить через `new`, освободить через `free` (или наоборот) — UB: `free` не вызовет деструктор, а `delete` на памяти от `malloc` может обратиться к служебным данным аллокатора в неожиданном формате; ASAN ловит это как alloc-dealloc-mismatch. Раз обе пары выделяют память откуда-то, логично развести, где именно — стек и куча.||
▪ **2.3 Стек и куча?**
||Стек — область памяти для автоматических (локальных) переменных функций, куча — область для памяти, которую программа запрашивает и освобождает явно во время выполнения (`new`/`malloc`). Стек растёт и уменьшается по строгой дисциплине LIFO — при входе в функцию под её локальные переменные резервируется кадр, при выходе кадр целиком снимается за одну операцию сдвига указателя стека, поэтому выделение и освобождение на стеке практически бесплатны; куча управляется аллокатором, который ищет подходящий свободный блок среди произвольно освобождаемых и занимаемых участков, и это заметно дороже по времени. Стек ограничен по размеру (порядка нескольких мегабайт на поток, точное значение зависит от настроек ОС и потока) и требует, чтобы размер объекта был известен на этапе компиляции или на входе в функцию, поэтому для больших или переменных по размеру данных, а также для данных, переживающих возврат из функции, используют кучу. Типичная ошибка на стеке — рекурсия без базового случая или слишком глубокая рекурсия переполняет стек (stack overflow), что обычно валит программу по SIGSEGV; на куче типичная ошибка обратная — забыть освободить память (утечка) или обратиться к памяти после освобождения (use-after-free), и то и другое ловит ASAN. Раз куча освобождается вручную, логично разобрать, что происходит, если это не сделать — утечку памяти.||
▪ **2.4 Что такое утечка памяти?**
||Утечка памяти — ситуация, когда программа выделила блок памяти в куче, но потеряла последний указатель на него, не освободив память, — блок остаётся занятым до конца работы процесса, хотя программа больше не может им воспользоваться. Это происходит, например, когда указатель от `new`/`malloc` перезаписывается новым значением до вызова `delete`/`free`, или когда путь выполнения кода с исключением или ранним `return` пропускает запланированное освобождение. В C/C++ управление временем жизни динамической памяти не автоматическое — программист сам отвечает за пару выделение/освобождение, и любое нарушение этой симметрии, даже в одном месте среди тысяч, оставляет память висеть. Маленькая утечка в короткоживущей программе незаметна, но в долгоживущем процессе (сервер, демон) она накапливается и постепенно съедает доступную память, что приводит к падению по нехватке памяти или к срабатыванию OOM killer в Linux; находят утечки LeakSanitizer (встроен в ASAN, `-fsanitize=address`), который показывает точный стек выделения не освобождённого блока, и valgrind — то же самое без пересборки, но заметно медленнее. Раз ручное управление памятью — источник утечек, логично спросить про механизм, который решает эту проблему систематически, — RAII.||
▪ **2.5 RAII?**
||RAII (Resource Acquisition Is Initialization) — идиома C++, при которой ресурс (память, файловый дескриптор, мьютекс, сетевое соединение) захватывается в конструкторе объекта и гарантированно освобождается в его деструкторе. Время жизни ресурса привязывается к времени жизни объекта-обёртки: пока объект существует на стеке, ресурс занят, а когда объект выходит из области видимости (обычный возврат, `break`, раскрутка стека при исключении), компилятор автоматически вызывает деструктор, который освобождает ресурс, без участия программиста в каждой конкретной точке выхода. RAII решает проблему, которую нельзя надёжно закрыть вручную — во всех местах, где возможен ранний выход из функции, пришлось бы дублировать код освобождения, и рано или поздно один путь выполнения будет забыт; RAII переносит освобождение в одно место — деструктор, — вызываемое гарантированно при любом способе покинуть область видимости. Так устроены `std::vector` (освобождает буфер в деструкторе), `std::ifstream` (закрывает файл), `std::lock_guard` (снимает мьютекс) и умные указатели; если написать RAII-обёртку и забыть про правило трёх/пяти — например, разрешить копирование объекта, владеющего сырым ресурсом, без явного копирующего конструктора — получится двойное освобождение при уничтожении обеих копий, что ASAN покажет как double-free. Раз RAII чаще всего применяется к владению памятью через указатель, естественный следующий вопрос — умные указатели, `unique_ptr` и `shared_ptr`.||
▪ **2.6 unique_ptr и shared_ptr?**
||`unique_ptr` — умный указатель с единственным владельцем ресурса: копировать его нельзя, можно только передать владение через перемещение; `shared_ptr` — умный указатель с разделяемым владением, несколько `shared_ptr` могут одновременно владеть одним объектом. `unique_ptr` — тонкая обёртка почти без накладных расходов (обычно размером с один указатель), которая в деструкторе вызывает `delete` на хранимом указателе, конструктор копирования у неё удалён, есть только перемещение. `shared_ptr` хранит рядом с указателем на объект указатель на блок управления со счётчиком ссылок: при копировании счётчик увеличивается, при уничтожении копии — уменьшается, а когда доходит до нуля, вызывается `delete` над объектом; изменение счётчика атомарное (для потокобезопасности), что дороже работы `unique_ptr`. Разделение на два типа отражает два сценария владения — единственный «хозяин», который нужно явно передавать (unique_ptr, дешевле и однозначнее), и совместное владение, когда ни одна часть программы не может точно сказать, когда объект можно удалить (shared_ptr, счётчик решает это за них). У `shared_ptr` есть известная проблема — цикл ссылок: если A хранит `shared_ptr` на B, а B хранит `shared_ptr` на A, счётчики друг друга никогда не дойдут до нуля, и оба объекта утекают даже при формально корректном коде; решается заменой одной из сторон цикла на `weak_ptr`, который не увеличивает счётчик. Раз шла речь про копирование и перемещение ресурсов, дальше логично разобрать правило трёх/пяти, которое формализует, какие операции нужно определить классу-владельцу ресурса.||
▪ **2.7 Правило трёх/пяти?**
||Правило трёх/пяти — рекомендация: если класс сам управляет ресурсом и поэтому определяет деструктор, он почти наверняка должен также явно определить конструктор копирования и оператор присваивания копированием (правило трёх), а в C++11 и позже — ещё и конструктор перемещения с оператором присваивания перемещением (правило пяти). Компилятор генерирует эти пять специальных функций автоматически, если их не объявить, но сгенерированная версия для копирования делает побитовое копирование каждого поля — для указателя на ресурс это значит, что два объекта получат указатель на один и тот же ресурс. Если у класса есть деструктор, освобождающий ресурс (например, `delete[]` на сыром указателе), а конструктор копирования остался поверхностным, копия объекта получит копию того же указателя, а не свой собственный ресурс — при уничтожении обеих копий деструктор вызовется дважды на одном адресе. Это прямой путь к double-free, который ASAN отмечает явно с двумя стеками вызовов; в современном C++ вместо ручного соблюдения правила пяти чаще применяют «rule of zero» — доверяют владение готовым RAII-обёрткам вроде `unique_ptr` или `std::vector`, и собственный класс вообще не объявляет ни одной из пяти функций, потому что сгенерированные компилятором версии автоматически корректно копируют/перемещают эти обёртки. Раз перемещение упомянуто как отдельная операция, логично разобрать, что конкретно делает `std::move`.||
▪ **2.8 Что делает std::move?**
||`std::move` сам по себе ничего не перемещает и не выполняет никакого действия во время выполнения программы — это `static_cast` объекта к rvalue-ссылке (`T&&`), то есть указание компилятору рассматривать объект как временный, из которого можно забрать содержимое. Когда объект приведён к rvalue-ссылке, компилятор при выборе перегрузки предпочитает конструктор/оператор присваивания перемещением, а не копированием, если такой определён у типа; именно конструктор перемещения выполняет реальную работу — например, у `std::vector` это означает скопировать указатель на внутренний буфер и обнулить его у источника, вместо копирования всех элементов. До `std::move` единственным способом получить rvalue-ссылку было создать временный объект — но иногда нужно явно сказать «мне больше не нужно это значение» применительно к объекту, у которого есть имя (значит, формально lvalue); `std::move` — это именно такая явная пометка программиста, а не автоматическое решение компилятора. Частая ошибка — использовать объект после `std::move(obj)`, полагая, что он не изменился: стандарт гарантирует только, что объект после перемещения находится в валидном, но неопределённом состоянии, и обращение к его старым данным — логическая ошибка, которую не поймает ни один санитайзер, потому что формально это не UB. Раз с UB санитайзеры не всегда справляются, логично отдельно разобрать, что такое UB в принципе.||
▪ **2.9 Что такое UB?**
||UB (undefined behavior, неопределённое поведение) — поведение программы, для которого стандарт языка не накладывает вообще никаких требований: компилятор вправе сгенерировать любой код, включая тот, что упадёт, выдаст неверный результат или отработает по-разному на разных платформах. Примеры: выход за границы массива, разыменование null или висящего указателя, знаковое переполнение (`INT_MAX + 1` для `int`), сдвиг числа на количество бит больше или равное его разрядности либо сдвиг в знаковый бит, гонка данных при одновременном доступе к общей переменной без синхронизации, использование неинициализированной переменной. Стандарт оставляет эти случаи неопределёнными намеренно, чтобы компилятор мог агрессивно оптимизировать код, предполагая, что UB не происходит; например, компилятор вправе считать, что переполнения signed int не бывает, и на основе этого убрать проверку, которая, по мнению программиста, должна была отработать. Опасность UB в том, что программа может работать правильно в отладочной сборке и сломаться только в релизной с оптимизациями, потому что оптимизатор использует предположение об отсутствии UB — баг не воспроизводится стабильно; находят такие случаи UBSAN (`-fsanitize=undefined`, ловит переполнения, некорректные сдвиги, нарушения выравнивания) и ASAN (границы, use-after-free), которые вставляют проверки во время выполнения и падают с точным указанием строки и типа нарушения. Раз санитайзеры уже дважды упомянуты, стоит развести отдельно, что конкретно проверяет каждый из них.||
▪ **2.10 Что проверяют ASAN и UBSAN?**
||ASAN (AddressSanitizer) проверяет ошибки работы с памятью во время выполнения программы, UBSAN (UndefinedBehaviorSanitizer) проверяет случаи неопределённого поведения, не связанные напрямую с адресами памяти. ASAN оборачивает каждое выделение памяти «красными зонами» — служебными участками до и после блока, обращение к которым сразу считается ошибкой, и подменяет освобождённую память специальными метками, поэтому ловит выход за границы, use-after-free, двойное освобождение и, вместе со встроенным LeakSanitizer, утечки памяти. UBSAN вставляет проверки прямо в сгенерированный код в местах, которые по стандарту являются UB — переполнение знаковых типов, сдвиг за пределы разрядности типа, нарушение требований выравнивания при разыменовании, — и при срабатывании печатает точное место в исходном коде. Они проверяют разные категории, потому что реализованы по-разному: ASAN работает на уровне памяти и адресов, UBSAN — на уровне отдельных операций языка (арифметика, приведения типов), поэтому их обычно включают вместе (`-fsanitize=address,undefined`), они не заменяют друг друга. Оба замедляют программу и требуют пересборки с флагом `-fsanitize=...`, поэтому используются в отладочных и тестовых сборках; типичный найденный кейс — ASAN укажет точную строку выхода за границы массива со стеком вызова, где память была выделена и где произошло некорректное обращение. Раз ASAN и UBSAN упомянули выравнивание, логично разобрать классический вопрос про размер структуры с padding.||
▪ **2.11 sizeof(struct { char a; int b; char c; }) на x86-64?**
||Размер такой структуры на типичной сборке x86-64 равен 12 байт, хотя поля `char a` (1 байт), `int b` (4 байта) и `char c` (1 байт) в сумме дают только 6 байт — разницу добирает выравнивание (padding). Компилятор размещает `a` по смещению 0; `b` — это `int` с требованием выравнивания 4 байта (адрес поля должен делиться на 4), поэтому между `a` и `b` вставляется 3 байта padding, и `b` занимает смещения 4–7; `c` идёт сразу за `b`, по смещению 8; после `c` структура была бы 9 байт, но выравнивание всей структуры определяется по самому строгому полю внутри — по `int`, требующему кратности 4, — поэтому в конец добавляется ещё 3 байта хвостового padding, и итоговый размер округляется до 12. Выравнивание существует, потому что процессору дешевле читать данные, адрес которых кратен их размеру, — обращение к `int` по невыровненному адресу на некоторых архитектурах запрещено аппаратно, на x86 разрешено, но медленнее; компилятор жертвует местом в памяти ради предсказуемой и быстрой работы с каждым полем. Типичная ошибка — сериализовать структуру побайтовым копированием и передать по сети или записать в файл, предполагая, что размер равен сумме полей: получатель на другой платформе с другим выравниванием прочитает мусор из padding-байтов как часть данных; для контроля раскладки используют `sizeof`, `offsetof` и явный `#pragma pack` там, где формат должен быть фиксированным (сетевые протоколы, бинарные форматы файлов). Раз порядок размещения полей в памяти зафиксирован компилятором, а не программой, логично спросить про другой порядок — вычисления аргументов функции.||
▪ **2.12 Порядок вычисления аргументов f(a(), b()) задан?**
||Нет, до C++17 порядок вычисления аргументов `f(a(), b())` был полностью не определён стандартом — компилятор мог вычислить `a()` раньше `b()` или наоборот, и полагаться на конкретный порядок нельзя. Стандарт гарантирует только, что к моменту вызова `f` оба аргумента вычислены и их значения готовы, но не фиксирует последовательность вычислений между собой — разные компиляторы, версии или уровни оптимизации могут выбрать разный порядок, потому что это даёт больше свободы для перестановки инструкций. Причина в том, что вычисление аргументов — независимые с точки зрения языка операции, если они не имеют побочных эффектов друг на друга, и фиксация конкретного порядка ограничила бы компилятор в оптимизации без реальной необходимости для большинства кода. Если `a()` и `b()` имеют побочные эффекты, влияющие друг на друга или на общее состояние (обе меняют один глобальный счётчик, или порядок логов важен), результат программы становится зависимым от компилятора — типичный симптом: тесты проходят на одном компиляторе и падают на другом, либо результат меняется при смене уровня оптимизации. Раз вызов функции связан с параметрами, логично разобрать ещё один модификатор у функции — `const` после сигнатуры метода.||
▪ **2.13 Что делает const после сигнатуры метода?**
||`const` после списка параметров метода (`void f() const`) означает, что метод не изменяет состояние объекта, на котором вызван, и такой метод можно вызывать на константном объекте или через константную ссылку/указатель. Технически `const` в конце сигнатуры меняет тип неявного параметра `this` — вместо `T*` он становится `const T*`, поэтому внутри такого метода попытка присвоить значение нестатическому полю объекта (кроме полей, помеченных `mutable`) — ошибка компиляции, компилятор проверяет это статически. Это часть системы типов C++, которая позволяет гарантировать на этапе компиляции, что объект, переданный как `const T&`, останется неизменным при вызове любых его const-методов — без этой гарантии `const`-ссылка была бы формальностью, которую легко обойти. Типичная ситуация — перегрузка метода в двух версиях, const и не-const (например, `operator[]` у контейнеров), и компилятор выбирает нужную в зависимости от того, является ли сам объект const; если забыть пометить метод, который ничего не меняет, как `const`, его нельзя будет вызвать на `const`-объекте — код просто не скомпилируется, и это самый частый повод добавить `const` постфактум. Раз const влияет на то, какую версию метода выбирает компилятор, логично рядом разобрать перегрузку операторов — ещё один механизм, где выбор кода зависит от типов.||
▪ **2.14 Что такое перегрузка операторов и зачем?**
||Перегрузка операторов — определение собственного поведения для стандартных операторов языка (`+`, `==`, `[]`, `<<` и других) применительно к пользовательскому типу, чтобы объекты этого типа можно было использовать теми же синтаксическими конструкциями, что и встроенные типы. Оператор перегружается как обычная функция (свободная или метод класса) с именем `operatorX`, например `T operator+(const T& a, const T& b)`; компилятор при встрече выражения `a + b`, где `a`/`b` — объекты класса, ищет подходящую перегрузку `operator+` по тем же правилам разрешения перегрузки, что и для обычных функций. Цель — дать пользовательскому типу естественный синтаксис, который читается так же, как для встроенных типов: `Vector3 v3 = v1 + v2;` понятнее и короче, чем `v1.add(v2)`, особенно когда таких операций в выражении несколько подряд, и это снижает шум в коде, если семантика оператора не удивляет. Перегружать можно почти любой оператор, но не `.`, `::`, `?:`, `sizeof` — они жёстко привязаны к синтаксису языка; типичная ошибка проектирования — перегрузить оператор так, что его поведение расходится с ожиданием (например, `operator+` для класса `Logger`, запускающий побочный эффект вместо арифметики) — код компилируется и работает, но вводит в заблуждение любого, кто читает выражение по аналогии со встроенными типами. Раз перегрузка операторов — это функции, которые компилятор находит и связывает с кодом, логично перейти к тому, как вообще устроен процесс компиляции по шагам.||
▪ **2.15 Компиляция по шагам?**
||Сборка программы на C/C++ проходит через препроцессор, компилятор и линковщик. Препроцессор обрабатывает директивы, начинающиеся с `#` — подставляет содержимое заголовков вместо `#include`, разворачивает макросы `#define`, обрабатывает условную компиляцию `#ifdef` — на выходе получается один файл без директив препроцессора; компилятор транслирует этот файл в объектный файл (машинный код единицы трансляции с нерешёнными ссылками на внешние символы); линковщик собирает объектные файлы и библиотеки вместе, находит и связывает вызовы функций и обращения к переменным, объявленным в одном файле, а определённым в другом, и производит один исполняемый файл или библиотеку. Разделение на этапы существует, потому что каждая единица трансляции (`.cpp`-файл) компилируется независимо и параллельно — компилятору для этого достаточно объявлений (прототипов функций, заголовков классов), а не определений, и только на финальном шаге линковщик должен найти ровно одно определение для каждого использованного символа во всей программе. Ошибка «undefined reference» (или «unresolved external symbol») — это ошибка именно линковщика: она означает, что функция или переменная объявлены и используются, но их определение либо не написано, либо не попало в сборку, и отличать её от ошибки компиляции важно — компиляция каждого файла по отдельности могла пройти без единой ошибки. Раз препроцессор подставляет заголовки текстом, логично спросить, зачем нужны header guards — они защищают именно от повторной подстановки одного и того же заголовка.||
▪ **2.16 Зачем header guards?**
||Header guards — механизм, который предотвращает повторное включение содержимого одного и того же заголовочного файла в одну единицу трансляции более одного раза. Классическая форма — обёртка `#ifndef HEADER_NAME` / `#define HEADER_NAME` / содержимое файла / `#endif`: при первом `#include` макрос ещё не определён, содержимое подставляется и заодно определяется макрос; при повторном включении того же файла (например, транзитивно через два разных заголовка, которые оба включают третий) препроцессор видит, что макрос уже определён, и пропускает содержимое между `#ifndef` и `#endif`. Нестандартная, но широко поддерживаемая альтернатива с тем же эффектом — директива `#pragma once`. Препроцессор работает текстовой подстановкой — `#include` буквально вставляет содержимое файла на место директивы, поэтому без защиты заголовок, объявляющий класс или структуру, будучи включённым дважды в одну единицу трансляции (что легко происходит транзитивно через цепочку заголовков), приведёт к тому, что компилятор увидит определение одного и того же типа дважды. Без header guards результат — ошибка компиляции о повторном определении типа («redefinition»), которая выглядит загадочно, потому что в исходном `.cpp`-файле заголовок подключён один раз — реальная причина всегда в транзитивном включении через несколько заголовков. Раз проблема связана с тем, что заголовок тянет за собой полное определение, логично разобрать альтернативу — forward declaration, когда полное определение вообще не нужно.||
▪ **2.17 Что такое forward declaration?**
||Forward declaration (предварительное объявление) — объявление имени класса, структуры или функции без их полного определения, которое сообщает компилятору, что такое имя существует и имеет определённый тип, оставляя детали на потом. Например, `class Foo;` перед использованием `Foo*` или `Foo&` — этого достаточно, чтобы компилятор знал, что `Foo` — тип класса, и мог обработать указатель или ссылку на него (у них фиксированный размер независимо от содержимого `Foo`); но чтобы создать объект `Foo` на стеке, обратиться к его полям или вызвать методы, компилятору уже нужен полный размер и состав класса — его определение, обычно через `#include` заголовка. Forward declaration позволяет избежать подключения тяжёлого заголовка там, где известны только указатели/ссылки на тип, что сокращает объём кода, перекомпилируемого при каждой единице трансляции, использующей заголовок, и разрывает циклические зависимости между заголовками, когда `A.h` и `B.h` ссылаются друг на друга указателями. Типичная ошибка — попытаться использовать forward-declared тип там, где нужен полный размер (объявить поле `Foo field;` вместо `Foo* field;`, или вызвать `foo->doSomething()`) — компилятор выдаст ошибку о неполном типе («incomplete type»), потому что не знает размер и состав класса на этом этапе. Раз объявления бывают конкретными для одного типа, логично разобрать механизм, который пишет код сразу для многих типов, — шаблоны.||
▪ **2.18 Что такое шаблон (template)?**
||Шаблон — механизм обобщённого программирования в C++, который позволяет писать код функции или класса один раз, оставляя тип (или несколько типов) параметром, подставляемым конкретным значением на этапе компиляции. Например, `template<typename T> T max(T a, T b) { return a > b ? a : b; }` — компилятор не генерирует код для абстрактного `T`, а для каждого конкретного типа, с которым шаблон реально используется в программе, генерирует отдельную типизированную версию функции — это называется инстанцированием и происходит при компиляции, а не во время выполнения. Альтернатива шаблонам — либо дублировать код для каждого типа вручную, либо использовать динамический полиморфизм через virtual-функции, но оба варианта хуже: дублирование кода — это дублирование багов при изменении, а динамический полиморфизм платит цену виртуального вызова и требует единой иерархии типов; шаблоны дают переиспользование кода без этой цены во время выполнения, потому что выбор типа зафиксирован на компиляции. Цена шаблонов — на этапе компиляции: ошибки в шаблонном коде часто выглядят длинными и малочитаемыми сообщениями компилятора, потому что возникают уже в момент инстанцирования конкретным типом; вторая цена — раздувание кода (code bloat), если шаблон инстанцируется для многих разных типов, в бинарник попадает отдельная копия машинного кода для каждого из них. Раз шаблоны — это код, параметризованный типом, логично рядом разобрать другой способ обобщённо передавать поведение — лямбда-функции.||
▪ **2.19 Что такое лямбда?**
||Лямбда — анонимная (не имеющая отдельного имени) функция, которую можно определить прямо в месте использования, обычно как аргумент другой функции. Синтаксис `[захват](параметры) { тело }`, где список захвата в квадратных скобках определяет, какие переменные из окружающего кода доступны внутри лямбды и как — `[&]` захватывает всё окружение по ссылке, `[=]` — по значению, можно захватывать отдельные переменные явно (`[x, &y]`); под капотом компилятор генерирует безымянный класс (замыкание) с перегруженным `operator()`, а захваченные переменные становятся полями этого класса, инициализированными в момент создания лямбды. До лямбд, чтобы передать «кусок поведения» как аргумент (предикат в `std::sort` или `std::find_if`), приходилось писать отдельную именованную функцию или функтор в другом месте кода, разрывая логику от места использования; лямбда позволяет написать поведение прямо там, где оно нужно, и по сути это синтаксический сахар над тем же функтором. Типичная ошибка — захватить переменную по ссылке (`[&]`) в лямбде, которая переживёт эту переменную (лямбда сохраняется и вызывается после выхода из функции, где переменная была локальной) — это висящая ссылка, обращение к ней UB, ASAN покажет это как use-after-scope на захваченной переменной. Раз лямбда генерирует безымянный класс, а классы вообще должны где-то жить без конфликтов имён, логично перейти к пространствам имён.||
▪ **2.20 Что такое пространство имён?**
||Пространство имён (namespace) — именованная область видимости, которая группирует объявления классов, функций, переменных под общим префиксом, чтобы избежать конфликта имён между разными частями программы или разными библиотеками. Объявление `namespace mylib { class Logger {}; }` помещает `Logger` внутрь пространства имён `mylib`, снаружи к нему обращаются как `mylib::Logger`, либо, чтобы не писать префикс каждый раз, используют `using namespace mylib;` или `using mylib::Logger;` в ограниченной области видимости; `std::` — пространство имён стандартной библиотеки, поэтому `std::vector`, `std::string` не конфликтуют с одноимёнными типами, которые мог бы объявить сам программист. Без пространств имён две библиотеки, определившие тип или функцию с одинаковым именем, не смогут быть подключены в одной программе одновременно — компилятор увидит конфликт на этапе линковки или компиляции; namespace превращает плоское глобальное пространство имён в иерархию, где совпадение коротких имён внутри разных пространств не проблема. Типичная ошибка — писать `using namespace std;` в заголовочном файле: это распространяет все имена `std::` без префикса на любой `.cpp`-файл, который подключит этот заголовок, резко повышая шанс конфликта имён в чужом коде — по этой причине `using namespace` в заголовках считается плохой практикой, в `.cpp`-файлах допустимо в ограниченном объёме. Раз речь про видимость и корректность данных, логично закрыть раздел вопросом про `volatile` — ключевое слово, которое тоже управляет тем, как компилятор трактует переменную, но по совсем другой причине.||
▪ **2.21 volatile — что это?**
||`volatile` — квалификатор типа, который говорит компилятору, что значение переменной может измениться в любой момент независимо от видимого потока выполнения программы, и поэтому его нельзя кэшировать в регистре процессора или оптимизировать обращения к нему. Без `volatile` компилятор при оптимизации вправе прочитать переменную из памяти один раз, оставить значение в регистре и дальше использовать закэшированную копию, если по коду переменная явно не меняется между обращениями; `volatile` запрещает эту оптимизацию — каждое чтение и запись переменной компилятор гарантированно транслирует в реальное обращение к памяти по её адресу, в написанном порядке. Это нужно там, где значение переменной меняется не текущим потоком выполнения программы в обычном смысле — регистр аппаратного устройства, изменяемый самим железом, переменная, изменяемая обработчиком сигнала, или память, доступная другому процессу через shared memory; без `volatile` компилятор, не видя явного изменения переменной в коде, мог бы «оптимизировать» цикл ожидания (`while (!flag) {}`) в бесконечный, один раз прочитав `flag` в регистр. Частая ошибка — думать, что `volatile` решает проблемы многопоточности: это не так, `volatile` ничего не гарантирует про атомарность операции и про видимость изменений между ядрами процессора и не создаёт барьеров памяти — для многопоточного кода нужен `std::atomic`; использование `volatile` вместо `std::atomic` компилируется без ошибок, но оставляет гонку данных, которую поймает только ThreadSanitizer, а не UBSAN/ASAN. Раздел C/C++ закрыт — дальше по базе идёт STL, где те же принципы (владение, сложности операций, копирование) проявляются уже на уровне готовых контейнеров.||
**3. STL**
▪ **3.1 Какие контейнеры знаешь?**
||Контейнер STL — это шаблонный класс, инкапсулирующий структуру данных и дающий к ней единый интерфейс через итераторы, `size()`, `begin()`/`end()`. Они делятся на три группы по устройству: последовательные (`vector` — динамический массив, `list` — двусвязный список, `deque` — массив блоков, `array` — статический массив) хранят элементы в порядке вставки; ассоциативные (`map`, `set`) держат ключи в сбалансированном дереве; неупорядоченные (`unordered_map`, `unordered_set`) — в хеш-таблице; адаптеры (`stack`, `queue`, `priority_queue`) не хранят данные сами, а ограничивают интерфейс другого контейнера. Такое разделение существует потому, что нет одной структуры, одинаково быстрой на всех операциях: массив быстр в доступе по индексу, но медленен при вставке в середину, список — наоборот, дерево и хеш-таблица различаются тем, нужен ли порядок ключей. Если выбрать контейнер не под доминирующую операцию задачи — например `map` там, где просто нужен быстрый доступ по ключу без сортировки, — теряешь производительность на обходе дерева и промахах кэша; это видно по бенчмарку против `unordered_map`. Дальше логично разобрать сложности операций у самого частого контейнера — `vector`.||
▪ **3.2 vector — сложности?**
||`vector` — это динамический массив: непрерывный блок памяти плюс три указателя (начало, конец занятых данных, конец выделенной памяти). Из непрерывности следует доступ по индексу за O(1) — адрес элемента вычисляется арифметикой указателя без обхода структуры. Вставка и удаление в середине стоят O(n), потому что все элементы после точки вставки физически сдвигаются в памяти, чтобы сохранить непрерывность блока; поиск без знания позиции — тоже O(n), это линейный перебор. `push_back` в конец — амортизированное O(1): подробнее почему, в следующем вопросе. Если в коде часто вставляют в начало или середину большого `vector`, это симптом неверного выбора контейнера — профиль покажет горячую точку в `memmove`, и решается заменой на `deque` или `list` в зависимости от паттерна доступа. Отсюда прямой вопрос — почему именно `push_back`, а не вставка, даёт амортизированную O(1)?||
▪ **3.3 Почему push_back амортизированное O(1)?**
||Амортизированная сложность — это средняя стоимость операции на длинной серии вызовов, а не гарантия для каждого отдельного вызова. Механизм: когда выделенной памяти не хватает, `vector` не увеличивает ёмкость на один элемент, а умножает её (типичная реализация, например libstdc++, удваивает), выделяет новый блок и переносит туда все существующие элементы — это разовая операция O(n). Из-за геометрического роста ёмкости такие дорогие реаллокации случаются экспоненциально реже: после k-й реаллокации следующая наступит только через примерно 2^k новых вставок, поэтому суммарная стоимость n вставок оказывается O(n), а не O(n²), и в среднем на одну вставку приходится O(1). Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка требовала бы полного копирования — суммарно O(n²), и именно поэтому геометрический рост — не оптимизация, а необходимое условие амортизации. Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на элементы `vector`, потому что блок памяти физически переехал — это частый источник use-after-free, который ловит ASAN. Если заранее известен объём данных, реаллокаций избегают вызовом `reserve` — про разницу `reserve`/`resize` дальше.||
▪ **3.4 list — сложности?**
||`list` — двусвязный список: каждый узел хранит значение и два указателя, на предыдущий и следующий узел, сами узлы разбросаны по куче отдельными аллокациями. Отсюда доступ по номеру — O(n), потому что нет арифметики адреса, только последовательный проход по указателям; а вставка и удаление при уже известном узле (есть итератор на него) — O(1), потому что операция — это просто перелинковка соседних указателей без сдвига остальных элементов. Поиск нужного узла всё равно O(n) — быстрая вставка не отменяет медленный поиск позиции. Такая структура на практике проигрывает `vector` по памяти и по факту тоже по скорости на многих задачах: два указателя на узел — это накладные расходы, а главное, узлы разбросаны по памяти, то есть плохая локальность и частые промахи кэша процессора, тогда как `vector` читается кэш-линиями подряд. Отсюда практическое правило: `list` оправдан только когда действительно часто вставляют/удаляют в середине по итератору и не нужен произвольный доступ, а не просто «потому что вставка O(1) на бумаге». Логичный следующий шаг — сравнить ассоциативные контейнеры: `map` против `unordered_map`.||
▪ **3.5 map против unordered_map?**
||`map` реализован как красно-чёрное дерево — самобалансирующееся бинарное дерево поиска, где инвариант балансировки (чередование цветов узлов, равное число чёрных узлов на любом пути от корня до листа) гарантирует высоту дерева порядка log n, отсюда операции вставки, удаления и поиска — O(log n), и обход даёт ключи в отсортированном порядке. `unordered_map` — хеш-таблица: ключ прогоняется через хеш-функцию, результат определяет бакет, в среднем это даёт O(1) на вставку/поиск, но не гарантированно — при коллизиях (несколько ключей в одном бакете) поиск внутри бакета линеен, и в худшем случае (плохая хеш-функция или атака на коллизии) деградирует до O(n). Разница в устройстве напрямую объясняет разницу в свойствах: дерево платит log n за порядок, хеш-таблица платит потерей порядка за скорость. На практике если объявить `unordered_map` с плохим или предсказуемым хешем для пользовательского ключа, все элементы могут попасть в один бакет — операции тихо деградируют до O(n) без ошибки компиляции, это ловится профилировщиком, а не тестами. Отсюда вопрос, который спрашивают сразу следом — что выбрать и когда.||
▪ **3.6 Что быстрее и когда?**
||Выбор между `map` и `unordered_map` определяется не абсолютной скоростью, а тем, какая операция нужна дальше по коду. Хеш-таблица `unordered_map` в среднем случае обгоняет дерево на чистом поиске/вставке по ключу за счёт O(1) против O(log n) и лучшей константы (меньше косвенных переходов по указателям, чем при спуске по дереву). Но если нужен упорядоченный обход, диапазонные запросы (`lower_bound`, «все ключи между X и Y») или устойчивый порядок при итерации, дерево `map` даёт это бесплатно, а хеш-таблица не даёт вообще — порядок бакетов не определён и может меняться при рехешировании. Отсюда следствие: смена `map` на `unordered_map` «для скорости» без проверки, не используется ли где-то в коде порядок обхода, — типичная скрытая ошибка, которая не упадёт на компиляции, а проявится как логически неверный результат при переборе. Дальше стоит разобрать итераторы — потому что именно они и есть то, что «переезжает» при реаллокации или ломается при удалении.||
▪ **3.7 Итераторы и их инвалидация?**
||Итератор — это обобщённый указатель на элемент контейнера с единым интерфейсом (`operator++`, `operator*`), который абстрагирует конкретную структуру данных от алгоритма, работающего с ней. Правила инвалидации следуют напрямую из внутреннего устройства контейнера: у `vector` реаллокация (см. вопрос про `push_back`) перемещает весь блок памяти в новый адрес, поэтому инвалидируются абсолютно все итераторы, указатели и ссылки на элементы, даже если конкретный элемент не менялся; вставка в середину `vector` без реаллокации инвалидирует итераторы после точки вставки, потому что элементы физически сдвинулись. У `list` узлы не переезжают при вставке или удалении других узлов — инвалидируется только итератор на сам удалённый узел, все остальные остаются рабочими, потому что перелинковка соседних указателей не трогает память самих узлов. Если после `push_back` или `insert` продолжать использовать старый итератор `vector`, получится use-after-free — неопределённое поведение, которое не всегда падает сразу, а проявляется случайным мусором в данных; ловится ASAN. Отсюда переходим к практической идиоме, которая как раз построена вокруг безопасного удаления элементов — erase-remove.||
▪ **3.8 Как удалить элементы из vector по условию?**
||Идиома erase-remove — это способ удалить элементы из `vector`, не оплачивая O(n) сдвигов на каждое удаление по отдельности. Механизм в два шага: `std::remove_if(begin, end, pred)` за один проход O(n) переставляет элементы так, что все «оставляемые» (не удовлетворяющие предикату) оказываются в начале диапазона в исходном относительном порядке, а «удаляемые» — в хвосте в неопределённом состоянии, и возвращает итератор на начало этого хвоста; сам `remove_if` физически размер контейнера не меняет. Второй шаг — `v.erase(it, v.end())` — реально уменьшает `size()`, удаляя хвостовой диапазон. Раздельность шагов нужна потому, что `remove_if` не знает, как правильно сокращать конкретный контейнер (это ответственность метода `erase` самого контейнера), а `remove_if` — это общий алгоритм, работающий с любым диапазоном итераторов, не только с `vector`. Частая ошибка — вызвать только `remove_if` и забыть `erase`: `size()` не изменится, а в хвосте останется «удалённый» мусор, из-за которого `for`-цикл по всему контейнеру покажет лишние или задвоенные значения. Отсюда рядом стоит вопрос про `reserve` и `resize` — обе операции тоже про управление размером и памятью `vector`, но по-разному.||
▪ **3.9 reserve и resize?**
||`reserve(n)` увеличивает ёмкость контейнера (выделенную, но не обязательно занятую память) минимум до n элементов, не создавая новых элементов и не меняя `size()` — это чисто предвыделение памяти на будущее. `resize(n)` меняет именно `size()`: если n больше текущего размера, новые элементы создаются конструктором по умолчанию (или копией переданного значения), если меньше — лишние элементы разрушаются деструктором. Разница существует потому, что это ответы на разные задачи: `reserve` избегает многократных реаллокаций, когда заранее известно примерное количество будущих `push_back`, а `resize` — это способ явно задать логическое содержимое контейнера. Если перепутать и вызвать `reserve(n)` вместо `resize(n)`, а затем обращаться по индексу `v[i]` при i < n, это UB — память выделена, но элементы там не сконструированы, `size()` всё ещё меньше n, и ASAN/UBSAN может это не поймать сразу, но `.at(i)` бросит исключение выхода за границы, потому что `.at` проверяет именно `size()`, а не ёмкость. Отсюда рядом обычно спрашивают про ещё одну оптимизацию вставки — `emplace_back`.||
▪ **3.10 emplace_back против push_back?**
||`push_back` принимает уже готовый объект (или временный) и кладёт его в контейнер копированием либо перемещением. `emplace_back` вместо этого принимает аргументы конструктора объекта и строит его прямо в памяти контейнера через placement new, минуя создание отдельного временного объекта. Разница в производительности следует именно из этого: `push_back(T(args...))` — это сначала конструктор временного T, потом перемещение (или копия) этого временного в слот контейнера и разрушение временного, а `emplace_back(args...)` — один-единственный вызов конструктора на итоговом месте. Для простых типов (`int`, указатель) разницы почти нет, но для тяжёлых объектов с дорогим конструктором копирования/перемещения `emplace_back` даёт заметный выигрыш, что легко увидеть, добавив в конструктор класса вывод в лог и посчитав число вызовов. Обратная сторона — `emplace_back` менее явный: если по ошибке передать аргументы, которые неявно конвертируются не в то, что ожидалось, компилятор молча создаст не тот объект, тогда как `push_back(T(...))` явно показывает, какой тип создаётся. Дальше по списку структур данных STL — куча, лежащая в основе `priority_queue`.||
▪ **3.11 Как работает priority_queue?**
||`priority_queue` — адаптер, реализующий бинарную кучу поверх обычного `vector` (по умолчанию), выдающий доступ только к максимальному (по умолчанию) элементу. Устройство: элементы хранятся линейно в `vector`, но интерпретируются как полное бинарное дерево через индексную арифметику (родитель и потомки вычисляются по индексу), и поддерживается инвариант кучи — значение в родителе не меньше значений в детях. Вставка (`push`) добавляет элемент в конец массива и «просеивает» его вверх (`sift-up`), сравнивая с родителем и меняя местами, пока инвариант не восстановится — это O(log n), поскольку высота дерева log n. Извлечение максимума (`pop`) меняет местами корень с последним элементом, уменьшает размер на единицу и «просеивает» новый корень вниз (`sift-down`) — тоже O(log n); а сам доступ к максимуму (`top`) — O(1), потому что по инварианту кучи максимум всегда лежит в корне, то есть в начале массива. Такая структура выбрана потому, что для задачи «дать максимум и уметь быстро добавлять новые элементы» не нужна полная сортировка (O(n log n) заранее) — куча поддерживает частичный порядок ровно настолько, насколько нужно для операций top/push/pop. Логичный переход — какие ещё алгоритмы STL работают со сложностями похожего порядка.||
▪ **3.12 Какие алгоритмы STL знаешь?**
||Алгоритмы STL — это шаблонные функции над диапазоном итераторов, не привязанные к конкретному контейнеру, что и отличает их от методов класса. `sort` в большинстве реализаций — интроспективная сортировка (гибрид быстрой, пирамидальной и вставками для малых участков), в среднем и в худшем случае O(n log n), но неустойчива — то есть не гарантирует сохранение относительного порядка равных элементов, потому что при обменах в quicksort/heapsort порядок равных ключей не отслеживается; для устойчивости есть `stable_sort`, которая обычно устроена как сортировка слиянием и поэтому гарантирует и O(n log n), и сохранение порядка, ценой дополнительной памяти. `find` и `count` — линейный перебор O(n), потому что не предполагают отсортированности входа; `lower_bound`/`upper_bound` дают O(log n), но только на уже отсортированном диапазоне — это бинарный поиск границы, и на неотсортированных данных он просто вернёт неверный, но «правдоподобный» результат без ошибки, что и есть частая скрытая ошибка. `accumulate` сворачивает диапазон в одно значение за O(n), `remove_if` уплотняет диапазон (см. erase-remove), `unique` убирает подряд идущие дубликаты — поэтому её обычно применяют после `sort`, а не до, иначе неподряд идущие дубликаты останутся. Рядом с алгоритмами обычно спрашивают, что именно в них передают третьим аргументом — функтор и предикат.||
▪ **3.13 Что такое функтор и предикат?**
||Функтор — это объект произвольного класса с перегруженным `operator()`, то есть объект, который можно вызвать как функцию через круглые скобки. Предикат — это более узкое понятие про смысл, а не про механизм: функция или функтор, которая возвращает `bool` и используется алгоритмом для проверки условия (фильтрация в `remove_if`, порядок в `sort`). Причина, по которой STL предпочитает функторы простым указателям на функции, — компилятор может инлайнить `operator()` функтора прямо в тело шаблонного алгоритма на этапе компиляции, потому что тип функтора известен статически, тогда как вызов через указатель на функцию — это косвенный переход в рантайме, который сложнее заинлайнить. Лямбда-выражение, рассмотренное раньше, — это как раз синтаксический сахар компилятора над анонимным функтором с полями под захваченные переменные. Если передать в `sort` предикат, не задающий строгий слабый порядок (например, `<=` вместо `<`), это UB — не ошибка компиляции, а неопределённое поведение самого алгоритма, которое может проявиться как зависание или порча памяти на некоторых реализациях. Отдельно стоит разобрать `string` — формально тоже контейнер STL, но с особенностями.||
▪ **3.14 string — это контейнер?**
||Да, `std::string` — это специализация шаблона `std::basic_string<char>`, то есть полноценный контейнер STL: у него есть итераторы, `size()`, `begin()`/`end()`, и данные лежат непрерывно в памяти, как у `vector<char>`. Из непрерывности следует, что `substr`, `find`, `append` и индексация работают так же по смыслу, как аналогичные операции у `vector`, а метод `.c_str()` даёт указатель на этот же буфер, гарантированно завершённый нулевым байтом, для совместимости с C-функциями. Многие реализации дополнительно применяют small string optimization — короткие строки хранятся прямо внутри объекта `string` без обращения к куче, и только при превышении внутреннего буфера происходит аллокация; это оптимизация под частый случай коротких строк, а не требование стандарта. Отсюда следствие для реализации: у `string`, как и у `vector`, реаллокация буфера при росте инвалидирует ранее полученные указатели и итераторы на символы — то же самое правило, что и в вопросе про инвалидацию итераторов. Типичная ошибка — сохранить указатель из `.c_str()` или `&s[0]`, затем изменить строку (например, `+=`) и продолжить использовать старый указатель — это use-after-free, ловится ASAN. На этом раздел STL закрыт, дальше логично перейти к тому, как эти структуры работают поверх операционной системы — процессы, память, файловые дескрипторы.||
---
**4. Linux и ОС**
▪ **4.1 Процесс и поток?**
||Процесс — это независимая единица исполнения со своим виртуальным адресным пространством, таблицей страниц, PID и таблицей открытых файловых дескрипторов. Поток — единица исполнения внутри процесса: у него свой стек и свой набор регистров (включая указатель инструкций), но адресное пространство, кучу и таблицу дескрипторов он делит со всеми остальными потоками того же процесса. Из этого разделения следует ключевая практическая разница: создание потока дешевле создания процесса, потому что не нужно строить новое адресное пространство и таблицу страниц — достаточно выделить стек и завести контекст; а общая память между потоками даёт быстрый обмен данными, но именно поэтому требует синхронизации, тогда как процессы изолированы друг от друга и общаются только через явные механизмы (pipe, сокеты, разделяемая память). Если два потока пишут в одну переменную без синхронизации, компилятор не выдаст ошибку — это гонка данных, которую видно только в рантайме через ThreadSanitizer или изредка воспроизводимый баг. Отсюда естественный переход к тому, как вообще появляется новый процесс — к `fork()`.||
▪ **4.2 Что делает fork()?**
||`fork()` — системный вызов, создающий новый процесс как копию вызывающего: новый процесс получает такую же таблицу дескрипторов, тот же код, тот же стек и кучу в момент вызова, но собственный PID. Особенность в возврате: `fork()` возвращает управление дважды — в родительском процессе возвращается PID только что созданного ребёнка, в самом ребёнке возвращается 0, а при ошибке (например, нехватке ресурсов) — −1 и родитель ребёнка не получает. Так устроено потому, что это единственный syscall, а не пара «создать + сконфигурировать» — оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова, и именно возвращаемое значение — единственный способ определить, в какой из двух копий сейчас исполняется код. Если не проверить возвращаемое значение и не разветвить логику по нему, родитель и ребёнок выполнят один и тот же код дважды — типичная ошибка новичков, симптом — задвоенный вывод или два процесса делают одну и ту же работу, видно в `ps aux` по двум PID одной программы. Раз процесс скопирован — логично спросить, что происходит с памятью при этом копировании, ведь копировать всё физически было бы дорого.||
▪ **4.3 Что с памятью при fork()?**
||При `fork()` физическая память не копируется сразу — работает механизм copy-on-write (COW): страницы памяти родителя и ребёнка после `fork()` указывают на одни и те же физические страницы, но таблицы страниц обоих процессов помечают эти страницы как доступные только для чтения. Как только любой из процессов (родитель или ребёнок) пытается что-то записать в такую страницу, происходит page fault, ядро перехватывает его, выделяет новую физическую страницу, копирует туда содержимое и переписывает таблицу страниц пишущего процесса на эту приватную копию — только после этого запись реально выполняется. Причина такого устройства — очень частый паттерн `fork()` сразу за которым следует `exec()`: если бы ядро копировало весь адрес пространства заранее, эта работа почти всегда оказывалась бы выброшенной, потому что `exec()` тут же заменяет всё содержимое памяти новой программой. Следствие: сразу после `fork()` оба процесса на деле делят одну физическую память, и если ребёнок неожиданно долго не вызывает `exec()`, а активно пишет в большие структуры данных, можно получить всплеск потребления памяти именно в момент этих записей, а не в момент самого `fork()` — это видно в мониторинге RSS по времени. Раз память общая только временно, логично спросить, что делает `exec()`, которым эта временная стадия обычно заканчивается.||
▪ **4.4 exec()?**
||Семейство `exec()` заменяет образ текущего процесса — сегменты кода, данных, кучу и стек — образом новой программы, загружаемой с диска, при этом PID и таблица открытых файловых дескрипторов (кроме помеченных `FD_CLOEXEC`) сохраняются. При успешном выполнении `exec()` не возвращается вообще, потому что кода, который мог бы получить управление обратно, уже не существует — он был заменён; возврат из `exec()` в коде означает, что вызов провалился, и тогда он возвращает −1. Именно поэтому связка `fork()` + `exec()` — стандартный способ запустить внешнюю программу в Unix: `fork()` даёт новый процесс с независимым PID и своей копией состояния (включая, например, изменённые до `exec` дескрипторы для перенаправления ввода-вывода), а `exec()` в этом новом процессе подгружает нужную программу, не трогая родителя. Если запустить `exec()` без предварительного `fork()`, текущая программа (например, сама оболочка) заменится новой и не вернёт управление — оболочка перестанет существовать, что и наблюдается в некоторых shell-скриптах, использующих `exec` напрямую для замены процесса. После того как ребёнок исполнился и завершился, родителю нужно об этом узнать — отсюда `waitpid`.||
▪ **4.5 Зачем waitpid?**
||`waitpid()` — системный вызов, которым родительский процесс забирает код возврата завершившегося дочернего процесса и разрешает ядру освободить последнюю запись об этом процессе в таблице процессов. Механизм: когда ребёнок вызывает `exit()`, ядро не удаляет его немедленно, а сохраняет минимальную запись (PID, код возврата, статистику использования ресурсов) до тех пор, пока родитель не заберёт её через `wait`/`waitpid`; именно поэтому родителю в принципе физически возможно узнать, как завершился ребёнок, даже спустя время после его фактической смерти. Причина такого устройства — иначе код возврата было бы негде и некому передать, ведь процесс уже прекратил исполнение и не может сам ничего сообщить. Если родитель никогда не вызывает `waitpid` для завершившихся детей, эти записи копятся как зомби-процессы (следующий вопрос) — не занимают память данных, но занимают слот в таблице процессов, и при накоплении множества таких записей можно упереться в лимит PID на системе, что видно как невозможность породить новые процессы. Отсюда прямой переход к самому термину «зомби» и парному ему термину «сирота».||
▪ **4.6 Зомби и сирота?**
||Зомби — это процесс, который уже вызвал `exit()` и прекратил исполнение, но его запись в таблице процессов ещё не была забрана родителем через `wait`/`waitpid`, поэтому ядро держит минимальную информацию (PID, код возврата) специально для этой передачи. В `ps` такой процесс виден со статусом `Z` (defunct) и не потребляет CPU или память данных — только слот в таблице процессов. Сирота — это процесс, чей родитель завершился раньше него, то есть стало некому в будущем вызвать `wait` для него; ядро решает эту проблему, переусыновляя такой процесс на init (традиционно PID 1, либо на выделенный subreaper), который в цикле собирает статусы всех своих детей и тем самым гарантированно не даёт им застрять зомби навсегда. Разница между двумя терминами именно в том, какой сбой произошёл: зомби — родитель жив, но не вызвал `wait`; сирота — родитель умер, но у процесса всё ещё есть кто-то, кто в итоге его дождётся. Практическое следствие для демонов: они должны либо сами корректно вызывать `wait` за своими детьми, либо явно отсоединяться (double fork), чтобы не плодить зомби при долгой работе — иначе `ps aux | grep Z` со временем покажет растущий список. С зомби и SIGKILL связан ещё один частый вопрос — как читать коды возврата вроде 137 и 139.||
▪ **4.7 Коды возврата 137 и 139?**
||Когда процесс завершается не сам через `exit()`, а его убивает сигнал, оболочка кодирует итоговый код возврата как 128 плюс номер сигнала — так она отличает «упал от сигнала» от «завершился сам с каким-то кодом». 137 = 128 + 9, где 9 — номер `SIGKILL`, то есть процесс был принудительно убит и не мог этому помешать; 139 = 128 + 11, где 11 — номер `SIGSEGV`, то есть процесс получил сигнал об обращении к недопустимой памяти и упал сам. Смещение именно на 128 — соглашение shell/wait-интерфейса, а не свойство самого сигнала; сам сигнал внутри ядра — это просто небольшое целое число из фиксированного набора. Практически это значит, что при виде кода 137 в логах CI или systemd не нужно искать баг в логике программы — это почти всегда OOM-killer или явный `kill -9` (например, от Docker, ограничившего память контейнера), тогда как 139 указывает искать баг в самой программе — разыменование null, выход за границы массива, use-after-free — и здесь уже помогает не код возврата, а `core dump` и `gdb`. Раз зашла речь про номера сигналов, логично разобрать сигналы целиком.||
▪ **4.8 Сигналы, основные?**
||Сигнал — это асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя действие по умолчанию (завершить, завершить с дампом памяти, игнорировать, приостановить). Основные: `SIGINT` (номер 2) отправляется по Ctrl+C из терминала и по умолчанию завершает процесс, но перехватывается; `SIGKILL` (9) безусловно завершает процесс, обрабатывается напрямую ядром, не может быть перехвачен или проигнорирован; `SIGTERM` (15) — стандартный «вежливый» запрос на завершение, по умолчанию завершает, но процесс может перехватить его и закрыться корректно; `SIGSEGV` (11) — сигнал о обращении к недопустимой области памяти; `SIGPIPE` (13) возникает при записи в сокет или pipe, у которого читающий конец уже закрыт; `SIGCHLD` (17 на Linux/x86) уведомляет родителя, что дочерний процесс изменил состояние (завершился или остановился). Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности управления системой: администратору и супервизору всегда нужен гарантированный способ остановить процесс, даже если тот завис в бесконечном цикле или сам содержит баг в обработчике сигналов — отсюда `SIGKILL`, который кладёт процесс на уровне ядра, минуя пользовательский код. Если процесс не завершается на `SIGTERM` за разумное время (например, завис в блокирующем вызове без обработки), это симптом отсутствия корректного обработчика — видно по тому, что systemd в итоге посылает `SIGKILL` после таймаута. Раз `SIGTERM` и `SIGKILL` упомянуты вместе, разберём их разницу отдельно, это частый уточняющий вопрос.||
▪ **4.9 SIGTERM против SIGKILL?**
||`SIGTERM` — сигнал, который процесс может перехватить, назначив собственный обработчик через `sigaction`, и в этом обработчике корректно завершиться: закрыть файлы, сбросить буферы на диск, освободить ресурсы, попрощаться с другими процессами. `SIGKILL` в принципе не проходит через пользовательский код процесса — ядро немедленно снимает процесс с исполнения на уровне планировщика, не давая ему шанса выполнить хоть одну инструкцию в ответ. Причина разницы — по дизайну: `SIGTERM` создан для штатного, управляемого завершения (то есть предполагает, что процесс в рабочем состоянии и способен среагировать), а `SIGKILL` — это последний резервный механизм для случаев, когда процесс завис, заблокирован или его обработчик сигналов сам содержит баг, и его в принципе нельзя обойти намеренно или случайно. Отсюда практическое правило эксплуатации: сначала всегда посылают `SIGTERM` и ждут (например, `systemctl stop` или `docker stop` по умолчанию ждут несколько секунд), и только если процесс не завершился за таймаут, посылают `SIGKILL` — потому что `SIGKILL` не даёт процессу дописать данные на диск, и незавершённая транзакция может остаться в неконсистентном состоянии. Если разработчик игнорирует `SIGTERM` в демоне (не ставит обработчик), при `docker stop` весь путь по умолчанию завершится вынужденным `SIGKILL` по истечении таймаута — что видно в логах как резкое обрывание процесса без finalизации. Раз про сигналы и процессы поговорили, следующий блок — файловые дескрипторы, то, через что процесс видит файлы и сокеты.||
▪ **4.10 Файловый дескриптор?**
||Файловый дескриптор — это целое неотрицательное число, служащее индексом в таблице открытых файлов конкретного процесса; каждая запись этой таблицы указывает на структуру ядра (открытое файловое описание) с текущей позицией чтения/записи и флагами, а та в свою очередь — на inode файла, сокет или иную сущность. По соглашению значения 0, 1 и 2 зарезервированы за стандартными потоками: 0 — stdin, 1 — stdout, 2 — stderr, и любая программа при запуске уже имеет их открытыми, унаследованными от родителя через `fork()`. Такое устройство через таблицу и уровень косвенности существует потому, что позволяет менять, куда физически указывает дескриптор (например, при редиректе `2>&1` дублируют дескриптор так, чтобы оба указывали на одно и то же открытое файловое описание), не трогая номер, под которым программа его знает. Если дескрипторы не закрывать (`close`) после использования, они накапливаются — это утечка дескрипторов, процесс упирается в лимит (`ulimit -n`) и начинает получать ошибку `EMFILE` при попытке открыть что-либо ещё; диагностируется через `lsof -p PID` или `/proc/PID/fd`. Раз речь про множество открытых дескрипторов сразу — логично перейти к тому, как эффективно следить сразу за многими из них: `epoll`.||
▪ **4.11 Что такое epoll?**
||`epoll` — механизм ядра Linux для мультиплексирования ввода-вывода: он позволяет процессу зарегистрировать набор файловых дескрипторов, за готовностью которых нужно следить, и затем одним вызовом получать список только тех, что реально готовы к чтению или записи. Механизм: `epoll_ctl` добавляет/удаляет/изменяет дескрипторы в «список интереса», который ядро хранит между вызовами; когда состояние какого-то дескриптора меняется (например, пришли данные в сокет), ядро само добавляет его в отдельный «список готовых»; `epoll_wait` просто возвращает содержимое этого готового списка. Из этого следует сложность O(1) на каждое готовое событие — работа пропорциональна числу реально готовых дескрипторов, а не общему их числу, потому что ядро уже сделало фильтрацию заранее и не пришлось заново просматривать весь набор. `select`/`poll`, наоборот, при каждом вызове передают в ядро весь набор дескрипторов заново и ядро обязано пройти по всем ним, чтобы понять, какие готовы — отсюда O(n) на вызов независимо от того, сколько реально готово; при десятках тысяч соединений (типичный сервер) это становится узким местом. Именно поэтому высоконагруженные серверы на Linux строятся вокруг `epoll`, а не `select` — деградация `select` с ростом числа соединений видна прямо в профилировщике как рост времени внутри самого системного вызова. Раз затронут ввод-вывод через дескрипторы, логично разобрать альтернативный способ работы с файлом — через отображение в память, `mmap`.||
▪ **4.12 mmap?**
||`mmap` отображает файл (или анонимную область) в виртуальное адресное пространство процесса, после чего работа с содержимым файла превращается в обычное разыменование указателя, а не в вызовы `read`/`write`. Механизм: при вызове `mmap` ядро сразу резервирует диапазон виртуальных адресов и связывает его со страницами файла в кэше страниц, но физически данные с диска не читаются — при первом обращении к странице происходит page fault, ядро подгружает нужную страницу с диска в page cache и связывает её с виртуальным адресом; дальнейшие обращения к той же странице идут уже без page fault, напрямую. Такая ленивая загрузка выгодна потому, что не тратит время и память на данные, к которым никогда не обратятся (например, при работе с частью большого файла), а также убирает лишнее копирование между буфером ядра и буфером пользователя, которое неизбежно при `read`/`write`. Кроме того, `mmap` естественно разделяет физические страницы между процессами (в том числе через тот же механизм copy-on-write, что и при `fork`), что делает его удобным для разделяемой памяти между процессами. Если изменения, сделанные через `mmap` в режиме `MAP_SHARED`, нужно гарантированно сохранить на диск, а не полагаться на то, что ядро само сбросит их когда-нибудь, вызывают `msync` — иначе при аварийном завершении процесса можно потерять последние записанные страницы. Работа со страницами напрямую подводит к вопросу про виртуальную память и сами страницы в целом.||
▪ **4.13 Виртуальная память и страница?**
||Виртуальная память — это абстракция, при которой у каждого процесса своё собственное адресное пространство, полностью изолированное от других процессов, а отображение виртуальных адресов на физические выполняет аппаратный блок MMU по таблицам страниц, которые ведёт ядро. Память при этом делится на страницы фиксированного размера (обычно 4 КБ на x86-64) — это минимальная единица, которой ядро оперирует при выделении, отображении и вытеснении памяти; таблица страниц процесса — это, по сути, набор записей «номер виртуальной страницы → номер физической страницы (плюс права доступа)». Такое устройство даёт сразу два следствия: изоляцию (процесс физически не может адресовать память другого процесса, потому что в его таблице страниц просто нет таких отображений) и гибкость (физическая память может быть фрагментирована, а процессу она видна как непрерывный диапазон адресов, потому что непрерывность — свойство только виртуального пространства). Постраничная организация также даёт единицу для более тонких механизмов — copy-on-write при `fork`, ленивая загрузка при `mmap`, вытеснение в swap — все они оперируют именно страницами, а не байтами или всем адресным пространством целиком. Если процесс обращается к адресу, для которого нет валидной записи в таблице страниц, происходит `SIGSEGV` — это и есть механическая причина падения из вопроса про 139. Раз память может физически не хватать, следующий логичный вопрос — что происходит при её нехватке, то есть про swap.||
▪ **4.14 Что такое swap?**
||Swap — это область на диске (раздел или файл), куда ядро выгружает содержимое страниц оперативной памяти, когда физической RAM не хватает под текущую нагрузку, освобождая физические страницы для более активно используемых данных. Механизм: ядро отслеживает активность страниц и по алгоритму, близкому к LRU (давно неиспользуемые вытесняются в первую очередь), записывает содержимое выбранной страницы в swap и помечает соответствующую запись таблицы страниц как «страница на диске»; при следующем обращении процесса к этой странице происходит page fault (так называемый major fault), и ядро читает её обратно с диска в физическую память. Такой механизм существует как компромисс: суммарный объём данных, которые теоретически могут понадобиться процессам, часто превышает объём RAM, но не все они нужны одновременно, поэтому дешевле держать «неактивную» часть на медленном диске, чем убивать процессы при первом же превышении лимита RAM. Плата за это — на несколько порядков более медленный доступ к диску по сравнению с RAM, поэтому активное использование swap («thrashing», когда система постоянно подкачивает и выгружает страницы) выглядит как резкое падение отзывчивости системы при том, что CPU вроде бы не загружен — это видно по высокому `iowait` в `top` и по ненулевым значениям `si`/`so` в `vmstat`. Раз речь зашла про диск и файлы — переходим к тому, как файл вообще хранится на файловой системе, то есть к inode.||
▪ **4.15 Что такое inode?**
||Inode — это структура метаданных файла на файловой системе (и её кэшированная копия в памяти ядра), которая хранит права доступа, владельца, размер, временные метки и указатели на блоки данных на диске, где реально лежит содержимое файла. Ключевой момент устройства: имя файла в этой структуре не хранится вообще — имя живёт отдельно, как запись в каталоге, которая сопоставляет строку-имя номеру inode. Именно из-за этого разделения возможны жёсткие ссылки (hard link): несколько разных имён в каталогах могут указывать на один и тот же номер inode, то есть на один и тот же файл с одними данными, и файл физически не удаляется, пока не исчезнет последняя ссылка на его inode (счётчик ссылок), а не последнее имя. Отсюда же следует, что переименование файла внутри одной файловой системы — это дешёвая операция изменения записи в каталоге, а не копирование данных: сам inode и блоки данных не трогаются. Практическое следствие, которое иногда удивляет: если процесс открыл файл (держит дескриптор на inode), а другой процесс этот файл удалил (`rm`), данные не пропадают немедленно — они физически освобождаются только когда счётчик ссылок на inode, включая открытые дескрипторы, дойдёт до нуля; это используется намеренно, например, для временных файлов. Раз заговорили про метаданные файла, логично разобрать конкретно права доступа — например, что означает 755.||
▪ **4.16 Права доступа 755?**
||Права доступа Unix кодируются девятью битами: по три бита (чтение, запись, исполнение) на каждую из трёх категорий — владелец, группа, остальные — и хранятся именно в inode файла (см. предыдущий вопрос). Восьмеричная запись сворачивает эти три бита в одну цифру: r=4, w=2, x=1, и цифра — их сумма для соответствующей категории; поэтому 755 разбирается как 7=4+2+1 (владельцу — читать, писать, исполнять), 5=4+0+1 дважды (группе и остальным — читать и исполнять, без записи). Компактная восьмеричная форма используется потому, что удобно записывать девять независимых битов тремя цифрами вместо девяти символов, при этом однозначно и без потери информации. Типичный случай применения 755 — исполняемые файлы и каталоги, к которым разрешён доступ на чтение/выполнение всем, но менять их может только владелец; если по ошибке дать 777 (запись всем), это дыра в безопасности — любой пользователь системы сможет подменить содержимое файла или каталога, что обнаруживается аудитом прав или `find / -perm -002`. Меняются права командой `chmod`, а владелец и группа — командой `chown`; обе команды просто переписывают соответствующие поля в inode. Раз речь про файлы и процессы — логично вернуться к тому, как процессы обмениваются данными через файловый интерфейс, то есть к pipe и перенаправлению.||
▪ **4.17 Что такое pipe и перенаправление?**
||Pipe — это однонаправленный канал, реализованный ядром как ограниченный по размеру буфер в памяти с двумя файловыми дескрипторами по краям: один только для записи, другой только для чтения; данные, записанные в один конец, становятся доступны для чтения с другого в том же порядке, без промежуточного файла на диске. Когда в shell пишут `cmd1 | cmd2`, оболочка перед запуском обеих команд создаёт pipe и через `dup2` подменяет стандартный вывод `cmd1` на пишущий конец, а стандартный ввод `cmd2` — на читающий конец, после чего запускает обе команды параллельно; поэтому `cmd2` начинает обрабатывать данные, как только `cmd1` их произвела, не дожидаясь полного завершения. Перенаправления `>`, `>>`, `2>&1` работают по тому же принципу подмены дескриптора перед запуском программы: `>` открывает файл с усечением и подменяет стандартный вывод на него, `>>` открывает с флагом добавления в конец, а `2>&1` не открывает файл заново, а дублирует дескриптор 2 так, чтобы он указывал на то же самое открытое файловое описание, куда сейчас указывает дескриптор 1 — отсюда важен порядок: `cmd > file 2>&1` работает иначе, чем `cmd 2>&1 > file`, потому что дублирование происходит в момент выполнения инструкции, а не в конце строки. Если поставить перенаправления в обратном порядке и получить пустой лог ошибок вместо ожидаемого — это как раз симптом того, что `2>&1` продублировал дескриптор до, а не после переключения stdout на файл. Раз с перенаправлением потоков разобрались — логично перейти к инструментам наблюдения за системой в целом.||
▪ **4.18 Как посмотреть процессы и нагрузку?**
||Эти утилиты — тонкая обёртка над `/proc`, виртуальной файловой системой, где ядро в реальном времени публикует состояние процессов и ресурсов в виде текстовых файлов, ничего специально «собирать» не нужно — данные уже там. `ps aux` читает `/proc/[pid]/stat` и смежные файлы для каждого процесса и печатает срез состояния на момент вызова; `top`/`htop` делают то же самое циклически с обновлением на экране, добавляя сортировку по нагрузке; `pidstat` даёт то же самое, но временны́е ряды по конкретным процессам. `free -h` читает `/proc/meminfo` и показывает распределение RAM между использованной, кэшем и свободной памятью; `df -h` показывает занятость примонтированных файловых систем через `statfs`; `iostat` читает `/proc/diskstats` и показывает нагрузку на диски — операции в секунду, время ожидания. Общая причина, по которой все эти инструменты существуют как отдельные узкоспециализированные команды, а не один универсальный — разные срезы одного и того же источника данных ядра нужны для разных вопросов: «что жрёт CPU» (top), «кончается ли память» (free), «не забит ли диск» (df), «не диск ли тормозит» (iostat). Если нагрузка на CPU низкая, а система «тормозит», это обычно симптом I/O-ожидания — стоит смотреть `iowait` в `top` и `iostat`, а не количество процессов. Раз упомянуты процессы, которые работают в фоне постоянно, — логично разобрать, что такое демон.||
▪ **4.19 Что такое демон?**
||Демон — это фоновый процесс, не привязанный к управляющему терминалу, то есть он не получает сигналы вроде `SIGHUP` при закрытии терминальной сессии и не может писать напрямую в терминал пользователя. Классически демон запускался через двойной `fork()`: первый `fork` отсоединяет процесс от группы процессов терминала, второй гарантирует, что процесс не станет лидером новой сессии и никогда не сможет снова захватить управляющий терминал — так исторически реализовывалась независимость от сессии запуска. На современных системах эту роль почти всегда берёт на себя systemd: он запускает и супервизирует процесс по декларативному unit-файлу, который описывает команду запуска, зависимости от других сервисов, политику перезапуска при падении, и предоставляет управление через `systemctl start/stop/status/restart`. Такой переход от ручного двойного форка к systemd произошёл потому, что supervisor не только отсоединяет процесс от терминала, но и следит за его жизненным циклом — перезапускает при падении, логирует вывод через journald, упорядочивает старт относительно зависимостей — то, что раньше каждый демон реализовывал сам и часто с ошибками. Если демон падает и не перезапускается автоматически, а его unit-файл не задаёт `Restart=`, это симптом неполной конфигурации systemd-юнита, а не бага самого демона — проверяется через `systemctl status` и `journalctl -u`. Работа демона на системном уровне сводится к системным вызовам к ядру — стоит разобрать эту границу отдельно.||
▪ **4.20 Что такое ядро и системный вызов?**
||Ядро — это привилегированный код операционной системы, работающий в защищённом режиме процессора (кольцо 0 на x86), который управляет всеми ресурсами компьютера: планирует CPU между процессами и потоками, управляет физической и виртуальной памятью, драйверами устройств, файловыми системами и сетевым стеком. Обычные программы работают в непривилегированном режиме (кольцо 3) и не имеют прямого доступа к железу или чужой памяти — чтобы попросить ядро что-то сделать (открыть файл, прочитать данные, создать процесс), они делают системный вызов: специальную инструкцию процессора, которая переключает CPU в привилегированный режим и передаёт управление заранее определённому обработчику в ядре, а после выполнения запроса управление возвращается программе в обычном режиме. Такое разделение на два уровня привилегий существует ради защиты и стабильности: если бы любая программа могла напрямую трогать память другого процесса или диск, ошибка или злой умысел в одной программе могли бы обрушить всю систему. `strace` работает именно на этой границе — он перехватывает каждый системный вызов процесса (через `ptrace`) и печатает его имя, аргументы и результат, что даёт возможность увидеть, какие именно запросы к ядру делает программа, не имея её исходного кода. Если программа зависает и непонятно почему, `strace -p PID` часто сразу показывает, на каком системном вызове она застряла (например, на `read` от сети, которая никогда не ответит) — это быстрее, чем гадать по исходникам. От синхронизации с ядром через системные вызовы логично перейти к синхронизации между потоками одного процесса — мьютексам, семафорам и атомикам.||
▪ **4.21 Мьютекс, семафор, атомик?**
||Мьютекс (mutual exclusion) — это примитив синхронизации, гарантирующий, что критическую секцию кода в любой момент времени исполняет не более одного потока: остальные потоки, пытающиеся его захватить, блокируются (усыпляются планировщиком) до освобождения, а не крутятся в цикле — на Linux это обычно реализовано через futex, который позволяет не тратить CPU на ожидание, если конкурентности сейчас нет. Семафор — обобщение той же идеи на счётчик: он допускает не одного, а до N потоков одновременно внутри защищённого участка, каждый успешный захват (`wait`/`acquire`) уменьшает внутренний счётчик, освобождение (`post`/`release`) увеличивает, а поток блокируется, если счётчик равен нулю — используется, когда нужно ограничить не эксклюзивный доступ, а количество одновременных пользователей ресурса (например, пул из N соединений). Атомик — это операция над одним словом памяти, которую процессор выполняет неделимо на аппаратном уровне (например, инструкция `CMPXCHG`), без участия планировщика ОС и без усыпления потоков — отсюда меньшие накладные расходы, но и меньшая выразительность: атомик защищает одну операцию (инкремент, сравнение-и-обмен), а не последовательность из нескольких связанных изменений состояния. Разница в цене синхронизации следует из разницы в механизме: мьютекс и семафор — это переключение контекста и системный вызов при конкуренции, атомик — одна инструкция CPU, поэтому для простого счётчика атомик на порядки дешевле, но попытка «собрать» из нескольких атомарных операций сложный инвариант (например, две связанные переменные) обычно всё равно даёт гонку между самими операциями, если их не объединить в одну критическую секцию под мьютексом. Раз затронута гонка — стоит явно разобрать, что это такое, вместе с дедлоком.||
▪ **4.22 Что такое гонка данных и дедлок?**
||Гонка данных (data race) — это ситуация, когда два или более потоков одновременно обращаются к одной области памяти без синхронизации между собой, и хотя бы одно из обращений — запись; итоговый результат в этом случае зависит от того, в каком порядке планировщик реально исполнил инструкции, то есть становится недетерминированным и может отличаться от запуска к запуску. Формально по стандарту C++ гонка данных — это уже недопустимое поведение (UB), а не просто «иногда неверный результат» — компилятор вправе оптимизировать код в предположении, что гонок нет, и на практике это может проявиться совсем не там, где ожидается. ThreadSanitizer ловит гонки инструментированием каждого обращения к памяти и отслеживанием happens-before отношений между потоками во время выполнения — то есть находит гонку по факту исполнения, а не по статическому анализу кода. Дедлок — принципиально другая проблема: несколько потоков захватывают несколько блокировок, и каждый ждёт освобождения ресурса, который держит другой поток, — классический случай, когда поток A захватил мьютекс 1 и ждёт мьютекс 2, а поток B в это время держит мьютекс 2 и ждёт мьютекс 1, и оба ждут вечно, потому что ни один не отпустит уже захваченное. Причина, по которой дедлок вообще возможен, — противоречивый порядок захвата нескольких блокировок в разных частях кода; лечится это установлением единого глобального порядка захвата мьютексов во всей кодовой базе либо использованием `std::lock`/`std::scoped_lock`, которые захватывают несколько мьютексов атомарно как единую операцию, избегая промежуточного состояния, где один уже захвачен, а другой ещё нет. С ожиданием потоками друг друга связан ещё один механизм, который специально предназначен для безопасного ожидания события, — condition_variable.||
▪ **4.23 Условие переменная (condition_variable)?**
||`condition_variable` — примитив для того, чтобы поток мог заснуть в ожидании некоторого условия и не тратить CPU на активный опрос (busy-wait), а быть разбуженным ровно тогда, когда условие потенциально изменилось. Механизм `wait(lock)`: поток должен уже держать связанный мьютекс, вызов `wait` атомарно освобождает этот мьютекс и переводит поток в состояние ожидания, а при пробуждении (по `notify_one`/`notify_all` от другого потока) снова захватывает тот же мьютекс перед тем, как вернуть управление коду — атомарность освобождения-и-сна важна, иначе между проверкой условия и уходом в сон мог бы вклиниться другой поток и изменить состояние незамеченно. Стандарт прямо разрешает ложные пробуждения — `wait` может вернуться без единого вызова `notify`, просто по решению реализации/ОС, а также уведомление может быть отправлено до того, как ожидающий поток успел зайти в `wait`, и тогда оно потеряется. Из-за обоих этих фактов правильный код всегда ждёт в цикле с перепроверкой предиката, а не одним вызовом: `while (!ready) cv.wait(lock);`, — только так гарантируется, что поток проснётся действительно тогда, когда условие выполнено, а не просто когда его разбудили. Если написать `if (!ready) cv.wait(lock);` вместо `while`, на некоторых системах или под нагрузкой это выстрелит редким и трудно воспроизводимым багом — поток продолжит работу, хотя реальное условие ещё не выполнено, что не ловится юнит-тестами, а проявляется только под конкурентной нагрузкой. На этом раздел про Linux и ОС закрыт — дальше по базе идёт сетевой блок (OSI, TCP/IP), где многие термины (сокет, дескриптор, блокирующий/неблокирующий вызов) уже опираются на понятия из этого раздела.||
**5. Сети: OSI, TCP/IP, TCP, UDP**
▪ **5.1 Семь уровней OSI?**
||OSI — эталонная модель, которая разбивает сетевое взаимодействие на семь независимых уровней: физический, канальный, сетевой, транспортный, сеансовый, представления, прикладной. Каждый уровень решает свою задачу и разговаривает только с соседними уровнями через фиксированный интерфейс, не зная деталей их реализации. Такое разделение сделано ради независимой заменяемости: физическую среду можно сменить с меди на оптику, ничего не трогая в IP и TCP, потому что канальный уровень скрывает эту деталь от сетевого. На практике верхние три уровня (сеансовый, представления, прикладной) редко разделяют явно — приложение само решает вопросы сессии и кодирования данных, поэтому реально работают с пятиуровневой моделью. Если на собеседовании перепутать уровень (например, назвать IP транспортным протоколом), это сразу заметная ошибка. Дальше логично разобрать, что именно происходит на каждом из уровней.||
▪ **5.2 Что на каждом уровне?**
||Это конкретизация задачи каждого уровня OSI в терминах данных, адресов и устройств, которые на нём работают. L1 передаёт биты по физической среде (витая пара, оптика) без понятия адреса. L2 собирает биты в кадры, адресует их MAC-адресами и коммутирует внутри сегмента (свитч). L3 упаковывает данные в пакеты, адресует IP-адресами и решает, через какой маршрутизатор идти дальше. L4 делит поток на сегменты или датаграммы TCP/UDP, добавляет порты и отвечает за доставку между конкретными приложениями на двух узлах. L5–L7 — это уже логика сессии, представления данных (кодировка, шифрование) и сами прикладные протоколы вроде HTTP и DNS. Каждый нижний уровень для верхнего — просто транспорт: L4 не знает, что внутри сегмента лежит HTTP-запрос, для него это набор байт. Если спутать, на каком уровне решается конкретная задача (например, сказать, что маршрутизацию делает коммутатор), это выдаёт непонимание модели. Логичный следующий шаг — понять, как эти уровни соотносятся со стеком TCP/IP, которым реально пользуются.||
▪ **5.3 TCP/IP модель?**
||TCP/IP — практическая четырёхуровневая модель: канальный, интернет, транспортный, прикладной. Канальный уровень объединяет физический и канальный уровни OSI (доставка кадра в пределах сегмента), интернет-уровень соответствует сетевому уровню OSI (IP, маршрутизация между сетями), транспортный — прямой аналог транспортного уровня OSI (TCP/UDP, порты), а прикладной уровень TCP/IP поглощает сразу сеансовый, представления и прикладной уровни OSI. Такое укрупнение сделано потому, что в реальных стеках вопросы сессии и кодирования данных решает само приложение (например, HTTP сам решает, как представлять данные), а не отдельный протокольный слой. Именно эта, а не семиуровневая модель описывает реально работающий стек Linux и большинства сетевых устройств: сокет создаётся на транспортном уровне, а не на сеансовом. Путаница возникает, если пытаться найти в реальном стеке отдельный "уровень представления" — его как отдельного программного слоя просто нет. Дальше стоит посмотреть, как данные физически заворачиваются друг в друга при проходе через эти уровни — то есть на инкапсуляцию.||
▪ **5.4 Инкапсуляция?**
||Инкапсуляция — это оборачивание данных верхнего уровня в заголовок (а иногда и трейлер) каждого нижележащего уровня при передаче. Данные приложения кладутся в TCP-сегмент с заголовком минимум 20 байт, тот — в IP-пакет с заголовком минимум 20 байт, тот — в Ethernet-кадр с заголовком 14 байт и трейлером — контрольной суммой кадра (FCS). На приёмной стороне процесс идёт в обратном порядке: каждый уровень снимает свой заголовок и передаёт содержимое выше, ориентируясь на поле типа протокола в заголовке (например, EtherType в Ethernet-кадре указывает, что внутри IP). Так устроено потому, что каждый уровень должен уметь работать независимо от содержимого — коммутатору не нужно знать про TCP, чтобы передать кадр дальше, ему хватает MAC-адреса в заголовке L2. Если разобрать дамп tcpdump, видно эту вложенность буквально: Ethernet, затем IP, затем TCP, затем данные — это не абстракция, а реальные байты в пакете. Логичное продолжение — байтовый состав самого нижнего, канального заголовка.||
▪ **5.5 Сколько байт в Ethernet-заголовке?**
||Ethernet-заголовок занимает 14 байт: 6 байт MAC-адрес получателя, 6 байт MAC-адрес отправителя, 2 байта поле типа (EtherType), которое говорит, что лежит внутри кадра — например IPv4 или ARP. После полезной нагрузки кадр завершается 4-байтовым трейлером контрольной суммы (FCS), который заголовком не считается, но передаётся с каждым кадром для проверки целостности на приёме. Если в сети настроены VLAN по стандарту 802.1Q, между адресами и EtherType добавляется ещё 4 байта тега VLAN — заголовок вырастает до 18 байт. Такая фиксированная и компактная структура сделана потому, что коммутатор должен разобрать заголовок кадра на аппаратной скорости без анализа содержимого выше — все поля имеют строго фиксированную длину и позицию. Если перепутать 14-байтовый заголовок с 18-байтовым VLAN-вариантом при расчёте MTU или размера кадра, получится ошибка на 4 байта, которую типично ловят сравнением дампа tcpdump с ожидаемой длиной. Из размера заголовка логично перейти к самим полям — в первую очередь к MAC-адресу.||
▪ **5.6 MAC-адрес?**
||MAC-адрес — это 48-битный (6-байтный) физический адрес сетевого интерфейса, который используется для адресации на канальном уровне внутри одного сегмента. Первые 3 байта (OUI) назначаются производителю оборудования организацией IEEE, оставшиеся 3 байта производитель присваивает конкретному интерфейсу — так адрес получается уникальным в теории, хотя на практике его можно программно подменить. MAC-адрес работает только в пределах локального сегмента (широковещательного домена): коммутатор пересылает кадр по MAC-адресу, но как только пакет должен покинуть сегмент через маршрутизатор, MAC-адрес получателя меняется на MAC-адрес следующего узла на пути, а IP-адрес остаётся прежним. Так устроено потому, что задачи двух уровней разные: L2 отвечает за доставку "из рук в руки" внутри сегмента, а L3 (IP) — за доставку через множество сегментов. Если приложение или драйвер путает MAC и IP-адрес при настройке сети, узел физически не найдёт получателя, и это видно как таймаут ARP-запроса в tcpdump. Раз для доставки внутри сегмента нужен MAC, а известен обычно только IP, логично перейти к протоколу, который их связывает — ARP.||
▪ **5.7 ARP?**
||ARP (Address Resolution Protocol) — протокол, который по известному IP-адресу узла в локальном сегменте находит его MAC-адрес. Механизм: отправитель рассылает широковещательный кадр "кто владеет IP X, сообщите свой MAC", все узлы сегмента его получают, но отвечает только владелец адреса — уже адресным unicast-пакетом со своим MAC. Полученную пару IP-MAC отправитель кладёт в локальный ARP-кэш, чтобы не повторять broadcast для каждого пакета — иначе на каждую отправку требовался бы новый широковещательный запрос, что перегрузило бы сегмент. Именно поэтому первый пакет к новому соседу в сети всегда чуть медленнее последующих — он ждёт ARP-ответа. Если ARP не проходит (узел выключен, неверная подсеть, фильтрация), это видно в tcpdump как повторяющиеся "who has X" без ответа, а приложение получит таймаут соединения без объяснения причины. ARP решает адресацию внутри сегмента, но пакет не может быть сколь угодно большим — дальше стоит разобрать ограничение размера кадра, MTU.||
▪ **5.8 MTU?**
||MTU (Maximum Transmission Unit) — это максимальный размер полезной нагрузки, который канальный уровень может передать в одном кадре, для стандартного Ethernet это 1500 байт. Если IP-пакет крупнее MTU исходящего интерфейса, он либо фрагментируется на несколько IP-пакетов меньшего размера (каждый со своим IP-заголовком), либо, если в заголовке выставлен флаг "не фрагментировать" (DF), узел на пути отбрасывает пакет и присылает отправителю ICMP-сообщение о необходимости фрагментации. Ограничение в 1500 байт исторически идёт из спецификации Ethernet и балансирует накладные расходы заголовка против задержки и вероятности ошибки на длинном кадре — чем крупнее кадр, тем дороже обходится его повторная передача при ошибке. Фрагментация — дорогая операция: она создаёт дополнительную нагрузку на маршрутизаторы и делает сеть уязвимой к потере одного фрагмента, из-за которого теряется весь исходный пакет, поэтому современные стеки стараются заранее подобрать размер пакета под MTU пути (PMTU discovery) и в тестах на этот случай ловят проблему через рост RTT или через ICMP "fragmentation needed" в дампе. Раз MTU задаёт границу для IP-пакета, логично посмотреть, что находится в самом IP-заголовке.||
▪ **5.9 IPv4-заголовок, что важно?**
||IPv4-заголовок — это структура минимум в 20 байт (без опций), которая предваряет данные транспортного уровня и содержит всё необходимое для маршрутизации пакета. Ключевые поля: версия и IHL (длина заголовка), общая длина пакета, идентификатор и флаги фрагментации, TTL (время жизни), номер протокола следующего уровня (например 6 для TCP, 17 для UDP), контрольная сумма заголовка, IP-адреса источника и назначения. TTL уменьшается на единицу на каждом маршрутизаторе, через который проходит пакет, и это сделано специально — чтобы зацикленный по ошибке маршрут не гонял пакет по сети бесконечно: при достижении нуля пакет отбрасывается и отправителю летит ICMP Time Exceeded. Именно на этом механизме построен traceroute: он последовательно отправляет пакеты с TTL 1, 2, 3 и по приходящим ICMP-ответам восстанавливает список промежуточных маршрутизаторов. Если контрольная сумма заголовка не совпадает при приёме, пакет молча отбрасывается — эта ошибка ловится счётчиками ошибок интерфейса или в дампе tcpdump как отсутствие ожидаемого ответа. От структуры одного пакета логично перейти к тому, как назначаются сами IP-адреса и подсети.||
▪ **5.10 Маски и подсети?**
||Маска подсети делит 32-битный IPv4-адрес на две части: номер сети и номер узла внутри неё, определяя тем самым, какие адреса считаются "своими" для локальной доставки, а какие требуют выхода через шлюз. Маска /24 оставляет 8 бит под узлы — это 256 адресов, из которых 254 можно раздать хостам (первый — адрес сети, последний — широковещательный, оба заняты служебно). Маска /26 оставляет 6 бит — 64 адреса минус 2 служебных, то есть 62 узла; /30 оставляет 2 бита — 4 адреса минус 2, то есть ровно 2 узла, чего достаточно для соединения точка-точка между двумя маршрутизаторами. Широковещательный адрес — это адрес подсети, где все биты хостовой части выставлены в единицу, он зарезервирован для рассылки всем узлам сегмента и не может быть выдан конкретному устройству. Такое деление сделано ради иерархической маршрутизации: маршрутизатору не нужно помнить адрес каждого хоста, достаточно знать, куда вести целую подсеть, а конкретный узел внутри неё находится уже через ARP. Ошибка в расчёте маски — типичная причина, когда два устройства "в одной сети" на самом деле не видят друг друга напрямую; это проверяется сравнением IP и маски на обоих узлах. Из деления на подсети логично следует вопрос, как узел решает, слать пакет напрямую или через шлюз — то есть маршрутизация.||
▪ **5.11 Маршрутизация?**
||Маршрутизация — это процесс выбора узлом или маршрутизатором, куда именно отправить пакет дальше, исходя из IP-адреса назначения. Механизм на конечном узле простой: он побитово сравнивает адрес назначения со своим адресом через маску подсети; если адрес попадает в ту же подсеть, узел находит MAC получателя через ARP и посылает кадр напрямую внутри сегмента; если адрес чужой, пакет отправляется на MAC-адрес шлюза по умолчанию (default gateway), а дальше решение принимает уже маршрутизатор по своей таблице маршрутов. Так устроено потому, что конечный узел физически не может держать полную карту всего интернета — ему достаточно знать одно правило: "своя подсеть — напрямую, всё остальное — через шлюз", а сложная логика выбора пути делегирована специализированным устройствам с таблицами маршрутизации. Маршрутизатор в таблице ищет наиболее точное совпадение префикса (longest prefix match) и пересылает пакет на соответствующий интерфейс, уменьшая TTL на единицу. Если шлюз по умолчанию не настроен или недоступен, узел успешно достучится только до соседей в своей подсети, а любой внешний адрес будет недоступен — это стандартная причина "интернет не работает, а локальная сеть работает" и диагностируется через `ip route` и ping шлюза. Раз маршрутизаторы обмениваются служебной информацией о доступности узлов и путей, логично перейти к протоколу ICMP, который как раз для этого служит.||
▪ **5.12 ICMP?**
||ICMP (Internet Control Message Protocol) — протокол сетевого уровня для служебных и диагностических сообщений, у него нет портов и он не переносит пользовательские данные приложений. Ключевые типы сообщений: echo request/reply (тип 8 и 0) — основа команды ping, Time Exceeded (тип 11) — присылается, когда TTL пакета обнулился, на чём строится traceroute, и Destination Unreachable (тип 3) — когда пакет физически не может быть доставлен (нет маршрута, порт закрыт и т. п.). ICMP существует отдельно от TCP/UDP потому, что диагностические сообщения о состоянии сети нужны на уровне, где ещё нет понятия соединения или порта — маршрутизатор должен уметь сообщить об ошибке доставки, даже не зная, TCP там был или UDP. Именно поэтому ping и traceroute работают даже к узлу, на котором не открыт ни один сервис поверх TCP/UDP: они используют не транспортный, а сетевой уровень. Если ICMP заблокирован файрволом (частая практика безопасности), ping не проходит, хотя TCP-соединение на конкретный порт может работать нормально — это видно, если сравнить `ping host` и `curl host` с разным результатом. С сетевого уровня логично подняться на транспортный и разобрать, как устанавливается TCP-соединение.||
▪ **5.13 Порядок установки TCP-соединения?**
||TCP-соединение устанавливается трёхсторонним рукопожатием (three-way handshake): клиент шлёт сегмент с флагом SYN и своим начальным порядковым номером, сервер отвечает сегментом с флагами SYN и ACK — подтверждает SYN клиента и присылает собственный начальный порядковый номер, клиент завершает обмен сегментом с флагом ACK, подтверждающим SYN сервера. Три шага нужны именно потому, что TCP-соединение полнодуплексное: каждая сторона должна не только сообщить свой стартовый порядковый номер для последующей нумерации байтов, но и получить подтверждение, что другая сторона его действительно получила — двух шагов недостаточно, потому что сервер не может быть уверен, что его SYN-ACK дошёл, пока не получит финальный ACK. После этого рукопожатия обе стороны знают начальные порядковые номера друг друга и могут независимо отслеживать доставку и порядок байт в каждом направлении. Если рукопожатие не завершается (например, файрвол блокирует ответный ACK), соединение зависает в состоянии SYN_RECV на сервере или SYN_SENT на клиенте — это видно по `netstat`/`ss` и в tcpdump как одинокий SYN без ответа. Раз соединение открывается тремя сегментами, логично спросить, сколько сегментов нужно, чтобы его закрыть.||
▪ **5.14 Как закрывается TCP?**
||Закрытие TCP-соединения обычно занимает четыре сегмента: сторона, завершившая передачу, шлёт FIN, вторая сторона подтверждает его ACK; когда и вторая сторона готова закрыться, она шлёт свой собственный FIN, а первая сторона подтверждает его финальным ACK. Четыре шага, а не два, нужны потому, что TCP-соединение дуплексное и закрытие каждого направления независимо: получение FIN от партнёра означает только "он больше не будет присылать данные", но сама сторона может ещё дописывать и досылать данные в обратном направлении, прежде чем закрыть свою половину соединения. Сторона, которая отправила последний ACK (то есть первой начала закрытие), переходит в состояние TIME_WAIT и ждёт там время, равное удвоенному MSL (Maximum Segment Lifetime), прежде чем окончательно освободить сокет. Ожидание в TIME_WAIT нужно, чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения — если бы порт освобождался сразу и переиспользовался новым соединением, устаревший сегмент мог бы по ошибке быть принят как часть новой сессии. На практике куча накопившихся TIME_WAIT-сокетов на активном сервере — известная проблема, видимая в `ss -tan state time-wait`, и решается через SO_REUSEADDR или уменьшение частоты пересоздания соединений. Раз закрытие и открытие соединения используют специальные биты в заголовке, логично перечислить флаги TCP целиком.||
▪ **5.15 Флаги TCP?**
||Флаги TCP — однобитовые поля в заголовке сегмента, которые определяют его роль в управлении соединением: SYN (запрос на синхронизацию/установку соединения), ACK (подтверждение получения данных или другого флага), FIN (запрос на корректное закрытие направления), RST (немедленный аварийный сброс соединения), PSH (просьба немедленно передать данные приложению, не буферизуя) и URG (указывает, что часть данных сегмента помечена как срочная через указатель urgent pointer). Устройство такое потому, что TCP-заголовку нужно компактно кодировать состояние протокола конечного автомата соединения (установка, передача, закрытие, разрыв) без отдельных пакетов-команд — флаги просто помечают обычный сегмент дополнительным смыслом, экономя один бит на бит состояния. RST отдельно важен: в отличие от вежливого FIN он сигнализирует именно об ошибке или невозможности продолжить — например, попытка подключиться к закрытому порту получает в ответ RST, а не тишину. Если приложение получает RST там, где ожидало нормальное закрытие через FIN, это обычно значит, что сокет был закрыт грубо (например, процесс убит) — типичный симптом в логах "connection reset by peer", который ловится сравнением с tcpdump, где виден флаг RST в последнем сегменте. От перечисления флагов логично перейти к вопросу, что именно эти механизмы вместе дают — то есть какие гарантии обеспечивает TCP.||
▪ **5.16 Что даёт TCP?**
||TCP — это протокол, который поверх ненадёжной доставки IP строит надёжный, упорядоченный байтовый поток между двумя приложениями. Механизм: каждый байт данных нумеруется порядковым номером, получатель подтверждает принятые данные через ACK, если подтверждение не пришло за таймаут — отправитель повторяет передачу (ретрансмиссия), а получатель на своей стороне переупорядочивает пришедшие не по порядку сегменты перед тем, как отдать их приложению. Дополнительно TCP регулирует скорость передачи двумя механизмами: окном приёма (flow control — сколько байт получатель готов принять) и контролем перегрузки (congestion control — сколько отправитель может слать, не перегружая сеть, через механизмы вроде slow start). Всё это заложено потому, что IP сам по себе не гарантирует ни доставку, ни порядок, ни отсутствие дублей — он просто пытается доставить пакет "как получится", а надёжность целиком вынесена на транспортный уровень, чтобы не усложнять каждый маршрутизатор на пути. Если приложению нужна гарантия доставки, но использовать UDP напрямую без этой логики, придётся реализовывать нумерацию и ретрансмиссию вручную — именно поэтому большинство протоколов (HTTP, SSH, FTP) построены поверх TCP, а не UDP. Раз flow control упомянут явно, логично раскрыть, что такое окно TCP отдельно.||
▪ **5.17 Что такое окно?**
||Окно TCP (window) — это объявляемое получателем количество байт, которое отправитель может передать, не дожидаясь подтверждения каждого сегмента в отдельности. Механизм: получатель в каждом ACK указывает текущий свободный размер своего приёмного буфера (receive window), а отправитель держит в памяти это значение и не отправляет данных больше, чем в него помещается, пока не придёт новое подтверждение, освобождающее место в окне. Такая схема нужна потому, что подтверждение каждого отдельного сегмента перед отправкой следующего сделало бы передачу крайне медленной — пришлось бы ждать полный round-trip time на каждый пакет; окно позволяет держать в полёте сразу много неподтверждённых байт и использовать пропускную способность канала эффективно. Одновременно окно защищает получателя от переполнения буфера: если приложение на принимающей стороне читает данные медленно, окно сужается, и отправитель автоматически притормаживает, вместо того чтобы завалить получателя данными, которые некуда складывать. Если окно упало до нуля и не восстанавливается (получатель не читает данные), передача останавливается полностью — это видно в tcpdump как "TCP Zero Window" и типичный симптом медленного или зависшего приложения на другом конце. TCP полагается на подтверждения и окно, а UDP всего этого лишён — логично сравнить их напрямую.||
▪ **5.18 UDP?**
||UDP (User Datagram Protocol) — это транспортный протокол без установки соединения и без гарантий доставки, порядка или отсутствия дублей. Его заголовок минимален — всего 8 байт: порт источника, порт назначения, длина и контрольная сумма, и всё — никаких порядковых номеров, подтверждений или окна, как у TCP. Такая простота сделана намеренно: приложениям, которым важна низкая задержка и минимальные накладные расходы больше, чем гарантия каждого байта, не нужен весь тяжёлый механизм TCP — отправил датаграмму и не тратишь ресурсы на хранение состояния соединения и ретрансмиссии. Именно поэтому на UDP строят DNS (короткий запрос-ответ, где проще переспросить целиком, чем ждать ретрансмиссии TCP), DHCP, VoIP и видеостриминг (устаревший потерянный кадр всё равно бесполезен, ждать его повторной передачи хуже, чем пропустить). Обратная сторона: если приложению всё же нужна надёжность поверх UDP, её приходится реализовывать самостоятельно в прикладном протоколе (как делает, например, QUIC) — иначе потерянный пакет просто пропадает молча, и это ловится только на прикладном уровне (пропуски в аудио, таймаут DNS-запроса). Раз у TCP и UDP разные гарантии, логично прямо сравнить, когда какой выбирать.||
▪ **5.19 TCP или UDP — когда что?**
||Выбор между TCP и UDP определяется тем, что важнее для конкретной задачи — целостность данных или задержка. TCP выбирают, когда критична полная и упорядоченная доставка каждого байта и можно позволить себе задержку на ретрансмиссии: передача файлов, HTTP, SSH — там потеря даже одного байта делает результат бесполезным, а время ожидания повторной передачи не критично. UDP выбирают, когда важнее низкая и предсказуемая задержка, а отдельные потери можно пережить или обработать на прикладном уровне: голосовая связь, видеостриминг, онлайн-игры, DNS-запросы — устаревший или потерянный пакет там либо игнорируется, либо переспрашивается заново самим приложением, что дешевле, чем ждать TCP-ретрансмиссию. Причина именно такого деления в том, что надёжность TCP не бесплатна — она стоит задержки на подтверждения и ретрансмиссии, и для интерактивного трафика реального времени эта задержка хуже, чем сама потеря данных. Если по ошибке выбрать TCP для потокового видео реального времени, при малейшей потере пакета видео "залипнет" в ожидании ретрансмиссии вместо того, чтобы просто пропустить кадр — это классический симптом неверного выбора протокола, видимый как рывки при слабой сети. Раз оба протокола идентифицируют приложение через номер, логично перейти к самому понятию порта.||
▪ **5.20 Что такое порт?**
||Порт — это 16-битное число (0–65535), которое идентифицирует конкретное приложение или службу на узле поверх IP-адреса, позволяя нескольким сетевым сервисам работать одновременно на одном хосте. Операционная система хранит таблицу соответствия открытых портов и процессов (сокетов), которые их слушают, и при получении пакета передаёт данные именно тому процессу, чей порт указан в TCP/UDP-заголовке. Диапазон 0–1023 закреплён за "хорошо известными" службами по соглашению (22 SSH, 53 DNS, 80 HTTP, 443 HTTPS) — так любой клиент заранее знает, куда стучаться, не выясняя номер порта отдельно. Такое разделение необходимо потому, что одного IP-адреса недостаточно для мультиплексирования — без порта сервер не смог бы понять, какому из десяти одновременно работающих сервисов на этом узле адресован конкретный пакет. Если два процесса пытаются слушать один и тот же порт одновременно, второй вызов `bind` завершится ошибкой "Address already in use" — это стандартная причина, по которой сервис не запускается после аварийного перезапуска, пока старый процесс ещё держит порт (или пока не истёк TIME_WAIT). Из списка портов логично разобрать первый из них подробнее — DNS на порту 53.||
▪ **5.21 DNS?**
||DNS (Domain Name System) — распределённая служба, которая превращает человекочитаемое доменное имя в IP-адрес, необходимый для реальной доставки пакетов. Механизм: клиент отправляет запрос резолверу (обычно через UDP на порт 53, поскольку типичный ответ помещается в один небольшой пакет и не требует установки соединения), резолвер либо отвечает из кэша, либо рекурсивно опрашивает корневые, затем доменные, затем авторитативные серверы, пока не получит финальный ответ. Если ответ не помещается в стандартный размер UDP-датаграммы (например, при передаче зоны или больших DNSSEC-записях), DNS переключается на TCP/53, где нет ограничения на размер одного пакета и есть гарантия доставки. Такое разделение сделано ради скорости: подавляющее большинство запросов укладываются в один короткий обмен, и заводить полноценное TCP-соединение с рукопожатием ради одного маленького запроса было бы избыточно медленно. Если DNS не резолвится, а по IP-адресу сервис доступен — это чётко указывает, что проблема именно в DNS, а не в сети, и диагностируется командами вроде `dig` или `nslookup`, а не ping. Логично продолжить другим сервисом, который тоже раздаёт узлам сетевые параметры автоматически, — DHCP.||
▪ **5.22 DHCP?**
||DHCP (Dynamic Host Configuration Protocol) — протокол, который автоматически выдаёт узлу IP-адрес и сопутствующие сетевые параметры (маску подсети, адрес шлюза по умолчанию, адреса DNS-серверов) при подключении к сети, без ручной настройки. Обмен идёт по классической схеме DORA: узел широковещательно посылает Discover ("кто-нибудь выдаст мне адрес"), сервер отвечает Offer с предложенным адресом, узел подтверждает выбор через Request, сервер финально закрепляет адрес сообщением Ack, обычно на ограниченный срок аренды (lease), который нужно периодически продлевать. Автоматизация нужна потому, что вручную прописывать уникальный IP на каждое устройство в сети из сотен узлов было бы неуправляемо и чревато конфликтами адресов при малейшей ошибке администратора. Если DHCP-сервер недоступен, узел либо не получает адрес вовсе, либо (в некоторых системах) назначает себе адрес из специального диапазона автоконфигурации — и в обоих случаях сеть за пределами локального сегмента будет недоступна, что видно по отсутствию адреса в `ip addr` или по логам клиента DHCP. Раз узлам с частными адресами всё равно нужно выходить в интернет через один внешний IP, логично перейти к механизму, который это обеспечивает, — NAT.||
▪ **5.23 NAT?**
||NAT (Network Address Translation) — механизм подмены IP-адресов (и обычно портов) при прохождении пакетов через пограничное устройство, который позволяет множеству узлов с частными адресами выходить в интернет через один общий внешний IP. Механизм: маршрутизатор при исходящем пакете подменяет внутренний адрес источника на свой внешний и запоминает в таблице трансляций соответствие "внутренний IP:порт — внешний IP:порт"; когда приходит ответный пакет на внешний адрес и порт, маршрутизатор по этой таблице находит нужного внутреннего получателя и подменяет адрес обратно. NAT возник как практическое решение нехватки публичных IPv4-адресов: адресов в 32-битном пространстве не хватает на все устройства мира, а NAT позволяет одному внешнему адресу обслуживать целую локальную сеть, различая внутренние узлы по номеру порта (PAT — Port Address Translation). Обратная сторона NAT — узел за ним не имеет собственного публичного адреса и не может принимать входящие соединения без специальной настройки (проброс портов), что становится проблемой для серверов и P2P-приложений и диагностируется тем, что снаружи видно только внешний адрес роутера, а не внутренний узел. Раз NAT и порты определяют, как узлы видны снаружи, логично перейти к тому, как приложение вообще открывает соединение программно — к сокетам.||
▪ **5.24 Сокеты: как устроен сервер?**
||Сокет — это программный интерфейс операционной системы для сетевого взаимодействия, а серверная сторона строится фиксированной последовательностью системных вызовов. Порядок: `socket()` создаёт файловый дескриптор сокета, `bind()` привязывает его к конкретному локальному IP-адресу и порту, `listen()` переводит сокет в пассивный режим приёма входящих подключений с очередью на подтверждение, `accept()` блокируется в ожидании и при приходе нового клиента возвращает новый отдельный сокет именно для этого соединения (сам слушающий сокет продолжает принимать следующих), после чего идёт обмен данными через `read`/`write`, а по завершении — `close()`. Клиент устроен проще: `socket()` и `connect()` к адресу и порту сервера, что инициирует TCP-рукопожатие. Такое разделение слушающего и клиентского сокетов нужно потому, что сервер должен одновременно принимать новые подключения и обслуживать уже установленные — с одним сокетом на всё это было бы невозможно совместить. Если сервер должен держать тысячи одновременных соединений, один поток с блокирующим `accept`/`read` на каждое соединение не масштабируется — отсюда необходимость в `epoll` и неблокирующих сокетах, чтобы одним потоком обслуживать множество дескрипторов одновременно. Раз речь зашла о неблокирующих сокетах, логично разобрать, что при этом означает EAGAIN.||
▪ **5.25 Что такое неблокирующий сокет и EAGAIN?**
||Неблокирующий сокет — это сокет, переведённый флагом `O_NONBLOCK`, у которого системные вызовы чтения и записи не ждут готовности данных, а немедленно возвращают управление независимо от того, есть данные или нет. Если данных для чтения нет (или буфер записи полон), вызов `read`/`recv`/`write`/`send` возвращает −1 и выставляет `errno` в `EAGAIN` (синоним `EWOULDBLOCK`) — это не ошибка в смысле сбоя, а сигнал "сейчас нечего делать, попробуй позже". Такой режим нужен потому, что при блокирующем вызове поток застревает на одном сокете и не может параллельно обслуживать остальные — а в связке с `epoll`, который сообщает, какие именно дескрипторы реально готовы к чтению или записи, неблокирующий режим позволяет одному потоку эффективно опрашивать тысячи соединений, обрабатывая только те, что действительно готовы. Если код ошибочно трактует `EAGAIN` как настоящую ошибку и, например, закрывает соединение при её получении, сервер будет рвать рабочие соединения просто потому, что в момент проверки данные ещё не пришли — такая ошибка обычно проявляется как случайные обрывы под нагрузкой и ловится логированием `errno` перед реакцией на ошибку. С точки зрения диагностики логично закончить раздел тем, чем реально смотрят происходящее в сети, — tcpdump и Wireshark.||
▪ **5.26 Чем смотрят трафик?**
||Трафик на интерфейсе смотрят анализаторами пакетов: `tcpdump` — консольный инструмент, который захватывает сырые пакеты прямо с сетевого интерфейса и печатает их разобранными по протоколам, Wireshark — его графический аналог с более удобной фильтрацией и построчным разбором каждого заголовка. Типичный вызов `tcpdump -i eth0 -nn port 80` означает: слушать интерфейс `eth0`, не резолвить в имена ни адреса, ни порты (`-nn`, чтобы не создавать лишний DNS-трафик и не ждать резолвинга), показывать только трафик на 80 порту. Такие инструменты работают на уровне драйвера сетевого интерфейса (через libpcap), то есть видят пакеты до и после любой обработки приложением — это единственный способ достоверно узнать, что реально ушло в сеть или пришло из неё, а не что, как кажется программе, должно было произойти. Именно поэтому tcpdump — конечный аргумент при разборе сетевых багов: если приложение утверждает, что отправило запрос, а партнёр говорит, что не получал, дамп с обеих сторон однозначно покажет, кто прав — ушёл ли SYN, ответил ли RST, потерялся ли пакет. Без захвата трафика отладка сетевого взаимодействия сводится к догадкам по логам приложения, которые могут врать о том, что происходило на самом деле на проводе.||
---
**6. Многопоточность**
▪ **6.1 Как создать поток в C++?**
||`std::thread` — это объект стандартной библиотеки, который при создании немедленно запускает переданную функцию в новом потоке операционной системы: `std::thread t(f, args...)` стартует поток параллельно основному сразу в конструкторе, а не при отдельном вызове "старт". Дальше есть ровно два законных способа расстаться с этим объектом: `t.join()` — дождаться завершения потока, блокируя вызывающий код до его окончания, или `t.detach()` — отсоединить поток, чтобы он жил и завершался независимо от объекта `std::thread`. Такое жёсткое требование заложено потому, что поток — это ресурс операционной системы, и если объект `std::thread`, всё ещё представляющий незавершённый и неприсоединённый поток, уничтожается (например, выходит из области видимости), стандарт требует вызвать `std::terminate` — программа аварийно падает, а не тихо "теряет" поток. При `detach()` нужно отдельно следить за временем жизни всего, что поток использует по ссылке или указателю: если основной поток уничтожит локальные объекты раньше, чем завершится отсоединённый поток, это use-after-free, который ловится ThreadSanitizer или ASAN, а не компилятором. Раз поток работает с общими данными, логично сразу перейти к тому, как эти данные защищать от одновременного доступа.||
▪ **6.2 Как защитить общие данные?**
||Общие данные защищают мьютексом (`std::mutex`), который гарантирует, что в критическую секцию кода одновременно входит только один поток, а для простых типов вроде счётчиков — атомарными операциями (`std::atomic`). На практике мьютекс почти никогда не захватывают вручную через `lock()`/`unlock()`, а оборачивают в RAII-объект: `std::lock_guard` — простая блокировка на время области видимости, `std::unique_lock` — то же самое, но с возможностью вручную разблокировать раньше, передавать владение и работать с `condition_variable`. Такая обёртка нужна потому, что ручной `unlock()` легко забыть на пути исключения или раннего `return`, и тогда мьютекс останется захваченным навсегда — RAII снимает блокировку автоматически в деструкторе при выходе из области видимости любым путём, так же как обычные ресурсы освобождаются в RAII-обёртках. `std::atomic` для счётчика дешевле мьютекса, потому что использует одну аппаратную атомарную инструкцию процессора (например compare-and-swap) без перехода в ядро и без усыпления потока — мьютекс же в случае конкуренции может потребовать системного вызова и контекстного переключения. Если общие данные меняются без всякой синхронизации, это гонка данных — неопределённое поведение, которое не всегда проявляется видимым багом, но надёжно ловится ThreadSanitizer (`-fsanitize=thread`). Раз мьютекс используется вместе с ожиданием события, логично разобрать, что такое ложное пробуждение при `wait`.||
▪ **6.3 Что такое ложное пробуждение?**
||Ложное пробуждение (spurious wakeup) — это ситуация, когда вызов `condition_variable::wait` возвращает управление потоку, хотя реального уведомления через `notify_one`/`notify_all` не было. Стандарт C++ прямо разрешает такое поведение реализациям, потому что на уровне операционной системы примитивы ожидания (futex в Linux и аналоги) иногда пробуждаются по внутренним причинам платформы, и гарантировать абсолютное отсутствие лишних пробуждений было бы дороже, чем просто заложить их возможность в контракт. Из-за этого ждать события нельзя одним вызовом `wait` — правильный паттерн ждать в цикле с проверкой предиката: `while (!ready) cv.wait(lock);`, либо использовать перегрузку `wait`, принимающую предикат напрямую, которая делает это же за программиста. Если проверку предиката убрать и понадеяться, что `wait` вернулся именно из-за реального события, поток может продолжить работу над данными, которые на самом деле ещё не готовы — это трудно воспроизводимый баг, который проявляется через раз под нагрузкой и обычно диагностируется не логами, а внимательным чтением кода, потому что гонка происходит не над памятью, а над логикой ожидания. Раз мьютексы и условные переменные могут использоваться неправильно и заблокировать программу навсегда, логично разобрать дедлоки и способы их избежать.||
▪ **6.4 Как избежать дедлока?**
||Дедлок — взаимная блокировка, когда два и более потоков ждут ресурсы, захваченные друг другом, и ни один не может продолжить выполнение. Классический сценарий: поток A держит мьютекс 1 и ждёт мьютекс 2, поток B держит мьютекс 2 и ждёт мьютекс 1 — оба зависают навсегда. Главный способ избежать этого — всегда захватывать несколько мьютексов в едином порядке во всей программе (например, по адресу объекта или по заранее заданному номеру), тогда цикл ожидания просто не может образоваться. Когда нужно захватить сразу несколько мьютексов в одном месте кода, для этого есть `std::lock` (или `std::scoped_lock` в C++17) — они захватывают несколько мьютексов атомарно, без риска, что между захватом первого и второго вклинится другой поток с обратным порядком. Дополнительное правило — не держать захваченную блокировку при вызове чужого или пользовательского кода (например, коллбэка), потому что этот код может попытаться захватить тот же мьютекс повторно или вызвать что-то, что приведёт к дедлоку неочевидным путём. Дедлок не всегда падает с ошибкой — чаще всего это зависшая программа без вывода в лог, и диагностируется он через `gdb`/`info threads` и просмотр стеков всех потоков, чтобы увидеть, кто на каком мьютексе застрял. Раз речь о синхронизации между потоками, логично перейти к типовому паттерну, где она особенно нужна, — producer/consumer.||
▪ **6.5 Producer/consumer — как?**
||Паттерн producer/consumer реализуется общей очередью, защищённой мьютексом, и условной переменной для уведомления о новых данных. Механизм: поток-производитель захватывает мьютекс, кладёт элемент в очередь, освобождает мьютекс и вызывает `notify_one` (или `notify_all`), чтобы разбудить ожидающих потребителей; поток-потребитель захватывает тот же мьютекс, ждёт на условной переменной с предикатом "очередь не пуста" (в цикле, из-за возможных ложных пробуждений), после пробуждения и выполнения условия забирает элемент из очереди и отпускает мьютекс. Условная переменная нужна именно здесь потому, что без неё потребителю пришлось бы в цикле постоянно проверять очередь на пустоту (busy-wait), впустую расходуя процессорное время — `wait` вместо этого усыпляет поток до реального уведомления, не тратя ресурсы. Мьютекс защищает саму структуру очереди от одновременного изменения с двух сторон — иначе `push`/`pop` на неё же могут гонка данных повредить внутреннее состояние контейнера. Если забыть удерживать мьютекс при вызове `wait` у `condition_variable`, или использовать разные мьютексы для очереди и для `wait`, поведение станет неопределённым — стандартная библиотека прямо требует, чтобы `wait` принимал `unique_lock`, уже захвативший тот же мьютекс, которым защищена очередь. Раз создание нового потока под каждую задачу в очереди дорого, логично перейти к тому, зачем нужен пул потоков.||
▪ **6.6 Пул потоков зачем?**
||Пул потоков — это заранее созданный набор из N рабочих потоков (обычно порядка числа ядер процессора), которые постоянно забирают задачи из общей очереди вместо того, чтобы под каждую задачу создавать и уничтожать отдельный `std::thread`. Причина в цене создания потока: операционной системе нужно выделить стек, зарегистрировать поток в планировщике и выполнить системный вызов на его создание и последующее уничтожение — при коротких и частых задачах эти накладные расходы легко превышают время самой полезной работы. Пул амортизирует эту цену: потоки создаются один раз при старте программы и затем переиспользуются для множества задач, так что стоимость создания размазывается на весь срок работы пула, а не платится за каждую отдельную задачу. Дополнительно фиксированное число потоков в пуле не даёт программе бесконтрольно наплодить тысячи параллельных потоков под наплывом задач и не утопить систему в переключениях контекста. Если вместо пула создавать поток на каждый входящий запрос на высоконагруженном сервере, под пиковой нагрузкой это проявляется резким ростом задержки и потребления памяти на стеки потоков — что видно по числу процессов/потоков в `top` и по времени создания потока в профилировщике. Раз пул и очередь задач — это тоже общая структура, логично закончить вопросом про потокобезопасность самого простого случая — счётчика размера.||
▪ **6.7 Потокобезопасный size()?**
||Метод, возвращающий размер коллекции (`size()`), потокобезопасен только тогда, когда чтение и все изменения счётчика синхронизированы — либо через `std::atomic<size_t>`, либо через тот же мьютекс, которым защищена сама коллекция. Причина в том, что инкремент или декремент обычного `size_t` — это не одна процессорная операция, а последовательность "прочитать значение — прибавить единицу — записать обратно" (read-modify-write), и если два потока выполняют её одновременно без синхронизации, оба могут прочитать одно и то же старое значение и оба записать одно и то же новое — в итоге один инкремент физически теряется. Атомарный тип или мьютекс устраняют это, гарантируя, что вся последовательность "прочитать-изменить-записать" выполняется как неделимая операция относительно других потоков. Если размер коллекции защищён отдельным мьютексом от самих данных, тоже может возникнуть рассинхронизация: например, `size()` вернёт значение, уже не соответствующее реальному состоянию контейнера, если между изменением данных и изменением счётчика вклинился другой поток — поэтому логичнее защищать оба одним и тем же мьютексом, а не двумя разными. На практике такая гонка не всегда воспроизводится стабильно и может месяцами "работать" в проде, пока не выстрелит под нагрузкой — надёжно её ловит только ThreadSanitizer (`-fsanitize=thread`), который явно укажет на конкурентный доступ к невладеющей защитой переменной, а не догадки по редким расхождениям в счётчике.||
**7. Git**
▪ **7.1 Основной цикл работы?**
||Это последовательность команд, которая переводит изменение от локального кода до отревьюженного результата в общей истории репозитория. git clone копирует репозиторий целиком, включая всю историю коммитов, а не только последний снимок кода; git checkout -b feature создаёт новую ветку — указатель на текущий коммит — и переключает на неё HEAD; дальше правишь файлы, git add переносит изменения в индекс (staging area — промежуточный снимок будущего коммита), git commit фиксирует состояние индекса как новый объект в базе Git со ссылкой на родителя, git push отправляет коммиты на удалённый репозиторий, после чего открывается pull request для ревью. Разделение add/commit нужно, чтобы коммитить не всё рабочее дерево целиком, а осмысленный кусок изменений — Git различает три состояния файла: рабочее дерево, индекс, история. Если пропустить git add и закоммитить через commit -a, легко утянуть в коммит чужие незавершённые правки или временный мусор; ещё одна типичная ошибка — коммит прямо в main без отдельной ветки, что ломает возможность ревью и линейность истории. Дальше логично разобраться, что такое сам коммит, ветка и HEAD.||
▪ **7.2 Что такое коммит, ветка, HEAD?**
||Коммит — неизменяемый объект в базе Git, хранящий снимок всего дерева файлов на момент фиксации, автора, сообщение и хеш родительского коммита (или нескольких, для слияний). У каждого коммита хеш вычисляется от его содержимого, поэтому два одинаковых коммита в разных репозиториях получают одинаковый идентификатор, а изменение любого байта истории меняет хеши всех последующих коммитов. Ветка — не копия файлов, а просто файл с именем, хранящий хеш коммита, на который она указывает; при новом коммите Git пересчитывает этот указатель на новый хеш. HEAD — указатель на то, где ты сейчас находишься: обычно это ссылка на текущую ветку, а при переключении на конкретный коммит вместо ветки возникает «detached HEAD» — новые коммиты в этом состоянии не принадлежат ни одной ветке и могут стать недостижимыми для сборки мусора, если их не закрепить веткой или тегом. Такая схема даёт дешёвое хранение истории как графа и позволяет проверить её целостность — подделать старый коммит незаметно нельзя, хеши всех потомков изменятся. Типичная ошибка — накоммитить в detached HEAD и потом растерянно искать эту работу после переключения ветки; спасает git reflog, который хранит историю перемещений HEAD некоторое время. Логично дальше спросить про merge и rebase — то есть как ветки соединяются обратно.||
▪ **7.3 merge и rebase?**
||Это два способа перенести изменения одной ветки в другую, различающиеся тем, что происходит с историей. git merge находит общего предка двух веток и создаёт новый коммит слияния с двумя родителями — история ветвится и сохраняет, что и когда делалось параллельно. git rebase берёт коммиты ветки один за другим начиная от общего предка и переигрывает их поверх нового основания — для каждого коммита вычисляется новый diff и создаётся новый объект с новым родителем и новым хешем, поэтому линия истории становится прямой. Раз коммит идентифицируется хешем от содержимого и родителя, смена родителя делает его физически другим коммитом — отсюда правило «rebase переписывает хеши», а merge ничего не переписывает, только добавляет новый коммит поверх существующих. Rebase нельзя применять к уже запушенной ветке, которую тянут другие: у них останутся старые коммиты, и при следующем pull получится расхождение и дублирование либо понадобится force-push, ломающий чужую историю; поэтому rebase используют для локальной ветки перед публикацией, а merge — для слияния уже опубликованного. Следующий логичный вопрос — что делать, если при слиянии или rebase возник конфликт.||
▪ **7.4 Конфликт — что делать?**
||Это ситуация, когда Git не может автоматически объединить изменения, потому что один и тот же участок файла изменён по-разному в обеих версиях. При merge или rebase Git сравнивает файл в трёх версиях — общий предок, твоя ветка, чужая ветка — и там, где правки не пересекаются, объединяет их сам; там, где пересекаются, вставляет в файл маркеры <<<<<<<, =======, >>>>>>> с обоими вариантами и помечает файл как unmerged в индексе. У Git нет способа угадать, какую версию строки автор хотел оставить, — семантику может определить только человек, поэтому граница ответственности проходит по конкретным строкам, а не по файлу целиком. Дальше нужно открыть помеченные файлы, вручную выбрать нужный текст и убрать маркеры, затем git add на исправленный файл (это подтверждает, что конфликт решён) и git merge --continue или git rebase --continue; если разобраться не удаётся, git merge --abort или git rebase --abort откатывает всё к состоянию до начала операции. Частая ошибка — забыть убрать маркеры конфликта и закоммитить их прямо в код, что ломает сборку; ловится ревью или простым grep по '<<<<<<<' перед коммитом. Дальше логично спросить про reset, revert и checkout — команды, которыми откатывают или переключают состояние.||
▪ **7.5 reset, revert, checkout?**
||Это три команды, меняющие состояние репозитория или рабочего дерева, но по-разному обращающиеся с историей. git reset двигает указатель текущей ветки на другой коммит и, в зависимости от режима (--soft, --mixed, --hard), дополнительно трогает индекс и рабочее дерево — --hard перезаписывает файлы, безвозвратно теряя незакоммиченные изменения, а сами «отброшенные» коммиты физически не стираются сразу, просто ветка на них больше не ссылается. git revert не двигает ветку назад, а создаёт новый коммит, применяющий изменения, обратные указанному, — история растёт вперёд, а не переписывается. git checkout (в новых версиях разделён на git switch для веток и git restore для файлов) переключает HEAD на другую ветку или коммит либо восстанавливает файлы рабочего дерева из индекса или коммита. reset годится, когда историю ещё никто не видел — можно смело переписывать локальную ветку; revert безопасен на опубликованной ветке, потому что не удаляет уже отданные наружу коммиты, а лишь добавляет компенсирующий поверх. Типичная ошибка — сделать reset --hard на запушенной ветке и потерять чужую работу при последующем force-push; для отмены публичного бага в проде поэтому всегда используют revert, а не reset. Логичный следующий вопрос — что делать с незакоммиченными изменениями, которые мешают переключиться на другую ветку, то есть про stash.||
▪ **7.6 stash?**
||Это временное хранилище незакоммиченных изменений, которое откладывает их в сторону и возвращает рабочее дерево к состоянию последнего коммита. git stash сохраняет разницу индекса и рабочего дерева как специальный коммит вне текущей ветки, в отдельном стеке refs/stash, и очищает рабочее дерево; git stash pop достаёт последнюю запись из стека и применяет обратно поверх текущего состояния, удаляя запись из стека, а git stash apply делает то же самое, но запись оставляет — на случай если применение вызовет конфликт и его придётся повторить. Git не даёт переключиться на другую ветку, если это приведёт к потере незакоммиченных изменений в отслеживаемых файлах, а коммитить незавершённую работу ради переключения — засорять историю; stash даёт третий вариант — отложить без коммита. Типичный сценарий: начал править фичу, прилетел срочный баг на другой ветке — git stash, переключение, правка бага, возврат на фичу, git stash pop. Ошибка — забыть про накопившиеся записи стеша: они не видны в обычном git status, список смотрят через git stash list. Дальше логично спросить про fetch и pull — как забирать изменения из общего репозитория.||
▪ **7.7 fetch и pull?**
||Это две команды получения изменений с удалённого репозитория, различающиеся тем, трогают ли они текущую рабочую ветку. git fetch скачивает новые коммиты и обновляет удалённые ветки-указатели (origin/main и подобные) локально, но не трогает рабочие ветки и рабочее дерево — можно спокойно посмотреть через git log origin/main или git diff, что изменилось, прежде чем что-то применять. git pull делает то же скачивание, а затем сразу выполняет merge (или, с флагом --rebase, rebase) текущей ветки на актуальный origin/main. Разделение сделано, чтобы можно было изучить чужие изменения до слияния — pull без раздумий может внезапно создать коммит слияния или конфликт прямо посреди работы. Типичная ошибка — делать pull с незакоммиченными изменениями, из-за чего merge конфликтует ещё и с рабочим деревом; безопаснее сначала commit или stash, потом pull. Логично дальше спросить, как вообще смотреть историю изменений — про log, diff, show, blame.||
▪ **7.8 Как посмотреть историю и что менялось?**
||Это набор команд для чтения истории репозитория без её изменения. git log --oneline --graph печатает коммиты по одному на строку с ASCII-графом ветвления, построенным по ссылкам на родителей каждого коммита; git diff без аргументов сравнивает рабочее дерево с индексом, git diff --staged — индекс с последним коммитом, показывая построчные изменения; git show <commit> печатает содержимое конкретного коммита — сообщение и diff относительно родителя; git blame построчно приписывает каждой строке файла коммит и автора, которые её последний раз меняли. Все эти команды читают один и тот же граф объектов — коммиты, деревья, блобы — просто с разным углом обзора; то, что диффы и blame вообще возможны, следствие того, что каждый коммит хранит полный снимок дерева, а не патч, и Git может напрямую сравнить любые два снимка. blame — первый инструмент при расследовании бага «эта строка появилась зачем и когда», а не гадание по текущему коду; типичная ошибка — искать причину в текущей версии файла, хотя нужно смотреть, в каком коммите и с каким сообщением строка была добавлена. Логично дальше спросить, что вообще не должно попадать в коммит.||
▪ **7.9 Что не коммитить?**
||Это категория файлов, которые не должны попадать в git-репозиторий, хотя физически лежат в рабочей директории: секреты и ключи доступа, артефакты сборки (объектные файлы, каталоги вроде build/, node_modules/) и большие бинарники. Механизм — файл .gitignore со списком шаблонов путей; Git при git add и git status сверяется с этим списком и пропускает совпадающие файлы, если они ещё не отслеживаются, но если файл уже был закоммичен раньше, .gitignore на него не подействует — нужно явно git rm --cached. Секреты в истории остаются навсегда даже после удаления файла новым коммитом — старые коммиты всё ещё хранят их содержимое и доступны любому, у кого есть клон; артефакты сборки просто регенерируются компилятором и раздувают репозиторий, конфликтуя между разработчиками с разными ОС и версиями тулчейна. Если секрет всё же закоммичен, недостаточно удалить его обычным коммитом — нужно переписывать историю (например git filter-repo) и считать ключ скомпрометированным, ротировать его; ловится это код-ревью и сканерами секретов в CI. Это закрывает блок Git — дальше логично перейти к Docker, где тоже есть своё «что не класть в образ», .dockerignore.||
**8. Docker**
▪ **8.1 Образ и контейнер?**
||Образ — неизменяемый шаблон файловой системы и метаданных для запуска приложения, контейнер — работающий экземпляр этого шаблона. Образ состоит из слоёв: каждый слой — diff файловой системы, порождённый одной инструкцией Dockerfile, слои read-only и кэшируются по хешу содержимого; при запуске контейнера поверх слоёв образа Docker через overlay-файловую систему добавляет один тонкий read-write слой, куда пишутся все изменения во время работы, а слои самого образа не трогаются. Если бы запуск копировал весь образ, старт занимал бы секунды-минуты и жрал диск на каждый инстанс; copy-on-write слой поверх общих read-only слоёв даёт запуск за доли секунды и совместное использование одинаковых слоёв между разными контейнерами. Удаление контейнера (docker rm) стирает его read-write слой и все данные в нём, если они не вынесены в volume — типичная ошибка новичка держать в контейнере важные данные и терять их при пересоздании. Логично дальше спросить, чем это принципиально отличается от виртуальной машины.||
▪ **8.2 Чем отличается от виртуальной машины?**
||Разница в уровне изоляции: контейнер изолирует процесс средствами хостовой ОС, VM эмулирует отдельный компьютер целиком. Контейнер использует namespaces (отдельное пространство имён для PID, сети, точек монтирования, hostname — процесс внутри контейнера видит только себя и своих потомков) и cgroups (ограничение и учёт ресурсов CPU, памяти, ввода-вывода), а ядро при этом одно, общее с хостом. VM через гипервизор эмулирует виртуальное железо, поверх которого грузится собственное ядро гостевой ОС со своим планировщиком и драйверами. Раз контейнер не грузит отдельное ядро и не эмулирует железо, его старт — это по сути fork/exec процесса с применёнными namespaces, отсюда секунды вместо минут и накладные расходы, близкие к нулю, вместо процентов CPU/памяти на гипервизор. Контейнер не даёт изоляции на уровне ядра — уязвимость в ядре или неправильно настроенные capabilities могут дать выход за пределы контейнера на хост, чего в VM с отдельным ядром добиться сложнее; поэтому для изоляции чужого недоверенного кода используют VM или дополнительные песочницы, а контейнеры — для изоляции своих же сервисов. Дальше логично спросить, как образ вообще описывается — про Dockerfile.||
▪ **8.3 Dockerfile — ключевые инструкции?**
||Это текстовый файл-рецепт, по которому Docker собирает образ пошагово. FROM задаёт базовый образ, от которого наследуются все последующие слои; RUN выполняет команду в момент сборки и фиксирует результат новым слоем (например установку пакетов); COPY переносит файлы с хоста в образ отдельным слоем; WORKDIR задаёт рабочую директорию для последующих инструкций и фиксируется в образе; ENV задаёт переменные окружения, доступные и при сборке, и при запуске контейнера; CMD задаёт команду по умолчанию при запуске, которую можно переопределить аргументом docker run, а ENTRYPOINT задаёт неизменяемую точку входа, которую CMD только дополняет аргументами; EXPOSE — документирующая инструкция про то, какой порт слушает приложение внутри, сама по себе порт наружу не публикует. Каждая инструкция — отдельный слой и отдельная точка кэширования сборки, поэтому порядок инструкций напрямую влияет на скорость пересборки. Частая ошибка — писать CMD там, где команда не должна перезаписываться, или наоборот жёстко зашивать ENTRYPOINT там, где нужна гибкость запуска; проверяется простым docker run с переопределением команды. Отсюда логичный следующий вопрос — почему порядок инструкций и слои вообще важны для скорости сборки.||
▪ **8.4 Зачем слои и порядок инструкций?**
||Это принцип кэширования сборки образа, основанный на том, что каждая инструкция Dockerfile — отдельный слой. Docker при сборке идёт по инструкциям сверху вниз и для каждой проверяет кэш: если инструкция и её входные данные (для COPY — содержимое копируемых файлов) не изменились с прошлой сборки, слой берётся из кэша без выполнения; как только один слой не совпал с кэшем, все последующие слои пересобираются заново, даже если сами по себе не менялись. Слой — это diff файловой системы, привязанный к хешу предыдущего состояния плюс своих входов, поэтому кэш линеен и рвётся в первой же точке расхождения. Отсюда практика: сначала COPY файлов зависимостей и их установка (меняются редко), и только потом COPY всего остального кода (меняется на каждом коммите) — тогда правка кода не форсирует переустановку всех зависимостей заново. Если сделать наоборот, любая правка одной строки кода инвалидирует кэш зависимостей, и сборка образа качает пакеты из сети заново каждый раз, что на CI ощутимо по времени. Логично дальше спросить про то, куда девать данные, которые должны пережить контейнер, — про volume и bind mount.||
▪ **8.5 Volume и bind mount?**
||Это два способа примонтировать в контейнер хранилище данных, которое живёт вне read-write слоя контейнера. Volume — область, которой управляет сам Docker, физически хранится в его служебной директории на хосте, создаётся и именуется явно или неявно и не зависит от структуры каталогов конкретной машины разработчика. Bind mount — прямое монтирование произвольного каталога хоста внутрь контейнера по указанному пути: контейнер и хост видят один и тот же каталог одновременно. Volume абстрагирован от хоста и переносим между машинами, поэтому годится для продакшн-данных вроде базы данных и персистентного состояния сервиса, а bind mount завязан на конкретный путь хоста, зато даёт прямой доступ и мгновенно видит правки — поэтому его используют для разработки, чтобы редактировать код на хосте и сразу видеть изменения в контейнере без пересборки образа. Типичная ошибка — держать данные БД в обычном, не смонтированном read-write слое контейнера: тогда docker-compose down -v или просто пересоздание контейнера безвозвратно их стирает; лечится явным именованным volume в docker-compose.yml. Дальше логично спросить про сети в Docker — как контейнеры вообще видят друг друга и внешний мир.||
▪ **8.6 Сети в Docker?**
||Это механизм, которым Docker даёт контейнерам сетевую связность друг с другом и с внешним миром. По умолчанию Docker создаёт сеть типа bridge — виртуальный коммутатор на хосте; каждому контейнеру в ней выдаётся собственный внутренний IP, и контейнеры в одной bridge-сети видят друг друга по имени через встроенный DNS или по IP, но снаружи хоста эти IP не видны. Чтобы достучаться до сервиса в контейнере снаружи, порт публикуют явно флагом -p 8080:80, что настраивает проброс с порта хоста на порт контейнера. Помимо bridge есть host-сеть, где контейнер использует сетевой стек хоста напрямую без изоляции портов, и overlay-сети для связи контейнеров между разными физическими хостами в кластере. Изоляция сети нужна, чтобы контейнеры разных приложений не видели чужие порты и не конфликтовали по занятым портам на одном хосте — каждый контейнер может слушать свой 80-й порт независимо от соседей. Забытый -p — частая причина «контейнер работает, но снаружи не достучаться»; проверяется docker ps по колонке PORTS. Логично дальше спросить, как описывать несколько таких контейнеров и их сети разом — про docker-compose.||
▪ **8.7 docker-compose?**
||Это инструмент и формат YAML-файла для описания и запуска нескольких связанных контейнеров как одного приложения. В docker-compose.yml перечисляются сервисы с указанием образа или пути к Dockerfile, портов, volume, переменных окружения и зависимостей между сервисами; команда docker compose up -d поднимает все описанные сервисы разом, автоматически создавая для них общую сеть, в которой они видят друг друга по имени из YAML как по hostname. Реальное приложение редко состоит из одного контейнера — обычно есть сама программа плюс база данных, кэш, очередь, и вручную поддерживать их сети, порядок запуска и параметры неудобно и невоспроизводимо; один файл фиксирует всю топологию декларативно и одинаково у всех разработчиков. docker compose down без флага -v останавливает и удаляет контейнеры, но сохраняет именованные volume — типичная ошибка предполагать, что down стирает данные базы данных, и наоборот случайно потерять их, добавив -v не подумав. Дальше логично спросить, зачем всё это вообще нужно именно в работе Eltex.||
▪ **8.8 Где это в Eltex?**
||Это практическая причина, зачем производителю сетевого оборудования вообще нужен Docker в разработке, а не только в вебе. Сборка прошивки, кросс-компиляция под целевую архитектуру SoC и тестовые окружения фиксируются внутри образа — конкретная версия компилятора, тулчейна, библиотек и системных зависимостей прописана в Dockerfile один раз и одинакова у каждого разработчика и на CI-сервере, вне зависимости от того, что реально установлено на его личной машине. Встраиваемая разработка особенно чувствительна к версии тулчейна: cross-compile тулчейн должен точно совпадать между сборками, иначе бинарник для целевого железа может собраться иначе или не собраться вовсе. Без зафиксированного образа типична ситуация «у меня собирается, у тебя нет» из-за разных версий системных библиотек на хостах — Docker убирает этот класс проблем, потому что сборка всегда идёт в одной и той же среде, а не в среде конкретного ноутбука. Это закрывает Docker — логично дальше перейти к GDB, инструменту, которым разбирают падения уже собранной программы.||
**9. GDB и отладка**
▪ **9.1 Как запустить?**
||Это минимальная последовательность действий, чтобы получить возможность отлаживать программу пошагово, а не только смотреть на её вывод. Компиляция с флагом -g встраивает в бинарник отладочную информацию — соответствие машинных адресов номерам строк исходника, именам переменных и их типам; -O0 отключает оптимизации компилятора, потому что оптимизатор переставляет и удаляет инструкции, инлайнит функции и переиспользует регистры под разные переменные, из-за чего отладочная информация перестаёт однозначно соответствовать исходному коду. Дальше gdb ./prog запускает отладчик с этим бинарником, а команда run внутри gdb передаёт управление процессу, останавливаясь на точках останова. Без -g gdb видит только адреса и ассемблер — bt покажет голые адреса вместо имён функций и номеров строк, отлаживать вслепую по ассемблеру для типовой задачи нецелесообразно. Типичная ошибка — собрать прод-бинарник с -O2 и пытаться отладить его в gdb, получая «прыгающие» и пропущенные строки; для отладки всегда пересобирают отдельно с -g -O0. Логично дальше спросить про сами команды внутри gdb.||
▪ **9.2 Основные команды?**
||Это набор команд для управления выполнением программы внутри отладчика и осмотра её состояния. break main или break file:line ставит точку останова — адрес, на котором выполнение приостанавливается; run стартует программу; next выполняет текущую строку целиком, включая вызовы функций, не заходя внутрь них; step, наоборот, заходит внутрь вызываемой функции, если для неё есть отладочная информация; continue продолжает выполнение до следующей точки останова; print x печатает текущее значение переменной по её типу из отладочной информации; bt печатает стек вызовов — цепочку кадров от текущей функции до main; frame N переключает контекст print/list на конкретный кадр этого стека; watch var останавливает выполнение при каждом изменении значения переменной через аппаратные точки наблюдения процессора; info threads показывает все потоки процесса и их состояние. Если бы step всегда заходил внутрь, отладка кода с вызовами библиотечных функций без отладочной информации была бы мучительной — next даёт способ пропустить неинтересную функцию, не теряя контроль. watch незаменим, когда переменная меняется как будто сама по себе из другого места кода — без него пришлось бы вручную расставлять точки останова по подозрению. Логично дальше спросить, как этими командами реально разбирают уже случившееся падение программы.||
▪ **9.3 Как отладить падение?**
||Это методика восстановления причины краша по состоянию программы в момент сбоя. Первый путь — запустить программу под gdb и дождаться сигнала (обычно SIGSEGV); gdb сам остановится на инструкции, вызвавшей сбой, дальше bt покажет полный стек вызовов, а print значений указателей и переменных в нужных кадрах обычно сразу показывает, например, что указатель равен нулю или мусорному значению. Второй путь — по core dump: если программа падает не под отладчиком, ядро может сохранить снимок её памяти в файл, а потом его открывают отдельно командой gdb prog core и получают тот же bt и print, но постфактум, без необходимости воспроизводить падение заново. Core dump вообще нужен потому, что не любой краш воспроизводится по требованию — падение может случиться редко и под нагрузкой, куда заранее gdb не прицепишь. По умолчанию система часто отключает сохранение core, поэтому перед тем как ждать падение, выставляют ulimit -c unlimited; типичная ошибка — забыть это сделать и потерять единственный шанс поймать редкий баг. Дальше логично уточнить, что такое сам core dump как объект.||
▪ **9.4 Что такое core dump?**
||Это файл-снимок памяти процесса, сохранённый ядром ОС в момент, когда процесс получил сигнал, приводящий к аварийному завершению. При таком сигнале ядро, если это разрешено настройками, сохраняет в файл содержимое всех сегментов адресного пространства процесса на момент краша — стек, кучу, регистры процессора, список загруженных библиотек, — этого достаточно, чтобы позже восстановить полный контекст выполнения без самого живого процесса. gdb при открытии core dump вместе с той же самой сборкой бинарника сопоставляет адреса из core с исходным кодом точно так же, как делал бы это для живого процесса, поэтому bt и print работают идентично отладке в реальном времени. Core dump — единственный способ разобрать баг, который воспроизводится редко или только в проде, где нельзя прицепить интерактивный отладчик. Типичная ошибка — пересобрать бинарник, даже без изменения логики, перед анализом core: адреса тогда не совпадут и gdb покажет мусор вместо реального стека. Логично дальше спросить про санитайзеры — инструменты, которые ловят похожие ошибки памяти ещё до падения, на лету.||
▪ **9.5 Санитайзеры?**
||Это набор инструментов компилятора, которые встраивают в бинарник дополнительные проверки во время выполнения и останавливают программу с диагностикой при первом нарушении, вместо того чтобы дать багу тихо испортить память. ASAN инструментирует каждое обращение к памяти, окружает выделенные блоки недоступными «красными зонами» и ведёт теневую карту состояния памяти, поэтому ловит выход за границы, use-after-free и двойное освобождение прямо в момент обращения, а не когда испорченные данные позже вызовут крах в неожиданном месте; в связке с LeakSanitizer он же в конце работы программы проверяет, какие выделенные блоки остались без ссылок, и репортит утечки. UBSAN инструментирует места, где стандарт C++ явно не определяет поведение — знаковое переполнение, сдвиг за пределы разрядности, — и печатает конкретную строку кода при нарушении. TSan отслеживает порядок операций доступа к общей памяти между потоками и синхронизирующие примитивы, чтобы поймать гонку данных, даже если в конкретном запуске она не привела к видимому неверному результату. Все включаются флагом -fsanitize=... на этапе компиляции — это перекомпиляция с дополнительными проверками, а не отдельный инструмент поверх готового бинарника. Краш от порчи памяти часто происходит далеко от места реальной ошибки, поэтому санитайзер останавливает программу ровно в момент нарушения с точным стеком, вместо того чтобы ловить последствия позже в gdb; санитайзеры дорого стоят по времени выполнения, поэтому их гоняют отдельным конфигом тестов в CI, а не постоянно в проде. Логично дальше спросить про valgrind — похожий по цели, но не требующий пересборки инструмент.||
▪ **9.6 valgrind?**
||Это инструмент динамического анализа программ, ищущий ошибки работы с памятью и утечки без необходимости пересобирать программу с особыми флагами. Модуль Memcheck запускает бинарник не напрямую, а внутри собственной виртуальной машины — эмулирует каждую машинную инструкцию программы на лету, отслеживая состояние памяти (инициализирован/не инициализирован, выделен/освобождён), и на каждое подозрительное обращение выводит диагностику со стеком вызовов, а при завершении программы отдельно репортит недостигнутые, утёкшие блоки. Эмуляция каждой инструкции в софтверной VM — принципиально более тяжёлый подход, чем компиляторная инструментация ASAN, которая добавляет проверки только вокруг реальных обращений к памяти уже в нативном коде, поэтому valgrind ощутимо медленнее санитайзеров — счёт идёт на кратное замедление, а не на проценты накладных расходов. Главное преимущество valgrind — не нужен доступ к исходникам и пересборка, поэтому его гоняют на готовых бинарниках или сторонних библиотеках, где ASAN не подключить; в собственном проекте с доступом к сборке обычно предпочитают санитайзеры именно из-за скорости на CI. Это закрывает блок GDB и отладки — логично дальше перейти к bash, которым эти же инструменты запускают и автоматизируют.||
**10. Bash и инструменты**
▪ **10.1 Что такое скрипт и его шапка?**
||Bash-скрипт — текстовый файл с последовательностью команд командного интерпретатора, который можно исполнить как программу. Первая строка #!/usr/bin/env bash — это shebang, специальная сигнатура, по которой ядро при попытке исполнить файл понимает, каким интерпретатором его запускать, вместо того чтобы пытаться исполнить как машинный код; env bash ищет bash через PATH, что переносимее жёсткого пути. Следом обычно ставят set -euo pipefail: -e останавливает скрипт при первой команде, вернувшей ненулевой код возврата, вместо того чтобы по умолчанию молча идти дальше; -u останавливает при обращении к необъявленной переменной вместо подстановки пустой строки; -o pipefail делает так, чтобы код возврата конвейера определялся по первой упавшей команде, а не только по последней. Bash по историческим причинам оптимизирован под интерактивную сессию, где удобно, чтобы одна неудачная команда не обрывала сессию, — но для скрипта это поведение по умолчанию опасно и маскирует ошибки. Без set -e скрипт может продолжить выполнение после неудачного cd в несуществующую директорию и удалить файлы совсем не в том месте; без pipefail ошибка первой команды в конвейере может остаться незамеченной. Логично дальше спросить про переменные и аргументы командной строки внутри скрипта.||
▪ **10.2 Переменные и аргументы?**
||Это механизм передачи и хранения данных внутри shell-скрипта. name=value присваивает значение переменной без пробелов вокруг знака равно; $name или ${name} разворачивает значение при использовании; аргументы, с которыми запущен скрипт, доступны как позиционные параметры $1…$9, $# — их количество, $@ — все аргументы, $? — код возврата последней выполненной команды, $$ — PID текущего процесса скрипта. Shell исторически работает со строками и словами, разделёнными пробелами, поэтому "$@" в кавычках критично отличается от $@ без кавычек: без кавычек аргумент с пробелом внутри разобьётся на несколько отдельных слов при подстановке. $? проверяют сразу после нужной команды, до выполнения любой другой команды, иначе он перезапишется её кодом возврата. Типичная ошибка — пропустить кавычки вокруг переменных с путями к файлам, из-за чего путь с пробелом в имени превращается в несколько аргументов и скрипт падает на несуществующем файле. Логично дальше спросить про grep, awk, sed — основной инструментарий обработки текста внутри таких скриптов.||
▪ **10.3 grep, awk, sed — для чего?**
||Это три классические Unix-утилиты обработки текстовых потоков, каждая заточена под свою задачу. grep построчно сопоставляет входной поток с регулярным выражением и выводит совпавшие строки целиком, не разбирая их содержимое; awk читает вход построчно и автоматически разбивает строку на поля по разделителю, давая доступ к ним как $1, $2… и $0, что удобно для агрегаций и подсчётов по колонкам; sed применяет к каждой строке команды редактирования, чаще всего замену по шаблону, и построчно печатает результат — это потоковый редактор, а не интерактивный. Все три написаны на C и читают поток построчно без создания процесса на каждую строку, тогда как цикл на bash с построчным чтением файла и вызовом внешних команд внутри цикла плодит новый процесс на каждой итерации — на файле в миллионы строк разница на порядки. Типичная ошибка новичка — обрабатывать лог циклом for с построчным чтением и вызовом grep/awk внутри цикла, что на большом файле работает минутами вместо секунд; правильный подход — один проход одной из этих утилит на весь поток. Логично дальше спросить про пайплайны и перенаправления, которыми эти утилиты соединяют между собой.||
▪ **10.4 Пайплайны и перенаправления?**
||Это механизм соединения команд между собой и с файлами через потоки ввода-вывода. У каждого процесса есть минимум три файловых дескриптора: 0 — stdin, 1 — stdout, 2 — stderr; cmd1 | cmd2 создаёт канал в ядре и подключает stdout первой команды к stdin второй, обе команды при этом работают параллельно, а не ждут полного завершения друг друга; > перенаправляет stdout в файл с перезаписью, >> дописывает в конец; 2> перенаправляет именно stderr, а 2>&1 дублирует дескриптор 2 туда же, куда сейчас указывает дескриптор 1 — порядок в команде важен, потому что перенаправления применяются слева направо; tee пишет входной поток одновременно в файл и на stdout. Разделение stdout и stderr на разные дескрипторы задумано, чтобы отделить полезные данные программы от диагностики — конвейер cmd1 | cmd2 передаёт дальше только stdout, а ошибки cmd1 в конвейер не попадают и видны в терминале отдельно. Типичная ошибка — перепутать порядок 2>&1 и > file, из-за чего в лог-файл попадает не весь ожидаемый вывод; отлаживается проверкой каждого перенаправления по отдельности. Дальше логично спросить про поиск файлов и выполнение команд над найденным — про find и xargs.||
▪ **10.5 Как найти файлы и выполнить команду?**
||Это инструменты для поиска файлов по критериям и последующего применения команды к каждому найденному. find /path -name '*.log' -mtime -1 обходит дерево каталогов от /path рекурсивно и фильтрует по имени и по времени модификации; шаблон имени берут в кавычки, чтобы его разворачивал сам find, а не shell заранее. Найденные пути можно передать дальше двумя способами: find ... -exec команда {} + подставляет найденные пути пачками в один вызов команды, а не по одному процессу на файл, либо через | xargs, который тоже читает пути из стандартного ввода и группирует их для меньшего числа вызовов. Создание процесса имеет заметную цену, и на большом количестве найденных файлов вызывать команду по одному означало бы огромное число отдельных процессов — batched-варианты сокращают это на порядки, объединяя аргументы в группы под лимит длины командной строки. Типичная ошибка — использовать xargs без учёта пробелов в именах файлов, из-за чего одно имя с пробелом превращается в два аргумента; безопасный вариант — find ... -print0 в паре с xargs -0, где имена разделяются нулевым байтом, который не может встретиться в имени файла. Логично дальше спросить про повседневный набор утилит для мониторинга состояния системы.||
▪ **10.6 Полезное на каждый день?**
||Это набор утилит для повседневной диагностики процессов, сети, дисков и логов на Linux-машине. ps aux и top/htop показывают снимок или живое обновляемое представление запущенных процессов с CPU, памятью, PID — данные они читают из псевдофайловой системы /proc, где ядро на лету экспонирует состояние каждого процесса; ss -tulpn показывает открытые сетевые сокеты (TCP, UDP, только слушающие, с именем процесса-владельца, с числовыми адресами); df -h показывает занятость файловых систем целиком, du -sh — суммарный размер конкретного каталога, и это разные вещи: df может показывать место занятым даже после удаления файла, если его всё ещё держит открытым какой-то процесс; tail -f непрерывно печатает дописываемые строки лог-файла, не завершаясь, пока файл растёт; journalctl -u сервис и systemctl status показывают логи и текущее состояние конкретного systemd-юнита. Это минимальный набор, покрывающий три типичных вопроса при инциденте: что жрёт ресурсы, кто слушает какой порт, что происходит прямо сейчас в логах сервиса. Типичный сценарий — сервис не отвечает на порту: ss -tulpn проверяет, вообще ли он слушает нужный порт, journalctl -u сервис — почему он мог упасть или не подняться, top — не съедает ли что-то CPU или память до предела. Это закрывает блок bash — логично дальше перейти к embedded и SoC, где многие из этих же инструментов используются уже применительно к железу.||
**11. Embedded и SoC**
▪ **11.1 Что такое bringup?**
||Это процесс первого запуска новой аппаратной платы до состояния, когда на ней стабильно работает операционная система и периферия. Последовательность строго снизу вверх: сначала загрузчик uboot должен инициализировать минимальный набор железа (память, консоль) и загрузить с носителя следующий образ; дальше он передаёт управление ядру вместе с параметрами (device tree, командная строка), ядро инициализирует остальные драйверы и монтирует корневую файловую систему; только после этого доводится до работы периферия конкретной платы — UART, I2C, GPIO, сетевые контроллеры, каждый со своим драйвером. Каждый следующий уровень зависит от того, что предыдущий уже предоставил рабочую платформу: ядро не может грузиться без того, чтобы память и вывод в консоль уже были инициализированы загрузчиком, а прикладной софт не может стартовать без смонтированной корневой файловой системы. На bringup типична ситуация, когда плата вообще не даёт никакого вывода — тогда единственный способ понять, на каком этапе всё встало, это UART-консоль с логами каждого этапа и, если нужно, JTAG для отладки на уровне до появления консоли. Логично дальше спросить конкретно про uboot — первый компонент в этой цепочке.||
▪ **11.2 uboot?**
||Это загрузчик — первая программа, которая выполняется на плате после включения питания и берёт на себя минимальную инициализацию железа перед запуском операционной системы. uboot исполняется из встроенной памяти платы ещё до того, как доступна нормальная виртуальная память или драйверы ОС; он настраивает контроллер оперативной памяти, минимальную консоль и нужные для загрузки интерфейсы, затем находит и загружает в память образ ядра Linux и device tree, после чего передаёт им управление вместе с параметрами командной строки ядра — например, где искать корневую файловую систему. Сразу после включения питания процессор не имеет доступа ни к файловой системе, ни к драйверам, ни к виртуальной памяти — нужен минимальный код, который умеет работать напрямую с конкретным железом платы и привести систему к состоянию, откуда уже может стартовать полноценное ядро со своими абстракциями. uboot даёт интерактивную консоль до загрузки ОС, где можно вручную поправить параметры загрузки, перепрошить образ или диагностировать, что плата вообще подаёт признаки жизни; если проблема в самом ядре, а не в железе, до его загрузки просто не доходит, и это видно по логам uboot. Логично дальше спросить про то, что uboot загружает следующим, — про модуль ядра и работу с ядром вообще.||
▪ **11.3 Модуль ядра?**
||Это часть функциональности ядра Linux, обычно драйвер, которую можно загрузить и выгрузить во время работы системы, не пересобирая и не перезагружая всё ядро целиком. Модуль — отдельно скомпилированный файл, в котором определены функция инициализации (вызывается при загрузке модуля) и функция очистки (вызывается при выгрузке); insmod загружает такой файл в адресное пространство ядра и вызывает init-функцию, rmmod выгружает модуль, предварительно вызвав exit-функцию, которая обязана освободить всё, что модуль занял — память, зарегистрированные устройства, прерывания. Модуль работает в режиме ядра, поэтому вместо printf он использует printk, сообщения которого попадают в кольцевой буфер ядра и читаются командой dmesg. Не каждому железу и не всегда нужен конкретный драйвер — держать поддержку всего возможного оборудования вкомпилированной намертво в ядро раздуло бы образ и требовало пересборки ради каждого нового устройства, а модуль можно подгрузить только когда устройство реально появилось. Ошибка в init-функции модуля валит всё ядро целиком, а не только сам модуль, потому что у кода в режиме ядра нет отдельного адресного пространства, которое можно изолированно уронить, в отличие от обычного процесса; отлаживается через dmesg и, если ядро упало полностью, через сообщение паники ядра в консоли. Логично дальше спросить конкретно про драйвер символьного устройства — самый частый практический пример модуля.||
▪ **11.4 Символьный драйвер?**
||Это драйвер, который представляет устройство пользовательскому пространству как обычный файл в /dev, к которому можно применять стандартные файловые операции. Драйвер регистрирует в ядре структуру file_operations — таблицу указателей на свои реализации open, read, write, ioctl — и связывает её с номером устройства; когда приложение открывает файл устройства, ядро по этому номеру находит зарегистрированную структуру, и дальше все системные вызовы над этим файловым дескриптором перенаправляются в соответствующие функции драйвера, а не в обычную файловую подсистему. У Unix философия «всё — файл»: приложениям удобнее работать с устройством через уже знакомые open/read/write/close, чем изучать под каждое устройство отдельный специфический API; ioctl существует отдельно как расширение для операций, которые не укладываются в простое чтение или запись байтового потока. Если в реализации read или write драйвера неверно передать данные между пространством ядра и пользователя — для этого нужны специальные функции копирования, а не прямая работа с указателем, — это либо упадёт с ошибкой, либо, хуже, тихо повредит память; такие ошибки ловятся тестированием драйвера и логами ядра. Логично дальше спросить про device tree — откуда драйвер вообще узнаёт, что за устройство и по какому адресу его искать.||
▪ **11.5 Device tree?**
||Это описание аппаратной конфигурации платы — какие устройства есть, по каким адресам и на каких прерываниях, — отдельное от кода ядра. Файл .dts описывает дерево узлов, каждый узел — устройство или шина со своими свойствами: базовый адрес регистров, номер прерывания, к какой шине подключено и какому драйверу ядра соответствовать; на этапе сборки .dts компилируется в бинарный .dtb, который uboot загружает в память вместе с ядром и передаёт ему адрес этой структуры, а ядро при старте парсит device tree и на основе найденных узлов инстанциирует нужные драйверы с правильными параметрами, не имея этих адресов зашитыми в код. Одно и то же ядро Linux должно работать на множестве разных плат с разной обвязкой одного процессора — SoC-платформы обычно различаются не самим процессором, а тем, что к нему распаяно на конкретной плате; вынесение этого описания в отдельный файл позволяет собирать один и тот же ядерный образ и просто подставлять под него нужный .dtb для конкретной платы. Если в device tree указан неверный адрес или прерывание для устройства, драйвер либо не найдёт железо, либо будет читать данные другого устройства по ошибочному адресу — типичный симптом на bringup, когда драйвер загрузился, но устройство не отвечает; диагностируется сверкой .dts с даташитом платы и логами dmesg о неудачной инициализации. Логично дальше спросить, как вообще собирают код под такую целевую плату, если у разработчика на столе обычный x86.||
▪ **11.6 Cross-compile?**
||Это сборка исполняемого кода на машине одной архитектуры для выполнения на машине другой архитектуры, например сборка под ARM на x86-компьютере. Обычный компилятор на x86-машине генерирует машинный код именно под x86-набор инструкций; для сборки под ARM нужен отдельный тулчейн — набор компилятора, ассемблера, линковщика и стандартных библиотек, собранных так, чтобы генерировать код под ARM-набор инструкций и ARM-ABI (соглашения о вызовах, размеры типов, выравнивание), например тулчейн вида arm-linux-gnueabihf-gcc; этот тулчейн запускается на x86-хосте, но результат — бинарник, который на самом x86 не выполнить, только перенести на целевую плату. Набор машинных инструкций и способ передачи аргументов в функции у x86 и ARM физически разные — один и тот же исходный код компилируется в совершенно разные последовательности байт под каждую архитектуру, и для этого нужен компилятор, явно умеющий генерировать целевой набор инструкций и слинковать бинарник с библиотеками, собранными под ту же архитектуру. Типичная ошибка на встраиваемых проектах — случайно собрать зависимость нативным компилятором вместо кросс-тулчейна и получить бинарник, который на целевой плате вообще не запускается или падает из-за несовпадения ABI; вторая частая ловушка — версия библиотек на хосте для линковки не совпадает с версией на целевой корневой файловой системе, что всплывает уже при запуске как ошибка неразрешённого символа. Логично дальше — раз это раздел «знать обзорно» — честно обозначить границу своего опыта здесь.||
▪ **11.7 Что сказать честно?**
||Это не технический факт, а рекомендация по формулировке ответа, когда реального продуктового опыта в этой области нет. Структура ответа: сначала прямо назвать, чего не хватает — продуктового опыта разработки драйверов и bringup, — затем показать, что теоретическая база есть, и назвать её конкретно: модуль ядра, символьный драйвер, device tree, cross-compile, — и закрыть готовностью доучиваться на месте. Попытка выдать теоретическое знание за практический опыт быстро вскрывается на первом же уточняющем вопросе про детали конкретного проекта и подрывает доверие ко всем остальным ответам, поэтому честная граница здесь надёжнее преувеличения. В описании вакансии embedded и SoC обозначены как «плюс», а не как обязательное ядро наравне с C/C++, Linux и TCP/IP, значит честно очерченная граница опыта здесь не дисквалифицирует и ожидаема для позиции с полутора-тремя годами опыта. На этом заканчивается раздел embedded — последний из закреплённых за этим блоком разделов HR_BASE.md.||
**12. Вопросы, которые задаст HR (и хорошие ответы)**
▪ **12.1 Расскажи о себе.**
||Это не автобиография, а способ HR за 30–40 секунд понять, совпадает ли профиль кандидата с вакансией, прежде чем переходить к техническим вопросам. Хороший ответ идёт по фиксированному порядку: кто ты и сколько лет пишешь на C/C++, в каком окружении работал (Linux, сети, встраиваемые системы), что делал на последнем месте конкретно, и почему интересна именно эта область — низкоуровневая разработка и сети. Порядок «прошлое → настоящее → будущее» выбран не случайно: именно так HR будет потом сверять рассказ с резюме и с требованиями вакансии, и любое расхождение сразу видно. Если начать с общих фраз о себе как о личности или растянуть ответ на несколько минут, HR теряет структуру и переспрашивает «а конкретнее», что съедает время скрининга и выглядит как неумение расставлять приоритеты. Дальше логично вытекает вопрос, почему именно эта компания.||
▪ **12.2 Почему Eltex?**
||Вопрос проверяет не эрудицию о компании, а осознанность выбора — понимает ли кандидат, куда идёт, или разослал резюме на все вакансии подряд с похожим стеком. Ответ строится в три шага: что конкретно привлекает в продукте (реальное железо — коммутаторы, маршрутизаторы, GPON, а не абстрактный сервис в облаке), какой профессиональный рост это даёт лично тебе (C++, Linux и сети на уровне, где баг виден в tcpdump или в логе ядра, а не только в стектрейсе), и почему это логичное продолжение опыта, а не случайный поворот. HR ищет совпадение мотивации кандидата с тем, что реально предлагает вакансия, потому что кандидат, который «просто хочет работу», уходит через несколько месяцев, а это стоит компании денег на повторный найм. Если ответ сводится к «у вас хорошие условия» или «близко от дома», это не показывает интереса к содержанию работы, и следующий вопрос HR — «а что вы знаете о компании» — сразу обнажает, что подготовки не было. Отсюда прямой переход к вопросу о том, что кандидат знает о компании.||
▪ **12.3 Что знаешь о компании?**
||Это проверка, что кандидат потратил хотя бы 15 минут на подготовку, а не пришёл на интервью вслепую. Достаточно 3–4 фактов, которые связываются с вакансией: масштаб (более 2000 человек), локация (Новосибирск), профиль продукции (Ethernet-коммутаторы, сервисные маршрутизаторы, Wi-Fi, GPON, IoT) и то, что часть устройств строится на собственных SoC — компания разрабатывает не только софт, но и железо под него. Связка «своё железо плюс своя прошивка» объясняет, зачем вакансии нужен именно C/C++ и Linux, а не более высокоуровневый стек, и это тот факт, который должен подтвердить кандидату, что позиция ему подходит. Если известно только название компании без деталей продукта, HR делает вывод, что интерес к позиции поверхностный, а это почти равнозначно ответу «нужна любая работа с C++». Дальше логично спросить, почему кандидат уходит с текущего места — мотивационная совместимость проверяется с обеих сторон.||
▪ **12.4 Почему уходишь с текущего места?**
||Вопрос проверяет, не будет ли кандидат токсичным по отношению к бывшему работодателю и не скрывается ли за уходом тревожный сигнал — конфликт, увольнение по статье, систематическая недисциплинированность. Правильная структура ответа — назвать нейтральную профессиональную причину, например желание больше глубины в системной разработке и сетях там, где сейчас потолок, и не упоминать людей, конфликты или зарплату как единственный мотив. HR слышит этот вопрос от каждого кандидата и использует его как фильтр на «жалуется на всех подряд»: если человек ругает предыдущее место, с высокой вероятностью через год он так же будет говорить про Eltex. Конкретика с именами, обидой в голосе или фразой «там было невозможно работать» дословно попадает в обратную связь HR коллегам-интервьюерам и снижает шанс дойти до технического этапа. Отсюда переход к вопросу о готовности к офису и релокации — это уже проверка практической, а не мотивационной совместимости.||
▪ **12.5 Готов к офису/гибриду в Новосибирске?**
||Это логистический фильтр, а не вопрос про желания: HR должен понять до найма, сможет ли кандидат физически выйти на работу в нужном городе и формате. Ответ должен быть прямым да/нет с уточнением по факту — живёшь в городе, готов переезжать со сроком, готов только на удалёнку. Такой вопрос задают рано в воронке специально, чтобы не тратить два технических интервью на кандидата, который физически не сможет выйти на офис или гибрид: раннее «нет» экономит время обеим сторонам. Расплывчатый ответ вроде «наверное, посмотрим» заставляет HR либо додумывать за кандидата, либо отсеивать на всякий случай, потому что неопределённость по логистике — самый дешёвый повод отказать без объяснений. Дальше вопрос закономерно переходит к деньгам — второму по значимости фильтру совместимости.||
▪ **12.6 Ожидания по зарплате?**
||Вопрос сверяет вилку кандидата с бюджетом позиции до того, как обе стороны вложат время в технические этапы. Правильный ответ — назвать конкретную вилку, обоснованную рынком с учётом опыта 1–3 года, стека C/C++/Linux и города, и закрыть фразой готовности обсуждать по итогам интервью, что оставляет пространство для торга по результатам технической оценки. Если назвать вилку сильно выше рынка или уйти в «как договоримся», HR либо сразу отсеивает по бюджету, либо теряет ориентир и вынужден переспрашивать на каждом следующем этапе. Типичная ошибка — назвать цифру без привязки к опыту или рынку: тогда она выглядит случайной, и рекрутер закладывает к ней скидку «на всякий случай» при финальном оффере. Отсюда логично вытекает вопрос о сильных и слабых сторонах — следующий фильтр, но уже не про деньги, а про самооценку.||
▪ **12.7 Сильные и слабые стороны?**
||Это проверка честности и способности к рефлексии, а не поиск идеального ответа без недостатков. Сильная сторона называется с примером поведения — довожу задачу до работающего результата, умею отлаживать, — а слабая называется реальной и сразу с тем, что делается для компенсации, например: раньше держал в голове сложности контейнеров, теперь сверяю по документации перед использованием. HR давно знает шаблонный приём «моя слабость — перфекционизм» и воспринимает его как отказ отвечать на вопрос, а не как ответ, поэтому конкретная слабость с планом компенсации звучит контринтуитивно сильнее, чем фальшивое достоинство. Если слабость обесценена до неузнаваемости, следующий вопрос интервьюера — «приведите пример, когда это мешало» — ставит кандидата в тупик, потому что примера для выдуманной слабости не существует. Дальше вопрос логично переходит к готовности решать алгоритмические задачи — это уже проверка не личности, а актуального навыка.||
▪ **12.8 Готов к задачам на алгоритмы?**
||Вопрос выясняет, готовился ли кандидат к формату технического этапа, где почти всегда есть задачи на структуры данных и сложности. Достаточно подтвердить готовность и назвать, что конкретно тренируется: базовые структуры (массивы, списки, деревья, графы), оценка сложности через O(...) и типовые задачи на строки и графы, которые чаще всего встречаются на технических собеседованиях уровня 1–3 года опыта. HR не оценивает сам навык — это сделает технический интервьюер, — а фильтрует кандидатов, которые вообще не в курсе, что впереди будет такой этап, и придут неподготовленными. Ответ «я не готовился, но постараюсь» сигнализирует, что кандидат либо не читал описание вакансии, либо не расставляет приоритеты перед важным интервью, и HR переносит это как прогноз на реальную работу. Отсюда естественный переход к тому, какие вопросы задаёт сам кандидат, — последний содержательный пункт скрининга.||
▪ **12.9 Есть вопросы к нам?**
||Это финальная проверка вовлечённости: молчание здесь читается как безразличие к месту, куда человек хочет попасть. Нужно заранее заготовить 3–4 вопроса по существу — про продукт и команду, про стек и железо, на котором придётся работать, про то, как устроен онбординг, как выглядит процесс разработки и ревью кода, и что считается успехом в первые месяцы на позиции. Вопросы про процесс и критерии успеха показывают, что кандидат уже мысленно моделирует себя в этой роли, а не просто ждёт оффер, и это ровно то отличие, которое HR ищет между «хочет эту работу» и «хочет любую работу». Фраза «нет, вы всё рассказали» закрывает интервью на нейтральной, а не на сильной ноте, и при равных технических навыках у пары кандидатов выбор чаще падает на того, кто спросил по делу. Дальше логично спросить, что стоит почитать перед интервью, — это уже подготовка к следующему, более техническому этапу.||
▪ **12.10 Что почитать про нас перед интервью?**
||Это не столько вопрос от HR, сколько чеклист для самого кандидата на этапе подготовки — что именно открыть накануне встречи. Минимум — сайт Eltex, раздел с продуктами, текст самой вакансии построчно, а также интервью или доклады сотрудников компании на профильных конференциях, если такие есть в открытом доступе. Текст вакансии почти всегда содержит формулировки, которые дословно всплывут в вопросах, например «многопоточные приложения» или «TCP/IP», и HR ожидает, что кандидат уже связал требования вакансии с тем, что рассказывает о себе. Если прийти без этой подготовки, ответы на «почему Eltex» и «что знаете о компании» получаются общими, и это заметно на фоне кандидатов, которые готовились предметно. Это последний пункт HR-блока — дальше в подготовке остаётся только зафиксировать числа, которые могут всплыть в технической части.||
---
**13. Шпаргалка чисел (выучить наизусть)**
- Ethernet-заголовок 14 байт, VLAN-тег +4, MTU 1500.
- IPv4-заголовок 20 байт, TCP-заголовок 20 байт, UDP-заголовок 8 байт.
- MAC 48 бит, IPv4 32 бита, порт 16 бит.
- Подсети: /24 → 254 узла, /26 → 62, /30 → 2.
- log₂(1000) ≈ 10, log₂(10⁶) ≈ 20.
- 137 = SIGKILL (128+9), 139 = SIGSEGV (128+11), 143 = SIGTERM (128+15).
- Порты: 22 SSH, 53 DNS, 80 HTTP, 443 HTTPS.
-507
View File
@@ -1,507 +0,0 @@
**База перед тех-скринингом (HR, видеосвязь)**
Собрано под вакансию Eltex «Разработчик C/C++» (Новосибирск, 1–3 года, ключевые навыки
C/C++, Linux, TCP/IP). Формат — вопрос → короткий ответ. Читать подряд, вслух проговаривая
ответы: на скрининге важно не «знаю», а «скажу связно за 20 секунд».
**1. ООП (спрашивают почти всегда)**
▪ **1.1 Что такое ООП?**
||Парадигма, где программа — набор объектов, обменивающихся сообщениями; объект объединяет данные и поведение.||
▪ **1.2 Три кита?**
||Инкапсуляция, наследование, полиморфизм. (Иногда добавляют абстракцию.)||
▪ **1.3 Инкапсуляция?**
||Сокрытие внутреннего состояния за публичным интерфейсом: поля private, доступ через методы.||
▪ **1.4 Наследование?**
||Переиспользование и расширение: класс-наследник получает члены базового.||
▪ **1.5 Полиморфизм?**
||Один интерфейс — разные реализации. В C++ динамический полиморфизм — через `virtual` (решение принимается в рантайме по фактическому типу), статический — перегрузка и шаблоны (на компиляции).||
▪ **1.6 Класс и объект?**
||Класс — описание (чертёж), объект — экземпляр в памяти.||
▪ **1.7 struct и class в C++?**
||Разница только в доступе по умолчанию: у struct public, у class private.||
▪ **1.8 Что такое виртуальная функция?**
||Функция, вызываемая по фактическому типу объекта. Реализуется через таблицу виртуальных функций (vtable) — отсюда +указатель на объект.||
▪ **1.9 Зачем виртуальный деструктор?**
||Если удалять наследника через `Base*` без него — UB, деструктор наследника не вызовется, ресурсы утекут.||
▪ **1.10 Можно ли вызвать virtual из конструктора?**
||Можно, но вызовется версия текущего класса, а не наследника: объект ещё не достроен.||
▪ **1.11 Абстрактный класс?**
||Класс с хотя бы одной чисто виртуальной функцией (`= 0`), экземпляр создать нельзя. В C++ нет отдельного `interface` — его роль играет такой класс.||
▪ **1.12 Перегрузка и переопределение — в чём разница?**
||Перегрузка: несколько функций с одним именем и разными параметрами в одной области видимости. Переопределение: virtual-функция наследника с той же сигнатурой.||
▪ **1.13 Что такое static-член?**
||Общий для всех объектов класса, живёт вне объекта; у static-метода нет `this`.||
▪ **1.14 Что такое SOLID?**
||S — единственная ответственность, O — открыт для расширения/закрыт для изменения, L — подстановка Лисков, I — разделение интерфейсов, D — инверсия зависимостей. Достаточно назвать и объяснить одну-две.||
▪ **1.15 Композиция или наследование?**
||Предпочитают композицию: наследование жёстко связывает классы, композиция гибче.||
**2. C/C++ (ядро вакансии)**
▪ **2.1 Указатель и ссылка — разница?**
||Указатель — адрес, может быть null, его можно менять и арифметикой ходить. Ссылка — псевдоним существующего объекта, не бывает null, перепривязать нельзя.||
▪ **2.2 new/delete против malloc/free?**
||`new` вызывает конструктор и возвращает типизированный указатель, `malloc` даёт сырой блок; `new` бросает исключение, `malloc` возвращает NULL; `delete` вызывает деструктор.||
▪ **2.3 Стек и куча?**
||Стек — автоматическая память (локальные переменные, быстрая, ограничена). Куча — динамическая (`new`/`malloc`, больше, но освобождать вручную).||
▪ **2.4 Что такое утечка памяти?**
||Выделили и не освободили. Ищется ASAN (LeakSanitizer), valgrind.||
▪ **2.5 RAII?**
||Ресурс захватывается в конструкторе, освобождается в деструкторе — утечек нет даже при исключениях. Так работают `std::vector`, `std::ifstream`, умные указатели.||
▪ **2.6 unique_ptr и shared_ptr?**
||`unique_ptr` — единственный владелец, без счётчика, дешевле. `shared_ptr` — общий, считает ссылки, есть накладные расходы и проблема циклических ссылок (`weak_ptr`).||
▪ **2.7 Правило трёх/пяти?**
||Если класс владеет ресурсом и задан деструктор — надо определить копирование и перемещение (в C++11+: конструктор/присваивание копированием и перемещением).||
▪ **2.8 Что делает std::move?**
||Ничего сам по себе — это `static_cast` к rvalue-ссылке. Перемещение выполняет конструктор/присваивание перемещением.||
▪ **2.9 Что такое UB?**
||Undefined behavior — стандарт не накладывает требований. Примеры: выход за границы, знаковое переполнение, сдвиг в знаковый бит, разыменование null, гонка данных.||
▪ **2.10 Что проверяют ASAN и UBSAN?**
||ASAN — выходы за границы, use-after-free, двойное освобождение, утечки. UBSAN — неопределённое поведение (переполнения, сдвиги, выравнивание).||
▪ **2.11 sizeof(struct { char a; int b; char c; }) на x86-64?**
||12: `a` в 0, padding 1–3, `b` в 4–7, `c` в 8, padding 9–11 до выравнивания структуры по 4.||
▪ **2.12 Порядок вычисления аргументов f(a(), b()) задан?**
||Нет, не определён — нельзя полагаться.||
▪ **2.13 Что делает const после сигнатуры метода?**
||Делает `this` указателем на константу: метод не меняет поля объекта, и его можно вызывать у const-объекта.||
▪ **2.14 Что такое перегрузка операторов и зачем?**
||Своя семантика для `+`, `==`, `[]` и т.п., чтобы тип вёл себя как встроенный. Нельзя перегружать `.`, `::`, `?:`, `sizeof`.||
▪ **2.15 Компиляция по шагам?**
||Препроцессор (`#include`, `#define`) → компиляция в объектные файлы → линковка в исполняемый. Ошибка «undefined reference» — от линковщика.||
▪ **2.16 Зачем header guards?**
||Чтобы заголовок не подключался дважды — иначе повторное определение типа.||
▪ **2.17 Что такое forward declaration?**
||Объявление класса/функции без определения, чтобы не тянуть `#include`; достаточно для указателей и ссылок.||
▪ **2.18 Что такое шаблон (template)?**
||Обобщённый код, тип подставляется при компиляции. Цена — ошибки и раздувание кода.||
▪ **2.19 Что такое лямбда?**
||Анонимная функция с захватом контекста (`[&]`, `[=]`). Синтаксический сахар над функтором.||
▪ **2.20 Что такое пространство имён?**
||Способ избежать конфликта имён; `std::` — пример.||
▪ **2.21 volatile — что это?**
||Говорит компилятору не кэшировать значение в регистре (регистры железа, переменные, меняемые извне). Это не про многопоточность — для неё `std::atomic`.||
**3. STL (обязательно, спрашивают про сложности)**
▪ **3.1 Какие контейнеры знаешь?**
||Последовательные: `vector`, `list`, `deque`, `array`. Ассоциативные: `map`, `set` (деревья, O(log n)). Хеш-таблицы: `unordered_map`, `unordered_set` (O(1) в среднем). Адаптеры: `stack`, `queue`, `priority_queue`.||
▪ **3.2 vector — сложности?**
||Доступ по индексу O(1), push_back амортизированное O(1), вставка/удаление в середине O(n), поиск O(n).||
▪ **3.3 Почему push_back амортизированное O(1)?**
||Ёмкость удваивается, реаллокация O(n) случается редко — в среднем одна операция O(1).||
▪ **3.4 list — сложности?**
||Доступ по номеру O(n), вставка/удаление при известном узле O(1), поиск O(n). Память дороже из-за указателей.||
▪ **3.5 map против unordered_map?**
||`map` — красно-чёрное дерево: O(log n), ключи упорядочены. `unordered_map` — хеш-таблица: O(1) в среднем, порядок не определён, нужен `std::hash`.||
▪ **3.6 Что быстрее и когда?**
||Поиск — обычно `unordered_map`. Если нужен порядок обхода или диапазонные запросы — `map`.||
▪ **3.7 Итераторы и их инвалидация?**
||Итератор — обобщённый указатель на элемент. У `vector` реаллокация инвалидирует все итераторы, у `list` — только итератор удалённого элемента.||
▪ **3.8 Как удалить элементы из vector по условию?**
||Идиома erase-remove: `v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());`.||
▪ **3.9 reserve и resize?**
||`reserve` меняет ёмкость (память), не размер. `resize` меняет размер (создаёт/удаляет элементы).||
▪ **3.10 emplace_back против push_back?**
||`emplace_back` конструирует объект на месте — без копирования/перемещения.||
▪ **3.11 Как работает priority_queue?**
||Бинарная куча поверх `vector`: вставка O(log n), извлечение максимума O(log n), доступ к максимуму O(1). По умолчанию max-куча.||
▪ **3.12 Какие алгоритмы STL знаешь?**
||`sort` (O(n log n), неустойчив), `stable_sort`, `find`, `count`, `lower_bound`/`upper_bound` (O(log n) на отсортированном), `accumulate`, `remove_if`, `unique`, `next_permutation`.||
▪ **3.13 Что такое функтор и предикат?**
||Объект с `operator()`. Предикат — функция/функтор, возвращающий bool, используется в алгоритмах.||
▪ **3.14 string — это контейнер?**
||Да, `std::basic_string<char>`: `size()`, `substr`, `find`, `append`; данные лежат непрерывно, `.c_str()` даёт C-строку.||
**4. Linux и ОС**
▪ **4.1 Процесс и поток?**
||Процесс — программа в исполнении со своим адресным пространством и PID. Поток — единица выполнения внутри процесса: общая память, свой стек и регистры.||
▪ **4.2 Что делает fork()?**
||Создаёт копию процесса. Возвращает дважды: в родителе — PID ребёнка, в ребёнке — 0, при ошибке −1.||
▪ **4.3 Что с памятью при fork()?**
||Copy-on-write: страницы общие, копируются при первой записи.||
▪ **4.4 exec?**
||Заменяет образ текущего процесса (код, данные, стек). При успехе не возвращается. `fork` + `exec` — запуск внешней программы.||
▪ **4.5 Зачем waitpid?**
||Забрать код возврата и не оставлять зомби.||
▪ **4.6 Зомби и сирота?**
||Зомби — завершившийся процесс, чья запись ждёт `wait`. Сирота — процесс, чей родитель умер; его усыновляет init (PID 1).||
▪ **4.7 Коды возврата 137 и 139?**
||137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV). Так кодирует оболочка.||
▪ **4.8 Сигналы, основные?**
||SIGINT (2, Ctrl+C), SIGKILL (9, не перехватить), SIGTERM (15, корректное завершение), SIGSEGV (11), SIGPIPE (13, запись в закрытый сокет), SIGCHLD (17).||
▪ **4.9 SIGTERM против SIGKILL?**
||SIGTERM можно перехватить и завершиться аккуратно, SIGKILL нельзя ни перехватить, ни проигнорировать.||
▪ **4.10 Файловый дескриптор?**
||Число, по которому программа обращается к открытому файлу, сокету, pipe. 0/1/2 — stdin/stdout/stderr.||
▪ **4.11 Что такое epoll?**
||Механизм мультиплексирования: ядро сообщает о готовых дескрипторах, сложность O(1) на событие. `select`/`poll` перебирают все дескрипторы — O(n).||
▪ **4.12 mmap?**
||Отображает файл в память: страницы подгружаются по обращению, вместо read/write работаешь с указателем.||
▪ **4.13 Виртуальная память и страница?**
||Каждому процессу — своё адресное пространство; память делится на страницы (обычно 4 КБ), отображаемые на физическую.||
▪ **4.14 Что такое swap?**
||Выгрузка страниц на диск при нехватке памяти. Медленно, но спасает от OOM.||
▪ **4.15 Что такое inode?**
||Структура метаданных файла (права, размер, владелец, указатели на блоки). Имя файла — отдельно, в каталоге.||
▪ **4.16 Права доступа 755?**
||Владелец rwx, группа r-x, остальные r-x. `chmod`, `chown` — смена прав и владельца.||
▪ **4.17 Что такое pipe и перенаправление?**
||`|` соединяет вывод одной программы со входом другой; `>`, `>>`, `2>&1` перенаправляют потоки.||
▪ **4.18 Как посмотреть процессы и нагрузку?**
||`ps aux`, `top`/`htop`, `pidstat`; `free -h` — память, `df -h` — диски, `iostat` — ввод-вывод.||
▪ **4.19 Что такое демон?**
||Фоновый процесс без управляющего терминала; запускается systemd (unit-файлы, `systemctl start/status`).||
▪ **4.20 Что такое ядро и системный вызов?**
||Ядро управляет ресурсами; программа обращается к нему через системные вызовы (`open`, `read`, `write`, `fork`). `strace` показывает эти вызовы.||
▪ **4.21 Мьютекс, семафор, атомик?**
||Мьютекс — блокировка на одного владельца. Семафор — счётчик на N владельцев. Атомик — операция без блокировки, аппаратно.||
▪ **4.22 Что такое гонка данных и дедлок?**
||Гонка — несинхронизированный доступ к общей памяти (ловит ThreadSanitizer). Дедлок — потоки ждут ресурсы друг друга; лечится единым порядком захвата.||
▪ **4.23 Условие переменная (condition_variable)?**
||Способ ждать событие: ждать в цикле с предикатом (`while (!ready) cv.wait(lock);`) — защита от ложных пробуждений.||
**5. Сети: OSI, TCP/IP, TCP, UDP (ядро вакансии)**
▪ **5.1 Семь уровней OSI?**
||Физический, канальный, сетевой, транспортный, сеансовый, представления, прикладной. Практически работают с пятью.||
▪ **5.2 Что на каждом уровне?**
||L1 — биты и среды (витая пара, оптика). L2 — кадры, MAC, коммутатор. L3 — пакеты, IP, маршрутизатор. L4 — TCP/UDP, порты. L5–L7 — сессии, кодирование, прикладные протоколы (HTTP, DNS).||
▪ **5.3 TCP/IP модель?**
||Канальный, интернет, транспортный, прикладной. Соответствует OSI снизу.||
▪ **5.4 Инкапсуляция?**
||Каждый уровень добавляет свой заголовок: Ethernet (14 байт) → IP (20) → TCP (20) → данные. При передаче пакет кладут внутрь кадра.||
▪ **5.5 Сколько байт в Ethernet-заголовке?**
||14 (MAC получателя 6, MAC отправителя 6, тип 2). VLAN 802.1Q добавляет 4.||
▪ **5.6 MAC-адрес?**
||48 бит, физический адрес интерфейса, действует в пределах сегмента.||
▪ **5.7 ARP?**
||Находит MAC по IP в локальном сегменте: широковещательный запрос → ответ.||
▪ **5.8 MTU?**
||Максимальный размер полезной нагрузки кадра, для Ethernet 1500 байт. Больше — фрагментация или ошибка.||
▪ **5.9 IPv4-заголовок, что важно?**
||Минимум 20 байт: версия/IHL, длина, TTL, протокол, контрольная сумма заголовка, адреса источника и назначения. TTL уменьшается на 1 на каждом маршрутизаторе.||
▪ **5.10 Маски и подсети?**
||Маска делит адрес на сеть и узел. `/24` — 254 узла, `/26` — 62, `/30` — 2 (точка-точка). Широковещательный адрес — все единицы в хостовой части.||
▪ **5.11 Маршрутизация?**
||Узел сравнивает адрес со своей маской: своя подсеть — ARP и напрямую, чужая — на шлюз. Шлюз по умолчанию — выход в другие сети.||
▪ **5.12 ICMP?**
||Служебные сообщения: `ping` (echo request/reply), `traceroute` (истечение TTL), «destination unreachable».||
▪ **5.13 Порядок установки TCP-соединения?**
||SYN → SYN-ACK → ACK (трёхстороннее рукопожатие).||
▪ **5.14 Как закрывается TCP?**
||FIN → ACK → FIN → ACK; сторона, закрывшая первой, ждёт TIME_WAIT (2×MSL), чтобы добить потерянные сегменты.||
▪ **5.15 Флаги TCP?**
||SYN, ACK, FIN, RST, PSH, URG.||
▪ **5.16 Что даёт TCP?**
||Надёжность: нумерация, подтверждения, ретрансмиссии по таймауту, окно (управление потоком), контроль перегрузки, сохранение порядка.||
▪ **5.17 Что такое окно?**
||Сколько байт можно отправить без подтверждения. Управляет скоростью и защищает от переполнения получателя.||
▪ **5.18 UDP?**
||Без соединения и гарантий: заголовок 8 байт, порядок и доставка не гарантируются. Быстро и дёшево: DNS, DHCP, VoIP, игры, стриминг.||
▪ **5.19 TCP или UDP — когда что?**
||TCP — когда важна целостность (файлы, HTTP, SSH). UDP — когда важна задержка или данные самодостаточны (DNS, видео, телеметрия).||
▪ **5.20 Что такое порт?**
||Номер приложения на узле (16 бит). Известные: 22 SSH, 53 DNS, 80 HTTP, 443 HTTPS.||
▪ **5.21 DNS?**
||Превращает имя в IP. Обычно UDP/53, при больших ответах — TCP/53.||
▪ **5.22 DHCP?**
||Автоматическая выдача IP, маски, шлюза, DNS.||
▪ **5.23 NAT?**
||Подмена адресов при выходе в интернет: много внутренних устройств через один внешний IP.||
▪ **5.24 Сокеты: как устроен сервер?**
||`socket` → `bind` → `listen` → `accept` → `read`/`write` → `close`. Клиент: `socket` → `connect`. Для множества соединений — `epoll` и неблокирующие сокеты.||
▪ **5.25 Что такое неблокирующий сокет и EAGAIN?**
||Вызов не ждёт данных: если их нет, возвращает −1 и `errno = EAGAIN` — надо вернуться позже.||
▪ **5.26 Чем смотрят трафик?**
||`tcpdump`, Wireshark. Базово: `tcpdump -i eth0 -nn port 80`.||
**6. Многопоточность (в вакансии: многопоточные приложения)**
▪ **6.1 Как создать поток в C++?**
||`std::thread t(f, args); t.join();` (или `detach`, но тогда следи за временем жизни).||
▪ **6.2 Как защитить общие данные?**
||`std::mutex` + `std::lock_guard`/`std::unique_lock`; для счётчиков — `std::atomic`.||
▪ **6.3 Что такое ложное пробуждение?**
||`wait` может вернуться без сигнала — поэтому ждут в цикле с предикатом.||
▪ **6.4 Как избежать дедлока?**
||Единый порядок захвата мьютексов, `std::lock`/`std::scoped_lock` для нескольких сразу, не держать блокировку при вызове чужого кода.||
▪ **6.5 Producer/consumer — как?**
||Очередь + мьютекс + condition_variable: производитель кладёт и уведомляет, потребитель ждёт и забирает.||
▪ **6.6 Пул потоков зачем?**
||Создание потока дорого; пул переиспользует N потоков и очередь задач.||
▪ **6.7 Потокобезопасный size()?**
||Нужен либо атомарный счётчик, либо мьютекс: без этого чтение и запись — гонка данных.||
**7. Git**
▪ **7.1 Основной цикл работы?**
||`git clone` → `git checkout -b feature` → правки → `git add` → `git commit` → `git push` → pull request.||
▪ **7.2 Что такое коммит, ветка, HEAD?**
||Коммит — снимок состояния с родителями. Ветка — подвижный указатель на коммит. HEAD — где ты сейчас.||
▪ **7.3 merge и rebase?**
||merge создаёт коммит слияния, история ветвится. rebase переносит коммиты поверх другой ветки — история линейная, но переписываются хеши.||
▪ **7.4 Конфликт — что делать?**
||Git помечает файлы, правишь вручную, `git add`, `git merge --continue` (или `--abort`).||
▪ **7.5 reset, revert, checkout?**
||`reset` двигает ветку (может потерять коммиты), `revert` создаёт обратный коммит — безопасно для общей ветки, `checkout`/`switch` переключает ветки и файлы.||
▪ **7.6 stash?**
||Отложить незакоммиченные изменения: `git stash`, потом `git stash pop`.||
▪ **7.7 fetch и pull?**
||`fetch` скачивает без слияния, `pull` = fetch + merge (или rebase).||
▪ **7.8 Как посмотреть историю и что менялось?**
||`git log --oneline --graph`, `git diff`, `git show <commit>`, `git blame`.||
▪ **7.9 Что не коммитить?**
||Секреты, артефакты сборки, большие бинарники — через `.gitignore`.||
**8. Docker**
▪ **8.1 Образ и контейнер?**
||Образ — неизменяемый шаблон (слои файловой системы). Контейнер — запущенный экземпляр образа плюс своё изменяемое состояние.||
▪ **8.2 Чем отличается от виртуальной машины?**
||Контейнер использует ядро хоста и изолирует процессы (namespaces, cgroups) — легче и стартует за секунды; VM несёт своё ядро.||
▪ **8.3 Dockerfile — ключевые инструкции?**
||`FROM`, `RUN`, `COPY`, `WORKDIR`, `ENV`, `CMD`/`ENTRYPOINT`, `EXPOSE`.||
▪ **8.4 Зачем слои и порядок инструкций?**
||Каждая инструкция — слой, слои кэшируются; часто меняющееся (код) ставят после редко меняющегося (зависимости), чтобы кэш работал.||
▪ **8.5 Volume и bind mount?**
||Volume — управляемое Docker хранилище (данные переживают контейнер). Bind mount — каталог хоста внутри контейнера.||
▪ **8.6 Сети в Docker?**
||По умолчанию bridge с внутренними IP; порты публикуются через `-p 8080:80`. Есть host и overlay для swarm.||
▪ **8.7 docker-compose?**
||Описание нескольких сервисов в одном YAML: `docker compose up -d`.||
▪ **8.8 Где это в Eltex?**
||Воспроизводимая сборка и тесты: один образ у всех, «у меня работает» перестаёт быть аргументом.||
**9. GDB и отладка**
▪ **9.1 Как запустить?**
||`g++ -g -O0 prog.cpp -o prog` (обязательно `-g`), затем `gdb ./prog`, `run`.||
▪ **9.2 Основные команды?**
||`break main` / `break file:line`, `run`, `next` (по шагам, через вызовы), `step` (внутрь), `continue`, `print x`, `bt` (стек вызовов), `frame N`, `watch var`, `info threads`.||
▪ **9.3 Как отладить падение?**
||Запустить, получить `bt`, посмотреть кадр с падением, `print` указателей; либо core dump (`ulimit -c unlimited`, затем `gdb prog core`).||
▪ **9.4 Что такое core dump?**
||Снимок памяти процесса в момент падения; позволяет разобраться постфактум.||
▪ **9.5 Санитайзеры?**
||ASAN (память), UBSAN (UB), TSan (гонки), MSan (неинициализированная память) — включаются флагом `-fsanitize=...`.||
▪ **9.6 valgrind?**
||Инструмент поиска утечек и ошибок памяти без пересборки; медленнее санитайзеров.||
**10. Bash и инструменты**
▪ **10.1 Что такое скрипт и его шапка?**
||`#!/usr/bin/env bash` плюс `set -euo pipefail` — падать на ошибках, на необъявленных переменных и в конвейерах.||
▪ **10.2 Переменные и аргументы?**
||`name=value`, `$name`, `$1..$9`, `$#`, `$@`, `$?` (код возврата), `$$` (PID).||
▪ **10.3 grep, awk, sed — для чего?**
||grep — поиск по шаблону, awk — обработка по колонкам и подсчёты, sed — замена/правка потока. Для больших файлов — только они, а не bash-цикл по строкам.||
▪ **10.4 Пайплайны и перенаправления?**
||`cmd1 | cmd2`, `>`, `>>`, `2>`, `2>&1`, `tee`.||
▪ **10.5 Как найти файлы и выполнить команду?**
||`find /path -name '*.log' -mtime -1`, `find ... -exec ... {} +`, `xargs`.||
▪ **10.6 Полезное на каждый день?**
||`ps/top`, `ss -tulpn` (порты), `df -h`, `du -sh`, `tail -f`, `journalctl -u сервис`, `systemctl status`.||
**11. Embedded и SoC (в вакансии «плюс», знать обзорно)**
▪ **11.1 Что такое bringup?**
||Запуск новой платы: uboot → ядро → корневая ФС → периферия. Задача — довести устройство до стабильной работы.||
▪ **11.2 uboot?**
||Загрузчик: инициализирует железо, грузит ядро и передаёт ему параметры (device tree).||
▪ **11.3 Модуль ядра?**
||Код, загружаемый в ядро (`insmod`/`rmmod`), с `init`/`exit`; печатает через `printk`, видно в `dmesg`.||
▪ **11.4 Символьный драйвер?**
||Даёт доступ к устройству как к файлу через `file_operations` (`open`, `read`, `write`, `ioctl`).||
▪ **11.5 Device tree?**
||Описание железа (адреса, прерывания, шины) для ядра — отдельно от кода.||
▪ **11.6 Cross-compile?**
||Сборка под другую архитектуру (например ARM на x86) тулчейном `arm-linux-gnueabihf-gcc`.||
▪ **11.7 Что сказать честно?**
||Опыта продуктовой разработки драйверов нет, теорию знаю: модуль, драйвер, device tree, cross-compile; готов добирать на месте.||
**12. Вопросы, которые задаст HR (и хорошие ответы)**
▪ **12.1 Расскажи о себе.**
||40 секунд: кто, сколько лет в разработке, на чём пишешь (C/C++, Linux), чем занимался последним, почему интересна встраиваемая разработка и сети.||
▪ **12.2 Почему Eltex?**
||Продукт настоящий и низкоуровневый: коммутаторы, маршрутизаторы, GPON. Хочу расти в C++/Linux/сетях на реальном железе, а не на абстрактных сервисах.||
▪ **12.3 Что знаешь о компании?**
||Более 2000 человек, Новосибирск, телекоммуникационное оборудование (Ethernet-коммутаторы, сервисные маршрутизаторы, Wi-Fi, GPON, IoT), свои SoC-устройства.||
▪ **12.4 Почему уходишь с текущего места?**
||Без негатива: хочу ближе к системной разработке и сетям, где больше глубины.||
▪ **12.5 Готов к офису/гибриду в Новосибирске?**
||Отвечай прямо, как есть; если релокация — скажи, что готов обсуждать сроки.||
▪ **12.6 Ожидания по зарплате?**
||Назови вилку с обоснованием по рынку и добавь «готов обсуждать по итогам интервью».||
▪ **12.7 Сильные и слабые стороны?**
||Сильная — довожу до работающего результата, разбираюсь в отладке. Слабая — называй реальную и что делаешь: например, «раньше писал по памяти, теперь веду заметки и проверяю сложности по коду».||
▪ **12.8 Готов к задачам на алгоритмы?**
||Да, тренируюсь: базовые структуры, сложности, типовые задачи на строки/массивы/графы.||
▪ **12.9 Есть вопросы к нам?**
||Обязательно спроси: какой продукт и команда, на каком стеке и железе, как устроен онбординг, как выглядит процесс разработки и ревью, что считается успехом в первые месяцы.||
▪ **12.10 Что почитать про нас перед интервью?**
||Сайт Eltex, раздел про продукты и вакансию; интервью и доклады сотрудников на конференциях.||
**13. Шпаргалка чисел (выучить наизусть)**
- Ethernet-заголовок 14 байт, VLAN-тег +4, MTU 1500.
- IPv4-заголовок 20 байт, TCP-заголовок 20 байт, UDP-заголовок 8 байт.
- MAC 48 бит, IPv4 32 бита, порт 16 бит.
- Подсети: /24 → 254 узла, /26 → 62, /30 → 2.
- log₂(1000) ≈ 10, log₂(10⁶) ≈ 20.
- 137 = SIGKILL (128+9), 139 = SIGSEGV (128+11), 143 = SIGTERM (128+15).
- Порты: 22 SSH, 53 DNS, 80 HTTP, 443 HTTPS.
+22 -71
View File
@@ -1,89 +1,40 @@
# ИУП: подготовка к собеседованию Eltex (C/C++, алгоритмы, Linux, сети) # ИУП: подготовка к собеседованию Eltex (C/C++, алгоритмы, Linux, сети)
Личный учебный план и пакет задач с автопроверкой. Обновляется по ходу недели. Личный учебный план и диагностический пакет. Репозиторий обновляется по ходу недели.
## Что внутри ## Что внутри
- `WEEK7.md` — **рабочий документ**: интенсив на 7 дней (D0 23.09 → D7 30.09), пересобранный - `WEEK7.md` — **рабочий документ**: интенсив на 7 дней (D0 23.09 → D7 30.09), три блока
по диагностике (квиз 14/32, карточки 11/60). День устроен так: в дне (алгоритмы / системное / практика+разбор), у каждого дня контрольная точка.
**урок (теория + разбор) → карточки → ступени задачи → зачёт `grade.py`**.
- `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 на пост-офферный период (модули, артефакты, материалы). - `PLAN.md` — полный трек M1–M10 на пост-офферный период (модули, артефакты, материалы).
- `HR_BASE_deep.md` — та же база в 152 вопросах, но каждый ответ — объяснение механизма - `CONTEXT.md` — вводные, разбор вакансии (Eltex, hh #136775547), статус лейна.
(«почему так» и «что ломается»), а не тезис. Это версия для чтения перед скринингом. - `diag/` — диагностический пакет:
- `diag/cards_src.tsv` — выжимка фактов из уроков и задач под карточки (level/domain/question/answer/ref). - `quiz.py` + `quiz.json` — 32 вопроса по 4 доменам (C/C++, алгоритмы, Linux, сети);
- `HR_BASE.md` — короткая база вопрос-ответ под тех-скрининг (ООП, C/C++, STL, Linux, сети, Git, - `tasks/01..09` — 9 практических задач с автопроверкой (ASAN/UBSAN, python-драйверы);
Docker, GDB, bash, embedded, вопросы HR) + шпаргалка чисел. - `grade.py` — прогон задач с автооценкой;
- `TRACKER.md` — дневник: что решено, где встал, сколько минут. Это вход для калибровки. - `questions_export.txt` — вопросы квиза текстом (можно отвечать строкой в чате).
- `diag/` — пакет: - `TRACKER.md` — дневник: что решено, где встал, сколько минут.
- `quiz.py` + `quiz.json` — 32 вопроса по 4 доменам;
- `tasks/01..09` — диагностика: биты, список, ring buffer, IPv4-заголовок, НВП, потоки,
epoll-сервер, bash по логу, разбор падения в gdb;
- `tasks/10..13` — добор под слабые домены: хеш-таблица с открытой адресацией, куча
(`heapify` O(n), top-K), Дейкстра, арифметика подсетей;
- `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` — вопросы квиза текстом.
## Требования
- **Linux / WSL / macOS**: `g++` (C++17), `gcc`, `gdb`, `python3`, `bash`, `awk`.
Одной строкой в Ubuntu: `sudo apt update && sudo apt install -y build-essential gdb python3`.
- **Windows-native (MinGW-w64/clang)**: работают задачи 01–06 и 10–13 (нужен `g++` в PATH),
а также карточки `facts_drill.py`. Задачи **07 (epoll), 08 (bash/awk) и 09 (ASAN+gdb)**
под Windows-native не запускаются — они помечаются `ПРОПУСК`, а не `FAIL`.
Полный прогон — в WSL:
```powershell
wsl --install -d Ubuntu
```
```bash
sudo apt update && sudo apt install -y build-essential gdb python3
cd /mnt/d/Projects/edu/eduplan-cpp-eltex/diag && python3 grade.py
```
- Если компилятора нет совсем, `grade.py` больше не падает трейсбеком: он печатает
`ПРОПУСК` с причиной по каждой задаче и подсказку, что доставить.
## Как начать ## Как начать
Порядок первого захода — читать урок, потом карточки базы, потом задачи:
```bash ```bash
git clone https://git.kodlo.art/Kodlo/eduplan-cpp-eltex.git git clone https://git.kodlo.art/Kodlo/eduplan-cpp-eltex.git
cd eduplan-cpp-eltex cd eduplan-cpp-eltex/diag
lessons/D1_algo.md # или открыть в редакторе python3 quiz.py # квиз, ответы — номер варианта
cd diag python3 grade.py # все 9 задач (с санитайзерами)
python3 facts_drill.py --level base --learn # 41 карточка базы, ошибки объясняются python3 grade.py 01 04 # выборочно
python3 facts_drill.py --level base,core # когда база закрыта (>=80%)
python3 quiz.py # квиз (или --answers "1-3 2-4 ...")
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 # все 149 карточек
python3 facts_drill.py --list --level base # вывести вопросы с ответами и пояснениями
``` ```
Нужны `g++` (C++17), `gcc`, `python3`; для `07_epoll` — Linux-сокеты, для `08_bash` — Нужны `g++` (C++17), `gcc`, `python3`; для задачи 07 — только Linux-сокеты,
bash/awk/sort. Внешних зависимостей нет. На Windows задачи 07 и 08 удобнее гонять в WSL. для 08 — bash/awk/sort. Никаких внешних зависимостей.
## Правила честности ## Правила честности
Эталонных решений в репозитории нет (лежат только на машинке студии) — иначе автопроверка Эталонных решений в репозитории нет — они лежат только на машинке студии, чтобы
превращается в шпаргалку. Требования по сложности, которые тест не ловит (например автопроверка не протухла. Задача 09 (`answer.txt`) — часть оценки: важен ход разбора.
`heapify` за O(n)), проверяются на разборе по коду. `answer.txt` в задаче 09 — обязательная
часть оценки.
## Куда отдавать результаты ## Как отдавать результаты
- ветка `answers/<день>` в этом репозитории; - `python3 grade.py --submit` — складывает `results_*.json` и свои файлы решений рядом;
- либо файлы/`results_*.json` в чат; - либо коммит в ветку `answers/<день>` и push (доступ на запись у аккаунта `tura`);
- либо `python3 grade.py --submit` — складывает результаты и решения рядом. - либо файлами в чат / в топик лейна.
Ежедневный блок плана приходит в топик лейна в 09:00 (НСК).
+114 -182
View File
@@ -1,206 +1,138 @@
# ИУП — интенсив 7 дней (фуллтайм, собеседование ≤ 1 неделя) # ИУП — интенсив 7 дней (фуллтайм, собеседование ≤ 1 неделя)
**Версия 3, 23.09** — пересобрана по диагностике (квиз 14/32, карточки 11/60) и правке Срок: собеседование максимум через неделю, подготовка — фуллтайм (~8 ч/день).
формата: сначала урок с теорией, потом задачи ступенями. Цель интенсива: закрыть ровно то, что спросят на входе в Eltex (C/C++, базовые алгоритмы и
Срок: собеседование максимум через неделю, подготовка фуллтайм (~8 ч/день). структуры данных, Linux, L2/L3-сети, инструменты), и не утонуть в глубине.
Цель: закрыть ровно то, что спросят на входе в Eltex (C/C++, базовые алгоритмы и структуры
данных, Linux, L2/L3-сети, инструменты), и не утонуть в глубине.
## Что показала диагностика (это и определило план) ## Правила интенсива
| Домен | Результат | Вывод | 1. Каждый день — три блока: **утро (3 ч) алгоритмы**, **день (3 ч) системное/язык**,
|---|---|---| **вечер (2 ч) практика + разбор**. Блок не начинается, пока не закрыта вчерашняя точка.
| C/C++ | 7/8 | сильная сторона, **времени почти не даём** |
| Алгоритмы | **1/8** | провал: формальные свойства (сложности, условия применимости) |
| Linux | 3/8 | пропущены fork/COW, epoll, mmap, waitpid — «не был уверен» |
| Сети | 3/8 | пропущены числа и байты (14/4/62/TTL), порядок TCP-хендшейка |
Отдельно: из 32 вопросов **11 пропущены**, и это почти все «точные факты» — числа, байты,
сложности. То есть база есть, а не хватает заученных формулировок и уверенности в них.
Поэтому в плане появился ежедневный блок карточек (`facts_drill.py`), а C++ урезан:
за него отвечает сильная сторона, забирать у неё часы смысла нет.
## Как устроен день (v3, после правки 23.09)
Первая версия была проверкой — а проверять пока нечего. Теперь каждый день начинается
с материала:
1. **Урок (30–40 мин чтения).** `lessons/D<день>_*.md` — теория по-русски, разобранные
примеры с кодом, готовые ответы на вопросы собеседования, ссылки на первопартийные
материалы. Ничего не решаешь, просто читаешь и запускаешь примеры.
2. **Карточки (20 мин).** `python3 facts_drill.py --level base` — начинать с базы;
в режиме `--learn` каждая ошибка объясняется. База закрыта (≥ 80%) — переходишь на
уровень `base,core` (по умолчанию), потом `all`.
3. **Ступени в задаче.** Каждая задача разбита на шаги по 10–30 минут, после каждого шага
прогон. Не «напиши хеш-таблицу», а «сначала хеш-функция, проверь, что 16/32/48 дают
разные индексы».
4. **Зачёт.** `python3 grade.py <номер>` — критерий `PASS`. Это конец дня, а не начало.
Итого: **урок → карточки → ступени → зачёт**. Задача без урока впереди — моя ошибка,
пиши, и я допишу материал.
2. День закрыт, только если: решены задачи дня **без подсказки**, написан/починен код дня, 2. День закрыт, только если: решены задачи дня **без подсказки**, написан/починен код дня,
сделана **одна задача вслух на таймер** (10 мин: разбор + код + сложность), заполнен сделана **одна задача вслух на таймер** (10 мин: 2 мин на разбор + код + сложность).
трекер. 3. Всё пишем, а не читаем: теория — только под конкретную задачу, максимум 40 минут.
3. Теория — только под конкретную задачу, максимум 40 минут. Всё остальное — код и разбор 4. Трекер: `/home/pi/eduplan/diag/submissions/` или файл у себя — что решено, где встал,
своих ошибок по трекеру. сколько минут. Это вход для калибровки на следующий день.
4. Требования по сложности, которые тест не ловит (например `heapify` за O(n)) — проверяю 5. Что осознанно НЕ лезет в неделю: полноценное ядро (модуль+драйвер), электроника,
глазами по коду при разборе. Если делаешь «на оценку» не то, что заявлено, увижу. Docker-продакшн. Ядро — обзорно на D6, электроника — после оффера (см. `PLAN.md`).
5. Что осознанно НЕ лезет в неделю: полноценное ядро (модуль+драйвер как продукт), 6. Если день провален — не догоняем всё, а переносим хвост в D7 (день повтора) и режем
электроника и схемотехника, Docker в продакшне. Ядро — обзорно на D6, электроника — второстепенное (M8-обзор, docker).
после оффера (полный трек `PLAN.md`, модули M8–M9).
6. Провалил день — не догоняю всё, переношу хвост в D7 и режу второстепенное (ядро, docker).
Материалы: cppreference (C и C++), man7.org (разделы 2/3/7), Beej's Guide to Network Материалы: cppreference (C и C++), man7.org (разделы 2/3/7), LKMPG, docs.kernel.org,
Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память), LKMPG, docs.kernel.org. OSTEP (главы про процессы/память), Beej's Guide to Network Programming, RFC 791/793,
Задачи: Codeforces с фильтром по тегу и рейтингу 800–1400 Wireshark wiki. Задачи: Codeforces с фильтром по тегам и рейтингу 800–1400
(`https://codeforces.com/problemset?tags=two+pointers,800-1400` — тег менять), (`https://codeforces.com/problemset?tags=two+pointers,800-1400` — менять тег),
Яндекс Тренировки по алгоритмам (`https://yandex.ru/yaintern/training/algorithm-training`), Яндекс Тренировки по алгоритмам (бесплатные контесты с разборами,
классика LeetCode — по названию (ссылки на leetcode.com открываются только в браузере). `https://yandex.ru/yaintern/training/algorithm-training`), классика LeetCode — по названию
(ссылки на leetcode.com открываются только в браузере).
## Пакет задач (каталог `diag/`)
- `01..09` — **диагностика**: битовые операции, список, ring buffer, IPv4-заголовок, НВП,
потоки, epoll-сервер, bash по логу, разбор падения в gdb.
- `10_hash`, `11_heap`, `12_dijkstra`, `13_subnet` — **добор под слабые домены**: своя
хеш-таблица с открытой адресацией, куча (`heapify` за O(n), top-K), Дейкстра на
`priority_queue`, арифметика подсетей.
- `python3 grade.py` — все 13 с автопроверкой (ASAN/UBSAN); `python3 grade.py 10 13` —
выборочно; `--fast` — без санитайзеров.
- `python3 facts_drill.py --level base` — 41 карточка базы с пояснениями (начинать отсюда);
`--level base,core` — 93; `--level all` — 101. Уровень `deep` (8 карточек, нишевое:
TID в /proc, ulimit, ICMP Time Exceeded и подобное) в базовый прогон не попадает.
Маркеры итога: `>=80%` ок, `50–79%` добор, `<50%` дыра.
- `lessons/` — уроки по дням: теория, разборы с кодом, ответы на вопросы собеседования.
- Трекер дня: `TRACKER.md` в корне репозитория.
--- ---
## D0 — 23.09, вход: база и знакомство ## D0 — 23.09, диагностика (3–4 ч)
- Квиз **14/32**, карточки **11/60** — значит базы пока нет, и задачи уровня «напиши - Квиз: `python3 quiz.py` (или ответы строкой в чат), 32 вопроса, 4 домена.
хеш-таблицу» решать рано. Это нормальный вход, просто порядок другой: сначала уроки. - Задачи: `python3 grade.py` — 9 задач с автопроверкой (ASAN/UBSAN).
- Сегодня: `python3 facts_drill.py --level base --learn` — 41 карточка базы, каждая ошибка - Разбор промахов вместе со мной, калибровка: какие дни усилить, что срезать.
объясняется. Это 20–30 минут и даёт словарь, без которого дальше нечего делать. - **Точка:** есть `results_quiz.json` и `results_tasks.json`, названы 3 слабейших темы.
- Задачи 01–13 сегодня решать не нужно. Смотрим на них как на цель недели, а не как на зачёт.
- **Точка:** пройдены базовые карточки, записаны 3 темы, которые «совсем не помню» —
по ним я усилю соответствующие дни.
## D1 — 24.09: сложность и хеш-таблицы | процессы и сигналы ## D1 — 24.09: массивы/строки + память в C + процессы Linux
- **Урок:** `lessons/D1_algo.md` (сложность, таблица структур, устройство хеш-таблиц, - Утро (3 ч) — алгоритмы: два указателя, скользящее окно, префиксные суммы, бинарный
разбор кода) и `lessons/D1_linux.md` (fork/exec/wait, COW, сигналы, разбор мини-шелла). поиск и `lower_bound` руками. Задачи: Two Sum, Valid Palindrome, Container With Most
Читать 40 минут, примеры из уроков запустить руками — они рабочие. Water, Longest Substring Without Repeating Characters, Binary Search + 6–8 задач с
- **Карточки:** `python3 facts_drill.py --level base` (домен `algo` — обязателен, остальное Codeforces по тегам `two pointers` и `binary search`, рейтинг 800–1400.
по времени). Если база даётся легко — `--level base,core`. - День (3 ч) — C и память: указатели и арифметика, выравнивание и padding, UB
- **Задача дня:** `tasks/10_hash` — но по ступеням из её `task.md` (Глава IV), по 10–30 минут (signed overflow, strict aliasing, неинициализированная память), malloc/free,
на шаг, после каждого шага `python3 grade.py 10`. Ступень 1 — это хеш-функция и проверка, переполнение буфера; сборка с `-Wall -Wextra -Werror -fsanitize=address,undefined`.
что 16/32/48 дают разные индексы, и только шестая ступень — перехеширование. Практика: переписать `tasks/01_bits` и `tasks/03_ring` на чистом C (без C++-обёрток),
- **Разминка:** `tasks/01_bits` (20 минут, четыре функции) и `tasks/03_ring` (40–60 минут, прогнать под санитайзерами.
тоже по ступеням). - Вечер (2 ч) — Linux: `fork/exec/wait`, зомби, сигналы, коды возврата. Практика: свой
- **Зачёт дня:** `PASS` по `01`, `03`, `10`; карточки `base` ≥ 80%. мини-шелл (fork + exec + wait, обработка `exit`) — это прямой вопрос на собеседовании.
## D2 — 25.09: деревья и куча | epoll и неблокирующие сокеты - **Точка:** 10+ задач решены; мини-шелл запускает `ls`/`cat`; ASAN чистый; лог дня.
- **Урок:** `lessons/D2_algo.md` (деревья, BST-инвариант, обходы, куча, `heapify` за O(n), top-K), ## D2 — 25.09: хеш-таблицы и сортировки + C++ (RAII, владение, STL) + git/Makefile
`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 ч) — алгоритмы: хеш-таблицы и множества (коллизии, открытая адресация vs
по памяти. цепочки), сортировки и компараторы, stability. Задачи: Group Anagrams, Top K Frequent
- Утро, алгоритмы (3 ч) — бинарные деревья и BST: обходы рекурсией и итерацией, Elements, Two Sum II, Merge Intervals, Sort Colors + 6–8 задач с тегами `hashing`,
валидация BST, LCA; куча: **`heapify` снизу вверх за O(n)**, `priority_queue`, top-K, `sortings`.
`nth_element`. Практика: **`tasks/11_heap`** + задачи Invert Binary Tree, Validate BST, - День (3 ч) — C++: RAII, правило 0/3/5, move-семантика и `std::move`, умные указатели
Kth Largest Element, Top K Frequent Elements. (unique/shared/weak, циклы ссылок), контейнеры STL и итераторы, лямбды, исключения и
- День, системное (3 ч) — Linux: файловый ввод-вывод (`open/read/write/lseek`), **`mmap`**, гарантии безопасности. Практика: свой `unique_ptr` и свой `vector` (упрощённый), класс-
владелец с правилом пяти; сборка с `-Werror`.
- Вечер (2 ч) — git (ветки, rebase, конфликты, `bisect`) и сборка: Makefile руками, затем
CMake для своих задач. Практика: репозиторий с 3 задачами, где сборка/тесты — одной
командой.
- **Точка:** мини-библиотека собирается с `-Werror`; git-сценарии (rebase, bisect) пройдены
руками; 10+ задач.
## D3 — 26.09: списки/стек + многопоточность + gdb
- Утро (3 ч) — алгоритмы: связные списки (разворот, цикл Флойда, k-й с конца), стек/
очередь/ring buffer, монотонный стек. Задачи: Reverse Linked List, Merge Two Sorted
Lists, Linked List Cycle, Valid Parentheses, Daily Temperatures + 6–8 задач с тегами
`linked list`, `stack`.
- День (3 ч) — многопоточность: `std::thread`, mutex/`lock_guard`, `condition_variable`
и predicate-цикл, атомики и `memory_order`, гонки, дедлоки, ложные пробуждения;
паттерны producer/consumer и пул потоков. Практика: довести `tasks/06_threads` до
продакшн-вида + написать пул потоков. TSan на этой машине недоступен (VMA) — проверяем
инвариантами + ASAN, в логе это помечено.
- Вечер (2 ч) — gdb: breakpoints, `watch`, `bt`, `frame`, отладка потоков, разбор
падения. Практика: `tasks/09_gdb` + своё падение (переполнение буфера) с письменным
разбором.
- **Точка:** 10+ задач; пул потоков проходит счётчики 4×4×20k; падение разобрано в gdb
письменно.
## D4 — 27.09: деревья/куча + Linux-системное (epoll) + L2/L3
- Утро (3 ч) — алгоритмы: бинарные деревья и BST, обходы (рекурсия и итерация), куча и
`priority_queue`, heapify за O(n), top-K, LCA. Задачи: Invert Binary Tree, Validate
Binary Search Tree, Binary Tree Level Order Traversal, Kth Largest Element, Lowest
Common Ancestor + 6–8 задач с тегами `trees`, `heaps`.
- День (3 ч) — Linux: файловый ввод-вывод (`open/read/write/lseek`), `mmap`,
мультиплексирование `select → poll → epoll` (LT/ET), неблокирующие сокеты, мультиплексирование `select → poll → epoll` (LT/ET), неблокирующие сокеты,
`SO_REUSEADDR`, `EAGAIN`/`EINTR`. Практика: **`tasks/07_epoll`** — эхо-сервер держит `SO_REUSEADDR`, обработка `EAGAIN/EINTR`. Практика: `tasks/07_epoll` — эхо-сервер
нагрузку, плюс нагрузочный тест своими руками. довести до вида «не падает под нагрузкой» + клиент, нагрузочный тест своими руками.
- Разбор и код (1,5 ч) — C++ **ровно по промахам**: выравнивание и padding (sizeof 12), - Вечер (2 ч) — сети L2/L3: OSI и TCP/IP, инкапсуляция, Ethernet (14 байт заголовка),
правило 0/3/5, virtual-деструктор, `std::move` как каст, UB — 45 минут, не больше; MAC, ARP, VLAN 802.1Q, IPv4-заголовок и контрольная сумма, маски и подсети, ICMP.
одна задача вслух; трекер. Практика: `tasks/04_ipv4` + два письменных разбора дампов (`tcpdump`: ARP-обмен и
- **Точка:** 11_heap и 07_epoll PASS; сервер держит 3+ соединений и не течёт по дескрипторам; TCP-handshake).
карточки `c_cpp` = 100%. - **Точка:** сервер держит 3+ одновременных соединения и не течёт по дескрипторам;
2 разбора дампов письменно; 10+ задач.
## D3 — 26.09: списки/стек | многопоточность и gdb ## D5 — 28.09: графы + TCP глубоко + bash/proc
- **Урок:** `lessons/D3_algo.md` (списки, разворот, цикл Флойда, кольцевой буфер, монотонный стек), - Утро (3 ч) — алгоритмы: представления графа, BFS/DFS, топологическая сортировка,
`lessons/D3_threads.md` (мьютекс, `condition_variable`, атомики и `memory_order`, дедлоки, пул потоков), компоненты связности, Дейкстра (неотрицательные веса), union-find. Задачи: Number of
`lessons/D3_gdb.md` (gdb: break/watch/bt/frame, core dump, почему краш далеко от причины). Islands, Course Schedule, Clone Graph, Network Delay Time, Redundant Connection + 6–8
задач с тегами `graphs`, `dfs and similar`, `dsu`.
- Карточки (30 мин) — `linux`: epoll O(1) против select O(n), mmap, waitpid, 137/139. - День (3 ч) — сети глубже: TCP-автомат состояний, окно и подтверждения, ретрансмиссии и
- Утро, алгоритмы (3 ч) — связные списки (разворот, **цикл Флойда**, k-й с конца), таймеры, закрытие соединения, UDP, порты, NAT, DNS/DHCP минимум; работа с
стек/очередь/ring buffer, монотонный стек. Практика: `tasks/02_list`, `tasks/03_ring`
(если ещё не сданы) + Valid Parentheses, Daily Temperatures, Reverse Linked List.
- День, системное (3 ч) — многопоточность: `std::thread`, mutex/`lock_guard`,
`condition_variable` и predicate-цикл, атомики и `memory_order`, гонки, дедлоки,
ложные пробуждения; паттерны producer/consumer и пул потоков. Практика:
`tasks/06_threads` до продакшн-вида + свой пул потоков. (TSan на этой машине может
отвалиться по VMA — тогда инварианты + ASAN, в логе это помечено.)
- Разбор и код (1,5 ч) — gdb: breakpoints, `watch`, `bt`, `frame`, отладка потоков;
`tasks/09_gdb` + **своё падение** с письменным разбором; одна задача вслух; трекер.
- **Точка:** пул потоков проходит счётчики 4×4×20k; падение разобрано письменно;
карточки `linux` ≥ 80%.
## 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.
Практика: **`tasks/12_dijkstra`** + Number of Islands, Course Schedule, Network Delay
Time, Redundant Connection + 6–8 задач с тегами `graphs`, `dsu`.
- День, системное (3 ч) — сети L2/L3: OSI и TCP/IP, инкапсуляция, Ethernet (14 байт),
MAC, ARP, VLAN 802.1Q (+4 байта), IPv4-заголовок (20 байт) и контрольная сумма, **TTL**,
маски и подсети, ICMP. Практика: `tasks/04_ipv4` + **`tasks/13_subnet`** + два письменных
разбора дампов (`tcpdump`: ARP-обмен и TCP-handshake).
- Разбор и код (1,5 ч) — задача вслух (граф или подсеть); разбор промахов дня; трекер.
- **Точка:** 12_dijkstra и 13_subnet PASS; 2 разбора дампов письменно; карточки `net` ≥ 80%.
## 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,
House Robber, Coin Change, Edit Distance + 5–6 задач с тегом `dp`.
- День, системное (3 ч) — TCP глубоко: автомат состояний, окно и подтверждения,
ретрансмиссии и таймеры, закрытие, UDP, порты, NAT, DNS/DHCP минимум; работа с
`tcpdump`/Wireshark. Практика: 3 письменных разбора дампов (ретрансмиссия, ICMP `tcpdump`/Wireshark. Практика: 3 письменных разбора дампов (ретрансмиссия, ICMP
unreachable, DNS) + захват трафика своей программой. unreachable, DNS) + свой захват трафика своей же программой.
- Разбор и код (1,5 ч) — bash и Linux-быт: пайплайны, `set -euo pipefail`, awk/sed, - Вечер (2 ч) — Linux-быт: `/proc`, `ulimit`, systemd-юниты обзорно; bash (пайплайны,
`/proc`, `ulimit`; `tasks/08_bash` (лог в 1e6 строк — только awk/sort, не bash-цикл); `set -euo pipefail`, awk/sed для логов). Практика: `tasks/08_bash` + свой скрипт
одна задача вслух; трекер. диагностики сети/логов.
- **Точка:** 05_lis и 08_bash PASS; 3 разбора дампов; скрипт держит лог 100k строк за секунды. - **Точка:** 8+ задач; 3 разбора дампов; скрипт работает на логе в 100k строк за секунды.
## D6 — 29.09: закрытие слабых мест | ядро обзорно, docker, резюме ## D6 — 29.09: ДП + ядро/embedded обзорно + docker + резюме
- **Урок:** `lessons/D6_kernel.md` (модули, `printk`, `file_operations`, device tree, cross-compile, - Утро (3 ч) — алгоритмы: ДП — состояния и переходы, рюкзак, LIS за O(n log n), строковые
uboot), `lessons/D6_docker.md` (namespaces и cgroups, слои, multi-stage, volumes). задачи. Задачи: Climbing Stairs, House Robber, Coin Change, Longest Increasing
Subsequence, Edit Distance, Word Break + 5–6 задач с тегом `dp`, рейтинг 800–1400.
- День (3 ч) — ядро и embedded обзорно: модуль ядра (сборка, insmod/rmmod, printk,
параметры, `/proc`), символьный драйвер (`file_operations`), device tree и cross-compile
— по касательной, ровно чтобы отвечать словами. Практика: hello-модуль + простейший
символьный драйвер (в QEMU, если на этой машине сборка не встанет).
- Вечер (2 ч) — docker (образ под сборку/тесты) и **резюме + сопроводительное под Eltex**
(учесть: рассматривают офисный формат).
- **Точка:** модуль грузится/выгружается; черновик резюме отревьюен.
- Карточки (30 мин) — все домены, полный прогон 60 карточек. ## D7 — 30.09: повтор, два мок-интервью, выход
- Утро, алгоритмы (3 ч) — **только слабые места по трекеру**: добить `10..13` до PASS,
повторить hash/BST/heap/Дейкстра/ДП там, где были FAIL или «не знаю».
- День, системное (3 ч) — ядро и embedded **обзорно**: модуль (сборка, insmod/rmmod,
printk, параметры, `/proc`), символьный драйвер (`file_operations`), device tree,
cross-compile, uboot/bringup — ровно чтобы отвечать словами; docker (образ под
сборку/тесты) — 45 минут.
- Разбор и код (1,5 ч) — **резюме + сопроводительное под Eltex** (учесть офисный формат),
вопросы работодателю, легенда по опыту.
- **Точка:** все 13 задач PASS; карточки ≥ 80% во всех доменах; резюме готово к отправке.
## D7 — 30.09: повтор и два мок-интервью - 2 ч — утренний повтор: только слабые места из трекера (не всё подряд).
- 3 ч — мок-интервью №1: алгоритмы, 2 задачи вслух с таймером, я веду и разбираю.
- **Урок:** `lessons/D7_mock.md` — сценарий двух мок-интервью (алгоритмы и системное), карта - 2 ч — мок-интервью №2: C/C++ + Linux + сети (вопросы по кругу, включая разбор дампа).
слабых мест по урокам, вопросы работодателю, легенда по опыту.
- 2 ч — повтор: только карточки с промахами и слабые темы из трекера.
- 3 ч — мок-интервью №1: алгоритмы, 2 задачи вслух с таймером (я веду и разбираю).
- 2 ч — мок-интервью №2: C/C++ + Linux + сети, включая разбор дампа.
- 1 ч — вопросы работодателю, легенда по опыту, финальная вычитка резюме. - 1 ч — вопросы работодателю, легенда по опыту, финальная вычитка резюме.
- **Точка:** ≤ 1 грубая ошибка на сессию; резюме и сопроводительное готовы. - **Точка:** ≤ 1 грубая ошибка на сессию; резюме и сопроводительное готовы.
@@ -210,4 +142,4 @@ Programming, RFC 791/793, Wireshark wiki, OSTEP (процессы/память),
Незакрытое (полный трек `PLAN.md`): M2 хвост (битовые/строковые алгоритмы, математика), Незакрытое (полный трек `PLAN.md`): M2 хвост (битовые/строковые алгоритмы, математика),
M6 глубина (STP, маршрутизация, HTTP), M7 полностью (Docker в бою, bisect на реальном M6 глубина (STP, маршрутизация, HTTP), M7 полностью (Docker в бою, bisect на реальном
репозитории), M8 ядро (полноценный драйвер, uboot/bringup), M9 электроника и схемотехника. репозитории), M8 ядро (полноценный драйвер, uboot/bringup), M9 электроника.
+1
View File
@@ -0,0 +1 @@
test
+2 -29
View File
@@ -21,19 +21,6 @@
| 08 | `08_bash` | разбор лога доступа: TOTAL/TOP-3/5XX, 110k строк | | 08 | `08_bash` | разбор лога доступа: TOTAL/TOP-3/5XX, 110k строк |
| 09 | `09_gdb` | отладка ASAN-падения + письменный разбор в `answer.txt` | | 09 | `09_gdb` | отладка ASAN-падения + письменный разбор в `answer.txt` |
Добор под слабые домены (добавлено 23.09 после диагностики — квиз 14/32):
| # | Задача | Что проверяется |
|---|--------|-----------------|
| 10 | `10_hash` | хеш-таблица с открытой адресацией: пробирование, перехеширование, erase |
| 11 | `11_heap` | куча: `heapify` снизу вверх O(n), `kth_largest`, `top_k` на 3M |
| 12 | `12_dijkstra` | Дейкстра на `priority_queue`: неотрицательные веса, 100k вершин |
| 13 | `13_subnet` | арифметика IPv4: сеть/broadcast/узлы, `/31`, `/32`, подбор префикса |
- `facts.json` + `facts_drill.py` — тренажёр «числа и факты»: 60 карточек
(algo 20, linux 14, net 14, c_cpp 12) под конкретные промахи квиза: сложности, байты
заголовков, `/26 = 62`, TTL, порядок SYN/SYN-ACK/ACK, паддинг структур, правило трёх/пяти.
Маркеры итога: `>=80%` ок, `50–79%` добор, `<50%` дыра.
- `grade.py` — прогон задач с автооценкой (`PASS/FAIL`, число проверок, время). - `grade.py` — прогон задач с автооценкой (`PASS/FAIL`, число проверок, время).
- `solutions/` — **эталонные решения, в руки не отдавать**: они лежат здесь, чтобы - `solutions/` — **эталонные решения, в руки не отдавать**: они лежат здесь, чтобы
автопроверка не «протухла» (эталон → все PASS, заглушка → FAIL). автопроверка не «протухла» (эталон → все PASS, заглушка → FAIL).
@@ -48,13 +35,9 @@ python3 grade.py # все задачи (с ASAN/UBSAN/TSAN)
python3 grade.py 01 04 # только выбранные python3 grade.py 01 04 # только выбранные
python3 grade.py --fast # без санитайзеров, быстрее python3 grade.py --fast # без санитайзеров, быстрее
python3 grade.py --submit # результаты + свои файлы в submissions/ python3 grade.py --submit # результаты + свои файлы в submissions/
python3 facts_drill.py # 20 карточек, интерактивно
python3 facts_drill.py --all # все 60
python3 facts_drill.py --domain algo # только алгоритмы
python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов разом
``` ```
Порядок: сначала квиз, потом задачи с 01 по 09 (диагностика), затем 10–13 как добор. Зависание = FAIL (есть таймауты). Порядок: сначала квиз, потом задачи с 01 по 09. Зависание = FAIL (есть таймауты).
Правила честности: без встроенных `popcount`/`reverse`-хелперов, без копирования чужих Правила честности: без встроенных `popcount`/`reverse`-хелперов, без копирования чужих
решений, `answer.txt` в задаче 09 — обязательная часть (оценивается ход разбора). решений, `answer.txt` в задаче 09 — обязательная часть (оценивается ход разбора).
@@ -62,20 +45,10 @@ python3 facts_drill.py --answers "20|62|ttl|..." # проверить стро
- `results_quiz.json`: балл по каждому домену + разбор промахов с пояснениями. - `results_quiz.json`: балл по каждому домену + разбор промахов с пояснениями.
- `results_tasks.json`: по задаче — PASS/FAIL, число проверок, время, полный лог сборки. - `results_tasks.json`: по задаче — PASS/FAIL, число проверок, время, полный лог сборки.
- `results_facts.json`: тренажёр карточек — балл по доменам и список промахов. - Итог и решение по плану (с каких модулей стартовать) собираются по этим двум файлам.
- Итог и решение по плану (с каких модулей стартовать) собираются по этим файлам.
- Рабочий план недели: `WEEK7.md` в корне репозитория; трекер дня — `TRACKER.md`.
## Публикация и доступ ## Публикация и доступ
Репозиторий: https://git.kodlo.art/Kodlo/eduplan-cpp-eltex — клонируется без пароля,
обновляется по ходу недели. Результаты можно присылать веткой `answers/<день>`, файлами
в чат или через `grade.py --submit`.
Репозиторий: https://git.kodlo.art/Kodlo/eduplan-cpp-eltex (клонируется без пароля,
обновляется по ходу недели). Результаты можно присылать веткой `answers/<день>`,
файлами в чат или через `grade.py --submit`.
- Пакет в каталоге: `/home/pi/eduplan/diag` (мир читает; каталог доступен на исполнение). - Пакет в каталоге: `/home/pi/eduplan/diag` (мир читает; каталог доступен на исполнение).
- Инбокс для ответов: `/home/pi/eduplan/diag/submissions/` (0777). - Инбокс для ответов: `/home/pi/eduplan/diag/submissions/` (0777).
- Архив для скачивания: `/home/pi/eduplan/diag/dist/eduplan_diag.tar.gz`. - Архив для скачивания: `/home/pi/eduplan/diag/dist/eduplan_diag.tar.gz`.
-614
View File
@@ -1,614 +0,0 @@
level domain question answer ref
base algo Во что превращается 3n + 100 в O-нотации O(n) урок D1_algo
base algo Сколько шагов у бинарного поиска в массиве из 10⁶ элементов 20 (log₂ 10⁶ ≈ 20) урок D1_algo
core algo Почему push_back в среднем O(1), а не O(n) ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n² урок D1_algo
core algo Что ломает удвоение ёмкости у vector указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти) урок D1_algo
deep algo Что будет, если ёмкость vector растить на +1 за раз вместо удвоения суммарная стоимость n вставок станет O(n²) урок D1_algo
base algo Сложность построения кучи (heapify) из произвольного массива O(n), не O(n log n) урок D1_algo
base algo Какая сортировка всегда O(n log n) и устойчива mergesort урок D1_algo
core algo Худший случай quicksort и когда он достигается O(n²), на уже отсортированном массиве при плохом выборе опорного урок D1_algo
core algo Нижняя граница сортировки сравнениями Ω(n log n) урок D1_algo
core algo За счёт чего map держит высоту log n инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа урок D1_algo
deep algo Почему нельзя полагаться на порядок обхода unordered_map порядок бакетов не гарантирован и может меняться при rehash урок D1_algo
base algo Формула фактора загрузки load = size / capacity урок D1_algo
base algo Порог load factor при открытой адресации, после которого делают rehash обычно ≤ 0.7 урок D1_algo
core algo Зачем tombstone при открытой адресации чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже урок D1_algo
core algo Почему ёмкость хеш-таблицы берут степенью двойки idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре урок D1_algo
core algo Что физически происходит при rehash выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap) урок D1_algo
deep algo Почему rehash даёт амортизированное O(1), а не O(n) на вставку та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n² урок D1_algo
base algo Сложность has_pair (вложенный цикл по всем парам) O(n²) урок D1_algo
core algo Сложность has_pair_fast по времени и по памяти O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество урок D1_algo
base algo Какие константы использует FNV-1a в этом коде (offset basis / prime) 1469598103934665603 / 1099511628211 урок D1_algo
core algo Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны урок D1_algo
core algo Чем открытая адресация выигрывает у цепочек по производительности и почему она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами урок D1_algo
deep algo Может ли load factor у цепочек быть больше 1 да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда) урок D1_algo
base linux Что возвращает `fork()` в родителе и в ребёнке в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1 урок D1_linux
core linux Что происходит со страницами памяти при `fork()` ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault) урок D1_linux
core linux Почему `fork` дешёвый даже для процесса с гигабайтами памяти копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят урок D1_linux
base linux Что нужно сделать с `stdout` перед `fork`, если он не пуст вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке урок D1_linux
deep linux Какой размер страницы памяти на x86-64, о которой копия делается при COW 4 КБ урок D1_linux
base linux Чем `exec` отличается от `fork` `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового урок D1_linux
core linux Что сохраняется у процесса после успешного `exec` PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC` урок D1_linux
core linux Почему `exec` при успехе никогда не возвращает управление старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом урок D1_linux
base linux Разница между `execlp`, `execv`, `execvp` `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH` урок D1_linux
base linux Зачем нужен `waitpid` забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов урок D1_linux
core linux Что такое зомби и почему он не исчезает сам процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait` урок D1_linux
core linux Чем зомби отличается от сироты зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1) урок D1_linux
base linux Откуда код возврата 137 и 139 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу урок D1_linux
core linux Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую урок D1_linux
deep linux Что показывает `WCOREDUMP(status)` что завершение по сигналу сопровождалось записью core-дампа на диск урок D1_linux
base linux Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD 2/9/15/11/13/17 урок D1_linux
core linux Чем SIGTERM отличается от SIGKILL SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую урок D1_linux
core linux Что можно делать внутри обработчика сигнала только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe) урок D1_linux
base linux Чем `sigaction` лучше `signal` поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику урок D1_linux
deep linux Что происходит, если сервис игнорирует SIGTERM при `systemctl stop` по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно урок D1_linux
base linux Что означает EINTR и как на него реагировать вызов прерван доставкой сигнала; корректная реакция — повторить вызов урок D1_linux
base linux Чем EAGAIN отличается от обычной ошибки чтения данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже» урок D1_linux
core linux Когда безопасно читать `errno` сразу после ошибки вызова, до любого другого вызова, способного его перезаписать урок D1_linux
deep linux Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса EMFILE (лимит задаётся `ulimit -n`) урок D1_linux
base linux Какой код возврата у шелла даст несуществующая команда 127 урок D1_linux
base linux Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork` урок D1_linux
core linux Каким флагом собрать бинарник для проверки на утечки и UB `-fsanitize=address,undefined -fno-omit-frame-pointer` урок D1_linux
base algo Из чего состоит узел бинарного дерева при представлении указателями значение + два указателя (left, right) урок D2_algo
core algo Размер `struct TreeNode { int val; TreeNode *left, *right; }` на x86-64 24 байта: 4 байта `val` + 4 байта паддинга (выравнивание указателя на 8) + 8 + 8 байт под указатели урок D2_algo
core algo Когда массивное представление дерева компактно, а когда нет компактно только для полного/близкого к полному дерева (куча); для разреженного дерева индексы `2i+1/2i+2` требуют до `2^h` ячеек, большинство из которых пустует урок D2_algo
deep algo Почему куча хранится в массиве, а не через указатели куча всегда почти полное дерево (заполнена по уровням слева направо без пропусков), индексная арифметика не тратит память впустую и не требует отдельной аллокации на узел урок D2_algo
base algo Порядок посещения в inorder-обходе left, node, right урок D2_algo
base algo Какой обход даёт отсортированный порядок для BST inorder урок D2_algo
core algo Память DFS (рекурсия или явный стек) по глубине дерева O(h) — пропорционально высоте урок D2_algo
core algo Память BFS (очередь) O(w) — пропорционально максимальной ширине уровня урок D2_algo
core algo Для полного сбалансированного дерева из 10⁶ узлов: высота и ширина последнего уровня h ≈ 20 (log₂10⁶≈20), ширина последнего уровня ≈ 500 000 — BFS может требовать памяти на порядки больше, чем DFS урок D2_algo
deep algo Какой обход используют, чтобы освободить дерево снизу вверх postorder (сначала оба ребёнка, потом сам узел) урок D2_algo
base algo Инвариант BST для каждого узла все значения в левом поддереве меньше значения узла, все значения в правом — больше урок D2_algo
base algo Сложность поиска в BST O(h), где h — высота дерева урок D2_algo
core algo Что даёт inorder-обход BST значения по возрастанию — прямое следствие инварианта урок D2_algo
core algo Высота BST в лучшем и худшем случае для n узлов сбалансированное: h ≈ log₂n; вырожденное: h = n урок D2_algo
deep algo Что произойдёт при вставке 1,2,...,n по порядку в обычный (небалансирующийся) BST дерево вырождается в цепочку (каждый новый узел — правый ребёнок предыдущего), поиск деградирует до O(n) урок D2_algo
base algo Почему нельзя валидировать BST, сравнивая узел только с непосредственным родителем нарушение инварианта может возникнуть через поколение (правый потомок левого поддерева больше корня, но меньше своего прямого родителя) — локальная проверка это не ловит урок D2_algo
core algo Как правильно валидировать BST рекурсивно передавать вниз границы (min, max): узел должен строго лежать между ними, для левого поддерева ужесточается верхняя граница, для правого — нижняя урок D2_algo
core algo Сложность LCA в BST и почему O(h): если оба искомых значения меньше текущего узла — влево, оба больше — вправо, иначе текущий узел и есть LCA урок D2_algo
core algo Сложность LCA в обычном бинарном дереве без порядка O(n): нет инварианта, отсекающего часть дерева, нужна рекурсия postorder по потенциально всем узлам урок D2_algo
deep algo Альтернативный способ валидации BST без явных границ (min, max) inorder-обход с проверкой строгого возрастания относительно предыдущего посещённого значения урок D2_algo
base algo Индексы детей и родителя в куче на массиве для узла i дети 2i+1, 2i+2; родитель (i-1)/2 (целочисленное деление) урок D2_algo
base algo Сложность доступа к максимуму (top) в max-heap O(1) — максимум всегда в корне по инварианту урок D2_algo
core algo Сложность sift-up/sift-down O(log n) — путь ограничен высотой дерева урок D2_algo
core algo За какое время строится куча через bottom-up heapify против n последовательных push O(n) против O(n log n): работа sift-down в узле пропорциональна его высоте h, а сумма Σ h/2^h по всем узлам сходится к константе урок D2_algo
deep algo С какого индекса стартует bottom-up heapify и куда идёт с последнего нелистового узла, индекс n/2 - 1, к корню (индекс 0) урок D2_algo
base algo Что такое `std::priority_queue` по умолчанию max-heap поверх vector; для min-heap передают компаратор std::greater<> урок D2_algo
core algo Сложность top-K через полную кучу O(n + k log n): O(n) heapify + k раз pop по O(log n) урок D2_algo
core algo Когда используют min-heap размера k вместо полной кучи на потоке данных, не помещающемся в память целиком: min-heap размера k хранит только k текущих кандидатов, O(n log k), память O(k) урок D2_algo
core algo Средняя и худшая сложность `nth_element` (quickselect) в среднем O(n), в худшем O(n²) — та же причина, что у quicksort: устойчиво плохой выбор опорного урок D2_algo
deep algo Почему top-K через nth_element не даёт готовый отсортированный список nth_element только частично упорядочивает вокруг k-го элемента без порядка внутри половин; нужна досортировка k-элементного префикса урок D2_algo
deep algo Как получить top-K частых элементов за O(n) без log-фактора подсчёт хеш-таблицей O(n), затем bucket sort по частоте: частота не превышает n, массив корзин размера n+1 индексируется частотой напрямую урок D2_algo
base c_cpp sizeof(struct { char a; int b; char c; }) на x86-64 12 байт урок D2_cpp
core c_cpp Смещения полей a, b, c в этой структуре a=0, b=4 (после 3 байт паддинга), c=8 урок D2_cpp
core c_cpp Почему в конце структуры ещё 3 байта паддинга размер всей структуры округляется вверх до кратного выравниванию самого строгого поля (здесь int, выравнивание 4): 9 → 12 урок D2_cpp
deep c_cpp Что делает #pragma pack(1) и какая у него цена убирает паддинг (sizeof(S) станет 6), но доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим выравниванием урок D2_cpp
base c_cpp Какие 5 функций входят в правило пяти деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением урок D2_cpp
base c_cpp Что делает сгенерированный компилятором конструктор копирования по умолчанию побитовое (memberwise) копирование каждого поля урок D2_cpp
core c_cpp Почему копирование объекта с владеющим сырым указателем даёт double-free оба объекта получают одно и то же значение указателя (адрес), оба деструктора вызывают delete на этом адресе — второй раз на уже освобождённой памяти урок D2_cpp
core c_cpp Что такое rule of zero не объявлять ни одну из пяти спецфункций вручную, доверив владение ресурсом готовым RAII-обёрткам (unique_ptr, vector, string) — их сгенерированные копирование/перемещение уже корректны урок D2_cpp
base c_cpp Что добавляет в объект наличие хотя бы одной virtual-функции скрытый указатель на vtable, обычно 8 байт на 64-битной платформе урок D2_cpp
core c_cpp Что произойдёт при delete через Base*, если ~Base() не virtual, а объект на деле Derived вызовется только ~Base(), ~Derived() не вызовется вообще — утечка ресурсов Derived, формально UB урок D2_cpp
core c_cpp Чем это ловится LeakSanitizer, со стеком выделения внутри конструктора Derived урок D2_cpp
deep c_cpp Что вызовет virtual-функция, вызванная из конструктора базового класса версию базового класса, а не переопределённую в наследнике: vtable объекта на этом этапе ещё указывает на таблицу Base урок D2_cpp
base c_cpp Что физически делает std::move во время выполнения программы ничего: это static_cast к rvalue-ссылке (T&&), явный каст, не операция урок D2_cpp
core c_cpp Что реально выполняет перемещение данных move-конструктор/move-оператор присваивания конкретного типа, выбранный благодаря касту std::move урок D2_cpp
core c_cpp Что происходит с vector при перемещении и за какое время копируются 3 внутренних указателя (начало, конец данных, конец ёмкости) в новый объект, у источника они обнуляются — O(1), без копирования элементов урок D2_cpp
deep c_cpp В каком состоянии находится объект после std::move(obj) по стандарту в валидном, но неопределённом состоянии — использование старых данных не UB, но логическая ошибка, не ловится санитайзерами урок D2_cpp
base c_cpp Что проверяет ASAN, а что UBSAN? — ASAN: ошибки работы с памятью (границы, use-after-free, double-free, утечки); UBSAN: операции, являющиеся UB по стандарту (переполнение, сдвиг, выравнивание) урок D2_cpp
base c_cpp Значение INT_MAX для 32-битного int 2147483647 урок D2_cpp
core c_cpp Почему UB опасен именно тем, что код может работать в отладочной сборке и падать в релизной оптимизатор релизной сборки строит код в предположении, что UB не происходит, и может убрать проверки, которые, по мнению программиста, должны были сработать урок D2_cpp
core c_cpp Какой флаг компилятора включает сразу оба санитайзера -fsanitize=address,undefined урок D2_cpp
deep c_cpp Почему разыменование nullptr на практике обычно даёт SIGSEGV, а не тихо читает мусор ОС намеренно не отображает страницу по адресу 0 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает урок D2_cpp
base linux Какие флаги комбинируются битовым ИЛИ при open() O_RDONLY/O_WRONLY/O_RDWR, O_APPEND, O_CREAT, O_TRUNC, O_NONBLOCK урок D2_linux
base linux Что возвращает read() при достижении конца файла 0 урок D2_linux
core linux Что делает lseek() и меняет ли он содержимое файла двигает позицию чтения/записи файла, содержимое не трогает урок D2_linux
core linux Чем O_APPEND отличается от ручного lseek(fd, 0, SEEK_END) перед каждой записью O_APPEND атомарно смещает позицию в конец непосредственно перед самой записью на уровне ядра, ручной lseek+write у двух процессов может гонку: оба сделают lseek, потом оба write, и один перезапишет данные другого урок D2_linux
base linux Может ли write() записать меньше байт, чем попросили, без ошибки да, это не ошибка, нужен цикл дозаписи урок D2_linux
core linux Чем fdatasync отличается от fsync fsync сбрасывает на диск данные и все метаданные файла, fdatasync — только данные и те метаданные, что нужны для последующего чтения (например, размер), пропуская остальные (время доступа) урок D2_linux
core linux Какой errno означает «вызов прерван сигналом, нужно просто повторить» EINTR урок D2_linux
base linux В чём разница MAP_SHARED и MAP_PRIVATE MAP_SHARED: изменения видны другим процессам и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны и не попадают в файл на диске урок D2_linux
base linux Что делает msync принудительно сбрасывает изменения MAP_SHARED-области на диск, не дожидаясь ядра урок D2_linux
core linux Чем minor page fault отличается от major minor: страница уже в страничном кэше, только добавляется отображение (дёшево); major: страница реально читается с диска (дорого) урок D2_linux
core linux Когда mmap проигрывает read по скорости при однократном последовательном чтении небольшого файла — накладные расходы на отображение и page fault не амортизируются урок D2_linux
deep linux Что ограничивает mmap на 32-битных системах размер доступного виртуального адресного пространства (порядка нескольких гигабайт) — файл целиком отобразить может не получиться урок D2_linux
base linux Жёсткий лимит числа дескрипторов у select и его значение FD_SETSIZE, 1024 урок D2_linux
base linux Сложность select и poll на один вызов O(n) от общего числа отслеживаемых дескрипторов, независимо от того, сколько готовы урок D2_linux
core linux Три функции epoll и их роль epoll_create1 (создать инстанс), epoll_ctl (ADD/MOD/DEL в списке интереса), epoll_wait (забрать готовые из списка готовых) урок D2_linux
core linux На чём построен список интереса epoll внутри ядра на красно-чёрном дереве урок D2_linux
core linux Сложность epoll_wait на одно готовое событие O(1) — ядро уже отфильтровало готовые дескрипторы заранее, работа не зависит от общего числа зарегистрированных урок D2_linux
base linux В чём разница level-triggered и edge-triggered LT: событие повторяется, пока условие истинно; ET: событие сообщается один раз, в момент перехода в готовое состояние урок D2_linux
core linux Почему ET требует неблокирующих дескрипторов на блокирующем дескрипторе цикл чтения до исчерпания данных на последнем вызове заблокируется навсегда вместо возврата EAGAIN урок D2_linux
core linux Что произойдёт, если в ET-режиме не дочитать данные до EAGAIN оставшиеся данные не будут сигнализированы повторно, пока состояние дескриптора не изменится снова (например, не придут новые данные) урок D2_linux
deep linux Какой флаг epoll_ctl включает edge-triggered режим EPOLLET урок D2_linux
base linux Что означает EAGAIN/EWOULDBLOCK на неблокирующем дескрипторе данных для чтения нет (или буфер записи полон) прямо сейчас — не ошибка, повторить позже урок D2_linux
core linux Совпадают ли числовые значения EAGAIN и EWOULDBLOCK на Linux да, это синонимы с одним и тем же значением урок D2_linux
base linux Сколько времени сокет проводит в TIME_WAIT 2×MSL (удвоенное время жизни сегмента в сети) урок D2_linux
core linux Зачем нужно состояние TIME_WAIT чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения и не спутать их с новым, если порт переиспользуют слишком быстро урок D2_linux
core linux Что даёт SO_REUSEADDR разрешает bind на адрес/порт, у которого уже есть сокеты в состоянии TIME_WAIT — иначе bind вернёт «Address already in use» урок D2_linux
base algo Сложность вставки в список, если указатель на место уже есть O(1) урок D3_algo
base algo Сложность поиска элемента в связном списке O(n) урок D3_algo
core algo Почему список медленнее массива на практике, хотя вставка O(1) на бумаге узлы разбросаны по куче, промахи кэша при обходе урок D3_algo
core algo Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего лишним указателем `prev` в каждом узле урок D3_algo
base algo Сложность разворота списка тремя указателями O(n) по времени, O(1) по памяти урок D3_algo
core algo Зачем нужен третий указатель `next` сохранить связь с остатком списка до перезаписи `curr->next` урок D3_algo
base algo Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины один урок D3_algo
core algo На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения на k шагов урок D3_algo
base algo На сколько узлов за шаг двигается `fast` в поиске середины на 2, `slow` — на 1 урок D3_algo
core algo Что вернёт `find_middle` для списка из 4 узлов (1→2→3→4) узел 3 (второй из двух средних) урок D3_algo
deep algo Почему условие цикла `fast && fast->next`, а не только `fast`? — без `fast->next` обращение `fast->next->next` на последнем узле разыменует `nullptr` урок D3_algo
base algo С какой относительной скоростью `fast` догоняет `slow` внутри цикла 1 узел за шаг урок D3_algo
core algo Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи один, второй остаётся в точке встречи, оба идут дальше по 1 шагу урок D3_algo
deep algo Почему после первой встречи путь от головы длиной `a` и путь от точки встречи длиной `c-b` сходятся в одной точке из равенства `2(a+b) = a+b+n·c`, откуда `a = (n-1)c + (c-b)` урок D3_algo
base algo Что решает dummy-узел убирает отдельную ветку кода для вставки/удаления в начало списка урок D3_algo
core algo Что возвращают в конце вместо dummy `dummy->next` как настоящую голову результата урок D3_algo
base algo Сложность push/pop у стека на массиве O(1) урок D3_algo
core algo Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между `in` и `out` не более одного раза урок D3_algo
core algo Средняя (амортизированная) сложность pop в очереди на двух стеках O(1), несмотря на то что отдельный вызов с переливом стоит O(n) урок D3_algo
base algo Формула перехода индекса на начало массива в кольцевом буфере `idx = (idx + 1) % capacity` урок D3_algo
base algo Сложность push/pop в кольцевом буфере O(1), без сдвига элементов урок D3_algo
core algo Как отличить полный кольцевой буфер от пустого, если `head == tail` счётчик `size`, либо держать одну ячейку всегда свободной урок D3_algo
core algo Более быстрая замена `% capacity` без деления `if (++idx == capacity) idx = 0;` урок D3_algo
deep algo Где кольцевой буфер встречается в драйверах буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных урок D3_algo
base algo Наивная сложность Next Greater Element вложенным циклом O(n²) урок D3_algo
base algo Сложность Next Greater Element через монотонный стек O(n) урок D3_algo
core algo Почему монотонный стек даёт O(n), если внутри есть вложенный `while` каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход урок D3_algo
core algo Что хранит монотонный стек в задаче Next Greater Element значения или индексы? — индексы (значения по ним убывают снизу вверх) урок D3_algo
deep algo Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение урок D3_algo
base linux Какие два флага компиляции нужны для комфортной отладки в gdb `-g -O0` урок D3_gdb
base linux Как называется формат отладочной информации, который встраивает `-g` DWARF урок D3_gdb
core linux Почему `-O2` мешает отладке даже при наличии `-g` оптимизатор переставляет/удаляет инструкции и переиспользует регистры, отладочная информация перестаёт однозначно совпадать с исходником урок D3_gdb
base linux Какая команда печатает стек вызовов в gdb `bt` урок D3_gdb
base linux Что означает `x/16xb ptr` 16 байт в hex начиная с адреса `ptr` (формат `x/NFU addr`) урок D3_gdb
core linux Чем `frame N` отличается от `bt` `bt` печатает весь стек, `frame N` переключает текущий контекст `print`/`info locals` на конкретный кадр из этого стека урок D3_gdb
base linux Чем `tbreak` отличается от `break` `tbreak` удаляется автоматически после первого срабатывания урок D3_gdb
core linux Чем `rwatch` отличается от `watch` `watch` реагирует на изменение значения, `rwatch` — на чтение переменной урок D3_gdb
deep linux Сколько аппаратных регистров отладки на x86 ограничивают число одновременных аппаратных watchpoint 4 (`DR0`–`DR3`) урок D3_gdb
base linux Чем `next` отличается от `step` `next` не заходит внутрь вызываемых функций, `step` заходит урок D3_gdb
core linux Что делает `finish` выполняет до возврата из текущей функции и печатает возвращаемое значение урок D3_gdb
core linux Зачем нужен `until` внутри цикла продолжить до строки с номером больше текущей, не проходя цикл пошагово урок D3_gdb
base linux Каким кодом завершается процесс при SIGSEGV 139 (128+11) урок D3_gdb
base linux Какой командой разрешить сохранение core-файлов перед ожиданием падения `ulimit -c unlimited` урок D3_gdb
core linux Как открыть core dump вместе с бинарником в gdb `gdb prog core` урок D3_gdb
core linux Почему пересборка бинарника перед анализом core ломает разбор адреса в новом бинарнике не совпадают с адресами, записанными в core, gdb покажет несогласованный стек урок D3_gdb
base linux Какая команда gdb показывает все потоки процесса разом `info threads` урок D3_gdb
core linux Как переключиться на конкретный поток по номеру для `bt`/`print` `thread N` урок D3_gdb
core linux Как gdb помогает диагностировать дедлок, если программа просто зависла без вывода `info threads` + `bt` по каждому потоку показывают, кто на каком мьютексе застрял урок D3_gdb
core linux В чём разница между точкой, которую покажет gdb при краше, и точкой, которую покажет ASAN gdb показывает точку симптома (где реально упало), ASAN — точку причины (момент нарушения) урок D3_gdb
base linux Каким флагом компиляции включается ASAN `-fsanitize=address` урок D3_gdb
base linux Каким флагом компиляции включается UBSAN `-fsanitize=undefined` урок D3_gdb
base linux Какой модуль valgrind ищет ошибки памяти `memcheck` (`valgrind --tool=memcheck`) урок D3_gdb
core linux Почему valgrind медленнее ASAN эмулирует каждую машинную инструкцию в софтверной VM, а не добавляет проверки только вокруг обращений к памяти в нативном коде урок D3_gdb
core linux Когда valgrind предпочтительнее санитайзеров когда нет доступа к исходникам/пересборке (готовый бинарник, сторонняя библиотека) урок D3_gdb
base linux Какой командой собирают `crash.c` для отладки с санитайзерами `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash` урок D3_gdb
base linux Что печатает `./crash` без аргументов после починки `len=5`, код возврата 0 урок D3_gdb
core linux Почему падение может произойти не в строке с самой ошибкой, а при выходе из программы испорченный служебный указатель (стек/метаданные кучи) проявляется только в момент разрушения объекта, а не в момент самой порчи урок D3_gdb
base threads Когда именно стартует новый поток при `std::thread t(f)` сразу, в конструкторе урок D3_threads
base threads Примерный размер стека потока на типичной Linux-системе ~8 МБ урок D3_threads
core threads Что произойдёт, если объект `std::thread` с незавершённым и не присоединённым потоком уничтожится `std::terminate`, программа аварийно завершится урок D3_threads
core threads Каким системным вызовом Linux создаёт новый поток `clone` урок D3_threads
base threads Что такое data race одновременный доступ к одной памяти минимум с одной записью без синхронизации урок D3_threads
core threads Является ли гонка на обычном `int` без атомиков UB по стандарту C++ да, даже если на конкретном железе чтение/запись `int` физически атомарны урок D3_threads
core threads Почему компилятору мало того, что чтение `int` атомарно на железе без синхронизации он вправе кэшировать значение в регистре и переупорядочивать обращения к памяти урок D3_threads
base threads Какая RAII-обёртка нужна для работы с `condition_variable::wait` `std::unique_lock` урок D3_threads
base threads Что делает `std::scoped_lock` атомарно захватывает несколько мьютексов сразу урок D3_threads
core threads Почему дедлок ловится ThreadSanitizer, а не компилятором это динамическая ошибка, зависящая от конкретной раскладки потоков в рантайме, а не от статической структуры кода урок D3_threads
base threads Что атомарно делает `cv.wait(lock)` при входе освобождает мьютекс и усыпляет поток одной неделимой операцией урок D3_threads
core threads Почему `while`, а не `if`, вокруг `wait` из-за spurious wakeup: `wait` может вернуться без единого вызова `notify` урок D3_threads
core threads Обязательно ли вызывать `notify` под захваченным мьютексом нет, обязательно лишь менять состояние под мьютексом; `notify` после `unlock()` — оптимизация, не требование корректности урок D3_threads
deep threads На каком примитиве ОС реализован `condition_variable` на Linux `futex` урок D3_threads
core threads Назови 4 условия Коффмана для дедлока взаимное исключение, удержание-и-ожидание, невозможность отбора, круговое ожидание урок D3_threads
core threads Какое условие обычно разрушают на практике фиксированным порядком захвата мьютексов круговое ожидание урок D3_threads
base threads Какой командой gdb смотрят, на каком мьютексе застрял каждый поток при зависании `info threads` (и стек каждого потока) урок D3_threads
base threads Чем `std::atomic` дешевле мьютекса для простого счётчика одна аппаратная инструкция (например CAS), без перехода в ядро и усыпления потока урок D3_threads
core threads Что гарантирует `memory_order_relaxed` только атомарность операции, без ограничений порядка с другими обращениями к памяти урок D3_threads
core threads Что запрещает `acquire`, а что `release`? — acquire запрещает переносить более поздние обращения ДО себя; release запрещает переносить более ранние обращения ПОСЛЕ себя урок D3_threads
deep threads Сколько состояний у кэш-линии в протоколе MESI 4 (Modified, Exclusive, Shared, Invalid) урок D3_threads
deep threads Какой memory_order используется по умолчанию у операций `std::atomic` `seq_cst` урок D3_threads
base threads Каким исключением отвечает `push` после `close()` `std::runtime_error` урок D3_threads
core threads Почему `size()` в этой задаче требует того же мьютекса, что и данные очереди инкремент/декремент — не атомарная операция read-modify-write, без синхронизации это гонка данных урок D3_threads
base threads Примерно сколько потоков создают в пуле относительно ядер CPU порядка числа ядер процессора урок D3_threads
core threads Какую конкретно цену амортизирует пул потоков стоимость создания потока (системный вызов, стек, регистрация в планировщике) на каждую отдельную короткую задачу урок D3_threads
base threads Что возвращает `std::async` сразу после вызова `std::future` с будущим результатом урок D3_threads
core threads Сколько раз можно вызвать `future.get()` для одного результата один; второй вызов — неопределённое поведение (`valid() == false`) урок D3_threads
base threads Каким флагом компиляции включается ThreadSanitizer `-fsanitize=thread` урок D3_threads
core threads Почему чистый прогон под TSan не гарантирует отсутствие гонки в коде вообще TSan ловит гонку по факту конкретной раскладки потоков в конкретном запуске, а не статическим анализом всех возможных раскладок урок D3_threads
base algo Память матрицы смежности для V вершин O(V²) урок D4_algo
base algo Память списка смежности O(V+E) урок D4_algo
core algo Сложность перебора всех соседей вершины в списке смежности O(deg(v)), в матрице — всегда O(V) урок D4_algo
core algo Почему для графа 100 000 вершин нужен список смежности, а не матрица матрица заняла бы 10¹⁰ ячеек, не влезает в память урок D4_algo
base algo Сложность BFS на списке смежности O(V+E) урок D4_algo
base algo Какую структуру данных использует BFS очередь (FIFO) урок D4_algo
core algo Что гарантированно находит BFS в невзвешенном графе кратчайший путь по числу рёбер от источника урок D4_algo
core algo Почему BFS даёт кратчайший путь именно из-за очереди, а не из-за чего-то ещё FIFO-порядок раскрывает граф строго по слоям расстояния, вершина расстояния k не может обработаться раньше всех вершин расстояния <k урок D4_algo
base algo Сложность DFS на списке смежности O(V+E) урок D4_algo
base algo Какую структуру данных использует DFS стек (явный или стек вызовов рекурсии) урок D4_algo
core algo Гарантирует ли DFS кратчайший путь нет, в отличие от BFS урок D4_algo
deep algo Почему рекурсивный DFS опасен на графе-цепочке из 100 000 вершин глубина рекурсии равна длине цепочки, стек вызовов переполняется урок D4_algo
base algo Сложность подсчёта связных компонент через BFS/DFS от каждой непосещённой вершины O(V+E), суммарно по всем запускам урок D4_algo
core algo Что в задаче Number of Islands является «вершиной» и «ребром» вершина — клетка `'1'`, ребро — соседство по 4 направлениям урок D4_algo
base algo Для какого типа графа определена топологическая сортировка направленный ациклический граф (DAG) урок D4_algo
base algo Сложность алгоритма Кана O(V+E) урок D4_algo
core algo Что такое степень входа вершины в алгоритме Кана число входящих в неё рёбер (нерассмотренных зависимостей) урок D4_algo
core algo Как алгоритм Кана обнаруживает цикл счётчик обработанных вершин `order.size()` меньше `n` после завершения урок D4_algo
core algo Как задача Course Schedule сводится к топологической сортировке курсы — вершины, пререквизит — направленное ребро, цикл = невозможно пройти все курсы урок D4_algo
base algo Обязательное условие применимости Дейкстры все веса рёбер неотрицательны (w ≥ 0) урок D4_algo
base algo Сложность наивной Дейкстры (без кучи) O(V²) урок D4_algo
core algo Сложность Дейкстры с `priority_queue` O((V+E) log V) урок D4_algo
core algo Почему отрицательное ребро ломает Дейкстру вершина считается решённой сразу после извлечения минимума, а отрицательное ребро из ещё не рассмотренной вершины теоретически могло бы уменьшить это «зафиксированное» расстояние урок D4_algo
core algo Что проверяет `if (d != dist[v]) continue;` в реализации на куче что извлечённая запись не устарела (для v уже нашли путь короче) урок D4_algo
deep algo Почему куча может держать до O(E) записей вместо O(V) при каждом улучшении расстояния в кучу кладётся новая запись вместо обновления старой (нет decrease-key) урок D4_algo
base algo Сложность Беллман-Форда O(V·E) урок D4_algo
base algo Сколько раз алгоритм проходит по всем рёбрам в основном цикле V-1 раз урок D4_algo
core algo Как Беллман-Форд обнаруживает отрицательный цикл если расстояние ещё можно уменьшить на дополнительной V-й итерации, в графе есть отрицательный цикл урок D4_algo
core algo Почему V-1 итераций достаточно кратчайший путь без отрицательных циклов не может содержать больше V-1 ребра (иначе повторяет вершину) урок D4_algo
base algo Какие два приёма дают почти-константную сложность union-find сжатие пути и объединение по рангу урок D4_algo
base algo Амортизированная сложность операции union-find с обоими приёмами O(α(n)), обратная функция Аккермана, практически O(1) урок D4_algo
core algo Сложность алгоритма Kruskal и что в ней доминирует O(E log E), доминирует сортировка рёбер урок D4_algo
core algo Как union-find решает Redundant Connection первое ребро, для которого `unite` вернул false (вершины уже в одной компоненте), и есть лишнее урок D4_algo
core algo Как Network Delay Time сводится к Дейкстре вершина k — источник, ответ — максимум из всех кратчайших расстояний, -1 если что-то недостижимо урок D4_algo
deep algo Каково верхнее ограничение обратной функции Аккермана для практических n α(n) ≤ 4 для любого n, меньшего числа атомов во вселенной урок D4_algo
base net Сколько уровней в модели OSI 7 урок D4_net
base net Сколько уровней в модели TCP/IP 4 урок D4_net
core net Какие три уровня OSI объединяет прикладной уровень TCP/IP сеансовый, представления, прикладной урок D4_net
core net На каком уровне создаётся сокет транспортном (L4 OSI / транспортный TCP/IP) урок D4_net
base net Размер заголовка TCP-сегмента (без опций) 20 байт урок D4_net
base net Размер заголовка IP-пакета (без опций) 20 байт урок D4_net
base net Размер заголовка Ethernet-кадра и трейлера FCS 14 байт заголовок + 4 байта FCS урок D4_net
core net По какому полю верхний уровень при приёме узнаёт, что лежит внутри нижнего по полю типа протокола (EtherType в Ethernet, `protocol` в IP) урок D4_net
base net Длина Ethernet-заголовка 14 байт урок D4_net
base net Длина трейлера FCS 4 байта урок D4_net
base net Длина MAC-адреса в битах и байтах 48 бит, 6 байт урок D4_net
core net Из скольки байт состоит OUI в MAC-адресе и кто его назначает 3 байта, назначает IEEE производителю урок D4_net
core net Что меняется в заголовках при переходе пакета через маршрутизатор MAC или IP получателя? — MAC-адрес получателя, IP остаётся прежним урок D4_net
base net Как рассылается ARP-запрос broadcast или unicast? — broadcast урок D4_net
base net Как отправляется ARP-ответ unicast, от владельца адреса урок D4_net
core net Зачем нужен ARP-кэш не повторять broadcast-запрос для каждого исходящего пакета к уже известному узлу урок D4_net
core net Что такое gratuitous ARP и для чего он нужен незапрошенный ARP про собственный IP; обновление чужих кэшей заранее и обнаружение конфликта адресов урок D4_net
base net Сколько байт добавляет VLAN-тег 802.1Q к Ethernet-заголовку 4 байта (14 → 18) урок D4_net
base net Значение поля TPID для VLAN-тега 0x8100 урок D4_net
core net Из каких трёх полей состоит TCI и сколько бит занимает VID PCP (3 бита), DEI (1 бит), VID (12 бит) урок D4_net
core net Диапазон реально используемых VLAN ID 1–4094 (0 и 4095 зарезервированы) урок D4_net
base net Значение MTU для стандартного Ethernet 1500 байт урок D4_net
core net Что происходит с IP-пакетом крупнее MTU, если флаг DF не выставлен фрагментируется на несколько IP-пакетов меньшего размера урок D4_net
core net Что происходит, если пакет крупнее MTU и выставлен флаг DF пакет отбрасывается, отправителю летит ICMP о необходимости фрагментации урок D4_net
deep net Почему фрагментация считается дорогой операцией нагружает маршрутизаторы на пути, и потеря одного фрагмента роняет весь исходный пакет урок D4_net
base net Минимальный размер IPv4-заголовка 20 байт урок D4_net
base net Значение поля Protocol для TCP / UDP / ICMP 6 / 17 / 1 урок D4_net
core net Сколько бит занимает поле IHL и что оно означает 4 бита, длина заголовка в 32-битных словах урок D4_net
core net Какие условия делают IPv4-пакет некорректным при разборе (по `tasks/04_ipv4`) буфер <20 байт, version≠4, ihl<5, ihl·4>len, total_length<ihl·4 или >len урок D4_net
deep net Почему в `tasks/04_ipv4` запрещён `reinterpret_cast` на буфер невыровненный адрес и другой порядок байт на проводе дают UB при чтении полей как структуры напрямую урок D4_net
base net На сколько уменьшается TTL на каждом маршрутизаторе на 1 урок D4_net
base net Какой ICMP-тип отправляется при обнулении TTL Time Exceeded, тип 11 урок D4_net
core net Как traceroute находит промежуточные маршрутизаторы, не имея отдельного протокола обнаружения пути последовательно шлёт пакеты с TTL=1,2,3..., каждый умирает на очередном хопе и присылает ICMP Time Exceeded с адресом этого хопа урок D4_net
base net Номера типов Echo Request и Echo Reply 8 и 0 урок D4_net
base net Тип ICMP-сообщения Destination Unreachable тип 3 урок D4_net
core net Какой код Destination Unreachable запускает PMTUD "fragmentation needed and DF set" урок D4_net
core net Почему ICMP не имеет портов диагностика работает на сетевом уровне, где ещё нет понятия транспортного соединения урок D4_net
deep net Почему `ping` может не проходить, а `curl` на тот же хост работать? — ICMP заблокирован файрволом отдельно от TCP-порта, это два разных уровня фильтрации урок D4_net
base net Алгоритм контрольной суммы IPv4-заголовка сумма в дополнительном коде по 16-битным словам, затем инверсия урок D4_net
core net Какое значение должна давать сумма при проверке валидности (с учётом поля суммы) 0xFFFF урок D4_net
core net Почему контрольная сумма покрывает только заголовок, а не данные заголовок (минимум TTL) меняется на каждом хопе и пересчитывается заново; целостность данных проверяют TCP/UDP-checksum и FCS канального уровня урок D4_net
deep net Что такое end-around carry в one's complement сложении перенос из старшего бита не отбрасывается, а прибавляется обратно к младшему биту суммы урок D4_net
base net Сколько хостов доступно в подсети /24 254 урок D4_net
base net Сколько хостов доступно в подсети /26 62 урок D4_net
base net Сколько хостов доступно в подсети /30 2 урок D4_net
core net Чем /31 отличается от остальных масок по числу служебных адресов оба адреса хостовые (RFC 3021), нет отдельного сетевого/broadcast адреса урок D4_net
core net Что означает запись `/N` в CIDR длина префикса сети в битах вместо классовой адресации A/B/C урок D4_net
deep net Формула подбора самой узкой подсети под h хостов за O(1) `32 - ceil(log2(h+2))` урок D4_net
base net На каком уровне работает хаб / коммутатор / маршрутизатор L1 / L2 / L3 урок D4_net
base net Что такое FDB коммутатора по структуре данных хеш-таблица MAC-адрес → порт урок D4_net
core net Как коммутатор заполняет FDB без отдельного протокола объявления самообучением по source MAC каждого входящего кадра урок D4_net
core net Что делает коммутатор с кадром, чей MAC получателя не найден в FDB рассылает на все порты кроме входного (flooding) урок D4_net
core net Зачем нужен aging записей FDB удалять устаревшие пары MAC-порт, если устройство отключилось или переехало на другой порт урок D4_net
deep net Что разделяет маршрутизатор, чего не делает коммутатор широковещательные домены (домены коллизий разделяет уже коммутатор) урок D4_net
base algo Два условия применимости ДП оптимальная подструктура + перекрывающиеся подзадачи урок D5_algo
core algo Почему наивный рекурсивный `fib(n)` работает за экспоненциальное время одни и те же подзадачи (`fib(k)` для одного и того же k) пересчитываются заново много раз урок D5_algo
core algo Что ломается, если применить ДП-переход к задаче без оптимальной подструктуры переход не отражает реальную зависимость оптимумов, ответ будет неверным независимо от таблицы урок D5_algo
base algo Сложность по времени мемоизации/табуляции O(число состояний) урок D5_algo
core algo Чем мемоизация рискует, а табуляция нет? — переполнением стека при большой глубине рекурсии урок D5_algo
core algo Что нужно определить в ДП-задаче до написания кода состояние (параметры подзадачи) и переход (формула через уже решённые состояния) урок D5_algo
base algo Сложность по памяти Climbing Stairs при развёрнутых `prev1`/`prev2` O(1) урок D5_algo
core algo Почему рюкзак 0/1 нельзя ужать до O(1), только до O(W) переход `dp[i][w]` зависит от целой предыдущей строки по весу, а не от 1–2 соседних чисел урок D5_algo
base algo Временная сложность 0/1-рюкзака с одномерным массивом O(n·W) урок D5_algo
core algo Почему в одномерном 0/1-рюкзаке веса обходят от W к weight[i], а не наоборот иначе `dp[w-weight[i]]` уже обновлён текущим предметом в этой же итерации, предмет посчитается дважды урок D5_algo
deep algo Как называется вариант рюкзака, в который случайно превращается 0/1-рюкзак при прямом проходе весов unbounded knapsack (неограниченное число копий предмета) урок D5_algo
base algo Сложность Coin Change (минимум монет) по времени O(amount · число_номиналов) урок D5_algo
core algo Какой порядок циклов в Coin Change II даёт число комбинаций, а какой перестановок? — монета снаружи/сумма внутри → комбинации; сумма снаружи/монета внутри → перестановки урок D5_algo
base algo Размер таблицы LCS/Edit Distance для строк длины n и m (n+1)×(m+1) урок D5_algo
base algo Временная сложность LCS и Edit Distance O(n·m) урок D5_algo
core algo Почему при несовпадении последних символов в LCS берут max(dp[i-1][j], dp[i][j-1]) оба последних символа одновременно в общую подпоследовательность войти не могут, значит хотя бы один из них можно отбросить без потери оптимальности урок D5_algo
base algo Наивная сложность LIS через dp[i] O(n²) урок D5_algo
base algo Сложность LIS через массив хвостов и бинарный поиск O(n log n) урок D5_algo
core algo Что хранится в `tails[k]` минимально возможный последний элемент возрастающей подпоследовательности длины k+1 урок D5_algo
core algo Почему `tails` остаётся отсортированным после каждой замены/добавления новое значение всегда меньше заменяемого и больше всех элементов левее позиции, найденной `lower_bound` урок D5_algo
core algo `lower_bound` или `upper_bound` нужен для строго возрастающей LIS `lower_bound` урок D5_algo
deep algo Сколько операций у наивного O(n²) LIS на 100 000 элементах и почему это не укладывается в тест порядка 10¹⁰, тест роняет прогон при времени > 2 с урок D5_algo
base linux Чей код возврата хранит `$?` после `cmd1 | cmd2 | cmd3` только последней команды (`cmd3`) урок D5_bash
core linux Как узнать код возврата каждой команды пайплайна отдельно массив `PIPESTATUS` (`${PIPESTATUS[@]}`) урок D5_bash
base linux Что делает `-e` в `set -euo pipefail` скрипт завершается сразу при ненулевом коде возврата любой команды урок D5_bash
base linux Что делает `-u` обращение к неопределённой переменной — ошибка вместо пустой строки урок D5_bash
core linux Что делает `pipefail` и какую проблему из раздела 1 это закрывает код возврата пайплайна = код первой упавшей команды, а не только последней; закрывает потерю ошибок середины пайплайна урок D5_bash
core linux В каком месте `-e` не остановит скрипт при ошибке команды если команда — часть условия (`if`, `while`, после `&&`/`||`) или не последняя команда пайплайна без `pipefail` урок D5_bash
base linux Что по умолчанию входит в IFS пробел, таб, перевод строки урок D5_bash
core linux Что отключают двойные кавычки вокруг `"$var"` word splitting и globbing (`*`/`?`/`[...]` не раскрываются) урок D5_bash
core linux Как правильно перебрать массив с элементами, содержащими пробелы `for x in "${arr[@]}"` (с кавычками) урок D5_bash
base linux Что хранит `$!` PID последнего фонового процесса урок D5_bash
core linux Зачем `trap ... EXIT` используют для временных файлов гарантирует очистку при любом пути завершения скрипта (успех, ошибка, сигнал), без дублирования кода в каждой точке выхода урок D5_bash
base linux Чем `-0` в `xargs -0` отличается от поведения по умолчанию вход разделяется нулевыми байтами `\0` вместо пробелов/переводов строк урок D5_bash
core linux С какой опцией `find` обычно комбинируют `xargs -0` `-print0` урок D5_bash
base linux Что печатает `grep -c` количество совпавших строк, а не сами строки урок D5_bash
base linux Почему `uniq -c` нужно применять после `sort` `uniq` схлопывает только соседние одинаковые строки, несмежные повторы не видит урок D5_bash
core linux Чем `sort -u` эквивалентен по результату `sort | uniq`, но за один проход урок D5_bash
base linux Что означает `$1` в awk первое поле текущей строки урок D5_bash
base linux Что содержит `NR` номер текущей строки от начала потока урок D5_bash
core linux Почему awk на 1e6 строк быстрее bash-цикла с grep внутри awk — один процесс с линейным проходом по строкам, bash-цикл форкает новый процесс на каждую итерацию (миллион fork+exec против одного процесса) урок D5_bash
base linux Что делает флаг `g` в `s/OLD/NEW/g` заменяет все вхождения в строке, а не только первое урок D5_bash
core linux Чем `sed -i` отличается от `sed` без флага редактирует файл на месте вместо печати результата в stdout урок D5_bash
base linux Что такое /proc с точки зрения хранения данных виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске урок D5_bash
base linux Чем разделены аргументы в /proc/<pid>/cmdline нулевыми байтами `\0` урок D5_bash
core linux Что можно узнать из /proc/<pid>/maps диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон урок D5_bash
core linux Откуда `free -h` берёт данные о памяти из /proc/meminfo урок D5_bash
base linux Что ограничивает `ulimit -n` максимальное число открытых файловых дескрипторов на процесс урок D5_bash
core linux Почему перед отладкой редкого краша выставляют `ulimit -c unlimited` по умолчанию размер core dump часто ограничен нулём, без этого файл с состоянием памяти на момент падения не создастся урок D5_bash
base linux Расшифровка прав 755 rwxr-xr-x (владелец: полный доступ, группа и остальные: чтение+выполнение) урок D5_bash
base linux Какие числа кодируют r, w, x r=4, w=2, x=1 урок D5_bash
core linux Что делает `umask 022` с правами нового файла по умолчанию (666) вычитает 022, получается 644 (rw-r--r--) урок D5_bash
base linux Что показывает `strace` системные вызовы процесса с аргументами и результатом, построчно урок D5_bash
core linux Чем `lsof -p PID` и `/proc/<pid>/fd/` пересекаются по смыслу оба показывают список файловых дескрипторов, открытых процессом урок D5_bash
base net Минимальный размер заголовка TCP 20 байт урок D5_net
base net Сколько бит занимает порт в заголовке TCP 16 бит (диапазон 0–65535) урок D5_net
core net Максимальный размер опций TCP-заголовка и почему именно столько 40 байт, потому что Data Offset (4 бита) кодирует длину заголовка в 32-битных словах максимум 15×4=60 байт, минус 20 байт фиксированной части урок D5_net
core net В каких сегментах передаётся опция MSS только в сегментах с флагом SYN, при установке соединения урок D5_net
deep net Какие два флага TCP появились позже исходного RFC 793 и для чего ECE и CWR (RFC 3168), сигнализация перегрузки сети (ECN) вместо/вместе с потерей пакета урок D5_net
base net Сколько сегментов в three-way handshake 3 (SYN, SYN+ACK, ACK) урок D5_net
core net Почему для установки TCP-соединения недостаточно двух сегментов соединение полнодуплексное, серверу нужно подтверждение, что его SYN-ACK (и его ISN) реально дошёл до клиента урок D5_net
core net Что означает ISN и синхронизируется ли он в одном экземпляре на оба направления начальный порядковый номер; нет, у каждого направления свой собственный ISN урок D5_net
base net В каком состоянии сервер ждёт входящие подключения LISTEN урок D5_net
core net Чем отличаются пути клиента и сервера в конечном автомате TCP клиент проходит SYN_SENT, сервер — LISTEN и SYN_RECEIVED; роли в рукопожатии асимметричны урок D5_net
core net Какой командой в Linux видно текущее состояние TCP-сокета `ss -tan` (столбец State) урок D5_net
base net Сколько сегментов нужно для полного закрытия TCP-соединения 4 (FIN, ACK, FIN, ACK) урок D5_net
base net Формула длительности TIME_WAIT 2×MSL урок D5_net
core net Почему закрытие TCP асимметрично («полузакрытие»), а не мгновенное закрытие по первому FIN соединение дуплексное, получение FIN означает только «собеседник больше не пришлёт данные», но сама сторона может ещё дописывать данные в обратном направлении урок D5_net
deep net Сколько секунд реально длится TIME_WAIT в Linux и совпадает ли это с 2×MSL по RFC 793 60 секунд, фиксированная константа ядра; не совпадает с номинальными 4 минутами (2×2 мин) по RFC 793 урок D5_net
base net Что получает клиент в ответ на попытку подключиться к закрытому порту RST урок D5_net
core net Чем RST принципиально отличается от FIN по смыслу RST — аварийный немедленный сброс (ошибка/невозможность продолжить), FIN — согласованное закрытие направления урок D5_net
base net Чем измеряется flow control в заголовке TCP полем Window урок D5_net
core net Чем отличается flow control от congestion control по цели flow control защищает получателя от переполнения буфера, congestion control защищает сеть от перегрузки урок D5_net
core net Во сколько раз падает cwnd при обнаруженной потере вдвое (ssthresh = cwnd/2) урок D5_net
core net Что такое fast retransmit ретрансмиссия по 3 дублирующим ACK без ожидания полного таймаута RTO урок D5_net
deep net Какой алгоритм congestion control используется в Linux по умолчанию CUBIC урок D5_net
core net Формула RTO по Джекобсону/Карелсу SRTT + 4×RTTVAR урок D5_net
deep net Какие коэффициенты сглаживания используются для SRTT и RTTVAR α=1/8 для SRTT, β=1/4 для RTTVAR урок D5_net
base net Что делает флаг TCP_NODELAY отключает алгоритм Нейгла, данные отправляются сразу без задержки на накопление урок D5_net
core net Почему Nagle + delayed ACK вместе дают заметные задержки обе стороны ждут друг друга: отправитель — ACK перед следующей мелкой отправкой, получатель — данные для отправки в обратную сторону, чтобы не слать ACK отдельно урок D5_net
base net Размер заголовка UDP 8 байт урок D5_net
base net Какие поля есть в заголовке UDP порт источника, порт назначения, длина, контрольная сумма урок D5_net
core net Почему DNS исторически использует UDP, а не TCP типичный запрос-ответ короткий и умещается в одну датаграмму, устанавливать TCP-соединение ради одного маленького обмена избыточно медленно урок D5_net
base net Диапазон well-known портов 0–1023 урок D5_net
base net В каком диапазоне ОС обычно выделяет эфемерные порты клиентским соединениям 49152–65535 урок D5_net
core net Что вернёт второй `bind` на уже занятый порт ошибку «Address already in use» урок D5_net
base net Что делает NAT с исходящим пакетом подменяет внутренний IP:порт источника на внешний IP:порт, запоминая соответствие в таблице трансляций урок D5_net
core net Почему NAT ломает входящие P2P-соединения без проброса портов запись в таблице трансляций создаётся только исходящим трафиком, входящему от незнакомого узла не с чем сопоставиться урок D5_net
base net Порт DNS по умолчанию и протокол 53, UDP (переключение на TCP/53 для больших ответов) урок D5_net
base net Что хранит запись типа A соответствие имени домена IPv4-адресу урок D5_net
core net Зачем у DNS-записи есть TTL ограничивает время кэширования резолверами; компромисс между скоростью распространения изменений и нагрузкой повторными запросами урок D5_net
base net Порты DHCP-сервера и клиента сервер 67/UDP, клиент 68/UDP урок D5_net
core net Из каких четырёх шагов состоит DORA Discover, Offer, Request, Ack урок D5_net
base net Команда для захвата TCP-трафика на 80 порту `tcpdump tcp port 80` урок D5_net
base net Флаг tcpdump для сохранения дампа в файл `-w` урок D5_net
core net Как в выводе tcpdump выглядит three-way handshake три строки подряд: `[S]` от клиента, `[S.]` от сервера, `[.]` от клиента урок D5_net
base tools Из каких двух механизмов ядра Linux состоит изоляция контейнера namespaces (изоляция видимости ресурсов) и cgroups (ограничение потребления ресурсов) урок D6_docker
base tools Перечисли namespaces, разбираемые в этом разделе pid, mnt, net, uts, ipc, user урок D6_docker
core tools Почему контейнер стартует за доли секунды, а VM за секунды? — контейнер не грузит собственное ядро, старт — это fork/exec с применёнными namespaces; VM грузит через гипервизор целое гостевое ядро урок D6_docker
core tools Почему изоляция контейнера слабее, чем у VM, на уровне безопасности контейнер и хост используют одно и то же ядро; уязвимость ядра или неверные capabilities могут дать выход на хост, а у VM с отдельным ядром такой прямой путь отсутствует урок D6_docker
base tools Что порождает каждая инструкция `RUN`/`COPY` в Dockerfile отдельный слой (diff файловой системы) урок D6_docker
core tools Что происходит с последующими слоями, если один слой не совпал с кэшем все последующие слои пересобираются заново, даже если сами по себе не менялись урок D6_docker
core tools Какой порядок инструкций Dockerfile правильный для скорости пересборки сначала зависимости (меняются редко), потом исходный код (меняется часто) урок D6_docker
base tools Почему `apt-get install` и `rm -rf /var/lib/apt/lists/*` объединяют в одну инструкцию `RUN` слой фиксирует файловую систему на момент завершения инструкции; в отдельном RUN кеш пакетов уже необратимо запечён в предыдущем слое урок D6_docker
core tools Почему нельзя копировать в образ каталог `build/`, собранный на хосте он собран под окружение хоста (версия компилятора, пути, архитектура), не гарантированно совместим с окружением контейнера; сборка должна идти внутри образа урок D6_docker
base tools Что переносит `COPY --from=builder` во второй `FROM` только указанные готовые файлы (например бинарник) из первого этапа, без слоёв тулчейна урок D6_docker
core tools Когда для финального этапа multi-stage можно использовать `FROM scratch` когда бинарник собран полностью статически и не нуждается ни в одной библиотеке окружения урок D6_docker
core tools Чем `debian:bookworm-slim` в качестве финального образа лучше полного `debian:bookworm` для рантайма не несёт тулчейн сборки и лишние пакеты, меньше размер и меньше поверхность атаки урок D6_docker
base tools Кто физически управляет расположением volume на диске сам Docker, служебная директория, не путь, выбранный вручную урок D6_docker
core tools Почему bind mount, а не volume, используют для разработки с редактированием кода на хосте bind mount даёт прямой одновременный доступ к каталогу хоста, правки на хосте сразу видны в контейнере без пересборки образа урок D6_docker
base tools Что делает флаг `--rm` у `docker run` автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса урок D6_docker
base tools Какая команда даёт интерактивный shell в уже запущенном контейнере `docker exec -it <container> bash` урок D6_docker
core tools Чем `--network=host` отличается от обычного режима с `-p` контейнер напрямую использует сетевой стек хоста без собственного network namespace и без проброса портов, но и без сетевой изоляции урок D6_docker
base tools Какая команда поднимает все сервисы из `docker-compose.yml` разом `docker compose up -d` урок D6_docker
core tools Что удаляет `docker compose down -v`, чего не удаляет `docker compose down` без флага именованные volume и данные в них урок D6_docker
base tools Что физически происходит с тегом `:latest` при каждом `docker push` без явного тега он перезаписывается на новый образ, становится мутируемым указателем, а не фиксированной версией урок D6_docker
core tools Почему фиксация `myapp@sha256:...` вместо тега `:latest` важна для воспроизводимости CI дайджест неизменяем по определению хеша, а тег `:latest` может незаметно указывать на другое содержимое образа в разное время урок D6_docker
base tools От какого пользователя запускается процесс в контейнере по умолчанию, если не указано иное root (UID 0) урок D6_docker
core tools Почему root в контейнере опаснее, чем root в отдельной VM контейнер не имеет отдельного ядра; эскалация до root хоста возможна через уязвимость общего ядра, неверные capabilities или смонтированный docker.sock, чего с отдельным ядром VM добиться сложнее урок D6_docker
core tools Что делает флаг `--user uid:gid` запускает процесс контейнера от непривилегированного UID/GID вместо root урок D6_docker
base tools Каким сигналом и с каким кодом завершения убивает процесс превышение лимита `--memory` SIGKILL, код завершения 137 (128 + 9) урок D6_docker
core tools Что ограничивает `--cpus=1.5` технически квоту CPU-контроллера cgroups, эквивалент 1.5 ядра процессорного времени вне зависимости от простаивающих ядер хоста урок D6_docker
base linux В каком кольце защиты x86 работает ядро Linux в кольце 0 (пользовательские процессы — в кольце 3) урок D6_kernel
base linux Какая инструкция делает системный вызов на x86-64 `syscall` (на ARM — `svc`) урок D6_kernel
core linux Почему системный вызов отдельная инструкция процессора, а не обычный `call`? — обычный `call` не меняет уровень привилегий CPU, а переход в кольцо 0 требует именно смены режима процессора урок D6_kernel
base linux Чем `insmod` отличается от `modprobe` `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости урок D6_kernel
base linux Какая команда показывает метаданные модуля, не загружая его `modinfo` урок D6_kernel
core linux Откуда `modprobe` берёт карту зависимостей модулей из файла `modules.dep`, который строит утилита `depmod` урок D6_kernel
base linux Какой макрос ядра аналог `printf`? — `printk` урок D6_kernel
base linux Команда для чтения буфера сообщений ядра `dmesg` урок D6_kernel
core linux Сколько уровней важности у `printk` и какие крайние 8 уровней, от `KERN_EMERG` (0) до `KERN_DEBUG` (7) урок D6_kernel
core linux Что произойдёт с модулем без `MODULE_LICENSE("GPL")` ядро станет tainted и закроет модулю доступ к символам `EXPORT_SYMBOL_GPL` урок D6_kernel
core linux Кто вызывает функции, зарегистрированные `module_init`/`module_exit` ядро автоматически, при `insmod`/`modprobe` и `rmmod` соответственно, а не сам программист урок D6_kernel
base linux Как передать параметр модулю при загрузке `insmod modname.ko имя_параметра=значение` урок D6_kernel
core linux Что означает третий аргумент `module_param`, если он ненулевой параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа урок D6_kernel
base linux Чем отличается соглашение о содержимом файлов `/sys` от `/proc` в `/sys` одно значение на файл, в `/proc` файл может содержать несколько значений свободным текстом урок D6_kernel
core linux Откуда `cat /proc/meminfo` берёт данные ядро формирует ответ на лету в момент чтения, это не файл на диске урок D6_kernel
base linux Какие 4 обработчика минимально нужны в `file_operations` символьного драйвера `.open`, `.read`, `.write`, `.release` урок D6_kernel
core linux Что означают major и minor номера устройства major определяет драйвер, minor — конкретный экземпляр устройства внутри этого драйвера урок D6_kernel
core linux Сколько бит под major и minor в `dev_t` 12 бит major, 20 бит minor (32-битное число целиком) урок D6_kernel
core linux Почему в драйвере нельзя напрямую разыменовать указатель из user space это чужое адресное пространство, страница может быть не загружена или указатель некорректен; прямое разыменование роняет ядро или блокируется SMAP/SMEP урок D6_kernel
base linux В какой функции `file_operations` реализуется `ioctl` `.unlocked_ioctl` урок D6_kernel
core linux Зачем коды ioctl-команд собирают через `_IO`/`_IOR`/`_IOW`/`_IOWR`, а не пишут произвольным числом макросы кодируют направление передачи, магическое число драйвера и номер команды, снижая риск коллизии кодов между разными драйверами урок D6_kernel
base linux Во что компилируется `.dts` и какой утилитой в бинарный `.dtb`, утилитой `dtc` урок D6_kernel
core linux Зачем device tree вообще нужен, если можно было бы прописать адреса регистров прямо в коде драйвера один и тот же бинарник ядра работает на разных платах без пересборки под каждую; хардкод адресов требовал бы правки и компиляции ядра под каждую конкретную плату урок D6_kernel
base linux Что задаёт переменная `CROSS_COMPILE` префикс имени инструментов тулчейна (например `arm-linux-gnueabihf-`) урок D6_kernel
core linux Что произойдёт при запуске бинарника, собранного с неверным `-march`, на реальной плате крах «illegal instruction» (процессор не поддерживает часть использованных инструкций) или отказ сборки урок D6_kernel
core linux Какую проблему в embedded снимает статическая линковка несовпадение версии динамических библиотек (например glibc) на целевой плате с версией сборки урок D6_kernel
base linux В каком порядке идёт цепочка загрузки платы Boot ROM → U-Boot → ядро+dtb → initramfs → переключение на настоящий rootfs → init (PID 1) урок D6_kernel
core linux Зачем нужен initramfs, если ядро уже загружено и работает ядру для монтирования настоящего диска может понадобиться драйвер, который сам является модулем и ещё не загружен; initramfs даёт минимальную среду в памяти, чтобы его подгрузить перед переходом на постоянный rootfs урок D6_kernel
base linux Что делает `volatile` с точки зрения компилятора заставляет реально выполнять каждое обращение к памяти по адресу, не кэшируя значение в регистре и не убирая повторные чтения/записи урок D6_kernel
core linux Даёт ли `volatile` атомарность или барьер памяти между несколькими ядрами CPU нет, только запрещает компилятору кэшировать/убирать обращения к конкретной переменной урок D6_kernel
core linux Какая функция ядра отображает физический адрес MMIO-региона в виртуальный адрес для доступа как к указателю `ioremap()` урок D6_kernel
deep linux Зачем нужен `wmb()` между записью DMA-дескриптора и записью в регистр запуска устройства без барьера порядок этих двух записей, как его видит железо, не гарантирован, и устройство может прочитать старое содержимое дескриптора раньше, чем увидит новые данные урок D6_kernel
base general Сколько уроков покрывает план перед D7 13 файлов (`D1_algo`…`D6_docker`) по алгоритмам, Linux, C++, сетям, потокам, отладке, bash и ядру урок D7_mock
core general Где искать формулировки механизмов, если в уроке дня их не хватает `HR_BASE_deep.md` урок D7_mock
base general Сколько часов длится каждый мок сегодня мок №1 (алгоритмы) 3 часа, мок №2 (системное) 2 часа урок D7_mock
base general Средняя сложность операций хеш-таблицы амортизированное O(1) урок D7_mock
core general При каком load factor обычно триггерят rehash при open addressing около 0.7 урок D7_mock
core general Зачем capacity берут степенью двойки `idx = hash & (cap - 1)` вместо дорогого деления по модулю урок D7_mock
deep general Каков стандартный max_load_factor по умолчанию у `std::unordered_map` в большинстве реализаций (libstdc++, MSVC) 1.0 урок D7_mock
core general Какую кучу держат для потокового top-K наибольших элементов размера k min-heap размера k урок D7_mock
core general Средняя и худшая сложность nth_element среднее O(n), худшее O(n²) урок D7_mock
deep general Как получить top-K частых элементов за O(n) без log-множителя подсчёт хеш-таблицей O(n) + bucket sort по частоте (массив корзин размера n+1) урок D7_mock
base general Сложность обнаружения цикла алгоритмом Флойда по времени и памяти O(n) время, O(1) память урок D7_mock
core general Как найти вход в цикл после первой встречи указателей сбросить один указатель на head, оба двигать по 1 шагу — встретятся на входе урок D7_mock
base general Сложность BFS на сетке R×C O(R·C) урок D7_mock
core general Почему BFS гарантирует кратчайший путь по числу рёбер FIFO-очередь раскрывает граф строго по слоям расстояния урок D7_mock
core general В какой момент нужно помечать узел посещённым, чтобы избежать повторных вставок в очередь в момент постановки в очередь, а не при извлечении урок D7_mock
deep general Как обойти граф со весами рёбер только 0 и 1 за O(V+E) без полноценной Дейкстры 0-1 BFS: deque, вес 0 — push_front, вес 1 — push_back урок D7_mock
base general Временная и пространственная сложность LCS/Edit Distance O(n·m) и по времени, и по памяти урок D7_mock
core general Три операции, между которыми выбирают минимум в Edit Distance замена, удаление, вставка урок D7_mock
base general Сложность наивного LIS и продвинутого через tails[] O(n²) и O(n log n) соответственно урок D7_mock
core general Почему наивный O(n²) не проходит на N=100 000 за 2 секунды 10¹⁰ операций, не укладывается в типичный лимит времени внутреннего теста урок D7_mock
core general Что хранит tails[k] минимальный возможный последний элемент возрастающей подпоследовательности длины k+1 урок D7_mock
deep general Как называется классический приём построения tails[] с заменой через бинарный поиск patience sorting (раскладка карт по кучкам) урок D7_mock
core general sizeof той же структуры с полями в порядке char a; char c; int b 8 байт урок D7_mock
base general Что физически делает std::move во время выполнения ничего: это static_cast к rvalue-ссылке урок D7_mock
core general За какое время перемещается std::vector и что именно переносится O(1): три внутренних указателя (начало, конец данных, конец ёмкости) урок D7_mock
core general Флаг компилятора, включающий сразу ASAN и UBSAN -fsanitize=address,undefined урок D7_mock
base general Откуда взялись коды возврата 137 и 139 128 + номер сигнала: 137 = SIGKILL(9), 139 = SIGSEGV(11) урок D7_mock
core general Какие два сигнала нельзя перехватить или заблокировать SIGKILL (9) и SIGSTOP (19) урок D7_mock
base general Состояние зомби-процесса в выводе ps Z урок D7_mock
core general Что именно занимает зомби-процесс память или что-то другое? — слот в таблице процессов, не память урок D7_mock
base general Жёсткий лимит select и его значение FD_SETSIZE, 1024 урок D7_mock
core general Сложность epoll_wait на одно готовое событие против select/poll на весь набор epoll_wait O(1) на событие; select/poll O(n) на весь набор при каждом вызове урок D7_mock
base general Разница MAP_SHARED и MAP_PRIVATE MAP_SHARED: изменения видны другим и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны урок D7_mock
base general Длина Ethernet-заголовка и длина заголовка с VLAN-тегом 14 байт без тега, 18 байт с тегом 802.1Q урок D7_mock
core general Значение TPID и число бит поля VID TPID = 0x8100, VID = 12 бит (диапазон 1–4094) урок D7_mock
base general Сколько хостов доступно в подсети /26 и как выглядит маска в десятичном виде 62 хоста, 255.255.255.192 урок D7_mock
core general Сеть, broadcast и диапазон хостов для 10.0.1.130/26 сеть 10.0.1.128, broadcast 10.0.1.191, хосты 129–190 урок D7_mock
base general На сколько уменьшается TTL на каждом маршрутизаторе и какой ICMP-тип шлётся при обнулении на 1; ICMP Time Exceeded, тип 11 урок D7_mock
core general С какого TTL traceroute начинает зондирование и почему именно так восстанавливает маршрут с TTL=1, наращивая на 1 — каждый пакет умирает на очередном хопе и раскрывает его адрес урок D7_mock
base general Сколько сегментов в three-way handshake и в полном закрытии соединения 3 (SYN, SYN+ACK, ACK) и 4 (FIN, ACK, FIN, ACK) урок D7_mock
deep general Сколько секунд реально длится TIME_WAIT в Linux 60 секунд (константа TCP_TIMEWAIT_LEN), не совпадает с номинальными 4 минутами по RFC 793 урок D7_mock
base general Сколько вопросов работодателю стоит подготовить заранее на такое интервью 8, каждый — конкретный, не риторический урок D7_mock
core general Когда обычно задают вопросы работодателю на техническом скрининге в конце интервью, по приглашению интервьюера урок D7_mock
base general Из каких 4 частей состоит структура STAR для ответа про опыт Situation, Task, Action, Result урок D7_mock
core general Как правильно называть пробел в алгоритмах на собеседовании скрывать или называть прямо? — называть прямо: конкретная тема добирается сейчас, с указанием, что уже понятно (сложность, инварианты) урок D7_mock
base algo Сколько байт меняет местами `bswap32` 4 задача 01_bits
core algo Что делает `x & (x - 1)` гасит младший единичный бит `x` задача 01_bits
core algo Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF` 32 (по числу единичных бит) задача 01_bits
deep algo Почему `1u << 31` пишут с суффиксом `u`, а не как `int` сдвиг знакового `int` в задача 01_bits
base algo Какой порядок байт использует сеть для многобайтовых полей network byte order задача 01_bits
base algo Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей в 2 раза задача 02_list
core algo Почему рекурсивный разворот списка не годится под требование O(1) памяти каждый задача 02_list
core algo На чём основано доказательство, что `fast` догонит `slow` при цикле внутри цикла задача 02_list
base algo Сколько дополнительных указателей нужно для итеративного разворота списка 3 задача 02_list
base algo Сложность `push`/`pop` кольцевого буфера O(1) задача 03_ring
core algo Как отличить пустой буфер от полного при `head_ == tail_` хранить отдельный задача 03_ring
core algo Чем `delete[]` отличается от `delete` для массива, выделенного `new[]` `delete` задача 03_ring
base algo Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов задача 03_ring
base net Минимальная длина IPv4-заголовка без опций 20 байт задача 04_ipv4
base net Код `protocol` для TCP / UDP / ICMP 6 / 17 / 1 задача 04_ipv4
core net В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию «как на проводе», задача 04_ipv4
core net Почему `reinterpret_cast` буфера в `Ipv4Header*` UB? — нет гарантии выравнивания задача 04_ipv4
deep net Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем задача 04_ipv4
base algo Сложность наивного DP-решения LIS O(n²) задача 05_lis
core algo Сложность решения через `tails` + бинарный поиск O(n log n) задача 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
core threads Почему `while` вокруг `wait`, а не `if` из-за spurious wakeup: `wait` может вернуться без вызова notify задача 06_threads
core threads Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one` можно разбудить не тот поток, а нужный останется ждать задача 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
base linux Сколько одновременных соединений сервер должен держать минимум 64 задача 07_epoll
core linux По какому событию сервер закрывает соединение с клиентом по EOF со стороны клиента задача 07_epoll
base linux Что означает `EAGAIN` на неблокирующем сокете не ошибка, сигнал «данных пока нет, попробуй позже» задача 07_epoll
core linux В чём разница между LT и ET режимами epoll LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов» задача 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
base linux Сколько соединений открывает тест `grade.py 07` 3 задача 07_epoll
base linux Каким сигналом тест проверяет корректное завершение сервера SIGTERM задача 07_epoll
base linux Сколько строк TOP выводится, если уникальных IP меньше трёх столько, сколько есть уникальных IP задача 08_bash
base linux Что печатает скрипт для пустого файла `TOTAL 0`, `5XX 0`, без строк TOP задача 08_bash
core linux По какому полю строки лога определяется IP по первому полю задача 08_bash
core linux Почему `while read line` в bash медленный на миллионе строк каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку задача 08_bash
core linux Что даёт связка awk + sort вместо построчного цикла один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов задача 08_bash
deep linux По какому полю сортируется список IP при равенстве числа запросов по возрастанию строки IP (вторичный ключ сортировки) задача 08_bash
base linux Что делает `set -e` останавливает скрипт при первой команде с ненулевым кодом возврата задача 08_bash
core linux В каких позициях `set -e` не останавливает скрипт при ошибке команды в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!` задача 08_bash
core linux Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри задача 08_bash
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=0`, код возврата 0 задача 09_gdb
core linux Какие два санитайзера должны не давать сообщений после починки ASan и UBSan (`-fsanitize=address,undefined`) задача 09_gdb
base linux Что даёт флаг `-g` при сборке для gdb отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных задача 09_gdb
core linux Почему для отладки собирают с `-O0`, а не с `-O2` оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику задача 09_gdb
core linux Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение все три, в момент обращения к памяти, а не постфактум задача 09_gdb
deep linux Что именно ловит UBSan, в отличие от ASan неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью задача 09_gdb
base linux Какая команда gdb показывает цепочку вызовов до краша `bt` задача 09_gdb
core linux Чем `watch var` отличается от обычного `break` останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода задача 09_gdb
core linux Зачем нужен `frame N` после `bt` переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции задача 09_gdb
base linux Сколько входов прогоняет `grade.py 09` 5 задача 09_gdb
base linux Каким компилятором собирается `crash.c` на проверке gcc (компилятор C) задача 09_gdb
base algo Сколько времени закладывается на задачу 10 1,5–2,5 часа со ступенями задача 10_hash
base algo Какой командой проверяется решение `python3 grade.py 10` задача 10_hash
deep algo Где в реальных устройствах используется похожая структура таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти задача 10_hash
base algo Почему `hash & (cap-1)` эквивалентно `hash % cap` только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления задача 10_hash
base algo Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс `19 & 7 = 3` задача 10_hash
core algo Среднее число проб при удачном поиске, load factor 0.7 ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`) задача 10_hash
core algo То же при load factor 0.9 ≈5,5 — рост почти в 2,5 раза от значения при 0.7 задача 10_hash
deep algo Среднее число проб при НЕудачном поиске, load factor 0.9 ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска задача 10_hash
core algo Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте задача 10_hash
base algo Сколько ключей вставляет тест на кластеризацию и какие они 512 ключей, кратных 16 задача 10_hash
base algo За какое время должны пройти 100 000 вставок быстрее 2 секунд задача 10_hash
base algo Какой порог load factor держит таблица ≤ 0.7 задача 10_hash
base algo Минимальная ёмкость таблицы по умолчанию 16, степень двойки задача 10_hash
base algo Индексы детей и родителя в max-heap на массиве дети `2i+1`, `2i+2`; родитель `(i-1)/2` задача 11_heap
base algo Что за 2 секунды должны выполниться на 3 000 000 элементов `heapify` и `kth_largest` с `k=1000` (по отдельности) задача 11_heap
base algo Какой ключевой запрет на реализацию `heapify` нельзя строить через `push` в цикле, только bottom-up за O(n) задача 11_heap
base algo Сложность доступа к максимуму (`top`) O(1), максимум всегда в корне по инварианту кучи задача 11_heap
base algo Сложность одного `sift-up`/`sift-down` от произвольного узла O(log n) — путь ограничен высотой дерева задача 11_heap
core algo Что сравнивается на каждом шаге `sift-down` узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла задача 11_heap
core algo За какое время строится куча через n последовательных `push` O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n` задача 11_heap
core algo За какое время строится куча через bottom-up heapify O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n` задача 11_heap
deep algo С какого индекса начинается bottom-up heapify и в какую сторону идёт с последнего нелистового узла `n/2 - 1`, к корню (индекс 0) задача 11_heap
core algo Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000 примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up задача 11_heap
base algo Сложность `kth_largest`/`top_k` через полную кучу O(n + k log n): O(n) на heapify плюс k раз pop по O(log n) задача 11_heap
core algo Когда используют min-heap размера k вместо полной кучи на весь массив на потоке данных, который не помещается в память: min-heap размера k хранит только k текущих кандидатов, сложность O(n log k), память O(k) задача 11_heap
core algo Чем `std::nth_element` отличается от кучи для top-K в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop задача 11_heap
base algo Что возвращает функция при `src == dst` 0 задача 12_dijkstra
base algo Что возвращает функция при недостижимости `dst` -1 задача 12_dijkstra
base algo В каком типе суммируются веса и почему не `int` `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути задача 12_dijkstra
base algo Сложность наивной Дейкстры (перебор минимума по массиву) O(V²) задача 12_dijkstra
base algo Сложность Дейкстры с бинарной кучей O((V+E) log V) задача 12_dijkstra
core algo При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка задача 12_dijkstra
core algo Что означает «ленивое удаление» устаревших записей в очереди вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая задача 12_dijkstra
deep algo Почему `std::priority_queue` не используют с decrease-key напрямую у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop задача 12_dijkstra
core algo Почему Дейкстра ломается на отрицательных рёбрах алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается задача 12_dijkstra
base algo Какой алгоритм нужен при отрицательных весах без отрицательных циклов Беллман-Форд, O(V·E) задача 12_dijkstra
base algo Почему self-loop с w≥0 не требует отдельной обработки добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию задача 12_dijkstra
base net Сколько узлов даёт /24 254 задача 13_subnet
base net Сколько узлов даёт /26 62 задача 13_subnet
base net Сколько узлов даёт /30 2 задача 13_subnet
base net Сеть, broadcast и диапазон узлов для `10.0.1.130/26` сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190` задача 13_subnet
base net Формула маски для префикса p (0<p≤32) `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB) задача 13_subnet
base net Как получить network и broadcast из ip и mask `network = ip & mask`, `broadcast = network | ~mask` задача 13_subnet
core net Формула host_count для prefix ≤ 30 `2^(32-prefix) - 2` задача 13_subnet
core net Почему /31 исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока задача 13_subnet
core net Что возвращает subnet_of для /32 network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback) задача 13_subnet
core net Формула `prefix_for_hosts(h)` `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2` задача 13_subnet
deep net Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1 /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами задача 13_subnet
Can't render this file because it contains an unexpected character in line 335 and column 82.
-1842
View File
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
#!/usr/bin/env python3
"""Тренажёр «числа и факты» — карточки под промахи диагностики.
Уровни карточек:
base — база, с этого начинать (объяснение есть в поле why)
core — то, что почти наверняка спросят
deep — нишевое, в базовый прогон не попадает
Использование (из каталога diag):
python3 facts_drill.py # 20 карточек уровня base+core, интерактивно
python3 facts_drill.py --level base # только база (первый заход — отсюда)
python3 facts_drill.py --level all --all # все 101
python3 facts_drill.py --domain algo --level base
python3 facts_drill.py --learn # после каждого ответа показать пояснение
python3 facts_drill.py --list --level base # вывести вопросы с ответами и пояснениями
python3 facts_drill.py --answers "20|62|ttl|..." # проверить строку ответов
python3 facts_drill.py --answers "..." --level all --all --seed 42
python3 facts_drill.py --selftest
Ответы сравниваются нестрого: регистр, «ё», тире и лишние пробелы не важны.
Результат пишется в results_facts.json.
"""
import argparse
import json
import os
import random
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
FACTS_PATH = os.path.join(HERE, "facts.json")
LEVELS = ("base", "core", "deep")
def norm(s: str) -> str:
s = (s or "").lower().replace("ё", "е")
s = s.replace("–", "-").replace("—", "-").replace("−", "-")
s = re.sub(r"[^0-9a-zа-я\-\s]", " ", s)
return re.sub(r"\s+", " ", s).strip()
def load():
with open(FACTS_PATH, encoding="utf-8") as f:
return json.load(f)["facts"]
def check(fact, answer: str) -> bool:
a = norm(answer)
if not a:
return False
return any(a == norm(x) for x in fact.get("accept", []))
def hint(fact) -> str:
"""Пояснение и ссылка, куда смотреть."""
parts = []
if fact.get("why"):
parts.append(fact["why"])
if fact.get("ref"):
parts.append("см. " + fact["ref"])
return " · ".join(parts)
def selftest(facts) -> int:
problems = []
for i, f in enumerate(facts, 1):
for key in ("domain", "q", "a", "accept", "level"):
if not f.get(key):
problems.append(f"карточка {i}: пустое поле {key}")
if f.get("level") not in LEVELS:
problems.append(f"карточка {i}: неизвестный уровень {f.get('level')!r}")
if f.get("accept") and not any(norm(x) for x in f["accept"]):
problems.append(f"карточка {i}: варианты ответа нормализуются в пустоту")
if not check(f, f["accept"][0]):
problems.append(f"карточка {i}: канонический ответ не проходит проверку")
if not (f.get("why") or f.get("ref")):
problems.append(f"карточка {i}: нет ни пояснения, ни ссылки — карточка не учит")
doms, lvls = {}, {}
for f in facts:
doms[f["domain"]] = doms.get(f["domain"], 0) + 1
lvls[f["level"]] = lvls.get(f["level"], 0) + 1
print(f"карточек: {len(facts)}; по доменам: {doms}; по уровням: {lvls}")
if problems:
print("ПРОБЛЕМЫ:")
for p in problems:
print(" -", p)
return 1
print("SELFTEST OK")
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--answers", default=None, help="ответы через | (тот же порядок, что в интерактиве)")
ap.add_argument("--count", type=int, default=20)
ap.add_argument("--all", action="store_true", help="взять все карточки после фильтров")
ap.add_argument("--domain", default=None, choices=["algo", "linux", "net", "c_cpp"])
ap.add_argument("--level", default="base,core", help="base,core,deep или all (по умолчанию base,core)")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--learn", action="store_true", help="показывать пояснение после каждого ответа")
ap.add_argument("--list", action="store_true")
ap.add_argument("--selftest", action="store_true")
args = ap.parse_args()
facts = load()
if args.selftest:
sys.exit(selftest(facts))
if args.level.strip().lower() == "all":
want = set(LEVELS)
else:
want = {x.strip() for x in args.level.split(",") if x.strip()}
bad = want - set(LEVELS)
if bad:
print(f"неизвестный уровень: {', '.join(sorted(bad))} (есть base, core, deep, all)")
sys.exit(2)
facts = [f for f in facts if f["level"] in want]
if args.domain:
facts = [f for f in facts if f["domain"] == args.domain]
if not facts:
print("нет карточек под фильтр")
sys.exit(1)
if args.list:
for i, f in enumerate(facts, 1):
print(f"{i:3d}. [{f['domain']}/{f['level']}] {f['q']}")
print(f" -> {f['a']}")
h = hint(f)
if h:
print(f" {h}")
print(f"\nвсего: {len(facts)} (полный набор: {FACTS_PATH})")
sys.exit(0)
rng = random.Random(args.seed)
pool = facts[:]
rng.shuffle(pool)
picked = pool if args.all else pool[:min(args.count, len(pool))]
results = []
if args.answers is not None:
given = [x.strip() for x in args.answers.split("|")]
if len(given) < len(picked):
print(f"ответов меньше, чем карточек: {len(given)} < {len(picked)} (проверяю сколько есть)")
for i, f in enumerate(picked):
ans = given[i] if i < len(given) else ""
results.append({"n": i + 1, "domain": f["domain"], "level": f["level"], "q": f["q"],
"answer": ans, "ok": check(f, ans), "correct": f["a"], "hint": hint(f)})
if args.learn:
for r in results:
if not r["ok"]:
print(f"#{r['n']} [{r['domain']}] {r['q']}")
print(f" твой ответ: {r['answer'] or '(пусто)'}")
print(f" верно: {r['correct']}")
if r["hint"]:
print(f" {r['hint']}")
print()
else:
print(f"Карточек в прогоне: {len(picked)} (уровни: {', '.join(sorted(want))}).")
print("Пустой ввод = не знаю, 'q' = выход.\n")
for i, f in enumerate(picked, 1):
print(f"[{i}/{len(picked)}] ({f['domain']}/{f['level']}) {f['q']}")
try:
ans = input(" > ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if ans.lower() in ("q", "quit", "выход"):
break
ok = check(f, ans)
if ok:
print(" ok" + (" — " + hint(f) if args.learn and hint(f) else ""))
else:
print(" нет. верно: " + f["a"])
h = hint(f)
if h:
print(" " + h)
results.append({"n": len(results) + 1, "domain": f["domain"], "level": f["level"],
"q": f["q"], "answer": ans, "ok": ok, "correct": f["a"], "hint": hint(f)})
ok = sum(1 for r in results if r["ok"])
total = len(results)
per_dom, per_lvl = {}, {}
for r in results:
for bucket, key in ((per_dom, r["domain"]), (per_lvl, r["level"])):
v = bucket.setdefault(key, {"total": 0, "ok": 0})
v["total"] += 1
v["ok"] += 1 if r["ok"] else 0
with open(os.path.join(HERE, "results_facts.json"), "w", encoding="utf-8") as fh:
json.dump({"seed": args.seed, "level": args.level, "correct": ok, "total": total,
"per_domain": per_dom, "per_level": per_lvl, "details": results},
fh, ensure_ascii=False, indent=2)
print(f"\n=== ИТОГ КАРТОЧЕК: {ok}/{total} ===")
for title, bucket in (("по доменам", per_dom), ("по уровням", per_lvl)):
print(f" {title}:")
for d, v in sorted(bucket.items()):
share = 100 * v["ok"] / v["total"] if v["total"] else 0
mark = "ок" if share >= 80 else ("добор" if share >= 50 else "дыра")
print(f" {d:8s} {v['ok']}/{v['total']} ({share:.0f}%) — {mark}")
print("Подробности: results_facts.json")
sys.exit(0 if total and ok == total else 1)
if __name__ == "__main__":
main()
+15 -136
View File
@@ -22,90 +22,9 @@ HERE = os.path.dirname(os.path.abspath(__file__))
TASKS = os.path.join(HERE, "tasks") TASKS = os.path.join(HERE, "tasks")
SUB = os.path.join(HERE, "submissions") SUB = os.path.join(HERE, "submissions")
WINDOWS = os.name == "nt"
LINUX = sys.platform.startswith("linux")
CXX = os.environ.get("CXX", "g++") CXX = os.environ.get("CXX", "g++")
CC = os.environ.get("CC", "gcc")
SAN = "-fsanitize=address,undefined -fno-omit-frame-pointer" SAN = "-fsanitize=address,undefined -fno-omit-frame-pointer"
FAST = False FAST = False
CXX_PATH = None
CC_PATH = None
HAS_SAN = False
# что нужно задаче, чтобы её вообще можно было запускать на этой машине
# (проверяется до компиляции; чего нет — задача попадает в ПРОПУСК, а не в FAIL)
REQUIRE = {
"07_epoll": ("linux+cxx", "epoll и unix-сокеты — только под Linux"),
"08_bash": ("bash", "нужны bash и awk/sort по большому логу"),
"09_gdb": ("cc-san", "нужны gcc/clang с ASAN/UBSAN"),
}
def tool(*names):
for n in names:
p = shutil.which(n)
if p:
return p
return None
def exe(name):
return name + ".exe" if WINDOWS else name
def run_path(name):
"""Путь для запуска локального бинаря (на Windows — с .exe и в кавычках-агностично)."""
return os.path.join(".", exe(name)) if not WINDOWS else exe(name)
def probe_sanitizers():
"""Может ли компилятор собрать простейший файл с ASAN/UBSAN."""
src = os.path.join(HERE, ".san_probe.cpp")
out = os.path.join(HERE, exe(".san_probe"))
try:
with open(src, "w", encoding="utf-8") as f:
f.write("int main(){int*p=new int[4];p[0]=1;int v=p[0];delete[]p;return v-1;}\n")
r = subprocess.run([CXX, "-std=c++17", "-O0"] + SAN.split() + [src, "-o", out],
capture_output=True, text=True, timeout=120)
ok = r.returncode == 0
except Exception:
ok = False
finally:
for p in (src, out):
try:
os.remove(p)
except OSError:
pass
return ok
def requirements_met(name):
"""(можно ли запускать, причина пропуска)"""
need, why = REQUIRE.get(name, (None, None))
if need == "linux+cxx":
if not LINUX:
return False, why
if not CXX_PATH:
return False, "не найден компилятор C++ (g++/clang++)"
return True, None
if need == "bash":
if WINDOWS:
return False, why + " (под Windows — только в WSL)"
if not tool("bash"):
return False, "не найден bash"
if not tool("awk"):
return False, "не найден awk"
return True, None
if need == "cc-san":
if not CC_PATH:
return False, "не найден gcc/clang"
if not HAS_SAN:
return False, "компилятор не умеет ASAN/UBSAN (MinGW/MSVC — WSL нужен)"
return True, None
if not CXX_PATH:
return False, "не найден компилятор C++ (g++/clang++)"
return True, None
# имя задачи: (тип, что запускать, таймаут, флаги компиляции) # имя задачи: (тип, что запускать, таймаут, флаги компиляции)
SPEC = { SPEC = {
@@ -118,10 +37,6 @@ SPEC = {
"07_epoll": ("bin", "test_epoll.py", 300), "07_epoll": ("bin", "test_epoll.py", 300),
"08_bash": ("bash", "test_bash.py", 300), "08_bash": ("bash", "test_bash.py", 300),
"09_gdb": ("py", "test_gdb.py", 300), "09_gdb": ("py", "test_gdb.py", 300),
"10_hash": ("cpp", "test_hash.cpp", 300),
"11_heap": ("cpp", "test_heap.cpp", 600),
"12_dijkstra":("cpp", "test_dijkstra.cpp",600),
"13_subnet": ("cpp", "test_subnet.cpp", 120),
} }
@@ -130,9 +45,6 @@ def sh(cmd, cwd, timeout):
try: try:
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout + r.stderr, time.time() - t0, False return r.returncode, r.stdout + r.stderr, time.time() - t0, False
except FileNotFoundError as e:
return 127, f"[НЕТ ПРОГРАММЫ] {e.filename or cmd[0]}: не найдена в PATH\n" \
f"Проверь установку компилятора/утилиты или запусти под WSL.", time.time() - t0, False
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
out = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "") out = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
return None, out + "\n[TIMEOUT] превышено время %s c" % timeout, time.time() - t0, True return None, out + "\n[TIMEOUT] превышено время %s c" % timeout, time.time() - t0, True
@@ -152,14 +64,13 @@ def run_task(name, root):
extra = [] if FAST else ["-fsanitize=thread"] extra = [] if FAST else ["-fsanitize=thread"]
elif not FAST: elif not FAST:
extra = SAN.split() extra = SAN.split()
binname = "t_" + name cmd = [CXX] + flags + (extra or []) + [test, "-o", "t_" + name]
cmd = [CXX] + flags + (extra or []) + [test, "-o", binname]
rc, out, secs, timed_out = sh(cmd, d, timeout) rc, out, secs, timed_out = sh(cmd, d, timeout)
log.append("$ " + " ".join(cmd) + "\n" + out) log.append("$ " + " ".join(cmd) + "\n" + out)
if rc != 0: if rc != 0:
return False, log, secs, timed_out return False, log, secs, timed_out
rc, out, secs2, timed_out = sh([run_path(binname)], d, timeout) rc, out, secs2, timed_out = sh(["./t_" + name], d, timeout)
log.append("$ " + run_path(binname) + "\n" + out) log.append("$ ./t_" + name + "\n" + out)
secs += secs2 secs += secs2
if "unsupported VMA" in out and not FAST: if "unsupported VMA" in out and not FAST:
# ThreadSanitizer не поддерживает этот размер адресного пространства # ThreadSanitizer не поддерживает этот размер адресного пространства
@@ -169,8 +80,8 @@ def run_task(name, root):
rc, out2, s, to = sh(cmd2, d, timeout) rc, out2, s, to = sh(cmd2, d, timeout)
log.append("$ " + " ".join(cmd2) + "\n# TSan недоступен на этой платформе, откат на ASAN\n" + out2) log.append("$ " + " ".join(cmd2) + "\n# TSan недоступен на этой платформе, откат на ASAN\n" + out2)
if rc == 0: if rc == 0:
rc, out2, s2, to = sh([run_path("t_" + name)], d, timeout) rc, out2, s2, to = sh(["./t_" + name], d, timeout)
log.append("$ " + run_path("t_" + name) + "\n" + out2) log.append("$ ./t_" + name + "\n" + out2)
secs += s2 secs += s2
elif kind == "bin": elif kind == "bin":
# epoll-сервер: solution.cpp -> бинарь solution, затем python-тест # epoll-сервер: solution.cpp -> бинарь solution, затем python-тест
@@ -195,7 +106,7 @@ def run_task(name, root):
def main(): def main():
global FAST, CXX, CC, CXX_PATH, CC_PATH, HAS_SAN global FAST
args = [a for a in sys.argv[1:]] args = [a for a in sys.argv[1:]]
FAST = "--fast" in args FAST = "--fast" in args
do_submit = "--submit" in args do_submit = "--submit" in args
@@ -205,56 +116,25 @@ def main():
else: else:
names = list(SPEC) names = list(SPEC)
CXX_PATH = tool(CXX, "g++", "clang++", "c++", "x86_64-w64-mingw32-g++")
CC_PATH = tool(CC, "gcc", "clang", "cc", "x86_64-w64-mingw32-gcc")
if CXX_PATH:
CXX = CXX_PATH
if CC_PATH:
CC = CC_PATH
os.environ["CC"] = CC
HAS_SAN = probe_sanitizers() if CXX_PATH else False
if not FAST and CXX_PATH and not HAS_SAN:
print("# Компилятор не собирает с ASAN/UBSAN — санитайзеры отключены автоматически.")
FAST = True
if WINDOWS:
print("# Windows-native сборка: работают задачи с компилятором C++ (01-06, 10-13).")
print("# 07 (epoll), 08 (bash/awk) и 09 (ASAN+gdb) требуют Linux — запускай их в WSL.")
if not CXX_PATH:
print("# Не найден компилятор C++ (g++/clang++). Поставь build-essential/MinGW-w64")
print("# или запусти задачи в WSL: wsl --install -d Ubuntu")
results = {} results = {}
skipped = [] print(f"{'задача':12s} {'итог':7s} {'проверок':9s} {'FAIL':5s} {'время':8s}")
print(f"{'задача':12s} {'итог':8s} {'проверок':9s} {'FAIL':5s} {'время':8s}")
for n in names: for n in names:
can, why = requirements_met(n)
if not can:
results[n] = {"pass": False, "skipped": True, "reason": why,
"checks_ok": 0, "checks_fail": 0, "seconds": 0.0, "log": []}
skipped.append((n, why))
print(f"{n:12s} {'ПРОПУСК':8s} — {why}")
continue
ok, log, secs, to = run_task(n, TASKS) ok, log, secs, to = run_task(n, TASKS)
body = log[-1] if log else "" body = log[-1]
n_ok = body.count("ok ") n_ok = body.count("ok ")
n_bad = body.count("FAIL ") n_bad = body.count("FAIL ")
results[n] = { results[n] = {
"pass": ok, "skipped": False, "checks_ok": n_ok, "checks_fail": n_bad, "pass": ok, "checks_ok": n_ok, "checks_fail": n_bad,
"seconds": round(secs, 2), "timeout": to, "log": log, "seconds": round(secs, 2), "timeout": to, "log": log,
} }
print(f"{n:12s} {'PASS' if ok else 'FAIL':8s} {n_ok:<9d} {n_bad:<5d} {secs:8.2f}") print(f"{n:12s} {'PASS' if ok else 'FAIL':7s} {n_ok:<9d} {n_bad:<5d} {secs:8.2f}")
tried = [n for n in names if not results[n].get("skipped")] total = len(names)
done = sum(1 for n in tried if results[n]["pass"]) done = sum(1 for n in names if results[n]["pass"])
with open(os.path.join(HERE, "results_tasks.json"), "w", encoding="utf-8") as f: with open(os.path.join(HERE, "results_tasks.json"), "w", encoding="utf-8") as f:
json.dump({"passed": done, "total": len(tried), "skipped": len(skipped), json.dump({"passed": done, "total": total, "tasks": results}, f,
"run_on": sys.platform, "tasks": results}, f,
ensure_ascii=False, indent=2) ensure_ascii=False, indent=2)
print(f"\nИТОГ ЗАДАЧ: {done}/{len(tried)} PASS. Подробности: results_tasks.json") print(f"\nИТОГ ЗАДАЧ: {done}/{total} PASS. Подробности: results_tasks.json")
if skipped:
print(f"Пропущено (не запускались, это не провал): {len(skipped)} — "
+ "; ".join(f"{n}: {why}" for n, why in skipped))
if do_submit: if do_submit:
os.makedirs(SUB, exist_ok=True) os.makedirs(SUB, exist_ok=True)
@@ -271,8 +151,7 @@ def main():
for fn in os.listdir(SUB): for fn in os.listdir(SUB):
os.chmod(os.path.join(SUB, fn), 0o644) os.chmod(os.path.join(SUB, fn), 0o644)
print(f"Ответы скопированы в {SUB}") print(f"Ответы скопированы в {SUB}")
print("Пакет: 01-09 диагностика, 10-13 добор по слабым доменам (см. WEEK7.md).") sys.exit(0 if done == total else 1)
sys.exit(0 if done == len(tried) else 1)
if __name__ == "__main__": if __name__ == "__main__":
+8 -106
View File
@@ -1,37 +1,8 @@
# Задача 01 — битовые операции (разминка) # Задача 01 — битовые операции (C)
> Первая задача дня: руки размялись, потом основная. Ориентир — 20–30 минут. Реализуй в `solution.cpp` четыре функции. Запрещено пользоваться встроенными
`__builtin_popcount`, `std::popcount`, `std::byteswap` — считается любая корректная
## Глава I. Общая информация ручная реализация.
- **Цель:** уверенно работать с битами — это ежедневный инструмент в embedded, сетях и
протоколах.
- **Почему это в Eltex:** заголовки пакетов, маски, флаги, регистры — всё битовое. Вопросы
вида «посчитай единичные биты» и «поменяй порядок байт» на собеседовании почти гарантированы.
На практике `bswap` нужен ровно потому, что сеть передаёт многобайтовые поля в network byte
order (big-endian), а x86 внутри — little-endian: без разворота байт число читается неверно.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 01`.
## Глава II. Что нужно знать до старта
Четыре инструмента, которых достаточно:
1. `x & (x - 1)` — гасит самый младший единичный бит. Механизм: в дополнительном коде `x - 1`
переворачивает все нули справа от младшего единичного бита в единицы, а сам этот бит — в ноль,
биты выше не трогает; операция `&` с исходным `x` поэтому обнуляет ровно один бит — младший
единичный. Отсюда классика: число единиц можно считать циклом, пока `x` не станет нулём, и
число итераций равно числу единичных бит, а не 32.
2. `x & 1` — младший бит; `x >> 1` — сдвиг вправо.
3. `x & (1u << k)` — проверка k-го бита.
4. Маски: `0x000000FF`, `0x0000FF00`, `0x00FF0000`, `0xFF000000` — это четыре байта 32-битного
числа. Комбинация «сдвинул и сложил» даёт любой порядок байт.
Почему дальше: та же логика масок и сдвигов нужна в задаче 04 — разбор IPv4-заголовка byte-по-byte.
## Глава III. Задание
Реализуй в `solution.cpp` четыре функции. Встроенные `__builtin_popcount`, `std::popcount`,
`std::byteswap` использовать нельзя — нужна своя реализация (любая корректная).
```c ```c
int popcount32(uint32_t x); // число единичных бит int popcount32(uint32_t x); // число единичных бит
@@ -40,77 +11,8 @@ bool is_power_of_two(uint32_t x); // x — степень двойки (0
uint32_t bswap32(uint32_t x); // поменять порядок байт местами uint32_t bswap32(uint32_t x); // поменять порядок байт местами
``` ```
Полезно сразу проверять себя руками: `bswap32(0x11223344) == 0x44332211`, Проверка: `python3 grade.py 01` из каталога `diag/`.
`popcount32(0b1011) == 3`, `is_power_of_two(1) == true`, `is_power_of_two(0) == false`, Критерий: все проверки `ok`, сборка без предупреждений, ASAN/UBSAN чистые.
`reverse_bits32(1) == 0x80000000`.
## Глава IV. Ступени Подсказки по разбору после сдачи: `x & (x - 1)`; реверс — классический цикл на 32 итерации
или пара «маска+сдвиг»; `bswap` — четыре маски.
**Ступень 1 (5 минут).** `is_power_of_two` — самая простая. Подумай, чем степень двойки
отличается от остальных чисел: у неё ровно один единичный бит. Значит подойдёт приём
`x & (x - 1)`. Отдельно обработай `0` (он не степень двойки) и проверь на `1` и на `0x80000000`.
**Ступень 2 (10 минут).** `popcount32` — цикл: пока `x != 0`, гаси младший бит и считай
итерации. Альтернатива — 32 сдвига с проверкой младшего бита; оба варианта верные, первый
в среднем быстрее.
**Ступень 3 (10 минут).** `reverse_bits32` — цикл на 32 итерации: берём младший бит `x`,
ставим его в результат на позицию `31 - i`, сдвигаем `x`. Внимание на типы: сдвиг `1u << 31`
для знакового `int` — UB, поэтому работай в `uint32_t`.
**Ступень 4 (10 минут).** `bswap32` — четыре байта. Самый наглядный вариант: маски и сдвиги,
`((x & 0x000000FFu) << 24) | ((x & 0x0000FF00u) << 8) | ((x & 0x00FF0000u) >> 8) | ((x & 0xFF000000u) >> 24)`.
**Ступень 5.** `python3 grade.py 01` — пока не станет `PASS`.
## Глава V. Критерии приёмки
`PASS` от `grade.py 01`: все проверки `ok`, сборка без предупреждений, ASAN/UBSAN чистые.
## Глава VI. Подсказки (после первой попытки)
<details>
<summary>UBSAN жалуется на сдвиг</summary>
Сдвиг знакового числа в старший бит — неопределённое поведение. Все промежуточные значения
держи в `uint32_t` и пиши константы с суффиксом `u`: `1u << 31`.
</details>
<details>
<summary>is_power_of_two возвращает true на нуле</summary>
У нуля нет единичных битов, поэтому `x & (x-1)` даёт 0 — как и у степеней двойки. Добавь
явную проверку `x != 0`.
</details>
<details>
<summary>reverse_bits32 работает «почти»</summary>
Проверь порядок операций в цикле: сначала ставим бит в результат, потом сдвигаем источник.
Если перепутать — биты уедут на одну позицию.
</details>
## Глава VII. Ловушки
- Сдвиги в `int` вместо `uint32_t` → сдвиг `1 << 31` для знакового типа — UB → UBSAN падает
на этой строке с диагностикой конкретного сдвига.
- Забыт ноль в `is_power_of_two` → `0 & (0 - 1) == 0`, функция возвращает `true` на нуле →
тест на `x == 0` красный.
- В `bswap32` перепутаны направления сдвигов (влево/вправо) → байты встают не на свои места →
`bswap32(0x11223344) != 0x44332211`, видно прямым сравнением.
- Использование `__builtin_popcount` — запрещено условием: смысл в том, чтобы написать самому;
формально тест это не всегда ловит по выводу, но нарушает условие задачи.
**Факты для карточек**
- base | Сколько байт меняет местами `bswap32`? — 4
- core | Что делает `x & (x - 1)`? — гасит младший единичный бит `x`
- core | Сколько итераций цикла `popcount32` на `x = 0xFFFFFFFF`? — 32 (по числу единичных бит)
- deep | Почему `1u << 31` пишут с суффиксом `u`, а не как `int`? — сдвиг знакового `int` в
знаковый бит — UB, `uint32_t` определён стандартом для любых сдвигов в пределах разрядности
- base | Какой порядок байт использует сеть для многобайтовых полей? — network byte order
(big-endian)
## После сдачи
Разбор: почему `x & (x-1)` гасит младший бит; как эти приёмы используются в реальных
протоколах (маски подсетей, флаги TCP-заголовка, разбор полей IPv4).
+1 -60
View File
@@ -1,17 +1,5 @@
# Задача 02 — связный список (C++) # Задача 02 — связный список (C++)
## Глава I. Общая информация
- **Цель:** развернуть список, найти середину и определить цикл — без единой дополнительной
аллокации, только перелинковка указателей.
- **Почему это в Eltex:** списки соединений, очереди задач, таблицы состояний — везде связные
структуры. Приём двух указателей (slow/fast), которым решаются `find_middle` и `has_cycle`,
— это алгоритм Флойда, тот же паттерн всплывает при поиске циклов в графах зависимостей
и в обходе кольцевых структур.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 02`.
## Глава II. Что нужно знать до старта
Даны функции над односвязным списком `Node{int value; Node* next;}`: Даны функции над односвязным списком `Node{int value; Node* next;}`:
```c ```c
@@ -20,57 +8,10 @@ Node* find_middle(Node* head); // середина; для чётной дл
bool has_cycle(Node* head); // есть ли цикл bool has_cycle(Node* head); // есть ли цикл
``` ```
Механизм двух указателей: `slow` идёт на 1 узел за шаг, `fast` — на 2. Для `find_middle`, когда Требования:
`fast` доходит до конца (`fast == nullptr` или `fast->next == nullptr`), `slow` стоит ровно в
середине — за счёт того, что `slow` проходит вдвое меньше узлов, чем `fast`. Для чётной длины
условие остановки должно давать именно второй из двух средних узлов — это проверяется на
списке длины 2 и 4.
Для `has_cycle` тот же дуэт указателей ловит цикл иначе: если цикл есть, `fast` заходит в него
и после каждого шага сокращает расстояние до `slow` внутри цикла на 1 узел (потому что относительная
скорость `fast` к `slow` внутри цикла — 1 узел/шаг), значит рано или поздно `slow == fast`; если
цикла нет, `fast` первым дойдёт до `nullptr`.
`reverse_list` разворачивается за один проход тремя указателями `prev/cur/next`: на каждом шаге
переставляется `cur->next = prev` до итерации по цепочке. Рекурсивный разворот сюда не годится —
он тратит O(n) памяти стека вызовов и нарушает требование O(1) дополнительной памяти.
Почему дальше: тот же принцип «два индекса вместо лишней памяти» — в задаче 03, только на
массиве, а не на указателях.
## Глава III. Требования
- никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно; - никаких аллокаций, O(1) дополнительной памяти, один проход там, где это возможно;
- `find_middle` и `has_cycle` — через два указателя (медленный/быстрый); - `find_middle` и `has_cycle` — через два указателя (медленный/быстрый);
- корректная работа с пустым списком и списком из одного узла. - корректная работа с пустым списком и списком из одного узла.
## Глава IV. Критерии приёмки
Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте Проверка: `python3 grade.py 02`. Критерий: все `ok`, ASAN/UBSAN чистые (утечки в тесте
считаются ошибкой — тест сам освобождает память). считаются ошибкой — тест сам освобождает память).
## Глава V. Ловушки
- Забыть проверку `head == nullptr` в начале любой из трёх функций → разыменование нулевого
указателя → падение/ASAN SEGV на пустом списке.
- В `find_middle` неверное условие остановки цикла (`fast->next` без проверки самого `fast`
на `nullptr`) → на списке чётной длины либо возвращается не тот из двух средних узлов, либо
падение на последнем шаге.
- В `reverse_list` переставить `cur->next = prev` до сохранения старого `cur->next` во
временную переменную → потеря хвоста списка, обход обрывается раньше конца.
- Рекурсивный `reverse_list` вместо итеративного → лишняя память стека на каждый вызов →
нарушение требования O(1), на длинном списке возможен stack overflow.
**Факты для карточек**
- base | Во сколько раз быстрее идёт `fast` относительно `slow` в паре двух указателей? — в 2 раза
- core | Почему рекурсивный разворот списка не годится под требование O(1) памяти? — каждый
вызов кладёт кадр в стек, суммарно O(n) памяти
- core | На чём основано доказательство, что `fast` догонит `slow` при цикле? — внутри цикла
расстояние между ними сокращается на 1 узел за шаг
- base | Сколько дополнительных указателей нужно для итеративного разворота списка? — 3
(`prev`, `cur`, `next`)
## После сдачи
Разбор: почему алгоритм Флойда работает за O(n) времени и O(1) памяти в обоих режимах
(поиск середины и поиск цикла), и как тот же приём переносится на поиск цикла в графе.
+6 -108
View File
@@ -1,34 +1,6 @@
# Задача 03 — кольцевой буфер (C++) # Задача 03 — кольцевой буфер (C++)
> Разминка уровня «основная»: структура маленькая, но встречается везде. Ориентир — 40–60 минут. Кольцевой буфер фиксированной ёмкости — базовая структура для embedded и сетевого кода.
## Глава I. Общая информация
- **Цель:** научиться писать структуру с фиксированной памятью и без сдвигов элементов.
- **Почему это в Eltex:** приём/передача пакетов, буферы DMA, очереди между потоками —
всё это кольцевые буферы. Понимание wrap-around и «полный/пустой» — прямой вопрос на
собеседовании. Механизм тот же, что у сетевой карты: пакеты пишутся в кольцо по мере
прихода, драйвер вычитывает их с другого конца, и обе стороны никогда не двигают уже
записанные данные по памяти — двигаются только индексы `head`/`tail`.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 03`.
## Глава II. Что нужно знать до старта
Идея: массив фиксированного размера + два индекса. `head` — куда писать, `tail` — откуда
читать. Когда индекс доходит до конца, он возвращается в начало: `idx = (idx + 1) % capacity`.
Сдвигать элементы не нужно никогда — в этом весь смысл: `push`/`pop` двигают только индекс,
а не байты в памяти, поэтому обе операции остаются O(1) независимо от размера буфера.
Ловушка, из-за которой задача попадает в собеседования: **как отличить пустой буфер от
полного, если оба индекса совпали?** При `head == tail` возможны оба состояния — буфер мог
быть только что создан (пуст) или заполнен ровно `capacity` раз (полон) — по одним индексам
это не различить. Варианты: хранить счётчик `size`, либо оставлять одну ячейку свободной.
В нашем интерфейсе есть `size()`, поэтому проще хранить счётчик.
Почему дальше: тот же счётчик `size_` вместо пересчёта по индексам — частый приём и в
`std::deque`, и в реализациях lock-free очередей, где индексы вообще нельзя лишний раз читать.
## Глава III. Задание
```c++ ```c++
class RingBuffer { class RingBuffer {
@@ -45,83 +17,9 @@ public:
``` ```
Требования: Требования:
- O(1) на push/pop, никаких сдвигов элементов;
- память выделяется один раз в конструкторе, освобождается в деструкторе;
- поведение после заворачивания индексов (wrap-around) корректное;
- копирование запрещать не обязательно, но двойного освобождения быть не должно.
- O(1) на `push`/`pop`, никаких сдвигов элементов; Проверка: `python3 grade.py 03`. Критерий: все `ok`, ASAN/UBSAN чистые.
- память выделяется один раз в конструкторе и освобождается в деструкторе;
- корректное поведение после заворачивания индексов (записали до конца, продолжили с начала);
- двойного освобождения быть не должно.
## Глава IV. Ступени
**Ступень 1 (10 минут).** Поля: указатель на массив, `capacity_`, `size_`, `head_`, `tail_`.
Конструктор выделяет массив и обнуляет поля. Деструктор освобождает. Если хочешь короче —
`std::vector<int>` вместо ручного `new[]`, тогда деструктор не нужен вовсе.
**Ступень 2 (10 минут).** `empty()` — это `size_ == 0`, `full()` — `size_ == capacity_`.
Проверь на буфере ёмкости 1: после одного `push` он полон, после одного `pop` пуст.
**Ступень 3 (15 минут).** `push`: если полон — вернуть `false` и ничего не менять; иначе
записать в `head_`, сдвинуть `head_ = (head_ + 1) % capacity_`, увеличить `size_`, вернуть `true`.
**Ступень 4 (15 минут).** `pop`: если пуст — `false`; иначе отдать `buffer_[tail_]`, сдвинуть
`tail_`, уменьшить `size_`, вернуть `true`.
**Ступень 5.** Проверь руками wrap-around: ёмкость 4, положи 4 элемента, сними 2, положи
ещё 3 — все значения должны идти в правильном порядке. Затем `python3 grade.py 03`.
## Глава V. Критерии приёмки
`PASS` от `grade.py 03`: сборка без предупреждений, ASAN/UBSAN чистые, все проверки `ok`,
включая порядок FIFO и корректный wrap-around.
## Глава VI. Подсказки (после первой попытки)
<details>
<summary>Значения после заворота идут не в том порядке</summary>
Проверь, что `pop` читает именно `tail_`, а `push` пишет именно в `head_`, и что оба индекса
инкрементируются **после** операции. Классическая ошибка — сдвинуть `head_` до записи.
</details>
<details>
<summary>Перезапись при полном буфере</summary>
Ты не проверяешь `full()` в `push`. По условию перезапись запрещена: полный буфер возвращает
`false` и не портит старые данные.
</details>
<details>
<summary>ASAN: утечка или двойное освобождение</summary>
Если память выделена через `new[]`, освобождать нужно `delete[]` (не `delete`). Проще взять
`std::vector<int>` — тогда вопрос исчезает.
</details>
## Глава VII. Ловушки
- Сдвиг элементов вместо индексов → цена `push`/`pop` вырастает до O(n) → теряется весь
смысл структуры, видно по сравнению с наивным `std::vector` на бенчмарке.
- Путаница «пусто/полно» при совпавших индексах (`head_ == tail_`) без отдельного `size_` →
`full()` и `empty()` дают одинаковый ответ на разных состояниях → тест на заполненный буфер
ёмкости > 1 падает.
- `%` на каждом шаге там, где можно было обойтись условием — не ошибка, но на собеседовании
спросят «а быстрее можно?» (ответ: `if (++idx == cap) idx = 0;`, деление по модулю дороже
сравнения).
- Копирование объекта без правила трёх/пяти → два объекта владеют одним и тем же `new[]`-буфером
→ двойное освобождение при выходе обоих из области видимости, ловится ASAN. Если сдаёшь с
ручным `new[]`, запрети копирование (`= delete`).
**Факты для карточек**
- base | Сложность `push`/`pop` кольцевого буфера? — O(1)
- core | Как отличить пустой буфер от полного при `head_ == tail_`? — хранить отдельный
счётчик `size_` (или жертвовать одной ячейкой)
- core | Чем `delete[]` отличается от `delete` для массива, выделенного `new[]`? — `delete`
без `[]` на массиве — UB, вызовет деструктор только для первого элемента и испортит подсчёт
размера аллокации
- base | Какое условие делает `push` дешевле, чем `% capacity_` на каждый вызов? —
`if (++idx == cap) idx = 0;`
## После сдачи
Разбор: как устроены буферы в драйверах (ring buffer с head/tail для DMA), почему в
многопоточном варианте нужны атомарные индексы и `memory_order`.
+2 -61
View File
@@ -1,35 +1,7 @@
# Задача 04 — разбор IPv4-пакета и контрольная сумма (C++) # Задача 04 — разбор IPv4-пакета и контрольная сумма (C++)
## Глава I. Общая информация Это то, что реально делают сетевые железки: взять буфер из сокета и корректно разобрать
заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
- **Цель:** разобрать буфер байт из сокета в структуру заголовка вручную, безопасно и без
UB — то, что реально делают сетевые железки на приёмном тракте.
- **Почему это в Eltex:** это то, что реально делают сетевые железки: взять буфер из сокета
и корректно разобрать заголовок, не выйдя за границы и не поверив «на слово» полям пакета.
Коммутатор/маршрутизатор обязан отбросить пакет с битой контрольной суммой или враньём в
`total_length`, иначе он либо читает чужую память, либо пересылает мусор дальше.
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 04`.
## Глава II. Что нужно знать до старта
Минимальная длина IPv4-заголовка — 20 байт (без опций). Байт-порядок в заголовке смешанный:
`total_length` и `header_checksum` хранятся в хост-порядке в структуре `Ipv4Header` (в задании
явно указано «хост-порядок» — значит в `parse_ipv4` их нужно правильно собрать из сетевого
порядка на проводе), а IP-адреса (`src_ip`, `dst_ip`) — «байты как на проводе»:
`10.0.0.1 -> 0x0A000001`, то есть без перестановки байт при сборке в `uint32_t`.
Почему нельзя просто `reinterpret_cast<const Ipv4Header*>(buf)`: у `buf` нет гарантии
выравнивания под `uint32_t` (нужно кратно 4 байтам) — компилятор вправе сгенерировать код,
предполагающий выровненный доступ, и на невыровненном адресе это UB (UBSAN ловит как
misaligned address); плюс раскладка полей в `Ipv4Header` определяется компилятором
(padding), а не байтовым форматом протокола — сети всё равно, как выровнены поля в C++-структуре.
Правильный путь — читать каждый байт по отдельности и собирать сдвигами (тот же приём, что
в задаче 01: маски и сдвиги для сборки многобайтового числа из байт).
Почему дальше: если разбор заголовка освоен, логичный следующий шаг — контрольная сумма TCP/UDP
поверх псевдозаголовка, которая считается тем же алгоритмом сложения в дополнительном коде.
## Глава III. Задание
```c++ ```c++
struct Ipv4Header { struct Ipv4Header {
@@ -58,34 +30,3 @@ bool checksum_valid(const uint8_t* buf, size_t len);
Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые. Проверка: `python3 grade.py 04`. Критерий: все `ok`, ASAN/UBSAN чистые.
Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть — Запрещено приводить буфер к структуре через `reinterpret_cast` и читать поля как есть —
на невыровненных адресах и в другом порядке байт это UB. на невыровненных адресах и в другом порядке байт это UB.
## Глава IV. Ловушки
- `reinterpret_cast<Ipv4Header*>(buf)` вместо побайтового разбора → чтение по невыровненному
адресу и/или с padding-полями структуры вместо реального формата пакета → UB, UBSAN
сообщает misaligned address, а на реальных данных поля читаются со сдвигом.
- Не обнулить поле `header_checksum` перед вызовом `compute_checksum` → в сумму попадает
старое значение поля → результат не совпадёт с ожидаемым, тест на `compute_checksum` падает.
- Пропустить перенос (carry) при сложении 16-битных слов в дополнительном коде, если
промежуточная сумма считается в 16-битном типе → биты переноса теряются вместо того чтобы
прибавиться обратно к младшим битам → неверная контрольная сумма на буферах, где сумма слов
переполняет 16 бит.
- Проверить `ihl*4 > len` до проверки `len >= 20` (или в неверном порядке) → чтение поля `ihl`
из буфера короче 1 байта → выход за границы, ASAN сообщает heap-buffer-overflow.
**Факты для карточек**
- base | Минимальная длина IPv4-заголовка без опций? — 20 байт
- base | Код `protocol` для TCP / UDP / ICMP? — 6 / 17 / 1
- core | В каком порядке лежат `src_ip`/`dst_ip` в буфере согласно заданию? — «как на проводе»,
без перестановки байт при сборке в `uint32_t`
- core | Почему `reinterpret_cast` буфера в `Ipv4Header*` — UB? — нет гарантии выравнивания
адреса под 4-байтные поля структуры
- deep | Чему должна быть равна сумма в доп. коде по `ihl*4` байтам заголовка (вместе с полем
checksum) при верной контрольной сумме? — 0xFFFF
## После сдачи
Разбор: как поле `header_checksum` защищает именно заголовок (не данные), почему на каждом
маршрутизаторе TTL уменьшается на 1 и, как следствие, контрольную сумму заголовка приходится
пересчитывать заново на каждом хопе, и чем отличается контрольная сумма TCP/UDP (считается с
псевдозаголовком) от чисто IP.
+1 -61
View File
@@ -1,40 +1,5 @@
# Задача 05 — длиннейшая возрастающая подпоследовательность (C++) # Задача 05 — длиннейшая возрастающая подпоследовательность (C++)
## Глава I. Общая информация
- **Цель:** посчитать длину строго возрастающей подпоследовательности массива за
O(n log n), а не за наивные O(n²).
- **Почему это в Eltex:** задача — эталон на «знаешь ли ты приём tails + бинарный поиск
поверх DP», а сам паттерн «поддерживать отсортированный массив минимально возможных хвостов
и подставлять новый элемент через `lower_bound`» переиспользуется в задачах на планирование
и в сравнении версий (diff-подобные алгоритмы ищут именно длиннейшие совпадающие/растущие
подпоследовательности).
- **Что сдаётся:** `solution.cpp`, проходящий `python3 grade.py 05`.
## Глава II. Что нужно знать до старта
Наивное решение — DP: `dp[i]` = длина LIS, заканчивающейся на элементе `i`, пересчитывается
перебором всех `j < i` — O(n²), при 100 000 элементах это ~10¹⁰ операций и тест не пройдёт
(ограничение — 2 секунды).
Быстрое решение — «хвосты» (patience sorting): массив `tails`, где `tails[k]` — минимально
возможный последний элемент возрастающей подпоследовательности длины `k+1`, накопленной по
уже просмотренному префиксу. Массив `tails` всегда отсортирован по построению, поэтому для
каждого нового `x` бинарным поиском (`lower_bound`) находится первая позиция с
`tails[pos] >= x`: если такая позиция есть — `x` заменяет значение в ней (подпоследовательность
той же длины, но с меньшим хвостом — выгоднее для будущих продолжений); если позиции нет —
`x` дописывается в конец, увеличивая длину LIS на 1. Именно `lower_bound`, а не `upper_bound` —
строгое возрастание требует заменить первый элемент `>= x`, а не `> x` (иначе повторяющиеся
значения ошибочно продлевают подпоследовательность).
Итоговая длина `tails` в конце обработки массива и есть `lis_length`. Значения в `tails` —
это не сама LIS, только корректная её длина.
Почему дальше: тот же приём «поддерживай отсортированный инвариант + `lower_bound`» стоит
знать и для задач на минимальное число возрастающих подпоследовательностей на весь массив.
## Глава III. Задание
```c++ ```c++
int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей) int lis_length(const std::vector<int>& a); // длина НВП (строго возрастающей)
``` ```
@@ -46,29 +11,4 @@ int lis_length(const std::vector<int>& a); // длина НВП (строго
- подпоследовательность не обязана быть непрерывной. - подпоследовательность не обязана быть непрерывной.
Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений. Проверка: `python3 grade.py 05`. Критерий: все `ok`, сборка без предупреждений.
Разбор после сдачи: «хвосты» — массив минимальных последних элементов + lower_bound.
## Глава IV. Ловушки
- `upper_bound` вместо `lower_bound` при строгом возрастании → повторяющиеся значения
ошибочно продлевают подпоследовательность → длина LIS завышена на массивах с дубликатами
(например, `[1, 1, 1]` должен дать 1, а не 3).
- Наивная DP-версия за O(n²) → проходит маленькие тесты, но на 100 000 элементах превышает
лимит в 2 секунды → `grade.py 05` роняет прогон по таймауту, а не по неверному ответу.
- Не обработан пустой вектор отдельным веткой → если код полагается на то, что цикл по
пустому диапазону сам вернёт 0, это обычно верно, но стоит явно проверить — тихая логическая
ошибка здесь не кидает исключение, просто даёт неверный ответ на пограничном тесте.
**Факты для карточек**
- base | Сложность наивного DP-решения LIS? — O(n²)
- core | Сложность решения через `tails` + бинарный поиск? — O(n log n)
- core | Что хранит `tails[k]`? — минимально возможный последний элемент возрастающей
подпоследовательности длины `k+1`
- deep | Почему для строгого возрастания нужен `lower_bound`, а не `upper_bound`? —
`lower_bound` находит первый элемент `>= x` и заменяет его, не давая повторам продлевать
подпоследовательность
## После сдачи
Разбор: почему массив `tails` не является самой LIS, а только хранит её длину через
минимальные возможные хвосты; как по `tails` при необходимости восстановить саму
подпоследовательность (дополнительный массив предшественников).
+7 -103
View File
@@ -1,10 +1,7 @@
# Задача 06 — потокобезопасная ограниченная очередь (C++) # Задача 06 — потокобезопасная ограниченная очередь (C++)
На собеседовании это не вопрос «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать Классический вопрос на собеседовании в embedded/сетевую разработку: не «знаешь ли ты
счётчик под нагрузкой» — то есть понимаешь ли механику мьютекса и условной переменной std::thread», а «умеешь ли ты не сломать счётчик под нагрузкой».
настолько, чтобы очередь не зависла и не словила гонку данных под ThreadSanitizer.
## Интерфейс и требования
```c++ ```c++
class BlockingQueue { class BlockingQueue {
@@ -19,105 +16,12 @@ public:
``` ```
Требования: Требования:
- `push` после `close()` бросает `std::runtime_error`; - push после close() бросает `std::runtime_error`;
- закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait); - закрытие разблокирует все ждущие потоки (никакого вечного ожидания и busy-wait);
- размер очереди никогда не превышает `capacity`; - размер очереди никогда не превышает capacity;
- ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()` - ни одной гонки: тест собирается и гоняется с ThreadSanitizer (в том числе `size()`
вызывается из другого потока — он тоже должен быть потокобезопасным). вызывается из другого потока — он тоже должен быть потокобезопасным).
**Факты для карточек** Проверка: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания.
- base | Каким флагом компилятора включается ThreadSanitizer? — `-fsanitize=thread` Разбор после сдачи: почему нужен predicate-цикл вокруг wait, и как один condition_variable
- base | Что бросает `push` после `close()`? — `std::runtime_error` на оба события даёт ложные пробуждения.
- core | Должен ли `size()` быть потокобезопасным в этой задаче? — да, его тоже вызывают из другого потока и он тоже под захватом мьютекса
- core | Что происходит с ждущими потоками при `close()`? — все разблокируются (никакого вечного `wait`)
Почему дальше: раз очередь общая между потоками, нужен механизм, который защищает и её
состояние, и само ожидание — мьютекс плюс условная переменная.
## Механизм: mutex + condition_variable + predicate-цикл
Общее состояние очереди (буфер, счётчик элементов, флаг `closed`) защищено одним
`std::mutex` через RAII-обёртку (`std::lock_guard`/`std::unique_lock`) — так `unlock()`
происходит автоматически в деструкторе при выходе из области видимости любым путём,
включая исключение, и его невозможно забыть вызвать вручную.
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток — атомарность
важна: если бы освобождение и уход в сон были двумя отдельными шагами, между ними мог бы
вклиниться другой поток, изменить состояние и отправить `notify`, которое тогда потеряется,
потому что ждущий поток ещё не успел зайти в `wait`. При пробуждении `wait` снова захватывает
тот же мьютекс перед тем, как вернуть управление — код после `wait` уже работает под защитой.
Стандарт C++ прямо разрешает ложные пробуждения (spurious wakeup) — `wait` может вернуться
без единого вызова `notify_one`/`notify_all`, просто по решению ОС (futex на Linux иногда
пробуждается по внутренним причинам платформы). Из-за этого `if (!ready) cv.wait(lock);`
недостаточно: после такого пробуждения предикат мог остаться ложным, и поток пойдёт работать
с данными, которые на самом деле не готовы — баг, который не ловится юнит-тестами и стреляет
только под конкурентной нагрузкой. Правильный паттерн — цикл с перепроверкой:
```c++
std::unique_lock<std::mutex> lock(mtx_);
while (!(size_ > 0 || closed_)) // predicate-цикл, не if
not_empty_.wait(lock);
```
Для `push` и `pop` нужны разные условия ожидания (`не полна` и `не пуста` или `закрыта`),
поэтому один `condition_variable` на оба события удобен только тогда, когда `notify_all`
будит все ждущие потоки и каждый сам перепроверяет свой предикат в цикле — так ложные
пробуждения для «не того» условия просто не проходят проверку и поток снова засыпает.
Если использовать `notify_one` при одном общем `condition_variable` для двух разных условий,
можно разбудить не тот поток (например, разбудить ждущего `push`, когда освободилось место
для `pop`), а нужный так и останется спать — отсюда правило: либо `notify_all`, либо два
раздельных `condition_variable`.
**Ловушки**
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
- Изменение состояния без удержания мьютекса (например, `size_++` вне `lock_guard`) →
гонка данных, UB → ловится ThreadSanitizer как data race при `size()` из другого потока.
- `notify_one` при общем `condition_variable` на два разных предиката → не тот поток проснулся,
нужный продолжает ждать → зависание, видно по таймауту теста, а не по явной ошибке.
- Забыть разбудить всех при `close()` → часть потоков навсегда в `wait` → зависание процесса,
тест не завершается за отведённое время.
**Факты для карточек**
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
- core | Почему `while` вокруг `wait`, а не `if`? — из-за spurious wakeup: `wait` может вернуться без вызова notify
- core | Чем опасен один `condition_variable` на два разных предиката вместе с `notify_one`? — можно разбудить не тот поток, а нужный останется ждать
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — futex
Почему дальше: раз тест явно требует ThreadSanitizer и отсутствия зависаний, логично понять,
что именно проверяет `grade.py` и почему «просто работает на глаз» здесь недостаточно.
## Проверка
Запуск: `python3 grade.py 06`. Критерий: все `ok`, TSan чистый, нет зависания. TSan
инструментирует каждое обращение к памяти и отслеживает happens-before между потоками во
время исполнения — то есть ловит гонку по факту конкретного запуска, а не статическим
анализом кода, поэтому «прогнать разок и не увидеть краша» не равно «гонки нет»: раскладка
потоков в конкретном запуске могла просто не проявить проблему.
**Факты для карточек**
- base | Какой командой запускается проверка задачи 06? — `python3 grade.py 06`
- core | Почему TSan может не показать гонку при одном прогоне, даже если она есть в коде? — он ловит гонку по факту конкретной раскладки потоков в этом запуске, а не статическим анализом
<details>
<summary>Проверь себя</summary>
1. Почему `cv.wait(lock)` должен принимать именно `unique_lock`, уже захвативший тот же
мьютекс, что защищает очередь, а не произвольный лок?
<details><summary>Ответ</summary>Потому что `wait` должен атомарно освободить именно этот
мьютекс перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы
предикат под разными блокировками, и защита состояния очереди перестала бы работать.</details>
2. Почему после `close()` `pop()` не должен блокироваться, даже если очередь ещё не пуста?
<details><summary>Ответ</summary>Требование — `close()` опустошает остаток: `pop()` должен
продолжать отдавать оставшиеся элементы (`true`) и только когда очередь действительно
пуста — отдавать `false`, не уходя в ожидание.</details>
3. Почему `size()`, который выглядит как «просто чтение одного числа», всё равно требует
захвата мьютекса?
<details><summary>Ответ</summary>Потому что запись в `size_` из `push`/`pop` без
синхронизации с чтением в `size()` — это гонка данных (одна сторона пишет, другая читает
без общего порядка) — UB по стандарту C++, а не просто «иногда неверное число».</details>
</details>
+4 -106
View File
@@ -4,116 +4,14 @@
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно, принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
завершается по SIGTERM/SIGINT аккуратно (exit 0). завершается по SIGTERM/SIGINT аккуратно (exit 0).
## Требования Требования:
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента); - мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи; - сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту; - `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
- до 64 одновременных соединений, закрытие по EOF со стороны клиента; - до 64 одновременных соединений, закрытие по EOF со стороны клиента;
- никаких утечек дескрипторов (тест проверяет закрытие). - никаких утечек дескрипторов (тест проверяет закрытие).
**Факты для карточек**
- base | На каком адресе и с каким флагом сокета должен слушать сервер? — 127.0.0.1, `SO_REUSEADDR`
- base | Сколько одновременных соединений сервер должен держать минимум? — 64
- core | По какому событию сервер закрывает соединение с клиентом? — по EOF со стороны клиента
Почему дальше: раз сервер должен обслуживать до 64 соединений одним потоком без блокировки
на каждом, нужен механизм, который скажет, какие именно дескрипторы готовы — `epoll`.
## Механизм: epoll, уровень vs фронт
`epoll` — мультиплексор ввода-вывода ядра Linux: `epoll_ctl` регистрирует дескрипторы в
«списке интереса», который ядро хранит между вызовами; когда состояние дескриптора меняется
(пришли данные, освободился буфер записи), ядро само кладёт его в «список готовых»;
`epoll_wait` просто возвращает этот список. Отсюда сложность на каждый вызов пропорциональна
числу реально готовых дескрипторов, а не общему их числу (в отличие от `select`/`poll`,
которые каждый раз заново проходят весь набор).
У `epoll` два режима уведомления:
- **LT (level-triggered, по умолчанию)** — `epoll_wait` возвращает дескриптор снова и снова,
пока в его буфере остаются непрочитанные данные (условие «уровня» истинно). Прочитал не
всё за один раз — на следующем `epoll_wait` дескриптор опять придёт в списке готовых.
- **ET (edge-triggered, флаг `EPOLLET`)** — `epoll_wait` сообщает о готовности только один раз,
в момент перехода состояния «не готов → готов» (фронт). Если не вычитать буфер целиком за
этот раз, повторного уведомления не будет, пока не придут новые данные (новый фронт).
Из этого прямо следует требование к сокетам: **ET обязывает читать/писать в цикле до
`EAGAIN`**, потому что единственный сигнал готовности уже был использован и другого не будет,
пока состояние снова не изменится. А раз цикл идёт до `EAGAIN`, дескриптор обязан быть
**неблокирующим** (`O_NONBLOCK`) — на блокирующем сокете последний вызов `read`/`write` в этом
цикле, когда данных больше нет, завис бы навсегда вместо того, чтобы вернуть `-1`/`EAGAIN`.
При LT неблокирующий режим тоже нужен по той же причине разделения потока между многими
клиентами, но не читать «всё до EAGAIN» за раз не так опасно — событие придёт снова.
`EAGAIN` (синоним `EWOULDBLOCK`) — не ошибка сбоя, а сигнал «сейчас нечего делать, попробуй
позже»; `EINTR` — вызов прерван доставкой сигнала (например, при обработке SIGTERM/SIGINT) и
его нужно просто повторить, а не трактовать как реальную ошибку. Частичное чтение/запись
(`read`/`write` вернул меньше байт, чем просили) — нормальное поведение сокета, а не признак
проблемы: буфер ядра ограничен, и остаток нужно дочитывать/дописывать следующим вызовом.
**Ловушки**
- Трактовать `EAGAIN` как ошибку и закрывать соединение → сервер рвёт рабочие соединения
просто потому, что в момент проверки данные ещё не пришли → видно как случайные обрывы
под нагрузкой, ловится логированием `errno` перед реакцией на ошибку.
- Забыть вычитывать буфер в цикле до `EAGAIN` при `EPOLLET` → повторного уведомления не будет,
часть данных «зависает» в буфере ядра → клиент не получает остаток эха, тест на эхо большого
объёма данных повисает или обрезает ответ.
- Не закрывать `fd` при ошибке/отключении клиента → утечка дескрипторов → процесс упирается
в лимит `ulimit -n` и получает `EMFILE` на новые `accept`, видно через `lsof -p PID` или
`/proc/PID/fd`.
- Игнорировать частичную запись (`write` вернул меньше, чем передали) → часть эха теряется
без ошибки → видно как обрезанный ответ клиенту при большом объёме данных за одну посылку.
**Факты для карточек**
- base | Что означает `EAGAIN` на неблокирующем сокете? — не ошибка, сигнал «данных пока нет, попробуй позже»
- core | В чём разница между LT и ET режимами epoll? — LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов»
- core | Почему ET требует неблокирующих дескрипторов? — цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет
- deep | Какая асимптотика у `epoll_wait` относительно select/poll? — O(1) на готовое событие против O(n) на весь набор у select/poll
Почему дальше: раз сервер должен «аккуратно завершаться по SIGTERM/SIGINT», нужно понимать,
как доставка сигнала прерывает системные вызовы и как это связать с циклом `epoll_wait`.
## Завершение по сигналу
Аккуратное завершение (`exit 0`) означает: сервер должен закрыть все сокеты и выйти из цикла
`epoll_wait`, а не просто дать процессу упасть от сигнала по умолчанию. Практический механизм —
установить обработчик на SIGTERM/SIGINT, который выставляет флаг (`volatile sig_atomic_t`), и
проверять этот флаг в цикле после каждого возврата из `epoll_wait` (который сам может вернуться
с `-1`/`EINTR` из-за прихода сигнала — это нужно отличать от реальной ошибки и не падать на ней).
**Факты для карточек**
- base | Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT? — 0
- core | Что возвращает `epoll_wait`, если его прервал сигнал? — -1 с `errno == EINTR`, не ошибка выполнения
## Проверка
Запускается как самостоятельный бинарник: `./solution <порт>`. Запускается как самостоятельный бинарник: `./solution <порт>`.
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения,
данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода льёт данные, проверяет эхо и корректное завершение по SIGTERM.
(использование epoll, обработка ошибок). Критерий: все `ok` + ревью кода (использование epoll, обработка ошибок).
**Факты для карточек**
- base | Сколько соединений открывает тест `grade.py 07`? — 3
- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM
<details>
<summary>Проверь себя</summary>
1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
покрыло все данные, которые были на момент события?
<details><summary>Ответ</summary>Потому что ET сообщает о фронте один раз; если между этим
чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
`EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.</details>
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
<details><summary>Ответ</summary>Требование — до 64 одновременных соединений одним потоком;
блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
отсюда обязательность `epoll` и неблокирующих сокетов.</details>
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
<details><summary>Ответ</summary>Это не ошибка: буфер сокета ограничен по размеру, ядро
приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
вызовом `write`, когда `EPOLLOUT` снова покажет готовность.</details>
</details>
+1 -121
View File
@@ -11,8 +11,7 @@ TOP <ip> <число запросов>
5XX <число ответов со статусом 500-599> 5XX <число ответов со статусом 500-599>
``` ```
## Правила формата Правила:
- IP — первое поле строки; - IP — первое поле строки;
- TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию - TOP — три самых частых IP по убыванию числа запросов; при равенстве — по возрастанию
строки IP (лексикографически); строки IP (лексикографически);
@@ -21,125 +20,6 @@ TOP <ip> <число запросов>
по времени — нужен awk/sort или один-два прохода; по времени — нужен awk/sort или один-два прохода;
- пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет. - пустой файл: `TOTAL 0`, `5XX 0`, строк TOP нет.
**Факты для карточек**
- base | Сколько строк TOP выводится, если уникальных IP меньше трёх? — столько, сколько есть уникальных IP
- base | Что печатает скрипт для пустого файла? — `TOTAL 0`, `5XX 0`, без строк TOP
- core | По какому полю строки лога определяется IP? — по первому полю
Почему дальше: раз файл может быть на миллион строк, нужно понять, почему построчный
`while read` в bash не укладывается по времени и что вместо этого реально делает работу
за O(n) без интерпретации каждой строки внутри самого bash.
## Механизм: почему не построчный цикл, а awk/sort
Построчный `while read line; do ... done < file` на миллионе строк запускает bash-интерпретатор
заново на каждую итерацию цикла (разбор строки, вызов внешних утилит вроде `cut`/`grep` из
цикла добавляет ещё и `fork+exec` процесса на каждую строку) — при миллионе строк это
миллион запусков процессов, что на порядки медленнее одного прохода специализированной
утилитой. `awk` и `sort` — скомпилированные бинарники, которые сами читают файл целиком
за один проход (awk) без порождения процесса на строку, и делают всю агрегацию (подсчёт по
IP, подсчёт 5xx, TOTAL) внутри одного вызова.
Практическая схема: один проход `awk` по файлу одновременно считает `TOTAL` (число строк),
инкрементирует счётчик по IP в ассоциативном массиве и считает строки с кодом 500–599
(код ответа — обычно отдельное поле в access.log), а на выходе печатает пары `IP count`.
Дальше `sort` сортирует эти пары по счётчику по убыванию, а при равенстве — по IP по
возрастанию (составной ключ сортировки), и `head -3` берёт первые три строки. Итог — два
прохода по данным (awk-агрегация, потом sort уже по маленькому списку уникальных IP, а не
по миллиону исходных строк), а не миллион запусков процессов.
**Факты для карточек**
- core | Почему `while read line` в bash медленный на миллионе строк? — каждая итерация — это работа интерпретатора bash, а вызов внешних утилит из цикла — ещё и `fork+exec` на строку
- core | Что даёт связка awk + sort вместо построчного цикла? — один проход по файлу целиком специализированным бинарником вместо миллиона запусков процессов
- deep | По какому полю сортируется список IP при равенстве числа запросов? — по возрастанию строки IP (вторичный ключ сортировки)
Почему дальше: раз скрипт нетривиальный (несколько шагов, внешние утилиты, граничный случай
с пустым файлом), в нём легко замаскировать ошибку — отсюда `set -e` и его реальные границы.
## `set -e` и где он реально ломается
`set -e` (`errexit`) останавливает скрипт при первой команде, вернувшей ненулевой код
возврата, вместо того чтобы по умолчанию молча идти дальше — это отклонение от исторического
поведения bash, оптимизированного под интерактивную сессию, где одна неудачная команда не
должна обрывать сессию целиком. `set -o pipefail` отдельно нужен для конвейеров (`awk ... |
sort | head`): без него код возврата конвейера — это код возврата только последней команды
(`head`), и падение `awk` или `sort` посередине останется незамеченным, даже если `set -e`
включён — сам по себе `-e` конвейер как единое целое не покрывает.
`set -e` **не срабатывает**, если неудачная команда стоит частью условия — в `if`, `while`,
`until`, слева или справа от `&&`/`||`, либо после `!` — потому что в этих позициях код
возврата команды явно проверяется вызывающим кодом, а не игнорируется, и bash считает это
ожидаемым путём выполнения, а не аварией.
Отдельная и менее очевидная ловушка — **команда в присваивании переменной внутри `local`**:
```bash
local total=$(wc -l < "$file") # опасно под set -e
```
Здесь исполняются фактически две команды: `wc -l` внутри подстановки `$(...)` и сам `local`.
Если `wc -l` завершится с ошибкой, `set -e` эту ошибку не поймает, потому что итоговый код
возврата всей строки — это код возврата `local`, а `local` возвращает 0 (успех) сам по себе,
даже если команда внутри `$(...)` упала. Подстановка «теряется» внутри составной команды.
Лечится разделением на две строки:
```bash
total=$(wc -l < "$file") # код возврата — это код возврата wc -l, set -e сработает
local total
```
**Ловушки**
- Полагаться на голый `set -e` для пайплайна `awk | sort | head` → падение `awk` посередине
незаметно, `head` вернёт 0 → нужен `set -o pipefail`, видно только по неверному выводу,
не по коду возврата.
- `local var=$(cmd)` в одну строку → ошибка `cmd` маскируется кодом возврата `local` (всегда 0
для валидного объявления) → `set -e` не остановит скрипт, видно по неверному/пустому `var`
без явной ошибки.
- Не обработать пустой файл отдельно → `sort`/`head` на пустом входе не печатают строк TOP,
но если код по ошибке считает `TOTAL` через конвейер с `wc -l | ...`, легко перепутать
0 строк с ошибкой чтения файла.
- Не заквотить `"$file"` → путь с пробелом разбивается на несколько слов → скрипт падает
на несуществующем файле или обрабатывает не тот аргумент.
**Факты для карточек**
- base | Что делает `set -e`? — останавливает скрипт при первой команде с ненулевым кодом возврата
- core | В каких позициях `set -e` не останавливает скрипт при ошибке команды? — в условиях `if`/`while`/`until`, слева/справа от `&&`/`||`, после `!`
- core | Почему `local var=$(cmd)` не ловится `set -e`, если `cmd` упал? — итоговый код возврата строки — это код возврата `local`, а он 0 даже при упавшей подстановке внутри
- core | Зачем `set -o pipefail` отдельно от `set -e`? — без него код возврата конвейера — это код возврата только последней команды, падение команды посередине конвейера остаётся незамеченным
## Проверка
Запуск: `bash solution.sh access.log` Запуск: `bash solution.sh access.log`
Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе). Проверка: `python3 grade.py 08` (тест гоняет скрипт на фикстуре и на большом логе).
Критерий: точное совпадение вывода, код возврата 0. Критерий: точное совпадение вывода, код возврата 0.
**Факты для карточек**
- base | Каким кодом возврата должен завершаться `solution.sh` при успехе? — 0
- core | Что именно сверяет `grade.py 08` с эталоном? — точное совпадение вывода (все четыре строки) на фикстуре и на большом логе
<details>
<summary>Проверь себя</summary>
1. Почему пайплайн `grep 500 access.log | wc -l` под одним `set -e` (без `pipefail`) может
молча пропустить ошибку `grep` (например, если файл не существует)?
<details><summary>Ответ</summary>Код возврата конвейера по умолчанию — это код возврата
последней команды (`wc -l`), которая отработает и вернёт 0, даже если `grep` перед ней
упал с ошибкой чтения файла; нужен `pipefail`, чтобы код возврата конвейера отражал первую
упавшую команду.</details>
2. Почему `local total=$(false)` не остановит скрипт при `set -e`, а `total=$(false)` (без
`local` на той же строке) — остановит?
<details><summary>Ответ</summary>В первом случае итоговый код возврата строки — это код
возврата встроенной команды `local`, которая возвращает 0 при корректном синтаксисе
объявления независимо от результата подстановки внутри; во втором случае код возврата
присваивания — это код возврата самой подстановки `$(false)`, то есть 1, и `set -e`
сработает.</details>
3. Почему построчный `while read ip status rest; do ...; done < access.log` с миллионом строк
не проходит по времени, даже если внутри цикла нет вызовов внешних утилит?
<details><summary>Ответ</summary>Каждая итерация цикла — это работа самого интерпретатора
bash (разбор строки, обновление переменных, проверка условий), и миллион таких итераций на
порядки медленнее одного прохода скомпилированным awk, который читает и агрегирует файл
целиком за один запуск процесса.</details>
</details>
+12 -125
View File
@@ -3,134 +3,21 @@
В `crash.c` лежит программа, которая падает на части входов и портит память. В `crash.c` лежит программа, которая падает на части входов и портит память.
Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**. Задача — не переписать её с нуля, а **найти дефекты отладчиком и починить минимально**.
## Ожидаемое поведение после починки Ожидаемое поведение после починки:
- `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0; - `./crash` (без аргумента) печатает `len=5` и завершается с кодом 0;
- `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0; - `./crash <слово>` печатает `len=<длина слова>` и завершается с кодом 0;
- `./crash ""` печатает `len=0` и завершается с кодом 0; - `./crash ""` печатает `len=0` и завершается с кодом 0;
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке. - сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
**Факты для карточек** Что сделать:
- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0 1. Собрать с отладочной информацией и санитайзерами:
- base | Что печатает `./crash ""` после починки? — `len=0`, код возврата 0 `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`
- core | Какие два санитайзера должны не давать сообщений после починки? — ASan и UBSan (`-fsanitize=address,undefined`) 2. Разобраться, что именно портит память, а что приводит к падению при выходе.
Полезно: `gdb ./crash`, `run`, `bt`, `frame`, `info locals`, `watch`.
3. Исправить `crash.c` (минимальные правки, стиль сохранить).
4. Заполнить `answer.txt`: сколько дефектов нашёл, какие именно, какими командами
отладчика это подтвердил (по шагам), почему падало именно так.
Почему дальше: чтобы вообще видеть номера строк и имена переменных в отладчике, а не голый Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN
ассемблер, программу нужно собрать определённым образом — с этого и начинается разбор. и прогоняет 5 входов.
`answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а не только результат).
## Сборка с отладочной информацией и санитайзерами
```
gcc -g -O0 -fsanitize=address,undefined crash.c -o crash
```
`-g` встраивает в бинарник отладочную информацию в формате DWARF — таблицу соответствия
машинных адресов номерам строк исходника, именам переменных, их типам и границам функций.
Именно из DWARF gdb берёт всё, что показывает человеку: `bt` печатает имена функций и строки
вместо голых адресов, `print x` знает тип `x` и умеет напечатать его по правилам этого типа
(структуру — по полям, массив — по элементам), `list` показывает исходный код вокруг текущей
точки. Без `-g` DWARF-секций в бинарнике нет, и gdb видит только адреса и ассемблер.
`-O0` отключает оптимизации компилятора: оптимизатор переставляет и удаляет инструкции,
инлайнит функции и переиспользует один регистр под несколько переменных с непересекающимся
временем жизни — из-за этого отладочная информация от `-O2` часто не соответствует
однозначно исходному коду (строки «прыгают», переменная в `print` показывает не то значение,
потому что регистр уже переиспользован под другую переменную). Отсюда правило: для отладки
всегда пересобирают отдельно с `-g -O0`, а не отлаживают прод-сборку с оптимизациями.
`-fsanitize=address,undefined` — это ASan и UBSan вместе, инструментирование на этапе
компиляции, а не отдельный инструмент поверх готового бинарника:
- **ASan** окружает каждый выделенный блок памяти недоступными «красными зонами» и ведёт
теневую карту состояния памяти — ловит выход за границы буфера, use-after-free и двойное
освобождение прямо в момент обращения, с точным стеком, а не когда испорченные данные
позже вызовут крах в случайном другом месте;
- **UBSan** инструментирует места, где поведение по стандарту C не определено — знаковое
переполнение, сдвиг за пределы разрядности типа, разыменование `NULL` с невалидным типом —
и печатает конкретную строку кода нарушения.
**Ловушки**
- Собрать без `-g` и пытаться понять крах по голому `bt` с адресами вместо строк → отладка
вслепую по ассемблеру, не соответствует задаче «найти минимальными правками».
- Отлаживать сборку с `-O2` → строки в `bt`/`print` не совпадают с реальным местом бага
из-за инлайнинга и переиспользования регистров → ложный след при поиске дефекта.
- Пропустить пересборку с `-fsanitize=address,undefined` перед финальной проверкой → баг,
который не падает явным креша при обычном запуске (например, чтение за границей внутри
тем же выделенного блока), останется незамеченным до `grade.py`.
**Факты для карточек**
- base | Что даёт флаг `-g` при сборке для gdb? — отладочную информацию (DWARF): соответствие адресов строкам, именам и типам переменных
- core | Почему для отладки собирают с `-O0`, а не с `-O2`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, из-за чего строки и значения в отладчике перестают однозначно соответствовать исходнику
- core | Что ловит ASan из перечисленного: выход за границы, use-after-free, двойное освобождение? — все три, в момент обращения к памяти, а не постфактум
- deep | Что именно ловит UBSan, в отличие от ASan? — неопределённое поведение по стандарту C (знаковое переполнение, сдвиг за пределы разрядности), а не ошибки работы с памятью
Почему дальше: раз отладочная информация уже в бинарнике, нужно знать конкретные команды
gdb, которыми по ней ходят при разборе краша.
## Команды gdb для разбора краша
Рабочий цикл: `gdb ./crash`, затем `run [аргумент]` — передаёт управление процессу; если
процесс получает сигнал (обычно `SIGSEGV` от порчи памяти или abort от санитайзера), gdb сам
останавливается на инструкции, вызвавшей сбой.
- `bt` — стек вызовов, цепочка кадров от текущей функции до `main`; первое, на что смотрят
после остановки — показывает, в какой функции и через какие вызовы дошли до краша.
- `frame N` — переключает контекст `print`/`list` на конкретный кадр стека из `bt`, чтобы
посмотреть локальные переменные вызывающей функции, а не только текущей.
- `info locals` — печатает все локальные переменные текущего кадра с их значениями по
информации из DWARF.
- `watch var` — аппаратная точка наблюдения: останавливает выполнение при каждом изменении
значения переменной. Нужна, когда переменная меняется как будто сама по себе из другого
места кода (типичный симптом переполнения буфера, которое затирает соседнюю память) — без
`watch` пришлось бы расставлять точки останова по подозрению вручную.
- `print x` — печатает текущее значение `x`, по типу из DWARF (структуру — по полям).
Санитайзер добавляет отдельный слой: при нарушении (ASan/UBSan) программа останавливается
сама, печатая стек и описание нарушения ещё до того, как повреждение памяти успело привести
к крашу в другом, не связанном с причиной месте — это часто быстрее, чем ловить последствия
вручную в gdb по голому `SIGSEGV`.
**Факты для карточек**
- base | Какая команда gdb показывает цепочку вызовов до краша? — `bt`
- core | Чем `watch var` отличается от обычного `break`? — останавливает выполнение при каждом изменении значения переменной, а не в заданной точке кода
- core | Зачем нужен `frame N` после `bt`? — переключает контекст `print`/`info locals` на конкретный кадр стека, чтобы смотреть переменные не только текущей функции
## Отчёт и проверка
Исправить `crash.c` (минимальные правки, стиль сохранить). Заполнить `answer.txt`: сколько
дефектов нашёл, какие именно, какими командами отладчика это подтвердил (по шагам), почему
падало именно так.
Проверка: `python3 grade.py 09` — компилирует `crash.c` (компилятор C, gcc) с ASAN/UBSAN и
прогоняет 5 входов. `answer.txt` проверяется ревью (это часть оценки: важен ход разбора, а
не только результат).
**Факты для карточек**
- base | Сколько входов прогоняет `grade.py 09`? — 5
- base | Каким компилятором собирается `crash.c` на проверке? — gcc (компилятор C)
<details>
<summary>Проверь себя</summary>
1. Почему для отладки нельзя использовать тот же бинарник, что уже собран с `-O2` для
финальной проверки производительности?
<details><summary>Ответ</summary>Оптимизатор переставляет и удаляет инструкции, инлайнит
вызовы и переиспользует регистры под разные переменные — отладочная информация от такой
сборки не соответствует однозначно исходным строкам, `bt`/`print` покажут «прыгающие» или
неверные значения; для отладки нужна отдельная сборка `-g -O0`.</details>
2. Программа падает с `SIGSEGV` без санитайзеров, но при сборке с ASan вместо краша печатается
сообщение о heap-buffer-overflow раньше, в другом месте кода. Почему это не противоречие?
<details><summary>Ответ</summary>ASan останавливает программу в момент фактического выхода
за границу буфера — в точке причины; без ASan повреждённая память могла не привести к
немедленному краху, а вызвать `SIGSEGV` значительно позже, когда испорченные данные
использовались уже в другом месте — это типичная картина «краш далеко от причины».</details>
3. Чем `watch var` полезнее обычной точки останова (`break`) для поиска места, где переменная
портится неожиданно?
<details><summary>Ответ</summary>`break` останавливает выполнение в заданной строке кода,
но не знает, где именно переменная меняется; `watch var` — аппаратная точка наблюдения,
которая остановит выполнение на любой инструкции, изменившей значение этой переменной, даже
если изменение происходит из неожиданного места (например, из-за записи за границей другого
буфера, затирающей соседнюю память).</details>
</details>
+2 -3
View File
@@ -6,7 +6,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "crash.c") SRC = os.path.join(HERE, "crash.c")
BIN = os.path.join(HERE, "crash_checked" + (".exe" if os.name == "nt" else "")) BIN = os.path.join(HERE, "crash_checked")
failures = 0 failures = 0
@@ -20,8 +20,7 @@ def check(cond, name, extra=""):
def main(): def main():
cc = os.environ.get("CC", "gcc") r = subprocess.run(["gcc", "-g", "-O1", "-Wall", "-Wextra",
r = subprocess.run([cc, "-g", "-O1", "-Wall", "-Wextra",
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fsanitize=address,undefined", "-fno-omit-frame-pointer",
SRC, "-o", BIN], capture_output=True, text=True, timeout=180) SRC, "-o", BIN], capture_output=True, text=True, timeout=180)
check(r.returncode == 0, "сборка с ASAN/UBSAN прошла", r.stderr[:800]) check(r.returncode == 0, "сборка с ASAN/UBSAN прошла", r.stderr[:800])
-31
View File
@@ -1,31 +0,0 @@
// Задача 10 — хеш-таблица с открытой адресацией.
// Реализуй методы ниже. Заглушка намеренно не проходит проверку.
#include <cstddef>
#include <vector>
class HashTable {
public:
explicit HashTable(std::size_t initial_capacity = 16) {
(void)initial_capacity;
}
void put(int key, int value) {
(void)key;
(void)value;
}
bool get(int key, int& out) const {
(void)key;
(void)out;
return false;
}
bool erase(int key) {
(void)key;
return false;
}
std::size_t size() const { return 0; }
std::size_t capacity() const { return 16; }
};
-237
View File
@@ -1,237 +0,0 @@
# Задача 10 — хеш-таблица с открытой адресацией
> Перед этой задачей прочитай урок `lessons/D1_algo.md` (разделы 3 и 5) — там разобран
> принцип и есть рабочий образец с цепочками. Здесь то же самое, но сложнее: коллизии
> разрешаются **внутри массива**.
## Глава I. Общая информация
- **Цель:** научиться писать контейнер, который используется в реальном коде (кэши,
таблицы маршрутизации, счётчики в логах) и который спрашивают на собеседовании в формате
«а сам сможешь?».
- **Почему это в Eltex:** таблицы MAC-адресов, FDB коммутатора, кэши сессий — всё это
хеш-таблицы с открытой адресацией и жёсткими требованиями к памяти.
- **Время:** 1,5–2,5 часа со ступенями. Если больше 3 часов — не долби в одиночку, скажи мне.
- **Что сдаётся:** файл `solution.cpp`, проходящий `python3 grade.py 10`.
**Факты для карточек**
- base | Сколько времени закладывается на задачу 10? — 1,5–2,5 часа со ступенями
- base | Какой командой проверяется решение? — `python3 grade.py 10`
- deep | Где в реальных устройствах используется похожая структура? — таблицы MAC-адресов (FDB) коммутатора, кэши сессий — открытая адресация с жёсткими ограничениями по памяти
Почему дальше: прежде чем писать код, нужно понять, почему индекс считается через `&`,
а не через `%`, и что физически происходит при коллизии.
## Глава II. Механизм: индекс, зондирование, tombstone, load factor
**Индекс через маску.** `idx = hash & (cap - 1)` работает как «остаток от деления», только
если `cap` — степень двойки. Причина: у степени двойки `cap - 1` в двоичном виде — это
подряд идущие единицы во всех младших битах (например, `cap = 8 = 0b1000` → `cap-1 = 0b0111`).
Операция `AND` с такой маской обнуляет все биты хеша выше этой границы и оставляет ровно
младшие `log2(cap)` бит — а это математически то же самое, что `hash mod cap`. Пример:
`cap = 8` (маска `0b111`), `hash = 19` (`0b10011`) → `19 & 7 = 3`.
`%` работает для любой ёмкости, но требует инструкции деления (на некоторых платформах
дороже, чем сдвиг/AND); отсюда практика ограничивать ёмкость степенью двойки и считать
индекс битовой маской.
**Линейное зондирование.** Если ячейка `idx` занята, пробуем `(idx+1) & (cap-1)`, потом
`(idx+2) & (cap-1)` и так по кругу, пока не найдём свободную ячейку (для вставки) или
нужный ключ (для поиска). Механически это обход массива по кругу начиная с `idx`.
**Tombstone.** Удалять «в ноль» (ставить `EMPTY`) нельзя: если между началом зондирования
и искомым элементом появится пустая ячейка, поиск остановится на ней раньше, чем дойдёт до
элемента — элемент физически на месте, но недостижим для `get`. Поэтому удалённая ячейка
помечается **tombstone** — «здесь был элемент, поиск должен идти дальше», но **вставка**
может переиспользовать tombstone-ячейку под новый ключ.
**Load factor и длина пробега.** `load = size / capacity`. По формуле среднего числа проб
при линейном зондировании (Кнут): удачный поиск — `0.5·(1 + 1/(1-load))` проб,
неудачный — `0.5·(1 + 1/(1-load)²)`. При `load = 0.7`: удачный поиск ≈ 2,2 пробы,
неудачный ≈ 6,1. При `load = 0.9`: удачный ≈ 5,5, неудачный ≈ 50,5 — рост нелинейный,
именно поэтому порог держат на 0.7, а не поднимают его «для экономии памяти».
**Факты для карточек**
- base | Почему `hash & (cap-1)` эквивалентно `hash % cap`? — только если `cap` — степень двойки: `cap-1` в двоичном виде — маска из всех младших бит, AND с ней даёт тот же результат, что остаток от деления
- base | Пример: `cap=8` (маска `0b111`), `hash=19` (`0b10011`). Индекс? — `19 & 7 = 3`
- core | Среднее число проб при удачном поиске, load factor 0.7? — ≈2,2 (формула Кнута `0.5·(1+1/(1-0.7))`)
- core | То же при load factor 0.9? — ≈5,5 — рост почти в 2,5 раза от значения при 0.7
- deep | Среднее число проб при НЕудачном поиске, load factor 0.9? — ≈50,5 (`0.5·(1+1/(1-0.9)²)`) — на порядок хуже удачного поиска
- core | Почему `EMPTY` вместо `TOMBSTONE` после удаления ломает поиск чужих ключей? — поиск идёт по цепочке проб до первой `EMPTY`; если такая ячейка появляется раньше искомого ключа, элементы за ней в этой цепочке становятся ненаходимыми, хотя физически на месте
Почему дальше: механизм понятен — теперь нужен точный интерфейс и числа, по которым
`grade.py` проверяет решение.
## Глава III. Задание
Свой контейнер, `std::unordered_map` использовать нельзя. Интерфейс ровно такой:
```c++
class HashTable {
public:
explicit HashTable(std::size_t initial_capacity = 16);
void put(int key, int value); // обновляет значение, если ключ уже есть
bool get(int key, int& out) const; // false — ключа нет
bool erase(int key); // false — ключа не было
std::size_t size() const;
std::size_t capacity() const; // степень двойки, >= 16
};
```
Требования:
- **ключи с совпадающими младшими битами обязаны находиться** — тест вставляет 512 ключей,
кратных 16, то есть все они попадают в одну-две стартовые ячейки. Это проверка
зондирования, а не «повезло с хешем»;
- `put` 100 000 ключей суммарно быстрее 2 секунд;
- после вставок `capacity()` растёт (степень двойки), загрузка остаётся ≤ 0.7;
- `size()` честно считает элементы: повторный `put` того же ключа **не** увеличивает `size`;
- `erase` работает: после удаления ключ не находится, но поиск другого ключа, стоявшего
за ним в цепочке зондирования, по-прежнему работает;
- удаление и последующая вставка не должны «терять» элементы, а `size()` после удаления
и вставки возвращается к правильному значению.
**Факты для карточек**
- base | Сколько ключей вставляет тест на кластеризацию и какие они? — 512 ключей, кратных 16
- base | За какое время должны пройти 100 000 вставок? — быстрее 2 секунд
- base | Какой порог load factor держит таблица? — ≤ 0.7
- base | Минимальная ёмкость таблицы по умолчанию? — 16, степень двойки
## Глава IV. Ступени (делай по одной, после каждой — прогон)
Не пытайся написать всё сразу. Каждая ступень проверяется отдельно, и это нормальный
порядок работы инженера.
**Ступень 1. Хеш-функция и индекс (20 минут).**
Напиши `size_t hash_of(int key)` и `size_t index_of(int key) const`. Для целых ключей
хорошая хеш-функция — перемешать биты умножением на большую нечётную константу:
`h = (uint64_t)key * 2654435761u;` (это `2^32 / φ` при золотом сечении `φ ≈ 1.618`,
классика — умножение на нечётную константу обратимо по модулю `2^32` и «размазывает»
младшие биты ключа по всей ширине результата, поэтому соседние по младшим битам ключи
после умножения расходятся по разным индексам).
Проверь себя на бумаге или в маленькой программе: для `cap = 16` ключи 16, 32, 48 должны
дать **разные** индексы. Если дают одинаковые — ты забыл перемешать биты, и все кратные 16
свалятся в одну ячейку.
**Ступень 2. Массивы и конструктор (15 минут).**
Нужны три вещи: массив значений, массив признаков состояния ячейки и счётчик размера.
Признаки: `EMPTY`, `OCCUPIED`, `TOMBSTONE`. Ёмкость в конструкторе приводи к степени двойки
(16 по умолчанию) — тест ожидает `capacity() >= 16` и степень двойки.
После этого прогон должен уже собираться, а часть проверок на пустой таблице — проходить.
**Ступень 3. Вставка без перехеширования (30 минут).**
Идёшь по ячейкам от `index_of(key)`, пока не найдёшь: свой ключ (обновить значение),
`EMPTY` или `TOMBSTONE` (вставить). **Важно:** если ключ найден — не увеличивай `size()`.
Tombstone можно переиспользовать под вставку, но тогда его признак меняется на `OCCUPIED`.
**Ступень 4. Поиск (15 минут).**
Идёшь так же, но: свой ключ — нашли; `EMPTY` — стоп, ключа нет; `TOMBSTONE` — идём дальше
(здесь легко ошибиться и вернуть `false` раньше времени).
**Ступень 5. Удаление через tombstone (20 минут).**
Нашёл ключ → ставим `TOMBSTONE`, `size--`. Если ключа нет — `false`, ничего не меняем.
**Ступень 6. Перехеширование (30 минут).**
Когда `size * 10 > capacity * 7` (то есть `load > 0.7`), создай массив вдвое больше и
**заново вставь все занятые ключи** (tombstone не переносим — в новой таблице пусто).
Учти: после этого `size` не должен измениться, а пробеги станут короче (см. формулу проб
из главы II — вдвое большая ёмкость при том же `size` резко снижает `load`, а с ним и
среднее число проб). Прогон: `python3 grade.py 10`.
**Ступень 7. Прогон под санитайзерами (10 минут).**
`grade.py` уже собирает с ASAN/UBSAN — если он зелёный, память чистая. Отдельно проверь,
что деструктор освобождает ровно то, что выделил конструктор (или не выделяй вручную вовсе,
а используй `std::vector` — так короче и безопаснее).
## Глава V. Критерии приёмки
Задача сдана, когда `python3 grade.py 10` печатает `PASS` — это значит: сборка без
предупреждений, ASAN/UBSAN чистые, все проверки `ok`, включая 512 ключей, кратных 16,
перехеширование, удаление из середины цепочки и повторные вставки.
## Глава VI. Подсказки (открывать после первой честной попытки)
<details>
<summary>Не находит ключи, кратные 16</summary>
Ты используешь `key & (cap-1)` без перемешивания. Ключи 16, 32, 48, … в маске дают 0 —
все в одну ячейку, пробег растёт, а при большом числе ключей ты упираешься в «таблица
полна». Лечится хеш-функцией: умножить ключ на большую нечётную константу перед маской.
</details>
<details>
<summary>size() растёт от повторных put</summary>
Сначала ищи существующий ключ. Нашёл — обнови значение и выйди, `size++` не делай.
Увеличивай счётчик только когда записал в пустую или tombstone-ячейку.
</details>
<details>
<summary>После erase пропадают соседние ключи</summary>
Ты ставишь `EMPTY` вместо `TOMBSTONE`, и поиск обрывается на этом месте. `EMPTY` — только
для никогда не занятых ячеек; после удаления — `TOMBSTONE`.
</details>
<details>
<summary>Зацикливается при полной таблице</summary>
Цикл зондирования обязан ограничиться `capacity` шагами. Если таблица переполнилась —
значит не сработал порог перехеширования (0.7) или ты неверно считаешь `size`.
</details>
## Глава VII. Ловушки
- Забыть перемешать хеш (использовать `key` напрямую как индекс) → ключи, кратные 16,
все попадают в одну-две ячейки → кластеризация, пробег растёт линейно с числом таких
ключей → тест на 512 ключей, кратных 16, падает по таймауту или по «not found».
- Забыть про tombstone при rehash (перенести признак `TOMBSTONE` в новую таблицу вместо
того, чтобы его отбросить) → «мёртвые» ячейки переезжают и занимают место в новой
таблице без необходимости → эффективная ёмкость меньше заявленной, load factor растёт
быстрее ожидаемого.
- Хранить ключ отдельно от значения в параллельных массивах и перепутать индекс при
перестановке → ASAN поймает не сразу, а в момент обращения к рассинхронизированной
ячейке; надёжнее держать ключ и значение в одной структуре ячейки.
- Проверять `size == capacity` вместо load factor (`size * 10 > capacity * 7`) → таблица
почти полностью заполняется, пробеги становятся длиной в десятки-сотни ячеек (см. формулу
неудачного поиска при load → 1), суммарное время `put` уходит за лимит 2 секунды на
100 000 ключей.
## Проверь себя
<details>
<summary>1. Почему `cap = 17` был бы плохим выбором ёмкости таблицы?</summary>
`cap-1 = 16 = 0b10000` — не маска из подряд идущих единиц, поэтому `hash & (cap-1)`
перестаёт быть эквивалентно `hash % cap`: часть значений индекса становится недостижимой,
а распределение по ячейкам — неравномерным. Степень двойки — обязательное условие, при
котором работает битовая маска вместо деления.
</details>
<details>
<summary>2. Во сколько раз в среднем растёт число проб при удачном поиске, если load factor
вырастет с 0.7 до 0.9?</summary>
Примерно в 2,5 раза: `0.5·(1+1/(1-0.7)) ≈ 2,2` против `0.5·(1+1/(1-0.9)) ≈ 5,5`.
Для неудачного поиска рост ещё резче — с ≈6,1 до ≈50,5, то есть почти в 8 раз.
</details>
<details>
<summary>3. Почему при вставке нельзя сразу увеличивать `size`, не проверив, есть ли уже
такой ключ?</summary>
`put` обязан обновлять значение существующего ключа, а не создавать дубликат. Если
инкрементировать `size` без предварительного поиска ключа по цепочке зондирования,
повторная вставка одного и того же ключа завысит `size()` и тест на честный подсчёт
элементов упадёт.
</details>
<details>
<summary>4. Почему tombstone-ячейки не переносятся в новую таблицу при перехешировании?</summary>
В новой, увеличенной вдвое таблице нет старых цепочек зондирования — переносятся только
реально занятые ключи, вставленные заново с нуля. Перенос tombstone бессмысленно тратил бы
место в и без того более просторной таблице и не даёт никакого выигрыша, поскольку сами
цепочки проб после rehash пересчитываются заново.
</details>
## После сдачи
Разбор со мной: почему ёмкость — степень двойки; чем открытая адресация лучше цепочек по
кэшу и хуже по кластеризации; как tombstone-метки спасают поиск после удаления; как это
устроено в реальных FDB коммутаторов (там обычно хеш с цепочками и ограничением глубины).
-78
View File
@@ -1,78 +0,0 @@
#include <cstdio>
#include <cstdint>
#include <random>
#include <chrono>
#include <unordered_map>
#include <vector>
#include "solution.cpp"
static int failures = 0;
#define CHECK(cond, name) do { if (cond) std::printf("ok %s\n", name); \
else { std::printf("FAIL %s (line %d)\n", name, __LINE__); ++failures; } } while (0)
int main() {
{
HashTable t;
CHECK(t.size() == 0, "новая таблица пуста");
CHECK(t.capacity() >= 16 && (t.capacity() & (t.capacity() - 1)) == 0,
"capacity — степень двойки и >= 16");
int v = -1;
CHECK(t.get(42, v) == false, "get по отсутствующему ключу -> false");
CHECK(t.erase(42) == false, "erase отсутствующего -> false");
}
{
HashTable t;
for (int i = 0; i < 1000; ++i) t.put(i, i * 7);
CHECK(t.size() == 1000, "1000 ключей: size == 1000");
bool all = true;
for (int i = 0; i < 1000; ++i) { int v = -1; if (!t.get(i, v) || v != i * 7) all = false; }
CHECK(all, "1000 ключей: все значения читаются");
t.put(500, 999);
int v = -1;
CHECK(t.get(500, v) && v == 999, "повторный put обновляет значение");
CHECK(t.size() == 1000, "повторный put не увеличивает size");
}
{
// ключи с одинаковыми младшими битами — проверка зондирования
HashTable t;
for (int i = 0; i < 512; ++i) t.put(i * 16, i);
bool all = true;
for (int i = 0; i < 512; ++i) { int v = -1; if (!t.get(i * 16, v) || v != i) all = false; }
CHECK(all, "512 ключей, кратных 16: все находятся (линейное зондирование)");
int missing = -1;
CHECK(t.get(15, missing) == false, "нечётный отсутствующий ключ -> false");
}
{
// удаление посреди цепочки зондирования
HashTable t;
for (int i = 0; i < 200; ++i) t.put(i * 16, i);
CHECK(t.erase(0) == true, "erase существующего -> true");
int v = -1;
CHECK(t.get(0, v) == false, "удалённый ключ не найден");
bool rest = true;
for (int i = 1; i < 200; ++i) { int x = -1; if (!t.get(i * 16, x) || x != i) rest = false; }
CHECK(rest, "остальные ключи из цепочки живы после удаления");
CHECK(t.size() == 199, "size после удаления == 199");
}
{
const int N = 100000;
HashTable t;
std::mt19937 rng(4242);
std::vector<int> keys(N);
for (auto& k : keys) k = int(rng() % 1000000);
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < N; ++i) t.put(keys[i], i);
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
std::printf(" (100k вставок: time=%.3fs, capacity=%zu, size=%zu)\n", sec, t.capacity(), t.size());
CHECK(sec < 2.0, "100k вставок за < 2 c");
CHECK(t.size() >= 95000 && t.size() <= 100000, "size в разумных пределах");
CHECK(t.capacity() >= 131072 && t.capacity() <= 262144, "capacity выросла (загрузка <= 0.7)");
bool all = true;
for (int i = 0; i < N; ++i) { int v = -1; if (!t.get(keys[i], v)) all = false; }
CHECK(all, "все 100k ключей читаются");
int v = -1;
CHECK(t.get(-12345, v) == false, "отсутствующий ключ -> false");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL PASS\n", failures);
return failures ? 1 : 0;
}
-19
View File
@@ -1,19 +0,0 @@
// Задача 11 — куча: heapify за O(n), kth_largest, top_k.
// Реализуй функции ниже. Заглушка намеренно не проходит проверку.
#include <vector>
void heapify(std::vector<int>& a) {
(void)a;
}
int kth_largest(const std::vector<int>& a, int k) {
(void)a;
(void)k;
return 0;
}
std::vector<int> top_k(const std::vector<int>& a, int k) {
(void)a;
(void)k;
return {};
}
-164
View File
@@ -1,164 +0,0 @@
# Задача 11 — куча: построение за O(n) и top-K (C++)
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`),
без `std::priority_queue`.
```c++
void heapify(std::vector<int>& a); // перестроить массив в кучу
int kth_largest(const std::vector<int>& a, int k); // k >= 1
std::vector<int> top_k(const std::vector<int>& a, int k); // k наибольших, по убыванию
```
Требования:
- `heapify` — **просеивание снизу вверх (bottom-up), O(n)**, а не `push` в цикле (это O(n log n));
проверка свойств кучи машинная, требование по сложности я сверяю глазами по коду — на
собеседовании спросят именно это;
- `heapify` обязан сохранить мультимножество элементов (ничего не терять и не дублировать);
- `k` в границах `1..n`; при `k > n` — `top_k` возвращает все по убыванию, `kth_largest` при `k > n`
возвращает значение минимального элемента (то есть не падает);
- 3 000 000 элементов: `heapify` быстрее 2 секунд, `kth_largest` с `k = 1000` быстрее 2 секунд.
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
ASAN/UBSAN чистые.
**Факты для карточек**
- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2`
- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности)
- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n)
Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше
нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла.
## Механизм: массив как дерево, sift-down/sift-up
Массив хранится линейно (`std::vector<int>`), но интерпретируется как полное бинарное
дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях.
**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец
массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс
сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`.
**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в
корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок
больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока
инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от
корня.
**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне,
то есть в начале массива, никакого поиска не требуется.
**Факты для карточек**
- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи
- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева
- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла
## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n)
**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до
`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`,
по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса
элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту.
**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое
отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте
всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с
малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще
пропускаются). Суммарная работа:
```
Σ h=0..log n (n / 2^(h+1)) · h
```
Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма
ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000`
(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем
push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а
разница на порядок при 3 миллионах элементов.
**Факты для карточек**
- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n`
- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n`
- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0)
- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up
Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся
`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных.
## kth_largest и top_k
Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down`
корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и
`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо
за счёт того, что сама куча строится линейно.
Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а
`kth_largest` — значение минимального элемента, без падения и без обращения за границу
массива.
**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо
построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый
элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум
удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной
сортировки, и память — `O(k)`, а не `O(n)`.
**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной
позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее
— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного
времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт
`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для
top-K как **отсортированного** списка кучу всё равно придётся досортировать после
`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию.
**Факты для карточек**
- base | Сложность `kth_largest`/`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 | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop
## Ловушки
- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка
свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на
3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят
на собеседовании отдельным вопросом).
- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать
значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением
отсортированной копии массива до и после `heapify`.
- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу
массива → падение или UB, видно по ASAN, а не просто неверный ответ.
## Проверь себя
<details>
<summary>1. Почему bottom-up heapify — O(n), хотя высота дерева log n?</summary>
Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
к `n`, умноженному на константу, а не на `log n`.
</details>
<details>
<summary>2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
чем bottom-up heapify?</summary>
Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
heapify) — то есть на порядок больше сравнений.
</details>
<details>
<summary>3. Почему `top()` кучи — O(1), а не O(log n)?</summary>
По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
обращение по индексу `0`, без поиска и без перестройки структуры.
</details>
<details>
<summary>4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
массив?</summary>
Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
заменяет его за O(log k) — суммарно O(n log k) и память O(k).
</details>
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.
-74
View File
@@ -1,74 +0,0 @@
#include <cstdio>
#include <vector>
#include <random>
#include <chrono>
#include <algorithm>
#include "solution.cpp"
static int failures = 0;
#define CHECK(cond, name) do { if (cond) std::printf("ok %s\n", name); \
else { std::printf("FAIL %s (line %d)\n", name, __LINE__); ++failures; } } while (0)
static bool is_max_heap(const std::vector<int>& a) {
for (std::size_t i = 1; i < a.size(); ++i)
if (a[(i - 1) / 2] < a[i]) return false;
return true;
}
int main() {
{
std::vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6};
std::vector<int> before = a;
heapify(a);
CHECK(is_max_heap(a), "маленький массив: свойство max-heap держится");
std::vector<int> s1 = before, s2 = a;
std::sort(s1.begin(), s1.end()); std::sort(s2.begin(), s2.end());
CHECK(s1 == s2, "мультимножество сохранено");
std::vector<int> empty_a;
heapify(empty_a);
CHECK(empty_a.empty(), "пустой массив -> пустой");
std::vector<int> one = {7};
heapify(one);
CHECK(one.size() == 1 && one[0] == 7, "один элемент не теряется");
}
{
std::vector<int> a = {1, 2, 3, 4, 5};
CHECK(kth_largest(a, 1) == 5, "kth_largest k=1 -> максимум");
CHECK(kth_largest(a, 5) == 1, "kth_largest k=n -> минимум");
CHECK(kth_largest(a, 3) == 3, "kth_largest k=3 -> медиана");
std::vector<int> z = {4, 4, 4};
CHECK(kth_largest(z, 2) == 4, "дубликаты: k=2 -> 4");
std::vector<int> dup = {5, 5, 1, 1, 3};
CHECK(kth_largest(dup, 4) == 1, "дубликаты: k=4 -> 1");
std::vector<int> big = top_k(a, 3);
CHECK(big.size() == 3 && big[0] == 5 && big[1] == 4 && big[2] == 3, "top_k(3) по убыванию");
std::vector<int> all = top_k(a, 10);
CHECK(all.size() == 5 && all.front() == 5 && all.back() == 1, "top_k(k>n) -> все по убыванию");
std::vector<int> neg = {-5, -1, -9};
CHECK(kth_largest(neg, 1) == -1, "отрицательные: максимум -1");
CHECK(kth_largest(a, 100) == 1, "k>n не падает и даёт минимум");
}
{
const int N = 3000000;
std::mt19937 rng(777);
std::vector<int> a(N);
for (auto& x : a) x = int(rng() % 1000000);
std::vector<int> ref = a;
auto t0 = std::chrono::steady_clock::now();
heapify(a);
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
std::printf(" (3M heapify: time=%.3fs, top=%d)\n", sec, a.empty() ? -1 : a[0]);
CHECK(is_max_heap(a), "3M: свойство max-heap держится");
CHECK(a[0] == *std::max_element(ref.begin(), ref.end()), "3M: корень == максимум");
CHECK(sec < 2.0, "3M heapify за < 2 c");
t0 = std::chrono::steady_clock::now();
int k = kth_largest(ref, 1000);
double sec2 = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
std::sort(ref.begin(), ref.end(), std::greater<int>());
std::printf(" (3M kth_largest k=1000: time=%.3fs, value=%d)\n", sec2, k);
CHECK(k == ref[999], "3M: kth_largest(k=1000) совпадает с сортировкой");
CHECK(sec2 < 3.0, "3M kth_largest за < 3 c");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL PASS\n", failures);
return failures ? 1 : 0;
}
-14
View File
@@ -1,14 +0,0 @@
// Задача 12 — кратчайший путь (Дейкстра).
// Реализуй функцию ниже. Заглушка намеренно не проходит проверку.
#include <vector>
#include <tuple>
long long shortest_path(int n,
const std::vector<std::tuple<int, int, int>>& edges,
int src, int dst) {
(void)n;
(void)edges;
(void)src;
(void)dst;
return -1;
}
-147
View File
@@ -1,147 +0,0 @@
# Задача 12 — Дейкстра на priority_queue (C++)
```c++
long long shortest_path(int n,
const std::vector<std::tuple<int,int,int>>& edges, // (u, v, w)
int src, int dst);
```
Требования:
- рёбра **ориентированные**, вес `w >= 0`, self-loop допустим; вершины `0..n-1`;
- вернуть длину кратчайшего пути `src -> dst` (`long long`, веса суммируются до 10^9);
- недостижимость → `-1`; `src == dst` → `0`;
- вес рёбер 0 допустим (условие применимости Дейкстры — **неотрицательные** веса);
- 100 000 вершин и 200 000 рёбер: быстрее 2 секунд. Значит нужна `std::priority_queue`
(O((V+E) log V)), а не O(V²) перебор минимума.
Проверка: `python3 grade.py 12`. Критерий: все `ok`, сборка без предупреждений.
**Факты для карточек**
- base | Что возвращает функция при `src == dst`? — 0
- base | Что возвращает функция при недостижимости `dst`? — -1
- base | В каком типе суммируются веса и почему не `int`? — `long long`; веса суммируются до 10^9, `int` может переполниться на длинном пути
Почему дальше: чтобы уложиться в 2 секунды на 100 000 вершин и 200 000 рёбер, важно понять,
почему наивный перебор минимума не подходит и что именно даёт куча.
## Механизм: куча против наивного перебора
**Наивная Дейкстра.** На каждом из `V` шагов алгоритм сканирует массив `dist[]` целиком,
чтобы найти непосещённую вершину с минимальным расстоянием — это `O(V)` на шаг. Суммарно
`V` шагов по `O(V)` — `O(V²)`, плюс `O(E)` на релаксацию рёбер (эта часть не доминирует).
Подходит, если граф плотный (`E ~ V²`), но не в этой задаче.
**Дейкстра с priority_queue.** Вместо линейного скана используется min-куча по текущему
известному расстоянию. При релаксации ребра `(u, v, w)`, если найден более короткий путь до
`v`, в кучу **добавляется новая запись** `(dist, v)` — `push` стоит `O(log размер_кучи)`.
Так как для одной вершины может накопиться несколько записей (по одной на каждое улучшение
расстояния), размер кучи ограничен числом релаксаций, то есть `O(V + E)`, и `log` от этой
величины по порядку — тот же `O(log V)` (`log(V²) = 2 log V`, то есть смена основания
не меняет асимптотику). Суммарно: `O((V + E) log V)`.
**Числа задачи.** `V = 100 000`, `E = 200 000`. Наивный `O(V²) = 100 000² = 10^10` операций
— в 2 секунды не укладывается ни при каких обстоятельствах. Куча: `(V+E)·log₂V ≈
300 000 · 16,6 ≈ 5·10^6` операций — на 3–4 порядка меньше, укладывается с большим запасом.
**«Ленивое» удаление устаревших записей.** `std::priority_queue` не умеет `decrease-key`
за `O(log n)` (не тот интерфейс), поэтому вместо обновления существующей записи в куче
для вершины `v` просто добавляется новая пара `(dist, v)` при каждом улучшении. В куче
одновременно может лежать несколько устаревших записей для одной вершины. При извлечении
самой записи с минимальным `dist` проверяется, актуальна ли она:
```c++
auto [d, v] = pq.top(); pq.pop();
if (d != dist[v]) continue; // запись устарела — лучшая уже обработана раньше
```
Это дешевле, чем поддерживать структуру с настоящим `decrease-key` (например, indexed
heap), и корректность не страдает — устаревшая запись всегда хуже уже найденного `dist[v]`
и просто пропускается.
**Факты для карточек**
- base | Сложность наивной Дейкстры (перебор минимума по массиву)? — O(V²)
- base | Сложность Дейкстры с бинарной кучей? — O((V+E) log V)
- core | При V=100 000, E=200 000, почему наивный O(V²) не укладывается в 2 секунды? — 100 000² = 10^10 операций против ≈5·10^6 у варианта с кучей — разница на 3–4 порядка
- core | Что означает «ленивое удаление» устаревших записей в очереди? — вместо decrease-key при каждом улучшении расстояния в кучу пушится новая пара (dist, v); при извлечении запись с `d != dist[v]` пропускается как устаревшая
- deep | Почему `std::priority_queue` не используют с decrease-key напрямую? — у контейнера-адаптера нет интерфейса для обновления произвольного элемента за O(log n); дешевле каждый раз пушить новую запись и лениво отбрасывать устаревшие при pop
Почему дальше: скорость решена кучей, но у Дейкстры есть жёсткое условие корректности —
неотрицательные веса. Нужно понять механизм, почему это условие обязательно.
## Механизм: почему нужны только неотрицательные веса
Дейкстра — жадный алгоритм: когда вершина `v` извлекается из очереди с минимальным на
данный момент расстоянием, алгоритм считает `dist[v]` **окончательным** и больше не
пересматривает эту вершину. Это верно только если все ещё не обработанные пути до `v` не
могут оказаться короче — а это гарантировано лишь при неотрицательных весах: любой другой
путь до `v` проходит через вершины с расстоянием `≥ dist[v]` и добавляет ребро `≥ 0`, то
есть не может дать сумму меньше `dist[v]`.
Если в графе есть отрицательное ребро, эта гарантия ломается: путь через уже
«финализированную» вершину может позже пройти по отрицательному ребру и дать меньшую
сумму, но алгоритм эту вершину уже не пересматривает — результат будет неверным без явного
падения или ошибки, то есть тихо неправильным.
Для графов с отрицательными весами (без отрицательных циклов) используется
**Беллман-Форд**: `V-1` раз релаксируются все `E` рёбер, `O(V·E)`. Дополнительно на `V`-м
проходе можно проверить, продолжает ли что-то релаксироваться — если да, в графе
отрицательный цикл, кратчайший путь не определён (можно уменьшать бесконечно).
Self-loop с весом `w ≥ 0` никогда не уменьшает кратчайший путь (добавление неотрицательного
веса к текущему расстоянию не улучшает его), поэтому не требует отдельной обработки —
алгоритм просто никогда не выберет такое ребро для релаксации выгодно.
**Факты для карточек**
- core | Почему Дейкстра ломается на отрицательных рёбрах? — алгоритм считает расстояние до извлечённой из очереди вершины окончательным; отрицательное ребро может позже уменьшить это расстояние, но вершина уже не пересматривается
- base | Какой алгоритм нужен при отрицательных весах без отрицательных циклов? — Беллман-Форд, O(V·E)
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если рёбра продолжают релаксироваться на V-м проходе (после V-1 гарантированно достаточных проходов), в графе есть отрицательный цикл
- base | Почему self-loop с w≥0 не требует отдельной обработки? — добавление неотрицательного веса к текущему расстоянию никогда не уменьшает его, значит такое ребро никогда не выигрывает релаксацию
## Ловушки
- Использовать `int` вместо `long long` для накопленной суммы весов → при весах рёбер до
10^9 и длинном пути сумма переполняет `int` → тихо неверный (отрицательный или
«случайный») результат без явного падения.
- Забыть проверку `if (d != dist[v]) continue` при извлечении из очереди → обрабатываются
устаревшие записи повторно → не влияет на корректность, но раздувает число операций и
на 200 000 рёбер может вывести время за лимит 2 секунды.
- Запустить алгоритм на графе с отрицательным ребром без проверки условия применимости →
результат тихо неверный (нет явной ошибки времени выполнения) — Дейкстра не обнаруживает
нарушение своего предположения сама.
- Перепутать направление рёбер (граф ориентированный) и релаксировать в обе стороны →
находится путь, которого нет в графе условия задачи, `shortest_path` возвращает заниженное
значение.
## Проверь себя
<details>
<summary>1. Почему при V=100 000 и E=200 000 наивный O(V²) не проходит по времени, а вариант
с кучей — проходит?</summary>
`O(V²) = 100 000² = 10^10` операций — на 3–4 порядка больше, чем позволяют 2 секунды.
Вариант с кучей — `O((V+E) log V) ≈ 300 000 · log₂(100 000) ≈ 300 000 · 16,6 ≈ 5·10^6`
операций, укладывается с большим запасом.
</details>
<details>
<summary>2. Что произойдёт с результатом Дейкстры, если в графе есть ребро веса -5, а
остальные рёбра положительные?</summary>
Результат может быть неверным без явной ошибки: если вершина на дешёвом с виду пути уже
извлечена из очереди и «финализирована», а позже к ней ведёт более короткий путь через
ребро -5, алгоритм это улучшение не увидит, так как вершина повторно не пересматривается.
</details>
<details>
<summary>3. Почему в очереди могут одновременно лежать несколько записей для одной и той же
вершины, и почему это не ошибка?</summary>
Каждое найденное улучшение расстояния до вершины добавляет новую запись `(dist, v)` в кучу
вместо обновления старой (`decrease-key` не поддерживается `std::priority_queue`). Это не
ошибка, потому что при извлечении устаревшая запись (`d != dist[v]`) просто пропускается —
корректность сохраняется, платится только лишней памятью в очереди и лишним `pop`.
</details>
<details>
<summary>4. Почему self-loop с весом w≥0 никогда не меняет кратчайший путь?</summary>
Self-loop добавляет вершине путь до самой себя длиной `w ≥ 0`. Поскольку путь длины 0 (не
двигаться) уже не хуже, прибавление неотрицательного веса к текущему `dist[v]` не может дать
меньшее значение — релаксация через такое ребро никогда не проходит условие «короче».
</details>
-58
View File
@@ -1,58 +0,0 @@
#include <cstdio>
#include <vector>
#include <tuple>
#include <random>
#include <chrono>
#include "solution.cpp"
static int failures = 0;
#define CHECK(cond, name) do { if (cond) std::printf("ok %s\n", name); \
else { std::printf("FAIL %s (line %d)\n", name, __LINE__); ++failures; } } while (0)
using E = std::tuple<int, int, int>;
int main() {
{
std::vector<E> e = {{0, 1, 4}, {0, 2, 1}, {2, 1, 1}, {1, 3, 1}};
CHECK(shortest_path(4, e, 0, 3) == 3, "0->3 через 2: 1+1+1 = 3");
CHECK(shortest_path(4, e, 0, 0) == 0, "src == dst -> 0");
CHECK(shortest_path(4, e, 3, 0) == -1, "обратного пути нет -> -1");
CHECK(shortest_path(4, e, 0, 2) == 1, "прямой путь 0->2 = 1");
}
{
std::vector<E> e = {{0, 1, 5}, {0, 2, 1}, {2, 1, 1}};
CHECK(shortest_path(3, e, 0, 1) == 2, "длинное ребро не должно побеждать короткий путь");
std::vector<E> z = {{0, 1, 0}, {1, 2, 0}};
CHECK(shortest_path(3, z, 0, 2) == 0, "нулевые веса допустимы");
std::vector<E> self = {{0, 0, 7}, {0, 1, 2}};
CHECK(shortest_path(2, self, 0, 1) == 2, "self-loop не ломает поиск");
std::vector<E> par = {{0, 1, 3}, {0, 1, 7}};
CHECK(shortest_path(2, par, 0, 1) == 3, "параллельные рёбра: берём минимальное");
std::vector<E> none;
CHECK(shortest_path(2, none, 0, 1) == -1, "без рёбер -> -1");
}
{
// цепочка 0-1-2-...-(n-1) по 1000, плюс «ловушки»: обратные рёбра по 100000.
// Ответ детерминирован: идти по цепочке дешевле, чем прыгать назад и снова вперёд.
const int n = 100000;
std::vector<E> e;
e.reserve(200000);
for (int i = 0; i + 1 < n; ++i) e.emplace_back(i, i + 1, 1000);
for (int i = 1; i < n; ++i) e.emplace_back(i, i - 1, 100000);
e.emplace_back(0, 0, 99999999);
auto t0 = std::chrono::steady_clock::now();
long long d = shortest_path(n, e, 0, n - 1);
double sec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
std::printf(" (100k вершин, 200k рёбер: dist=%lld, time=%.3fs)\n", d, sec);
CHECK(d == (long long)(n - 1) * 1000, "цепочка: кратчайший путь по цепочке");
CHECK(sec < 2.0, "100k вершин за < 2 c");
}
{
// большие веса: сумма должна влезать в long long
std::vector<E> e;
for (int i = 0; i + 1 < 2000; ++i) e.emplace_back(i, i + 1, 1000000);
CHECK(shortest_path(2000, e, 0, 1999) == 1999LL * 1000000LL, "большие веса: 1999 * 1e6");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL PASS\n", failures);
return failures ? 1 : 0;
}
-29
View File
@@ -1,29 +0,0 @@
// Задача 13 — арифметика подсетей IPv4.
// Реализуй функции ниже. Заглушка намеренно не проходит проверку.
#include <cstdint>
struct Subnet {
uint32_t network = 0;
uint32_t broadcast = 0;
uint32_t first_host = 0;
uint32_t last_host = 0;
long long host_count = 0;
};
Subnet subnet_of(uint32_t ip, int prefix) {
(void)ip;
(void)prefix;
return Subnet{};
}
bool same_subnet(uint32_t a, uint32_t b, int prefix) {
(void)a;
(void)b;
(void)prefix;
return false;
}
int prefix_for_hosts(int hosts) {
(void)hosts;
return -1;
}
-152
View File
@@ -1,152 +0,0 @@
# Задача 13 — арифметика подсетей IPv4 (C++)
Адреса передаются в **host byte order** (`0x0A000001` = 10.0.0.1), маска — длиной префикса.
```c++
struct Subnet {
uint32_t network; // адрес сети
uint32_t broadcast; // широковещательный адрес
uint32_t first_host; // первый адрес для узла
uint32_t last_host; // последний адрес для узла
long long host_count; // сколько адресов узлов доступно (для /0 не влезает в int)
};
Subnet subnet_of(uint32_t ip, int prefix); // 0 <= prefix <= 32
bool same_subnet(uint32_t a, uint32_t b, int prefix);
int prefix_for_hosts(int hosts); // самая узкая подсеть, куда влезает hosts
```
Определения, которые нужно соблюсти:
- для `prefix <= 30`: `host_count = 2^(32-prefix) - 2`, `first_host = network + 1`,
`last_host = broadcast - 1`;
- `/31` (RFC 3021): `host_count = 2`, `first_host = network`, `last_host = broadcast`;
- `/32`: `host_count = 1`, `first_host = last_host = network = broadcast = ip`;
- `prefix_for_hosts(h)`: **наибольший** `prefix` из диапазона `0..30` (то есть самая узкая
подсеть), при котором `host_count >= h`. `h < 1` или `h > 2^32-2` → `-1`.
Исключения `/31` и `/32` в подборе не участвуют — они для линков и одиночных адресов.
Эталонные примеры, которые обязаны сойтись: `/26` → 62 узла; `/24` → 254; `/30` → 2;
`10.0.1.130/26` → сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190`;
`prefix_for_hosts(62)` → 26, `prefix_for_hosts(63)` → 25, `prefix_for_hosts(254)` → 24,
`prefix_for_hosts(1)` → 30.
Проверка: `python3 grade.py 13`. Критерий: все `ok`, сборка без предупреждений.
**Факты для карточек**
- base | Сколько узлов даёт /24? — 254
- base | Сколько узлов даёт /26? — 62
- base | Сколько узлов даёт /30? — 2
- base | Сеть, broadcast и диапазон узлов для `10.0.1.130/26`? — сеть `10.0.1.128`, broadcast `10.0.1.191`, узлы `129..190`
Почему дальше: чтобы получить эти числа программно, а не подбором, нужен механизм вычисления
маски, границ сети и обратной задачи — подбора префикса по числу узлов.
## Механизм: маска, границы сети, обратный подбор префикса
**Маска по префиксу.** Маска — это `prefix` единиц в старших битах и `32-prefix` нулей в
младших: `mask = 0xFFFFFFFFu << (32 - prefix)` для `prefix > 0`. Для `prefix == 0` формула
не годится напрямую — сдвиг на 32 бита для 32-битного типа в C/C++ является неопределённым
поведением (UB), поэтому этот случай обрабатывается отдельно: `mask = 0`.
**Границы сети.** `network = ip & mask` — обнуляет все биты хостовой части (сохраняет
только биты сети). `broadcast = network | ~mask` — выставляет все биты хостовой части в
единицу (`~mask` — это как раз маска хостовой части, инвертированная сетевая). Число бит,
отданных под узлы, — `32 - prefix`; всего адресов в блоке — `2^(32-prefix)`.
**Разбор эталонного примера `10.0.1.130/26`.** `/26` оставляет `32-26=6` бит под узлы,
блок из `2^6 = 64` адресов. Адрес `.130` попадает в блок `128..191` (границы блоков кратны
64): `network = 10.0.1.128`, `broadcast = 10.0.1.191`. Обычные узлы — `first_host =
network+1 = .129`, `last_host = broadcast-1 = .190`. Из 64 адресов блока минус 2 служебных
(сеть и broadcast) — 62 узла, что совпадает с `/26 → 62 узла`.
**Почему `/26` даёт именно 62 узла.** `host_count = 2^(32-26) - 2 = 2^6 - 2 = 64 - 2 = 62`.
Аналогично `/24`: `2^8 - 2 = 254`; `/30`: `2^2 - 2 = 2` — ровно минимум для соединения
точка-точка между двумя маршрутизаторами.
**`/31` — исключение (RFC 3021).** По общей формуле `/31` дал бы `2^1 - 2 = 0` узлов —
бессмысленный результат для блока из 2 адресов. RFC 3021 разрешает для каналов
точка-точка (ровно 2 узла на линке) отдавать под хосты оба адреса блока: широковещательный
адрес такому каналу физически не нужен (получателей всего два, и оба известны заранее), а
резервировать половину 2-адресного блока под network/broadcast — чистая потеря адресного
пространства. Поэтому `host_count = 2`, `first_host = network`, `last_host = broadcast`.
**`/32` — единичный адрес.** Блок из одного адреса: `network = broadcast = first_host =
last_host = ip`, `host_count = 1`. Используется для маршрутов на конкретный узел
(host route) или loopback-адресов маршрутизатора.
**Обратная задача — `prefix_for_hosts(h)`.** Нужен наибольший `prefix` (самая узкая
подсеть), при котором `2^(32-prefix) - 2 >= h`, то есть `2^(32-prefix) >= h+2`, то есть
`32-prefix >= log2(h+2)`. Так как число хостовых бит — целое, наименьшее подходящее
значение — `32-prefix = ceil(log2(h+2))`, откуда:
```
prefix_for_hosts(h) = 32 - ceil(log2(h+2))
```
Проверка на эталонных примерах: `h=62` → `h+2=64`, `log2=6`, `prefix=26` ✓. `h=63` →
`h+2=65`, `log2(65)≈6.02`, `ceil=7`, `prefix=25` ✓ (62 узла `/26` уже не хватает на 63-й
хост, нужен на бит шире — `/25` с 126 узлами). `h=254` → `h+2=256`, `log2=8`, `prefix=24` ✓.
`h=1` → `h+2=3`, `log2(3)≈1.58`, `ceil=2`, `prefix=30` ✓.
**Факты для карточек**
- base | Формула маски для префикса p (0<p≤32)? — `0xFFFFFFFF << (32-p)`; для p=0 маска = 0 отдельным случаем (сдвиг на 32 — UB)
- base | Как получить network и broadcast из ip и mask? — `network = ip & mask`, `broadcast = network | ~mask`
- core | Формула host_count для prefix ≤ 30? — `2^(32-prefix) - 2`
- core | Почему /31 — исключение и даёт 2 узла вместо 0 по общей формуле? — RFC 3021: у канала точка-точка ровно 2 узла, broadcast не нужен, поэтому оба адреса 2-адресного блока отдаются под хосты вместо потери половины блока
- core | Что возвращает subnet_of для /32? — network=broadcast=first_host=last_host=ip, host_count=1 (host route/loopback)
- core | Формула `prefix_for_hosts(h)`? — `32 - ceil(log2(h+2))`, из условия `2^(32-prefix) >= h+2`
- deep | Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 всего на 1? — /26 даёт только 62 узла, этого не хватает даже на 1 хост меньше требуемых 63; нужно расширить хостовую часть на 1 бит — /25 с 126 узлами
## Ловушки
- Вычислить маску как `0xFFFFFFFF << (32 - prefix)` при `prefix == 0` без отдельной ветки
→ сдвиг на 32 бита для 32-битного типа — неопределённое поведение в C/C++ (компилятор
может дать любое значение, не обязательно 0) → UBSan ловит это как сдвиг за пределы
разрядности типа.
- Применить общую формулу `host_count = 2^(32-p) - 2` к `/31` без проверки исключения →
получится 0 узлов вместо 2 → тест на `/31` (RFC 3021) падает.
- Использовать `int` вместо `long long` для `host_count` → для `/0` значение `2^32 - 2` не
влезает в `int` (переполнение со знаком — UB) → в условии структуры явно указан `long long`
именно из-за этого случая.
- Перепутать host byte order с network byte order при сравнении с реальным трафиком или
утилитами вроде `tcpdump` → `10.0.0.1` в host order этой задачи — это `0x0A000001`, но в
сетевом порядке байты переставлены (`0x0100000A` при little-endian хосте) — конвертация
через `htonl`/`ntohl`, а не прямое сравнение чисел.
## Проверь себя
<details>
<summary>1. Почему /31 не подчиняется общей формуле «минус 2 служебных адреса»?</summary>
Потому что у 2-адресного блока резервирование network и broadcast по общей формуле оставило
бы 0 узлов — бессмысленно для канала точка-точка, где всего два участника и оба заранее
известны, широковещание не нужно. RFC 3021 явно отдаёт оба адреса блока под хосты.
</details>
<details>
<summary>2. Дано `ip=10.0.1.130`, `prefix=26`. Посчитать network, broadcast, first_host,
last_host по шагам.</summary>
Хостовых бит: `32-26=6`, размер блока `2^6=64`. Адрес `.130` попадает в блок, начинающийся
на границе, кратной 64: `128 <= 130 < 192`, значит `network=10.0.1.128`,
`broadcast=10.0.1.128+63=10.0.1.191`, `first_host=network+1=10.0.1.129`,
`last_host=broadcast-1=10.0.1.190`.
</details>
<details>
<summary>3. Почему `prefix_for_hosts(63) = 25`, а не 26, хотя 62 меньше 63 только на
единицу?</summary>
`/26` физически даёт ровно 62 адреса под хосты — этого не хватает даже на один хост меньше
требуемых 63. Следующий шаг «расширения» подсети — не +1 узел, а удвоение блока: снятие
одного бита из префикса (`/25`) сразу даёт 126 узлов. Промежуточных вариантов между /26 и
/25 не существует, поэтому единственный подходящий ответ — /25.
</details>
<details>
<summary>4. Почему для `prefix=0` нельзя вычислять маску как `0xFFFFFFFF << (32-0)`?</summary>
Это сдвиг на 32 бита для 32-битного беззнакового типа — стандарт C/C++ определяет сдвиг
только для величины меньше ширины типа в битах, сдвиг на саму ширину или больше — UB
(на практике часто даёт исходное значение без сдвига вместо ожидаемого 0). Поэтому
`prefix == 0` обрабатывается отдельной веткой: `mask = 0` напрямую.
</details>
Разбор после сдачи: как считать префикс по числу узлов за O(1) (`32 - ceil(log2(h+2))`),
почему `/31` — исключение, что такое маска в бинарном виде.
-66
View File
@@ -1,66 +0,0 @@
#include <cstdio>
#include <cstdint>
#include "solution.cpp"
static int failures = 0;
#define CHECK(cond, name) do { if (cond) std::printf("ok %s\n", name); \
else { std::printf("FAIL %s (line %d)\n", name, __LINE__); ++failures; } } while (0)
static uint32_t ip4(int a, int b, int c, int d) {
return (uint32_t(a) << 24) | (uint32_t(b) << 16) | (uint32_t(c) << 8) | uint32_t(d);
}
int main() {
{
Subnet s = subnet_of(ip4(10, 0, 1, 130), 26);
CHECK(s.network == ip4(10, 0, 1, 128), "/26: адрес сети 10.0.1.128");
CHECK(s.broadcast == ip4(10, 0, 1, 191), "/26: broadcast 10.0.1.191");
CHECK(s.first_host == ip4(10, 0, 1, 129), "/26: первый узел 10.0.1.129");
CHECK(s.last_host == ip4(10, 0, 1, 190), "/26: последний узел 10.0.1.190");
CHECK(s.host_count == 62, "/26: 62 узла (не 64)");
}
{
Subnet s = subnet_of(ip4(192, 168, 5, 200), 24);
CHECK(s.network == ip4(192, 168, 5, 0), "/24: сеть 192.168.5.0");
CHECK(s.broadcast == ip4(192, 168, 5, 255), "/24: broadcast 192.168.5.255");
CHECK(s.host_count == 254, "/24: 254 узла");
Subnet t = subnet_of(ip4(172, 16, 4, 9), 30);
CHECK(t.network == ip4(172, 16, 4, 8) && t.broadcast == ip4(172, 16, 4, 11),
"/30: сеть 172.16.4.8, broadcast 172.16.4.11");
CHECK(t.host_count == 2 && t.first_host == ip4(172, 16, 4, 9) && t.last_host == ip4(172, 16, 4, 10),
"/30: 2 узла, 9 и 10");
Subnet z = subnet_of(ip4(10, 0, 0, 5), 32);
CHECK(z.network == ip4(10, 0, 0, 5) && z.broadcast == ip4(10, 0, 0, 5) && z.host_count == 1,
"/32: один адрес, сеть == broadcast == узел");
Subnet p31 = subnet_of(ip4(10, 0, 0, 1), 31);
CHECK(p31.host_count == 2 && p31.first_host == ip4(10, 0, 0, 0) && p31.last_host == ip4(10, 0, 0, 1),
"/31: RFC 3021 — 2 узла без вычета");
Subnet eight = subnet_of(ip4(10, 200, 30, 40), 8);
CHECK(eight.network == ip4(10, 0, 0, 0) && eight.broadcast == ip4(10, 255, 255, 255),
"/8: сеть 10.0.0.0, broadcast 10.255.255.255");
Subnet zero = subnet_of(ip4(8, 8, 8, 8), 0);
CHECK(zero.network == 0 && zero.broadcast == 0xFFFFFFFFu && zero.host_count == 4294967294LL,
"/0: вся сеть, узлов 2^32-2");
}
{
CHECK(same_subnet(ip4(10, 0, 1, 130), ip4(10, 0, 1, 150), 26), "/26: 130 и 150 в одной подсети");
CHECK(!same_subnet(ip4(10, 0, 1, 130), ip4(10, 0, 1, 200), 26), "/26: 130 и 200 — разные подсети");
CHECK(!same_subnet(ip4(10, 0, 1, 130), ip4(10, 0, 1, 127), 26), "/26: 130 и 127 — разные подсети");
CHECK(same_subnet(ip4(10, 0, 1, 1), ip4(10, 0, 2, 1), 16), "/16: разные третьи октеты — одна сеть");
CHECK(!same_subnet(ip4(10, 0, 1, 1), ip4(10, 1, 1, 1), 16), "/16: 10.0.x и 10.1.x — разные");
CHECK(same_subnet(ip4(10, 0, 0, 0), ip4(10, 0, 0, 1), 31), "/31: соседи по RFC 3021");
}
{
CHECK(prefix_for_hosts(62) == 26, "prefix_for_hosts(62) -> 26");
CHECK(prefix_for_hosts(63) == 25, "prefix_for_hosts(63) -> 25 (63 в /26 не влезает)");
CHECK(prefix_for_hosts(254) == 24, "prefix_for_hosts(254) -> 24");
CHECK(prefix_for_hosts(255) == 23, "prefix_for_hosts(255) -> 23");
CHECK(prefix_for_hosts(2) == 30, "prefix_for_hosts(2) -> 30");
CHECK(prefix_for_hosts(1) == 30, "prefix_for_hosts(1) -> 30 (узкая подсеть общего вида)");
CHECK(prefix_for_hosts(0) == -1, "prefix_for_hosts(0) -> -1");
CHECK(prefix_for_hosts(4294967295) == -1, "prefix_for_hosts(2^32-1) -> -1 (не влезает)");
CHECK(prefix_for_hosts(1000000) == 12, "prefix_for_hosts(1000000) -> 12");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL PASS\n", failures);
return failures ? 1 : 0;
}
-322
View File
@@ -1,322 +0,0 @@
# D1, часть 1. Сложность и хеш-таблицы (урок)
Это не проверка, а урок: сначала разбираем, потом сам решаешь. Читать сверху вниз,
код в разборах можно копировать и запускать — это образец, а не ответ на задачу.
## 1. Что такое O-нотация
O-нотация отвечает на вопрос «как растёт время работы, когда данных становится в 10 раз
больше». Константы и младшие слагаемые отбрасываются: 3n + 100 → O(n) — при n → ∞ слагаемое
100 и множитель 3 не меняют форму роста, поэтому их не пишут.
Три правила, которых хватает для 90% вопросов:
1. **Последовательные блоки складываются, остаётся старший.** O(n) + O(n²) → O(n²).
2. **Вложенные циклы перемножаются.** Цикл n по циклу n → O(n²).
3. **Деление задачи вдвое даёт log n.** Бинарный поиск в 1 000 000 элементов:
2²⁰ ≈ 1 048 576, значит 20 шагов → **O(log n), а число сравнений ≈ 20**.
Полезно помнить наизусть: `log₂(1000) ≈ 10`, `log₂(10⁶) ≈ 20`, `log₂(10⁹) ≈ 30`.
Каждое умножение данных на 1000 добавляет примерно 10 шагов — это и есть смысл log n.
**Амортизированная сложность** — средняя стоимость операции на длинной серии вызовов, а не
гарантия для каждого отдельного вызова. Механизм на примере `std::vector::push_back`:
когда выделенной памяти не хватает, вектор не увеличивает ёмкость на 1, а **удваивает** её,
выделяет новый блок и переносит туда все элементы — это разовая операция O(n). Из-за
геометрического роста ёмкости такие реаллокации случаются экспоненциально реже: после
k-й реаллокации следующая наступит примерно через 2^k новых вставок. Сумма стоимости всех
реаллокаций на n вставок — геометрическая прогрессия n/2 + n/4 + n/8 + ... ≈ n, то есть
суммарно O(n) на n операций, а не O(n²) — отсюда амортизированное **O(1)** на одну вставку.
Если бы ёмкость росла линейно (+1 каждый раз), каждая вставка копировала бы весь массив —
суммарно O(n²). Геометрический рост — не оптимизация, а необходимое условие амортизации.
Практическое следствие: реаллокация инвалидирует все указатели, ссылки и итераторы на
элементы вектора, потому что блок памяти физически переехал — источник use-after-free,
который ловит ASAN.
**Худший случай ≠ средний.** Хеш-таблица: в среднем поиск O(1), но если хеш-функция плохая
и все ключи попали в одну корзину, поиск вырождается в перебор → **O(n)**.
**Факты для карточек**
- base | Во что превращается 3n + 100 в O-нотации? — O(n)
- base | Сколько шагов у бинарного поиска в массиве из 10⁶ элементов? — 20 (log₂ 10⁶ ≈ 20)
- core | Почему push_back в среднем O(1), а не O(n)? — ёмкость растёт геометрически (удвоение), сумма реаллокаций на n вставок — геометрическая прогрессия ≈ n, а не n²
- core | Что ломает удвоение ёмкости у vector? — указатели/итераторы/ссылки на старые элементы (реаллокация переносит блок памяти)
- deep | Что будет, если ёмкость vector растить на +1 за раз вместо удвоения? — суммарная стоимость n вставок станет O(n²)
Почему дальше: если push_back амортизированно O(1) за счёт удвоения, какие структуры данных вообще гарантируют O(1) в среднем на операцию — переходим к таблице сложностей и хеш-таблицам.
## 2. Таблица сложностей, которую надо знать
| Структура | Поиск | Вставка | Удаление | Память |
|---|---|---|---|---|
| Массив (не отсортирован) | O(n) | O(1) в конец | O(n) | O(n) |
| Отсортированный массив | O(log n) | O(n) | O(n) | O(n) |
| Связный список | O(n) | O(1) по указателю | O(1) по указателю | O(n) |
| Хеш-таблица (средн.) | O(1) | O(1) | O(1) | O(n) |
| Хеш-таблица (худш.) | O(n) | O(n) | O(n) | O(n) |
| Бинарная куча | O(n) поиск | O(log n) | O(log n) удалить корень | O(n) |
| Сбалансированное BST (map) | O(log n) | O(log n) | O(log n) | O(n) |
Почему связный список даёт O(1) на вставку/удаление: если узел уже найден (есть указатель),
операция — просто перелинковка соседних указателей без сдвига остальных элементов; O(n) в
поиске появляется отдельно, потому что нет арифметики адреса — только последовательный проход.
Почему у отсортированного массива поиск O(log n), а вставка O(n): бинарный поиск делит
диапазон пополам, но вставка сдвигает все элементы после точки вставки, чтобы сохранить
непрерывность блока памяти.
Кучи отдельно: **построение из произвольного массива — O(n)** (не O(n log n) — это
частый вопрос: heapify идёт снизу вверх от середины массива к началу, и суммарная работа
по всем уровням даёт линейную оценку), вставка одного элемента — O(log n) (просеивание
вверх на высоту кучи), взятие максимума — O(1) (это корень).
Сортировки: quicksort — в среднем O(n log n), в худшем **O(n²)** (уже отсортированный
массив при плохом выборе опорного); mergesort — всегда O(n log n) и **устойчив**;
heapsort — O(n log n), неустойчив, O(1) доп. памяти. Нижняя оценка для сортировки
сравнениями — **Ω(n log n)**, быстрее сравнениями нельзя (это доказывается через дерево
решений: n! перестановок, глубина дерева бинарных сравнений — минимум log₂(n!) ≈ n log n).
Устойчивость = равные элементы сохраняют исходный порядок. Устойчивы: merge, insertion,
bubble, counting. Неустойчивы: quick, heap, selection.
**map vs unordered_map**: `map` — красно-чёрное дерево, инвариант балансировки (чередование
цветов узлов, равное число чёрных узлов на любом пути от корня до листа) держит высоту
порядка log n, отсюда O(log n) на все операции и ключи всегда в отсортированном порядке при
обходе. `unordered_map` в среднем быстрее на чистом поиске/вставке (O(1) и меньше косвенных
переходов по указателям), но не даёт упорядоченного обхода и не гарантирует порядок бакетов
между вызовами rehash.
**Факты для карточек**
- base | Сложность построения кучи (heapify) из произвольного массива? — O(n), не O(n log n)
- base | Какая сортировка всегда O(n log n) и устойчива? — mergesort
- core | Худший случай quicksort и когда он достигается? — O(n²), на уже отсортированном массиве при плохом выборе опорного
- core | Нижняя граница сортировки сравнениями? — Ω(n log n)
- core | За счёт чего map держит высоту log n? — инвариант красно-чёрного дерева: чередование цветов + равное число чёрных узлов на пути от корня до листа
- deep | Почему нельзя полагаться на порядок обхода unordered_map? — порядок бакетов не гарантирован и может меняться при rehash
Почему дальше: таблица говорит, что хеш-таблица в среднем O(1) — дальше разбираем механизм, который это обеспечивает и почему он иногда ломается до O(n).
## 3. Хеш-таблица: как устроена
Идея: по ключу считаем число (хеш) и превращаем его в индекс массива. Хотим получить
адрес за одно действие, без перебора.
Компоненты: **массив корзин**, **хеш-функция**, **правило разрешения коллизий**, **фактор
загрузки** (сколько занято от общего размера).
Коллизия — два разных ключа дали один индекс. Это норма, а не ошибка: при n ключах и m
корзинах коллизии статистически неизбежны уже при n, сравнимом с √m (парадокс дней рождения).
Два способа разрешения:
- **Цепочки (chaining):** в каждой корзине список/вектор элементов. Просто, но много
мелких аллокаций; при плохом хеше одна цепочка растёт до O(n).
- **Открытая адресация (open addressing):** все элементы лежат в самом массиве. Занято —
ищем следующую свободную ячейку по правилу: линейное зондирование `(i+1) % cap`,
квадратичное `(i + k²) % cap`, двойное хеширование `(i + k·h2) % cap`.
Быстрее по кэшу (элементы лежат подряд в памяти, меньше промахов кэша, чем при обходе
разбросанных по куче узлов списка), но есть проблема **удаления**: если просто очистить
ячейку, цепочка зондирования порвётся и поиск не найдёт элемент дальше. Решение —
**tombstone** (надгробие): помечаем ячейку «был элемент, но сейчас пусто», поиск идёт
дальше сквозь неё, а вставка может её переиспользовать. Без tombstone поиск останавливался
бы на первой пустой ячейке и не долистывал бы до элемента, который на самом деле лежит
дальше по цепочке зондирования.
**Фактор загрузки** `load = size / capacity`. При открытой адресации держат ≤ 0.7:
чем плотнее массив, тем длиннее пробеги до свободной ячейки (при load → 1 среднее число
проб на поиск растёт неограниченно). При превышении порога — **rehash**: физически
выделяется новый массив вдвое больше старого, и **каждый** элемент вставляется в него заново
по новому индексу (`hash % new_cap`), потому что индекс зависит от текущей ёмкости — старые
позиции для новой ёмкости в общем случае неверны. Это разовая операция O(n), но происходит
она редко и по той же геометрической прогрессии, что и рост `vector` (раздел 1) — отсюда
**амортизированное O(1)** на вставку, а не просто «в среднем быстро».
Почему ёмкость берут степенью двойки: тогда `idx = hash & (cap - 1)` вместо дорогого
деления по модулю — побитовое И на порядок дешевле целочисленного деления на процессоре.
Отсюда же требование: хеш-функция должна хорошо перемешивать именно младшие биты (при
делении по модулю участвуют все биты хеша, при `& (cap-1)` — только младшие log₂(cap)),
для строк — FNV-1a или `std::hash<std::string>`.
**Ловушки**
- Взять ёмкость не степенью двойки при использовании `hash & (cap-1)` → маска отрежет не те биты → часть корзин никогда не используется, видно по неравномерному распределению цепочек.
- Удалять элемент простой очисткой ячейки при открытой адресации вместо tombstone → поиск последующих элементов той же цепочки зондирования обрывается раньше времени → `get` возвращает false для существующего ключа, видно в тесте «insert A, B (коллизия с A), delete A, get B» → false.
- Не проверять load factor перед вставкой → цепочки/пробеги растут неограниченно → поиск деградирует к O(n), видно по профилировщику как рост времени `unordered_map::find` с размером таблицы.
- Пользовательский ключ с плохим/предсказуемым хешем → все элементы в одном бакете → тихая деградация до O(n) без ошибки компиляции, ловится только профилировщиком.
**Факты для карточек**
- base | Формула фактора загрузки? — load = size / capacity
- base | Порог load factor при открытой адресации, после которого делают rehash? — обычно ≤ 0.7
- core | Зачем tombstone при открытой адресации? — чтобы удаление не обрывало цепочку зондирования: поиск должен пройти сквозь помеченную ячейку до элемента, вставленного позже
- core | Почему ёмкость хеш-таблицы берут степенью двойки? — idx = hash & (cap-1) вместо деления по модулю — дешевле на процессоре
- core | Что физически происходит при rehash? — выделяется массив вдвое больше, каждый элемент переставляется по новому индексу (зависит от cap)
- deep | Почему rehash даёт амортизированное O(1), а не O(n) на вставку? — та же геометрическая прогрессия, что у vector::push_back: суммарная стоимость n вставок ≈ n, а не n²
Почему дальше: те же формулы сложности стоит применить к конкретному коду — переходим к разбору задач, где нужно на глаз определить сложность.
## 4. Разбор примера: считаем сложности
```cpp
// (а) сумма элементов
long long sum(const std::vector<int>& v) { // O(n): один проход
long long s = 0;
for (int x : v) s += x;
return s;
}
// (б) пары с суммой k
bool has_pair(const std::vector<int>& v, int k) { // O(n^2): вложенный цикл
for (size_t i = 0; i < v.size(); ++i)
for (size_t j = i + 1; j < v.size(); ++j)
if (v[i] + v[j] == k) return true;
return false;
}
// (в) то же, но через хеш-множество
bool has_pair_fast(const std::vector<int>& v, int k) { // O(n) в среднем
std::unordered_set<int> seen;
for (int x : v) {
if (seen.count(k - x)) return true; // поиск в среднем O(1)
seen.insert(x);
}
return false;
}
```
(в) — типовой ответ на собеседовании: «перебор O(n²), но с хеш-множеством получаем O(n)
за счёт O(n) дополнительной памяти». Уметь назвать и время, и память — половина ответа.
Механизм ускорения: (б) на каждой паре (i, j) делает сравнение за O(1), но пар — O(n²);
(в) вместо перебора пар один раз кладёт каждый элемент в хеш-множество (O(1) в среднем на
вставку) и один раз проверяет наличие дополнения k - x (O(1) в среднем на поиск) — итого
O(n) вставок и O(n) поисков вместо O(n²) сравнений.
**Факты для карточек**
- base | Сложность has_pair (вложенный цикл по всем парам)? — O(n²)
- core | Сложность has_pair_fast по времени и по памяти? — O(n) по времени в среднем, O(n) дополнительной памяти под хеш-множество
Почему дальше: чтобы поверить в O(1) на вставку/поиск из примера (в), нужно понять, что внутри unordered_set/unordered_map — переходим к ручной сборке хеш-таблицы.
## 5. Разбор примера: как руками собрать хеш-таблицу
Учебный минимальный вариант (это разбор, не зачётная задача — запусти и поиграйся):
```cpp
#include <cstdint>
#include <string>
#include <vector>
#include <iostream>
// 1. хеш-функция: FNV-1a, хорошо перемешивает
uint64_t fnv1a(const std::string& s) {
uint64_t h = 1469598103934665603ULL;
for (unsigned char c : s) { h ^= c; h *= 1099511628211ULL; }
return h;
}
// 2. таблица с цепочками
struct HashTable {
struct Node { std::string key; int val; Node* next; };
std::vector<Node*> buckets;
size_t sz = 0;
explicit HashTable(size_t cap = 8) : buckets(cap, nullptr) {}
size_t index(const std::string& k) const { return fnv1a(k) % buckets.size(); }
void put(const std::string& k, int v) {
Node* n = buckets[index(k)];
for (; n; n = n->next) if (n->key == k) { n->val = v; return; } // уже есть — обновляем
buckets[index(k)] = new Node{k, v, buckets[index(k)]}; // вставка в голову цепочки
++sz;
if (sz * 10 > buckets.size() * 7) rehash(); // load > 0.7
}
bool get(const std::string& k, int& out) const {
for (Node* n = buckets[index(k)]; n; n = n->next)
if (n->key == k) { out = n->val; return true; }
return false;
}
void rehash() {
std::vector<Node*> old = buckets;
buckets.assign(old.size() * 2, nullptr);
for (Node* head : old)
for (Node* n = head; n; ) {
Node* next = n->next;
size_t i = index(n->key);
n->next = buckets[i];
buckets[i] = n;
n = next;
}
}
};
```
Что здесь важно понять по шагам: `index()` — где именно ищем; `put` — сначала ищем
существующий ключ (иначе будут дубли), потом вставляем; `rehash` — заново раскладываем
**все** узлы, потому что индекс зависит от размера массива (`fnv1a(k) % buckets.size()`) —
после удвоения `buckets.size()` старый индекс для того же ключа почти всегда неверен, поэтому
пересчёт нужен для каждого узла, а не только для новых. Здесь ёмкость (8, 16, 32, ...) —
степень двойки, но индекс считается через `%`, а не `&`; замена на `hash & (cap-1)` дала бы
тот же результат быстрее, именно потому что cap — степень двойки.
Открытая адресация отличается только поиском места: вместо цепочки идём вперёд по массиву
до свободной ячейки, а при удалении ставим tombstone.
**Факты для карточек**
- base | Какие константы использует FNV-1a в этом коде (offset basis / prime)? — 1469598103934665603 / 1099511628211
- core | Почему rehash пересчитывает индекс для каждого узла, а не переносит их как есть? — индекс = hash % buckets.size(), а size() изменился (удвоился), старые индексы для нового размера в общем случае неверны
Почему дальше: разобрав таблицу вручную, полезно свести готовые формулировки ответов на типовые вопросы интервью в один блок.
## 6. Что спросят на собеседовании (готовые ответы)
- «Средняя и худшая сложность поиска в хеш-таблице?» — амортизированное O(1), худшая O(n)
при коллизиях.
- «Что такое load factor и зачем rehash?» — доля занятых ячеек; при превышении порога
(обычно 0.7–1.0) массив растёт, иначе пробеги/цепочки удлиняются.
- «Как удалять при открытой адресации?» — tombstone, иначе порвётся цепочка зондирования.
- «Почему ёмкость — степень двойки?» — `hash & (cap-1)` вместо `%`, дешевле.
- «Чем цепочки отличаются от открытой адресации?» — цепочки проще и терпят load > 1,
но аллокации; открытая адресация кэш-дружелюбнее, но требует load ≤ 0.7 и tombstone.
**Факты для карточек**
- core | Чем открытая адресация выигрывает у цепочек по производительности и почему? — она кэш-дружелюбнее: элементы лежат подряд в массиве, а не разбросаны по куче отдельными узлами
- deep | Может ли load factor у цепочек быть больше 1? — да, цепочка просто станет длиннее (в отличие от открытой адресации, где load ≤ 1 всегда)
## 7. Материалы (первопартийные)
- cppreference: `std::unordered_map`, `std::hash` — https://en.cppreference.com/w/cpp/container/unordered_map
- OSTEP, часть «Data Structures»/«Hashing» — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Codeforces EDU, курс по структурам данных — https://codeforces.com/edu/courses
- Визуализация открытой адресации — https://www.cs.usfca.edu/~galles/visualization/OpenHash.html
## 8. Проверь себя (ответы внизу, не подглядывай сразу)
1. Сложность поиска в `unordered_map` в среднем и в худшем?
2. Сколько сравнений в худшем случае у бинарного поиска в массиве из 10⁶ элементов?
3. `heapify` из произвольного массива — за сколько?
4. Какая из сортировок устойчива: quick, merge, heap?
5. Зачем tombstone при открытой адресации?
6. Почему `push_back` вектора и `rehash` хеш-таблицы оба амортизированно O(1) — что у них общего в механизме?
7. Почему ёмкость хеш-таблицы удобно делать степенью двойки?
<details>
<summary>Ответы</summary>
1. Амортизированное O(1); худшая O(n) — все ключи в одной корзине.
2. 20 (`log₂ 10⁶ ≈ 20`).
3. O(n), снизу вверх от середины массива к началу.
4. merge (устойчива), quick и heap — нет.
5. Чтобы удаление не разрывало цепочку зондирования: поиск должен пройти дальше удалённой
ячейки до элемента, который был вставлен за ней.
6. Оба удваивают ёмкость при переполнении вместо роста на фиксированный шаг — редкая
операция O(n) размазывается по геометрической прогрессии вставок, суммарная стоимость
n операций ≈ n, а не n².
7. Индекс можно считать как `hash & (cap - 1)` (побитовое И) вместо деления по модулю —
дешевле для процессора.
</details>
## 9. Ссылки на задачи этого дня
- `tasks/10_hash` — своя таблица с открытой адресацией (главная задача дня, идёт ступенями).
- `tasks/01_bits` — битовые операции на C, разминка для рук.
- `tasks/03_ring` — кольцевой буфер, база для сетевого кода.
-388
View File
@@ -1,388 +0,0 @@
# D1, часть 2. Процессы: fork, exec, wait, сигналы (урок)
Модель одна на весь урок: процесс = адресное пространство (`mm_struct` в ядре) + поток
выполнения + таблица открытых файловых дескрипторов + запись в таблице процессов
(`task_struct`). `fork` копирует эту запись и помечает память как copy-on-write, `exec`
заменяет содержимое адресного пространства, оставляя PID и дескрипторы, `wait` забирает у
ядра код возврата и освобождает запись, сигнал — асинхронное прерывание исполнения ядром.
## 1. fork(): что реально происходит
`pid_t fork(void)` создаёт **новый процесс** — копию вызывающего: своё адресное
пространство, свой PID, свой поток. Родитель продолжает с того же места.
Механически ядро: (1) выделяет новый `task_struct` и PID; (2) копирует таблицу файловых
дескрипторов — обе записи после `fork` указывают на те же открытые файловые описания в
ядре, а не на независимые; (3) копирует таблицу страниц родителя, помечая все страницы
данных и кучи как read-only в обеих копиях; (4) добавляет новый процесс в очередь
планировщика. Само копирование данных при этом не происходит — см. COW ниже.
Возвращает **дважды** — и это ключ к пониманию:
- в родителе — PID ребёнка (> 0);
- в ребёнке — 0;
- при ошибке — −1 (и `errno`), ребёнок не создан.
Оба процесса продолжают исполнение с одной и той же точки кода сразу после вызова —
единственный способ понять, в какой копии сейчас исполняется код, это проверить
возвращённое значение. Поэтому классический код всегда ветвится:
```cpp
pid_t pid = fork();
if (pid == 0) {
// это ребёнок
execlp("ls", "ls", "-l", nullptr);
_exit(127); // exec не вернулся — значит не смог
} else if (pid > 0) {
// это родитель: pid — номер ребёнка
int status = 0;
waitpid(pid, &status, 0); // дождаться и забрать код возврата
} else {
perror("fork"); // ошибка
}
```
**Copy-on-write.** Память физически не копируется: обе стороны смотрят на одни физические
страницы, помеченные «только чтение» в таблице страниц каждого процесса. При первой записи
в такую страницу возникает page fault, ядро перехватывает его, выделяет новую физическую
страницу (обычно 4 КБ на x86-64), копирует туда содержимое и переписывает таблицу страниц
только пишущего процесса — только тогда. Поэтому `fork` дешёвый, даже если процесс занимает
гигабайты: копируется не память, а только записи таблицы страниц. Именно это спрашивают в
формулировке «что происходит со страницами памяти при fork?» — ответ: **COW, копируются
при первой записи**, а не при самом `fork`.
Причина именно такого устройства — частый паттерн «`fork` сразу за которым `exec`»: если бы
ядро копировало всё адресное пространство заранее, эта работа почти всегда оказывалась бы
выброшенной, ведь `exec` тут же заменяет содержимое памяти новой программой.
Совет: `fflush(stdout)` перед `fork`, иначе непустой буфер `stdout` скопируется в ребёнка
вместе с памятью (COW это не мешает) и будет сброшен на диск/терминал дважды.
**Ловушки**
- Не проверить возвращаемое значение `fork()` и не разветвить логику по нему → родитель и
ребёнок выполняют один и тот же код дважды → видно как задвоенный вывод или два PID в
`ps aux`, делающих одну и ту же работу.
- Ребёнок долго не вызывает `exec()`, активно пишет в большие структуры данных → всплеск
реального потребления памяти именно в момент записи (COW-копирование страниц), а не в
момент `fork` → видно по росту RSS в `top`/`ps` уже после fork, а не сразу.
- Вызвать `exit()` вместо `_exit()` в ребёнке после неудачного `exec` → `exit()` сбрасывает
стандартные буферы stdio, которые ребёнок унаследовал от родителя через COW, и может
продублировать ранее не выведенный текст родителя.
**Факты для карточек**
- base | Что возвращает `fork()` в родителе и в ребёнке? — в родителе PID ребёнка (>0), в ребёнке 0, при ошибке −1
- core | Что происходит со страницами памяти при `fork()`? — ничего сразу: COW, физическая копия страницы создаётся при первой записи в неё (page fault)
- core | Почему `fork` дешёвый даже для процесса с гигабайтами памяти? — копируются не данные, а только записи таблицы страниц; сами страницы данных не дублируются, пока их не изменят
- base | Что нужно сделать с `stdout` перед `fork`, если он не пуст? — вызвать `fflush(stdout)`, иначе буфер продублируется в ребёнке
- deep | Какой размер страницы памяти на x86-64, о которой копия делается при COW? — 4 КБ
Почему дальше: раз память после `fork` временно общая и почти всегда тут же заменяется — что конкретно делает `exec` с этим адресным пространством?
## 2. exec(): замена образа
`exec*` **не создаёт процесс**, а заменяет содержимое текущего: код, данные, стек —
всё новое. PID и открытые дескрипторы сохраняются. Возврата при успехе нет никогда:
при успехе функция не возвращается (программа уже другая), при ошибке возвращает −1.
Механизм: `execve` (системный вызов, к которому в итоге сводится всё семейство `exec*`)
загружает исполняемый файл с диска, разбирает его как ELF, строит новый `mm_struct` —
новые сегменты кода и данных, новую кучу, новый стек — и подменяет им адресное пространство
текущего `task_struct`, не трогая PID и таблицу файловых дескрипторов. Дескрипторы, открытые
до `exec`, остаются открытыми в новой программе **кроме** помеченных флагом `FD_CLOEXEC` —
это то, чем перенаправление ввода-вывода (`dup2` на 0/1/2 перед `exec`) переживает замену
образа, а служебные дескрипторы, которые новой программе видеть не нужно, — нет.
Отсюда рабочий шаблон: `fork` + `exec` в ребёнке = запуск внешней программы: `fork` даёт
новый процесс с независимой копией состояния (в том числе уже перенастроенные дескрипторы
для редиректа), а `exec` в этом новом процессе подгружает нужную программу, не трогая
родителя. Если вызвать `exec` без предварительного `fork`, текущая программа заменится и не
вернёт управление — например, `exec` внутри shell-скрипта заменяет саму оболочку.
Семейство: `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`.
**Ловушки**
- Забыть `_exit(127)` (или любой выход) после неудачного `exec*` в ребёнке → код продолжает
исполняться как будто это родительская логика → дублирование родительской работы в
дочернем процессе, видно по неожиданным побочным эффектам после «сбоя» запуска.
- Не поставить `FD_CLOEXEC` на служебный/секретный дескриптор перед `exec` → он утекает в
запущенную внешнюю программу → видно в `/proc/<pid>/fd` запущенного процесса — там лишний
открытый файл, которого «не должно быть».
**Факты для карточек**
- base | Чем `exec` отличается от `fork`? — `fork` создаёт новый процесс, `exec` заменяет образ текущего процесса, не создавая нового
- core | Что сохраняется у процесса после успешного `exec`? — PID и таблица открытых файловых дескрипторов, кроме помеченных `FD_CLOEXEC`
- core | Почему `exec` при успехе никогда не возвращает управление? — старого кода, куда можно было бы вернуться, больше не существует — адресное пространство заменено новым образом
- base | Разница между `execlp`, `execv`, `execvp`? — `execlp` ищет в `PATH`, `execv` — по полному пути, `execvp` — по имени в `PATH`
Почему дальше: ребёнок исполнился и завершился — как родитель узнаёт, чем это закончилось, и что мешает ему узнать об этом мгновенно?
## 3. wait/waitpid и коды возврата
Завершившийся ребёнок не исчезает: когда он вызывает `exit()`, ядро не удаляет его
`task_struct` немедленно, а сохраняет минимальную запись (PID, код возврата, статистику
использования ресурсов), пока родитель не заберёт её через `wait`/`waitpid`. Такой процесс
называется **зомби** (состояние `Z` в `ps`). Зомби не занимает память данных, но занимает
слот в таблице процессов — их накопление плохо, вплоть до упора в лимит PID на системе.
Зомби не исчезает сам именно потому, что ядру физически некуда передать код возврата, кроме
как дождаться, когда родитель за ним придёт — сам процесс уже не исполняется и ничего
сообщить не может.
- `wait(&status)` — ждёт любого ребёнка;
- `waitpid(pid, &status, 0)` — конкретного; `WNOHANG` — не блокироваться.
Разбор `status` делается макросами, а не вручную, потому что в одном `int` закодированы
сразу два разных случая (нормальный выход и завершение по сигналу) в разных битах:
```cpp
if (WIFEXITED(status)) printf("exit code %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
```
Дополнительно есть `WIFSTOPPED`/`WSTOPSIG` (ребёнок остановлен, например, по `SIGSTOP`) и
`WCOREDUMP(status)` (завершение по сигналу сопровождалось дампом памяти на диск, `core`).
**Откуда 137 и 139.** Оболочка показывает код как 128 + номер сигнала:
- **137 = 128 + 9** → SIGKILL (убит `kill -9`, часто OOM-killer);
- **139 = 128 + 11** → SIGSEGV (падение по памяти);
- 143 = 128 + 15 → SIGTERM (корректный запрос на завершение).
**Сирота** — процесс, чей родитель умер раньше него: его усыновляет init/systemd (PID 1,
либо выделенный subreaper), который в цикле собирает статусы всех своих детей — поэтому
сирота гарантированно не застревает зомби навсегда, в отличие от зомби при живом, но
нерадивом родителе. Разница именно в том, кто виноват: зомби — родитель жив, но не вызвал
`wait`; сирота — родитель умер, но дождаться его теперь придётся init.
**Ловушки**
- Родитель никогда не вызывает `waitpid` для завершившихся детей → записи зомби копятся →
видно как растущий список `ps aux | grep Z`, в пределе — упор в лимит PID.
- Сравнивать `status` напрямую с кодом возврата вместо `WEXITSTATUS(status)` → в `status`
закодированы и код выхода, и флаг сигнала одновременно, сырое значение не совпадает с тем,
что вернула программа.
- Долгоживущий процесс с `fork`-воркерами не занимается сбором детей вообще → зомби
накапливаются постепенно, а не сразу → проявляется не в первый час работы, а через дни
аптайма ростом числа `Z`-процессов.
**Факты для карточек**
- base | Зачем нужен `waitpid`? — забрать код возврата завершившегося ребёнка и позволить ядру освободить его запись в таблице процессов
- core | Что такое зомби и почему он не исчезает сам? — процесс, завершивший `exit()`, чью запись ещё не забрал родитель через `wait`; ядру некуда передать код возврата, кроме как дождаться `wait`
- core | Чем зомби отличается от сироты? — зомби: родитель жив, но не вызвал `wait`; сирота: родитель умер, процесс усыновлён init/systemd (PID 1)
- base | Откуда код возврата 137 и 139? — 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так оболочка кодирует завершение по сигналу
- core | Как правильно проверить, что ребёнок убит сигналом, а не завершился штатно? — макросами `WIFSIGNALED(status)`/`WTERMSIG(status)`, не сравнением `status` напрямую
- deep | Что показывает `WCOREDUMP(status)`? — что завершение по сигналу сопровождалось записью core-дампа на диск
Почему дальше: коды 137/139/143 — это сигналы, доставленные процессу; что вообще такое сигнал и какие из них процесс может перехватить, а какие — нет?
## 4. Сигналы
Сигнал — асинхронное уведомление, которое ядро доставляет процессу, прерывая его обычное
исполнение и передавая управление либо зарегистрированному обработчику, либо выполняя
действие по умолчанию (завершить, завершить с core-дампом, игнорировать, приостановить).
Основные: SIGINT (2, Ctrl+C, по умолчанию завершает, перехватывается), SIGKILL (9, нельзя
перехватить или проигнорировать), SIGTERM (15, «завершись корректно», перехватывается),
SIGSEGV (11, обращение к недопустимой памяти), SIGPIPE (13, запись в закрытый сокет/pipe),
SIGCHLD (17 на Linux/x86, ребёнок изменил состояние — завершился или остановился).
Разделение на перехватываемые и неперехватываемые сигналы существует ради надёжности
управления системой: администратору и супервизору всегда нужен гарантированный способ
остановить процесс, даже если тот завис в бесконечном цикле или его собственный обработчик
сигналов содержит баг — отсюда SIGKILL, который ядро обрабатывает на уровне планировщика,
снимая процесс с исполнения без единой инструкции пользовательского кода в ответ. SIGTERM
устроен наоборот — он предполагает, что процесс жив и способен среагировать: закрыть файлы,
сбросить буферы, освободить ресурсы. Отсюда практика эксплуатации: сначала всегда посылают
SIGTERM и ждут; так, `systemctl stop`/`docker stop` по умолчанию ждут несколько секунд
(в systemd таймаут задаётся `TimeoutStopSec`, по умолчанию около 90 секунд) и только затем,
если процесс не завершился, посылают SIGKILL — потому что SIGKILL не даёт дописать данные
на диск, и незавершённая операция может остаться в неконсистентном состоянии.
Обработчик ставится `sigaction` (надёжнее устаревшего `signal` — поведение `signal`
исторически различалось между Unix-системами), внутри обработчика можно менять только
`volatile sig_atomic_t` — никаких `printf`/`malloc` (не async-signal-safe: `malloc` не
реентерабелен и может быть прерван сигналом посреди изменения своих внутренних структур,
что при вызове `malloc`/`printf` из обработчика способно повредить кучу или подвесить
процесс).
```cpp
static volatile sig_atomic_t stop = 0;
static void on_term(int) { stop = 1; } // минимум действий
int main() {
struct sigaction sa{}; sa.sa_handler = on_term;
sigaction(SIGTERM, &sa, nullptr); // ловим мягкое завершение
while (!stop) { /* работа */ }
}
```
**Ловушки**
- Вызвать `printf`/`malloc`/`free` внутри обработчика сигнала → не async-signal-safe → в
редких случаях повреждение кучи или взаимная блокировка, если сигнал прервал программу
ровно во время работы аллокатора — воспроизводится нестабильно, под нагрузкой.
- Использовать `signal()` вместо `sigaction()` → поведение (сброс обработчика в default
после первого срабатывания, поведение при повторном сигнале) исторически различается
между реализациями Unix → код, проверенный на одной системе, ведёт себя иначе на другой.
- Ждать, что демон корректно остановится по SIGTERM, не поставив на него обработчик →
`docker stop`/`systemctl stop` в итоге шлют SIGKILL по таймауту → в логах виден резкий
обрыв процесса без финализации (незакрытые файлы, недописанные данные).
**Факты для карточек**
- base | Номера сигналов SIGINT/SIGKILL/SIGTERM/SIGSEGV/SIGPIPE/SIGCHLD? — 2/9/15/11/13/17
- core | Чем SIGTERM отличается от SIGKILL? — SIGTERM перехватывается и позволяет корректно завершиться, SIGKILL не перехватывается и не игнорируется, ядро снимает процесс с исполнения напрямую
- core | Что можно делать внутри обработчика сигнала? — только менять `volatile sig_atomic_t`; `printf`/`malloc` небезопасны (не async-signal-safe)
- base | Чем `sigaction` лучше `signal`? — поведение `signal` исторически различается между Unix-системами, `sigaction` даёт предсказуемую и переносимую семантику
- deep | Что происходит, если сервис игнорирует SIGTERM при `systemctl stop`? — по истечении `TimeoutStopSec` (по умолчанию ~90 c) systemd посылает SIGKILL принудительно
Почему дальше: сигнал может прервать системный вызов на середине — как код узнаёт об этом и что делать дальше?
## 5. errno и возвраты системных вызовов
Системные вызовы сообщают об ошибке через возврат (−1 или NULL) и `errno`. Читать `errno`
можно только сразу после ошибки, до вызова любой другой функции, которая может сама
переписать `errno` (например, `printf` при внутренней ошибке форматирования). EINTR — вызов
прерван сигналом, надо повторить; EAGAIN — данных сейчас нет на неблокирующем дескрипторе,
повторить позже; EINPROGRESS — неблокирующее соединение в процессе; EMFILE — процесс упёрся
в лимит открытых дескрипторов (`ulimit -n`), диагностируется через `lsof -p PID` или
`/proc/PID/fd`.
```cpp
ssize_t n = read(fd, buf, sizeof buf);
if (n < 0) {
if (errno == EINTR) continue; // прервали сигналом — просто повторить
perror("read"); // иначе настоящая ошибка
break;
}
```
**Ловушки**
- Проверить `errno` без предварительной проверки, что вызов вообще вернул ошибку → `errno`
может быть ненулевым от предыдущего, уже обработанного вызова → ложное срабатывание.
- Вызвать любую функцию (даже `printf`) между системным вызовом и чтением `errno` →
промежуточный вызов может переписать `errno` → в обработчике окажется код чужой ошибки.
**Факты для карточек**
- base | Что означает EINTR и как на него реагировать? — вызов прерван доставкой сигнала; корректная реакция — повторить вызов
- base | Чем EAGAIN отличается от обычной ошибки чтения? — данных сейчас нет на неблокирующем дескрипторе, это не сбой, а сигнал «попробуй позже»
- core | Когда безопасно читать `errno`? — сразу после ошибки вызова, до любого другого вызова, способного его перезаписать
- deep | Какой errno сигнализирует об исчерпании лимита открытых дескрипторов процесса? — EMFILE (лимит задаётся `ulimit -n`)
Почему дальше: fork/exec/wait/сигналы/errno вместе — из этого уже можно собрать минимальный shell; что там на практике ломается первым?
## 6. Разбор примера: мини-шелл на 40 строк
Это образец (запусти, поиграйся), зачётная версия — отдельная задача дня.
```cpp
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <string>
#include <vector>
#include <sstream>
int main() {
std::string line;
while (std::printf("sh> "), std::fflush(stdout), std::getline(std::cin, line)) {
if (line == "exit") break;
if (line.empty()) continue;
std::istringstream is(line);
std::vector<std::string> argv; std::string tok;
while (is >> tok) argv.push_back(tok);
if (argv.empty()) continue;
std::vector<char*> cargv;
for (auto& a : argv) cargv.push_back(a.data());
cargv.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargv[0], cargv.data());
std::fprintf(stderr, "не могу запустить %s\n", cargv[0]);
_exit(127); // exec не удался
} else if (pid > 0) {
int status = 0;
waitpid(pid, &status, 0);
if (WIFEXITED(status))
std::printf("код возврата: %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
std::printf("убит сигналом: %d (код %d)\n",
WTERMSIG(status), 128 + WTERMSIG(status));
} else {
std::perror("fork");
}
}
}
```
Что тут проверить руками: `ls -l` работает; `sleep 5` в фоне (`&` — уже доработка);
`kill -9` по своему процессу из другого терминала даёт «убит сигналом 9 (код 137)»;
несуществующая команда даёт 127. Дочерний процесс перед `execvp` уже унаследовал от
родителя дескрипторы 0/1/2 (stdin/stdout/stderr) через `fork`, поэтому вывод запущенной
программы сразу идёт в тот же терминал — отдельно настраивать редирект не нужно, пока не
требуется перенаправление в файл или pipe.
Проверка на утечки и падения — санитайзеры:
`g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer shell.cpp -o shell`
**Ловушки**
- Не проверять, пуст ли `argv` перед `execvp` → `execvp(nullptr, ...)` на пустой строке →
неопределённое поведение вместо ожидаемого «ничего не делать» (в коде это уже
предусмотрено проверкой `if (argv.empty()) continue;`, но при рефакторинге легко потерять).
- Забыть `_exit(127)` после неудачного `execvp` → дочерний процесс продолжит исполнять
тело цикла `while` наравне с родителем → двойной ввод команд из одного терминала.
**Факты для карточек**
- base | Какой код возврата у шелла даст несуществующая команда? — 127
- base | Какие дескрипторы дочерний процесс получает от родителя при `fork` и почему вывод команды сразу виден в терминале? — 0/1/2 (stdin/stdout/stderr) наследуются через копию таблицы дескрипторов при `fork`
- core | Каким флагом собрать бинарник для проверки на утечки и UB? — `-fsanitize=address,undefined -fno-omit-frame-pointer`
Почему дальше: те же вопросы про fork/exec/wait/сигналы задают на собеседовании почти дословно — какие формулировки ждут в ответ?
## 7. Что спросят на собеседовании (готовые ответы)
- «Что делает fork и что возвращает?» — создаёт копию процесса; в родителе PID ребёнка,
в ребёнке 0, при ошибке −1.
- «Что с памятью при fork?» — copy-on-write, страницы копируются при первой записи.
- «Чем exec отличается от fork?» — fork создаёт новый процесс, exec заменяет образ
текущего, не создавая процесса.
- «Зачем waitpid?» — забрать код возврата и не оставлять зомби.
- «Как узнать, что процесс убит сигналом?» — `WIFSIGNALED`/`WTERMSIG`, код оболочки
128 + сигнал, например 137 = SIGKILL, 139 = SIGSEGV.
- «SIGTERM против SIGKILL?» — SIGTERM можно перехватить и завершиться корректно,
SIGKILL не перехватывается и не игнорируется.
## 8. Материалы (первопартийные)
- `man 2 fork`, `man 2 execve`, `man 2 waitpid`, `man 7 signal` — локально, читаются за 10 минут
- OSTEP, главы 4–5 (процессы, API процессов) — https://pages.cs.wisc.edu/~remzi/OSTEP/
- Beej, «Processes» — https://beej.us/guide/bgipc/
- Про COW: `man 2 fork` + статья «Anatomy of a Program in Memory» —
https://manybutfinite.com/post/anatomy-of-a-program-in-memory/
## 9. Проверь себя
1. Что вернёт `fork()` в ребёнке и в родителе?
2. Что происходит со страницами памяти при `fork()`?
3. Что такое зомби и кто его убирает?
4. Откуда код возврата 137 и 139?
5. Почему `exec` не возвращает управление при успехе?
6. Какие файловые дескрипторы сохраняются после `exec` и какой флаг это меняет?
7. Что произойдёт с процессом, который игнорирует SIGTERM, при `systemctl stop`?
<details>
<summary>Ответы</summary>
1. В ребёнке 0, в родителе — PID ребёнка, при ошибке −1.
2. Ничего сразу: copy-on-write, копия страницы создаётся при первой записи.
3. Завершившийся процесс, чья запись ждёт `wait/waitpid`; убирает родитель (или init,
если родитель умер).
4. 137 = 128+9 (SIGKILL), 139 = 128+11 (SIGSEGV) — так кодирует оболочка.
5. Потому что адресное пространство заменено новым образом: старого кода больше нет.
6. Все дескрипторы, открытые до `exec`, кроме помеченных `FD_CLOEXEC`.
7. По истечении таймаута остановки (`TimeoutStopSec`, по умолчанию ~90 c) systemd пришлёт
SIGKILL принудительно.
</details>
## 10. Задачи дня
- `tasks/03_ring` — кольцевой буфер (база для сетевого кода).
- Мини-шелл — в зачётной части дня (полное ТЗ придёт с D1-заданиями).
</content>
-287
View File
@@ -1,287 +0,0 @@
# D2, часть 1. Деревья, BST, куча (урок)
Дерево — рекурсивная структура: узел + набор поддеревьев. На собеседовании не спрашивают
определение, спрашивают следствия: как способ хранения (указатели vs массив) и выбранный
инвариант (порядок BST vs экстремум кучи) напрямую определяют сложность операций. Порядок
разбора: как дерево лежит в памяти → как его обходят → что даёт BST-инвариант → что даёт
heap-инвариант → куда это применяется в top-K, LCA, валидации.
## 1. Представление: узлы с указателями vs массив
**Узлы с указателями.** Типичный `TreeNode` — значение плюс два указателя на детей:
```c++
struct TreeNode {
int val;
TreeNode *left, *right;
};
```
На x86-64 `sizeof(TreeNode) == 24`: `val` (`int`, 4 байта) лежит по смещению 0, дальше 4
байта паддинга — указатель должен быть выровнен на 8 байт, — `left` по смещению 8 (8 байт),
`right` по смещению 16 (8 байт). Итого 24, а не 20 — та же механика паддинга, что и в
структурах вообще (`char+int+char` → 12 байт по тем же правилам выравнивания). Каждый узел —
отдельная аллокация в куче, значит отдельный вызов аллокатора и произвольный адрес — узлы
дерева обычно не лежат рядом в памяти, отсюда промахи кэша при обходе.
**Массив.** Для узла с индексом `i` дети — `2i+1` и `2i+2`, родитель — `(i-1)/2` (целочисленное
деление) — индексная арифметика заменяет указатели. Представление компактно только для
**полного или близкого к полному** дерева (все уровни заполнены слева направо без пропусков,
как в куче): тогда индексы плотно покрывают массив без дыр. Для разреженного дерева такое
представление может потребовать до `2^h` ячеек массива, из которых заполнена малая часть —
это и есть причина, по которой куча всегда хранится в массиве, а произвольное дерево (BST,
дерево поиска) — почти всегда через указатели.
**Факты для карточек**
- base | Из чего состоит узел бинарного дерева при представлении указателями? — значение + два указателя (left, right)
- core | Размер `struct TreeNode { int val; TreeNode *left, *right; }` на x86-64? — 24 байта: 4 байта `val` + 4 байта паддинга (выравнивание указателя на 8) + 8 + 8 байт под указатели
- core | Когда массивное представление дерева компактно, а когда нет? — компактно только для полного/близкого к полному дерева (куча); для разреженного дерева индексы `2i+1/2i+2` требуют до `2^h` ячеек, большинство из которых пустует
- deep | Почему куча хранится в массиве, а не через указатели? — куча всегда почти полное дерево (заполнена по уровням слева направо без пропусков), индексная арифметика не тратит память впустую и не требует отдельной аллокации на узел
Почему дальше: раз дерево можно линейно уместить в массив только при полном заполнении по уровням, как вообще перебирают узлы произвольного дерева — обходы.
## 2. Обходы: DFS (preorder/inorder/postorder) и BFS
Три порядка DFS отличаются местом, где посещается сам узел относительно детей:
- **preorder** (node, left, right) — узел раньше детей; используется, когда нужно
восстановить структуру дерева по порядку посещения (сериализация, копирование).
- **inorder** (left, node, right) — для BST даёт значения **по возрастанию** (см. раздел 3).
- **postorder** (left, right, node) — оба ребёнка раньше узла; используется, когда узел
нельзя обработать/освободить до того, как обработаны/освобождены его дети (удаление дерева
снизу вверх, вычисление арифметического дерева выражений).
**DFS рекурсией** использует неявный стек вызовов, глубина = высота дерева `h` → память
`O(h)`. **DFS итеративно** — явный `std::stack<TreeNode*>`, та же асимптотика `O(h)`, но без
риска переполнения стека потока: кадр функции тяжелее записи в explicit-стеке (адрес возврата
+ сохранённые регистры против одного указателя, 8 байт), а стек потока на Linux ограничен
(обычно порядка нескольких мегабайт) — на сильно вырожденном дереве (`h` порядка `n`)
рекурсия может упереться в этот лимит раньше, чем закончится память под явный стек.
**BFS** обходит уровень за уровнем через очередь; память — `O(w)`, где `w` — максимальная
ширина уровня, а не высота. Для полного сбалансированного дерева из `n` узлов высота
`h ≈ log₂ n`, но ширина последнего уровня — около `n/2`: при `n = 1 000 000` это `h ≈ 20`
(`log₂10⁶ ≈ 20`) против ширины последнего уровня ≈ 500 000. То есть BFS-очередь на пике может
требовать памяти на порядки больше, чем DFS-стек, для одного и того же дерева.
**Ловушки**
- Не проверить `node == nullptr` перед обращением к `node->val` в рекурсии → разыменование нулевого указателя → SIGSEGV, UBSAN отдельно ловит это с `-fsanitize=null`.
- В итеративном DFS перепутать порядок `push` детей (класть left раньше right вместо наоборот при эмуляции preorder через стек — стек разворачивает порядок) → обход посещает поддеревья не в том порядке, тихий баг, виден только сравнением с рекурсивным эталоном.
- Использовать рекурсивный DFS на сильно несбалансированном дереве (`h ≈ n`, например после вставки отсортированной последовательности) → глубина рекурсии `n` → переполнение стека потока, а не просто медленная работа.
**Факты для карточек**
- base | Порядок посещения в inorder-обходе? — left, node, right
- base | Какой обход даёт отсортированный порядок для BST? — inorder
- core | Память DFS (рекурсия или явный стек) по глубине дерева? — O(h) — пропорционально высоте
- core | Память BFS (очередь)? — O(w) — пропорционально максимальной ширине уровня
- core | Для полного сбалансированного дерева из 10⁶ узлов: высота и ширина последнего уровня? — h ≈ 20 (log₂10⁶≈20), ширина последнего уровня ≈ 500 000 — BFS может требовать памяти на порядки больше, чем DFS
- deep | Какой обход используют, чтобы освободить дерево снизу вверх? — postorder (сначала оба ребёнка, потом сам узел)
Почему дальше: обходы работают одинаково независимо от порядка значений в узлах; BST добавляет конкретный порядковый инвариант — разберём, что именно он даёт.
## 3. BST-инвариант и что он даёт
Инвариант: для каждого узла все значения в левом поддереве меньше значения узла, все значения
в правом — больше. Отсюда напрямую:
- **Поиск/вставка/удаление — O(h).** На каждом узле решение однозначно (значение меньше —
влево, больше — вправо), путь до цели или до `nullptr` не длиннее высоты дерева.
- **Inorder-обход = отсортированный порядок.** Прямое следствие инварианта: в любом
поддереве все значения левой части меньше корня, все значения правой — больше, рекурсивно
это верно на каждом уровне, поэтому обход left→node→right посещает значения строго по
возрастанию.
Высота `h` — не константа, а зависит от формы дерева: у сбалансированного BST (например,
построенного случайными вставками или явно балансирующегося, как красно-чёрное дерево)
`h ≈ log₂ n`; у **вырожденного** — `h = n`. Конкретный пример вырождения: вставка значений
`1, 2, 3, ..., n` по порядку в обычный (не самобалансирующийся) BST — каждый новый узел
становится правым ребёнком предыдущего максимума, дерево превращается в цепочку, и поиск,
ожидаемо `O(log n)`, на деле деградирует до `O(n)` — то есть до линейного перебора связного
списка.
**Факты для карточек**
- base | Инвариант BST? — для каждого узла все значения в левом поддереве меньше значения узла, все значения в правом — больше
- base | Сложность поиска в BST? — O(h), где h — высота дерева
- core | Что даёт inorder-обход BST? — значения по возрастанию — прямое следствие инварианта
- core | Высота BST в лучшем и худшем случае для n узлов? — сбалансированное: h ≈ log₂n; вырожденное: h = n
- deep | Что произойдёт при вставке 1,2,...,n по порядку в обычный (небалансирующийся) BST? — дерево вырождается в цепочку (каждый новый узел — правый ребёнок предыдущего), поиск деградирует до O(n)
Почему дальше: раз наивный BST может выродиться в список, встают два практических вопроса — как правильно проверить дерево на BST-инвариант и как искать общего предка, используя (или не используя) этот инвариант.
## 4. Валидация BST и LCA
**Частая ошибка валидации** — сравнивать узел только с непосредственным родителем
(`node->left->val < node->val` и `node->right->val > node->val` на каждом шаге). Контрпример:
корень `5`, его левый ребёнок `3`, а правый ребёнок узла `3` — `6`. Локальная проверка
`6 > 3` проходит (это правый ребёнок `3`, и `6` больше `3`), но `6` находится в левом
поддереве корня `5`, а `6 > 5` — инвариант BST для всего дерева нарушен. Локальное сравнение
с родителем этого не ловит, потому что оно ничего не знает про предков выше.
**Правильная валидация** — рекурсия с передачей вниз границ `(min, max)`: каждый узел должен
строго лежать между ними; при спуске влево верхняя граница ужесточается до значения узла
(`max = node->val`), при спуске вправо ужесточается нижняя (`min = node->val`). Альтернатива —
inorder-обход с проверкой строгого возрастания относительно последнего посещённого значения
(`O(n)` время, `O(1)` дополнительной памяти, если не копить весь список, а сравнивать на лету).
**LCA в BST** использует порядок значений: начиная с корня, если оба искомых значения меньше
текущего узла — идти влево, если оба больше — вправо, иначе текущий узел и есть точка, где
пути к двум значениям расходятся, то есть LCA. Сложность `O(h)` — та же логика, что у поиска.
**LCA в произвольном бинарном дереве** (без порядка) не может отбросить половину дерева на
каждом шаге — порядка, который это позволяет, нет. Стандартное решение — рекурсия postorder:
если узел `nullptr` или совпадает с одним из искомых — вернуть его; рекурсивно получить
результат из левого и правого поддерева; если оба непустые — текущий узел и есть LCA;
иначе вернуть тот результат, который непустой (продвинуть найденный узел вверх). Сложность
`O(n)` — в худшем случае нужно посетить каждый узел один раз; память `O(h)` на стек рекурсии.
**Ловушки**
- Валидировать BST сравнением только с прямым родителем → пропускает нарушения через поколение (контрпример: корень 5, левый 3, правый потомок 3 равен 6) → тест с таким деревом должен упасть, а наивная проверка его пропустит.
- Использовать нестрогое сравнение (`<=` вместо `<`) без явного решения, допустимы ли дубликаты и в какое поддерево они кладутся → BST с равными значениями валидируется непредсказуемо.
- В LCA по BST не проверить, что оба узла реально принадлежат дереву → функция вернёт правдоподобный, но неверный узел вместо явной ошибки.
**Факты для карточек**
- base | Почему нельзя валидировать BST, сравнивая узел только с непосредственным родителем? — нарушение инварианта может возникнуть через поколение (правый потомок левого поддерева больше корня, но меньше своего прямого родителя) — локальная проверка это не ловит
- core | Как правильно валидировать BST рекурсивно? — передавать вниз границы (min, max): узел должен строго лежать между ними, для левого поддерева ужесточается верхняя граница, для правого — нижняя
- core | Сложность LCA в BST и почему? — O(h): если оба искомых значения меньше текущего узла — влево, оба больше — вправо, иначе текущий узел и есть LCA
- core | Сложность LCA в обычном бинарном дереве без порядка? — O(n): нет инварианта, отсекающего часть дерева, нужна рекурсия postorder по потенциально всем узлам
- deep | Альтернативный способ валидации BST без явных границ (min, max)? — inorder-обход с проверкой строгого возрастания относительно предыдущего посещённого значения
Почему дальше: и валидация, и LCA используют дерево как структуру поиска по значению; для задач top-K важнее не порядок всех элементов, а быстрый доступ к экстремуму — для этого нужен другой инвариант, куча.
## 5. Куча: инвариант и индексы
Инвариант max-heap: значение в родителе не меньше значений в обоих детях. Хранится в массиве
(см. раздел 1): для индекса `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`.
- **`top()` — O(1).** По инварианту максимум всегда лежит в корне, то есть в начале массива —
прямое обращение по индексу 0, без поиска.
- **`sift-up`/`sift-down` — O(log n).** Оба переставляют элемент вдоль одного пути от узла до
корня или от корня до листа; длина этого пути ограничена высотой дерева.
**Bottom-up heapify — O(n), а не O(n log n).** Через `n` последовательных `push` (каждая —
`sift-up` до `O(log i)` на i-й вставке) суммарная стоимость — `Σ log₂(i)` для `i = 1..n`, это
порядка `n log₂ n`. Bottom-up heapify стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс 0), вызывая `sift-down` на каждом узле; работа `sift-down`
в узле пропорциональна **высоте именно этого узла `h`**, а не высоте всего дерева — узлов
большой высоты экспоненциально мало (в корне — один узел высоты `log n`, листьев высоты 0 —
около `n/2`, они вообще пропускаются). Сумма `Σ h/2^h` по всем высотам сходится к константе
(≈2) при росте `n`, поэтому суммарная работа — `O(n)`, а не `O(n log n)`.
**Факты для карточек**
- base | Индексы детей и родителя в куче на массиве для узла i? — дети 2i+1, 2i+2; родитель (i-1)/2 (целочисленное деление)
- base | Сложность доступа к максимуму (top) в max-heap? — O(1) — максимум всегда в корне по инварианту
- core | Сложность sift-up/sift-down? — O(log n) — путь ограничен высотой дерева
- core | За какое время строится куча через bottom-up heapify против n последовательных push? — O(n) против O(n log n): работа sift-down в узле пропорциональна его высоте h, а сумма Σ h/2^h по всем узлам сходится к константе
- deep | С какого индекса стартует bottom-up heapify и куда идёт? — с последнего нелистового узла, индекс n/2 - 1, к корню (индекс 0)
Почему дальше: куча даёт O(1) доступ к экстремуму и O(log n) перестройку — на этом строится вся группа задач top-K, и у кучи есть конкурент по сложности — quickselect.
## 6. priority_queue, top-K и nth_element
`std::priority_queue` по умолчанию — max-heap поверх `std::vector` (сравнение `std::less`,
для min-heap передают `std::greater<>` третьим параметром шаблона).
Три способа получить top-K:
1. **Полная куча.** `heapify` за `O(n)`, затем `k` раз `pop` (`sift-down` корня) по `O(log n)`
каждый — итого `O(n + k log n)`.
2. **Потоковый (min-heap размера k).** Когда данные не помещаются в память целиком: держат
min-heap ровно из `k` элементов, каждый новый элемент сравнивают с минимумом кучи (`top`,
O(1)), и если новый больше — минимум вытесняется, новый добавляется. Сложность
`O(n log k)`, память `O(k)` вместо `O(n)`.
3. **`nth_element` (quickselect).** Партиционирует диапазон вокруг опорного элемента и
рекурсивно спускается только в ту половину, где лежит нужный ранг: в среднем `T(n) =
T(n/2) + O(n) → O(n)`, в худшем случае (устойчиво плохой выбор опорного, та же причина,
что и у quicksort) — `O(n²)`. Результат — частичный порядок (всё слева не больше опорного,
всё справа не меньше, но без порядка внутри половин), не отсортированный список: для
готового top-K по убыванию `nth_element` придётся досортировать `k`-элементный префикс,
тогда как `pop` из кучи уже отдаёт элементы по убыванию сам по себе.
**Top K Frequent** — отдельный паттерн: подсчёт частот хеш-таблицей за `O(n)`, затем либо
heap размера `k` (`O(n log k)`), либо **bucket sort по частоте**: частота любого элемента не
превышает `n`, поэтому заводят массив корзин размера `n + 1`, кладут элемент в
`bucket[частота]` и сканируют от максимальной частоты к минимальной — `O(n)` без
логарифмического множителя вообще.
**Ловушки**
- Строить кучу через `push` в цикле вместо bottom-up `heapify` → результат корректен (инвариант тот же), но `O(n log n)` вместо `O(n)` — на миллионах элементов заметно медленнее, и это отдельный вопрос на собеседовании.
- Использовать `nth_element` там, где нужен полностью отсортированный top-K, и забыть досортировать k-элементный префикс → порядок в выводе неверный, хотя набор элементов правильный.
- В потоковом top-K сравнивать новый элемент с максимумом кучи вместо минимума (перепутать min-heap и max-heap для этой задачи) → кандидаты вытесняются в обратном порядке, итоговый top-K — неверный набор элементов.
**Факты для карточек**
- base | Что такое `std::priority_queue` по умолчанию? — max-heap поверх vector; для min-heap передают компаратор std::greater<>
- core | Сложность top-K через полную кучу? — O(n + k log n): O(n) heapify + k раз pop по O(log n)
- core | Когда используют min-heap размера k вместо полной кучи? — на потоке данных, не помещающемся в память целиком: min-heap размера k хранит только k текущих кандидатов, O(n log k), память O(k)
- core | Средняя и худшая сложность `nth_element` (quickselect)? — в среднем O(n), в худшем O(n²) — та же причина, что у quicksort: устойчиво плохой выбор опорного
- deep | Почему top-K через nth_element не даёт готовый отсортированный список? — nth_element только частично упорядочивает вокруг k-го элемента без порядка внутри половин; нужна досортировка k-элементного префикса
- deep | Как получить top-K частых элементов за O(n) без log-фактора? — подсчёт хеш-таблицей O(n), затем bucket sort по частоте: частота не превышает n, массив корзин размера n+1 индексируется частотой напрямую
Почему дальше: дерево, BST-инвариант и куча — это и есть механизмы за стандартными задачами интервью; дальше — какая задача проверяет какой именно из них.
## 7. Задачи-триггеры
- **Invert Binary Tree** — рекурсивно поменять местами `left`/`right` у каждого узла: `O(n)`
время (каждый узел посещается один раз), `O(h)` память на стек рекурсии.
- **Validate BST** — рекурсия с границами `(min, max)`, передаваемыми вниз (раздел 4).
- **Kth Largest** — min-heap размера `k` (`O(n log k)`) или `nth_element`/quickselect
(в среднем `O(n)`) (раздел 6).
- **Top K Frequent** — хеш-таблица + bucket sort по частоте (`O(n)`) или heap размера `k`
(`O(n log k)`) (раздел 6).
Ссылка на задачу дня: `tasks/11_heap` — там же ключевое требование «`heapify` только
bottom-up, не `push` в цикле», прямая проверка раздела 5.
## Проверь себя
<details>
<summary>1. Почему нельзя проверить BST, сравнивая каждый узел только с прямым родителем?</summary>
Потому что нарушение инварианта может произойти через поколение: узел может быть больше
своего прямого родителя, но при этом лежать в поддереве более раннего предка, для которого
он слишком велик (например, правый потомок левого ребёнка корня оказывается больше самого
корня). Правильная проверка передаёт вниз границы (min, max), актуальные для всего пути от
корня, а не только для одного родителя.
</details>
<details>
<summary>2. Почему bottom-up heapify — O(n), хотя каждый sift-down в отдельности — O(log n)?</summary>
Потому что работа sift-down в конкретном узле пропорциональна высоте именно этого узла, а не
высоте всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел
высоты log n, листьев высоты 0 — около n/2, для них sift-down почти ничего не делает). Сумма
Σ h/2^h по всем высотам сходится к константе, поэтому суммарная работа растёт линейно с n.
</details>
<details>
<summary>3. Чем LCA в BST отличается по сложности от LCA в обычном бинарном дереве и почему?</summary>
В BST — O(h): порядок значений позволяет на каждом шаге однозначно решить, идти влево, вправо
или остановиться. В обычном дереве такого порядка нет, поэтому приходится рекурсивно
обходить дерево (postorder) и в худшем случае посетить все n узлов — O(n).
</details>
<details>
<summary>4. Почему nth_element в среднем O(n), но не гарантирует худший случай?</summary>
Потому что рекурсия спускается только в ту часть, где лежит искомый ранг, отбрасывая
остальное после каждого партиционирования — в среднем это даёт T(n) = T(n/2) + O(n) → O(n).
Но при устойчиво плохом выборе опорного элемента (та же причина, что и у худшего случая
quicksort) партиционирование каждый раз отбрасывает лишь один элемент, и сложность
деградирует до O(n²).
</details>
<details>
<summary>5. Почему массив — плохое представление для сильно несбалансированного (не полного) дерева?</summary>
Потому что индексы 2i+1/2i+2 предполагают позицию узла в полном дереве соответствующей
глубины; если дерево заполнено не полностью (например, вырожденная цепочка), индексы,
которые физически используются, разбросаны на диапазон до 2^h, и большая часть массива
такого размера останется пустой.
</details>
## Задачи дня
- `tasks/11_heap` — куча: bottom-up `heapify` за O(n), `kth_largest`/`top_k`; ключевая
проверка — запрет строить кучу через `push` в цикле.
## Материалы
- Документация `std::priority_queue` (куча в top-K) — https://en.cppreference.com/w/cpp/container/priority_queue
- Документация `std::nth_element` (quickselect для k-го элемента) — https://en.cppreference.com/w/cpp/algorithm/nth_element
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
-268
View File
@@ -1,268 +0,0 @@
# D2, часть 3. C++ по промахам (45 минут, плотно)
Пять тем, которые почти гарантированно спросят и почти гарантированно проверят не на
определении, а на конкретном примере: посчитать размер структуры, найти double-free в коде,
объяснить, что делает `std::move`, назвать, что именно является UB. Разбор без разгона — сразу
к механизму.
## 1. Выравнивание и padding
```c++
struct S { char a; int b; char c; };
static_assert(sizeof(S) == 12);
static_assert(offsetof(S, b) == 4);
static_assert(offsetof(S, c) == 8);
```
Поля дают 6 байт (1+4+1), но `sizeof(S) == 12` на x86-64. Раскладка по смещениям:
`a` — смещение 0 (1 байт); дальше **3 байта паддинга**, потому что `int b` требует
выравнивания на 4 байта (адрес поля должен делиться на 4), а следующий свободный адрес — 1;
`b` занимает смещения 4–7; `c` — смещение 8 (1 байт). На этом полезные данные кончаются на
9 байте, но размер **всей структуры** округляется вверх до кратного выравниванию самого
строгого поля внутри (здесь `int`, выравнивание 4) — отсюда ещё **3 байта хвостового
паддинга**, и итоговый размер 12, а не 9.
Выравнивание — требование от процессора: обращение к `int` по адресу, не кратному 4, на x86
разрешено, но обходится дороже (может потребовать двух обращений к памяти вместо одного); на
архитектурах со строгим выравниванием (некоторые режимы ARM) — это аппаратное исключение.
Компилятор жертвует местом в памяти ради предсказуемой и быстрой работы с каждым полем.
`offsetof(S, member)` — макрос, дающий точное смещение поля в байтах, посчитанное так же, как
это делает компилятор при раскладке структуры (в примере выше — 4 и 8). `#pragma pack(1)`
убирает паддинг полностью — `sizeof(S)` станет 6, но каждое обращение к `b` и `c` идёт по
невыровненному адресу: цена — либо замедление на x86, либо падение на платформах со строгим
выравниванием. `#pragma pack` оправдан там, где формат байт фиксирован извне (сетевой
протокол, бинарный формат файла) и точное совпадение раскладки важнее скорости доступа.
**Ловушки**
- Сериализовать структуру побайтовым копированием (`memcpy` всей структуры целиком) и передать по сети/записать в файл, предполагая, что размер равен сумме полей → получатель на платформе с другим выравниванием прочитает мусор из паддинг-байтов как часть данных.
- Полагаться на порядок полей в памяти как на что-то определяемое исходным кодом → компилятор вправе вставлять паддинг между полями в порядке их объявления, но не обязан оптимизировать порядок сам — реальную раскладку проверяют `sizeof`/`offsetof`, а не читают код на глаз.
**Факты для карточек**
- base | sizeof(struct { char a; int b; char c; }) на x86-64? — 12 байт
- core | Смещения полей a, b, c в этой структуре? — a=0, b=4 (после 3 байт паддинга), c=8
- core | Почему в конце структуры ещё 3 байта паддинга? — размер всей структуры округляется вверх до кратного выравниванию самого строгого поля (здесь int, выравнивание 4): 9 → 12
- deep | Что делает #pragma pack(1) и какая у него цена? — убирает паддинг (sizeof(S) станет 6), но доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим выравниванием
Почему дальше: раскладка структуры в памяти — то, что копирует компилятор по умолчанию при копировании объекта; когда объект владеет ресурсом через указатель, это копирование становится опасным — отсюда правило 0/3/5.
## 2. Правило 0/3/5
Если класс сам управляет ресурсом (владеющий сырой указатель, файловый дескриптор, мьютекс) и
поэтому определяет деструктор — он почти наверняка должен явно определить и **конструктор
копирования**, и **оператор присваивания копированием** (правило трёх), а с C++11 — ещё и
**конструктор перемещения** с **оператором присваивания перемещением** (правило пяти). Если
ни одну из пяти функций не объявить, компилятор генерирует все пять сам; сгенерированная
версия копирования — **побитовое (memberwise) копирование** каждого поля.
```c++
class Buffer {
int* data;
size_t n;
public:
Buffer(size_t n) : data(new int[n]), n(n) {}
~Buffer() { delete[] data; }
// конструктор копирования и operator= не объявлены —
// компилятор сгенерирует побитовую копию указателя data
};
Buffer a(10);
Buffer b = a; // побитовая копия: b.data == a.data, один и тот же адрес
``` // при выходе из области видимости оба деструктора вызовут delete[] на одном адресе
Для указателя побитовая копия означает, что `b.data` получает **то же значение адреса**, что
и `a.data`, — не копию массива, а второй указатель на один и тот же блок памяти. Когда `a` и
`b` выходят из области видимости, оба деструктора вызывают `delete[]` на одном и том же
адресе — второй вызов освобождает уже освобождённую память. Это классический **double-free**,
UB; ASAN отмечает его явно, с двумя стеками вызовов — одним для первого `delete[]`, вторым
для попытки повторного.
**Rule of zero.** Вместо ручного написания всех пяти функций чаще доверяют владение готовым
RAII-члену (`std::unique_ptr`, `std::vector`, `std::string`) и не объявляют ни одной из пяти
функций вообще — сгенерированные компилятором версии корректно копируют/перемещают саму
обёртку, а обёртка уже сама правильно управляет ресурсом.
**Факты для карточек**
- base | Какие 5 функций входят в правило пяти? — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением
- base | Что делает сгенерированный компилятором конструктор копирования по умолчанию? — побитовое (memberwise) копирование каждого поля
- core | Почему копирование объекта с владеющим сырым указателем даёт double-free? — оба объекта получают одно и то же значение указателя (адрес), оба деструктора вызывают delete на этом адресе — второй раз на уже освобождённой памяти
- core | Что такое rule of zero? — не объявлять ни одну из пяти спецфункций вручную, доверив владение ресурсом готовым RAII-обёрткам (unique_ptr, vector, string) — их сгенерированные копирование/перемещение уже корректны
Почему дальше: правило 0/3/5 регулирует копирование данных объекта, но у полиморфных объектов есть отдельный, ещё более резкий способ потерять часть состояния при удалении — отсутствие virtual-деструктора.
## 3. virtual-деструктор и vtable
Если у класса есть хотя бы одна `virtual`-функция, компилятор добавляет в каждый объект
скрытый указатель на **vtable** — таблицу указателей на реализации виртуальных функций,
специфичную для конкретного класса (на типичной 64-битной платформе этот указатель — 8 байт,
и он увеличивает размер каждого объекта на эту величину). Вызов `obj->f()` через указатель на
базовый класс разрешается в рантайме: сначала читается указатель на vtable из объекта, потом
из таблицы берётся указатель на нужную функцию — и это уже реализация **фактического** типа
объекта, не типа указателя.
```c++
struct Base {
virtual ~Base() = default; // без virtual — источник утечки ниже
virtual void run() {}
};
struct Derived : Base {
int* owned = new int[1000];
~Derived() override { delete[] owned; }
};
Base* p = new Derived();
delete p; // если ~Base() не virtual: вызовется только ~Base(), ~Derived() не вызовется
```
Если деструктор `Base` **не** `virtual`, вызов `delete p` привязывается компилятором к типу
указателя (`Base*`) **на этапе компиляции** — вызовется только `~Base()`, `~Derived()` не
вызовется вообще, хотя объект физически был типа `Derived`. `Derived::owned` не освобождается
— прямая утечка. Формально это UB; LeakSanitizer покажет утечку со стеком выделения внутри
конструктора `Derived`. Правило: если класс задуман как базовый для полиморфного использования
(в нём уже есть другие `virtual`-методы, объекты удаляются через указатель на базовый класс),
его деструктор обязан быть `virtual`.
**Ловушки**
- Забыть `virtual` у деструктора базового класса, предназначенного для полиморфного использования → `delete` через `Base*` не вызывает `~Derived()` → утечка ресурсов, которыми владел `Derived`, видна по LeakSanitizer со стеком выделения в конструкторе `Derived`.
- Вызвать `virtual`-функцию из конструктора базового класса, ожидая переопределённое в `Derived` поведение → на этом этапе vtable объекта ещё указывает на таблицу `Base` (подобъект `Derived` ещё не построен), вызовется версия `Base` — тихий баг без ошибки компиляции.
**Факты для карточек**
- base | Что добавляет в объект наличие хотя бы одной virtual-функции? — скрытый указатель на vtable, обычно 8 байт на 64-битной платформе
- core | Что произойдёт при delete через Base*, если ~Base() не virtual, а объект на деле Derived? — вызовется только ~Base(), ~Derived() не вызовется вообще — утечка ресурсов Derived, формально UB
- core | Чем это ловится? — LeakSanitizer, со стеком выделения внутри конструктора Derived
- deep | Что вызовет virtual-функция, вызванная из конструктора базового класса? — версию базового класса, а не переопределённую в наследнике: vtable объекта на этом этапе ещё указывает на таблицу Base
Почему дальше: virtual-деструктор освобождает ресурс через уничтожение объекта; альтернативный способ распорядиться ресурсом объекта — не уничтожить его, а перенести владение — это `std::move`, и здесь часто путают, что именно он делает.
## 4. std::move — это каст, а не перемещение
`std::move(x)` не перемещает данные и не выполняет вообще никакого действия во время
выполнения — это `static_cast<T&&>(x)`, явное приведение объекта к rvalue-ссылке. Единственный
эффект — при выборе перегрузки компилятор теперь предпочитает конструктор/оператор
присваивания **перемещением**, а не копированием, если такой у типа определён.
```c++
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
// std::move(a) сам по себе ничего не делает — просто приводит a к vector<int>&&
// реальную работу выполняет move-конструктор vector: он копирует указатель на
// внутренний буфер a в b и обнуляет указатель у a — O(1), без копирования элементов
```
Реальную работу делает **move-конструктор** конкретного типа: для `std::vector` это означает
скопировать три указателя (начало, конец данных, конец ёмкости) в новый объект и обнулить их
у источника — O(1) вместо O(n) поэлементного копирования. `std::move` — это лишь явная пометка
программиста «мне больше не нужно значение этого именованного объекта (формально lvalue)»,
позволяющая выбрать move-перегрузку там, где без этой пометки компилятор выбрал бы копирующую.
Стандарт гарантирует только, что объект после перемещения находится в **валидном, но
неопределённом состоянии** — обращение к его старым данным (например, чтение элементов
`std::vector`, из которого только что сделали `std::move`) не UB и не ловится ни одним
санитайзером, но является логической ошибкой: конкретное содержимое непредсказуемо.
**Факты для карточек**
- base | Что физически делает std::move во время выполнения программы? — ничего: это static_cast к rvalue-ссылке (T&&), явный каст, не операция
- core | Что реально выполняет перемещение данных? — move-конструктор/move-оператор присваивания конкретного типа, выбранный благодаря касту std::move
- core | Что происходит с vector при перемещении и за какое время? — копируются 3 внутренних указателя (начало, конец данных, конец ёмкости) в новый объект, у источника они обнуляются — O(1), без копирования элементов
- deep | В каком состоянии находится объект после std::move(obj) по стандарту? — в валидном, но неопределённом состоянии — использование старых данных не UB, но логическая ошибка, не ловится санитайзерами
Почему дальше: логическая ошибка использования объекта после move не UB и не ловится санитайзерами — но есть отдельная категория ошибок, которая формально UB и которую санитайзеры как раз находят.
## 5. UB: конкретные случаи и что ловят ASAN/UBSAN
UB (undefined behavior) — поведение, для которого стандарт не накладывает вообще никаких
требований: компилятор вправе сгенерировать любой код, в том числе тот, что работает
по-разному в отладочной и релизной сборке, потому что оптимизатор строит код в предположении,
что UB не происходит.
- **Знаковое переполнение.** `INT_MAX + 1` для `int` (`INT_MAX = 2147483647` на типичной
32-битной `int`) — UB, не гарантированное переполнение по модулю, в отличие от `unsigned`,
для которого переполнение определено стандартом (арифметика по модулю 2^разрядность).
- **Некорректный сдвиг.** Сдвиг на число бит, большее или равное разрядности типа (`1 << 32`
для 32-битного `int`), либо сдвиг влево, затрагивающий знаковый бит отрицательного числа —
UB.
- **Нарушение выравнивания.** Приведение указателя к типу с более строгим выравниванием и
разыменование (например, `char*`, не кратный 4, приведённый к `int*` и разыменованный) — UB,
даже если конкретная архитектура физически позволяет такое чтение.
- **Разыменование null.** `*(int*)nullptr` — UB; на практике обычно даёт `SIGSEGV`, потому что
ОС намеренно оставляет страницу по адресу 0 непримапленной именно для того, чтобы такие
обращения падали предсказуемо, а не читали случайные данные.
**ASAN** (`-fsanitize=address`) проверяет ошибки **работы с памятью**: оборачивает выделения
«красными зонами», обращение к которым — сразу ошибка, и ловит выход за границы,
use-after-free, double-free; встроенный LeakSanitizer ловит утечки. **UBSAN**
(`-fsanitize=undefined`) вставляет проверки прямо в код в местах, являющихся UB по стандарту —
переполнение знаковых типов, некорректный сдвиг, нарушение выравнивания при разыменовании —
и печатает точную строку исходного кода при срабатывании. Они проверяют разные категории
(ASAN — адреса памяти, UBSAN — отдельные операции языка) и не заменяют друг друга, поэтому их
включают вместе:
```
g++ -g -fsanitize=address,undefined -fno-omit-frame-pointer file.cpp -o file
```
Оба замедляют программу и требуют пересборки с флагом `-fsanitize=...`, поэтому используются
в отладочных/тестовых сборках, а не в проде.
**Ловушки**
- Полагаться на то, что `int` переполняется предсказуемо «как unsigned» (по модулю) → UB даёт компилятору право отбросить проверку переполнения при оптимизации, если решит, что переполнения «не бывает» → код, работающий в `-O0`, ломается в `-O2`.
- Включить только ASAN и решить, что этого достаточно против всех UB → ASAN не ловит знаковое переполнение и некорректные сдвиги — это зона UBSAN, нужны оба флага вместе.
**Факты для карточек**
- base | Что проверяет ASAN, а что — UBSAN? — ASAN: ошибки работы с памятью (границы, use-after-free, double-free, утечки); UBSAN: операции, являющиеся UB по стандарту (переполнение, сдвиг, выравнивание)
- base | Значение INT_MAX для 32-битного int? — 2147483647
- core | Почему UB опасен именно тем, что код может работать в отладочной сборке и падать в релизной? — оптимизатор релизной сборки строит код в предположении, что UB не происходит, и может убрать проверки, которые, по мнению программиста, должны были сработать
- core | Какой флаг компилятора включает сразу оба санитайзера? — -fsanitize=address,undefined
- deep | Почему разыменование nullptr на практике обычно даёт SIGSEGV, а не тихо читает мусор? — ОС намеренно не отображает страницу по адресу 0 в физическую память, поэтому любое обращение к ней гарантированно и предсказуемо падает
## Проверь себя
<details>
<summary>1. Почему sizeof(struct { char a; int b; char c; }) равен 12, а не 6 или 9?</summary>
6 — это сумма размеров полей без паддинга, физически недостижима из-за требования
выравнивания int на 4 байта: между a (смещение 0) и b нужно 3 байта паддинга, b занимает
смещения 4–7, c — смещение 8. 9 байт — это конец полезных данных после c, но итоговый размер
структуры округляется вверх до кратного выравниванию самого строгого поля (int, 4) — 9
округляется до 12, добавляя ещё 3 байта хвостового паддинга.
</details>
<details>
<summary>2. Почему копирование объекта с необъявленными спецфункциями и владеющим указателем даёт double-free?</summary>
Компилятор генерирует конструктор копирования по умолчанию, если ни одну из пяти спецфункций
не объявили сами. Он делает побитовое копирование полей — указатель копируется как значение
адреса, оба объекта получают один и тот же адрес. При уничтожении обоих объектов оба
деструктора вызывают delete на этом адресе — второй вызов на уже освобождённой памяти.
</details>
<details>
<summary>3. Почему delete через Base* без virtual-деструктора не роняет программу сразу, а просто течёт?</summary>
Компилятор жёстко привязывает вызов delete к статическому типу указателя (Base*) на этапе
компиляции, потому что деструктор не virtual — вызывается только ~Base(). Память под объект
освобождается корректно (адрес правильный), падения не происходит, но ~Derived() не
выполняется, и ресурсы, которыми управлял именно Derived (например, отдельный new[]), никогда
не освобождаются — это утечка, а не крах.
</details>
<details>
<summary>4. Что конкретно делает std::move и кто выполняет реальное перемещение?</summary>
std::move — это static_cast к rvalue-ссылке, никакого действия во время выполнения не
происходит. Реальную работу — например, перенос трёх внутренних указателей vector в новый
объект и обнуление их у источника — делает move-конструктор/move-оператор присваивания
конкретного типа, который компилятор выбирает благодаря этому касту.
</details>
<details>
<summary>5. Почему сдвиг 1 << 32 для 32-битного int — это UB, а не просто 0 или неожиданный результат?</summary>
Стандарт определяет поведение сдвига только для сдвига на число бит меньше разрядности типа.
Сдвиг на количество бит, равное или большее разрядности (32 для 32-битного int), — UB:
компилятор не обязан давать какой-либо конкретный результат, и на разных платформах или при
разных уровнях оптимизации результат может отличаться, включая непредсказуемое значение.
</details>
## Материалы
- cppreference: правило трёх (rule of three) — https://en.cppreference.com/w/cpp/language/rule_of_three
- cppreference: конструктор перемещения — https://en.cppreference.com/w/cpp/language/move_constructor
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html
-299
View File
@@ -1,299 +0,0 @@
# D2, часть 2. Файловый ввод-вывод и мультиплексирование (урок)
Один и тот же файловый дескриптор можно читать тремя разными способами: обычными
`read`/`write` (данные копируются между буфером ядра и буфером пользователя на каждый
вызов), через `mmap` (данные читаются напрямую из страничного кэша по факту обращения), или
не читать самому, а ждать готовности сразу многих дескрипторов через `select`/`poll`/`epoll`.
Разбор идёт в этом порядке: базовые вызовы → почему `write` не гарантирует запись всего
буфера → `mmap` как альтернатива → мультиплексирование и его цена → неблокирующие сокеты и
retry-цикл, без которого ET-режим `epoll` не работает.
## 1. open/read/write/lseek/close и флаги
`int open(const char *path, int flags, mode_t mode)` возвращает файловый дескриптор — целое
число, индекс в таблице открытых файлов процесса. Флаги комбинируются побитовым ИЛИ:
`O_RDONLY`, `O_WRONLY`, `O_RDWR` (режим доступа, взаимоисключающие), `O_APPEND` (каждая
запись атомарно смещается в конец файла перед записью), `O_CREAT` (создать файл, если не
существует, требует третий аргумент `mode`), `O_TRUNC` (обрезать существующий файл до нуля
при открытии), `O_NONBLOCK` (не блокироваться на операциях, для которых обычно ждут — актуально
для FIFO, сокетов, терминалов).
`ssize_t read(int fd, void *buf, size_t count)` и `ssize_t write(int fd, const void *buf,
size_t count)` возвращают число реально прочитанных/записанных байт, `0` от `read` означает
EOF, `-1` — ошибку с кодом в `errno`. `off_t lseek(int fd, off_t offset, int whence)` двигает
позицию чтения/записи файла без самого I/O (`SEEK_SET`/`SEEK_CUR`/`SEEK_END`) — именно эта
позиция определяет, откуда начнёт читать следующий `read`. `close(fd)` освобождает
дескриптор; незакрытые дескрипторы копятся до лимита `ulimit -n` и дают `EMFILE`.
**Факты для карточек**
- base | Какие флаги комбинируются битовым ИЛИ при open()? — O_RDONLY/O_WRONLY/O_RDWR, O_APPEND, O_CREAT, O_TRUNC, O_NONBLOCK
- base | Что возвращает read() при достижении конца файла? — 0
- core | Что делает lseek() и меняет ли он содержимое файла? — двигает позицию чтения/записи файла, содержимое не трогает
- core | Чем O_APPEND отличается от ручного lseek(fd, 0, SEEK_END) перед каждой записью? — O_APPEND атомарно смещает позицию в конец непосредственно перед самой записью на уровне ядра, ручной lseek+write у двух процессов может гонку: оба сделают lseek, потом оба write, и один перезапишет данные другого
Почему дальше: `read`/`write` возвращают число реально обработанных байт, а не гарантированное — отсюда прямое следствие про буферизацию и частичную запись.
## 2. Буферизация: почему write не обязан записать всё
`write(fd, buf, count)` может вернуть значение меньше `count` — это не ошибка. Причины:
буфер ядра для канала/сокета заполнен и вмещает меньше, чем просят записать; вызов был
прерван сигналом до завершения (`EINTR`); для сокетов и pipe частичная запись — штатное
поведение при большом объёме данных. Правильный код пишет весь буфер циклом:
```c++
size_t written = 0;
while (written < count) {
ssize_t n = write(fd, buf + written, count - written);
if (n < 0) {
if (errno == EINTR) continue; // прервано сигналом — повторить тот же вызов
break; // настоящая ошибка
}
written += (size_t)n;
}
```
То же верно для `read`: он может вернуть меньше байт, чем запрошено, даже если EOF ещё не
достигнут (например, из pipe пришла только часть данных) — цикл чтения нужен так же, как и
цикл записи.
**`fsync`/`fdatasync`.** `write` пишет в буфер страничного кэша ядра, а не сразу на физический
носитель — данные могут какое-то время лежать только в памяти. `fsync(fd)` блокирует вызов до
тех пор, пока и данные, и все метаданные файла (время модификации, размер и прочее) не будут
сброшены на диск. `fdatasync(fd)` делает то же самое для данных, но пропускает метаданные, не
влияющие на последующее чтение файла (например, время последнего доступа) — дешевле по числу
операций записи на диск, если метаданные важны только там, где влияют на сами данные
(например, размер файла).
**Ловушки**
- Считать, что `write(fd, buf, count)` всегда пишет ровно `count` байт → без цикла часть данных теряется молча, особенно на pipe и сокетах под нагрузкой → видно как обрезанные данные на приёмной стороне без единой ошибки в логе.
- Не проверять `errno == EINTR` отдельно от настоящих ошибок в цикле записи/чтения → сигнал, пришедший во время `write`, интерпретируется как фатальная ошибка и обрывает передачу.
- Полагаться на `write` без `fsync` там, где данные должны пережить аварийное отключение питания → данные остаются только в буфере страничного кэша и теряются при падении до сброса на диск.
**Факты для карточек**
- base | Может ли write() записать меньше байт, чем попросили, без ошибки? — да, это не ошибка, нужен цикл дозаписи
- core | Чем fdatasync отличается от fsync? — fsync сбрасывает на диск данные и все метаданные файла, fdatasync — только данные и те метаданные, что нужны для последующего чтения (например, размер), пропуская остальные (время доступа)
- core | Какой errno означает «вызов прерван сигналом, нужно просто повторить»? — EINTR
Почему дальше: `write`/`read` всегда копируют данные между буфером ядра и буфером пользователя — `mmap` даёт способ работать с файлом вообще без этого копирования на каждый вызов.
## 3. mmap: отображение файла в память
`mmap` резервирует диапазон виртуальных адресов процесса и связывает его со страницами файла
в странично́м кэше ядра — дальше работа с содержимым файла становится обычным разыменованием
указателя, а не парой вызовов `read`/`write`.
Механизм ленивый: при самом вызове `mmap` физически с диска ничего не читается, только
резервируется адресный диапазон. При первом обращении к странице внутри этого диапазона
происходит **page fault**: если страница уже есть в странично́м кэше — это **minor fault**
(дешёвая операция, просто добавить отображение), если нет — **major fault** (ядро реально
читает страницу с диска). Дальнейшие обращения к уже загруженной странице идут без page fault
вообще — прямое чтение по адресу.
**`MAP_SHARED` vs `MAP_PRIVATE`.** `MAP_SHARED` — изменения видны всем процессам, отобразившим
тот же файл, и в конечном счёте попадают обратно в файл на диске. `MAP_PRIVATE` — copy-on-write:
страницы изначально общие (как при `fork`), но при первой записи процесс получает собственную
копию изменённой страницы, а исходный файл на диске не меняется — тот же механизм COW, что и
у `fork()`. `msync(addr, len, flags)` принудительно сбрасывает изменения `MAP_SHARED`-области
на диск, не дожидаясь, пока это сделает ядро само — без него при аварийном завершении процесса
последние записанные страницы можно потерять. `munmap(addr, len)` снимает отображение.
**Когда mmap быстрее read.** Повторный или случайный доступ к большому файлу: не нужно копировать
данные между буфером ядра и буфером пользователя на каждый вызов (та самая копия, которую
всегда делает `read`), и не нужен системный вызов на каждое обращение — только на первое
касание страницы. Также удобен для разделения памяти между процессами через один и тот же
файл, отображённый `MAP_SHARED`.
**Когда mmap хуже.** Однократное последовательное чтение небольшого файла: накладные расходы
на настройку отображения и page fault на каждую новую страницу не амортизируются, обычный
`read` одним вызовом может оказаться дешевле. При доступе к огромным областям растёт давление
на TLB (аппаратный кэш трансляции виртуальных адресов в физические, ограничен по числу записей)
— трансляция адреса вне TLB требует обхода таблицы страниц, что медленнее прямого попадания.
На системах с ограниченным адресным пространством (32-бит, порядка нескольких гигабайт) размер
файла, который вообще можно отобразить целиком, ограничен размером адресного пространства —
на 64-битных системах это практически не проблема.
**Факты для карточек**
- base | В чём разница MAP_SHARED и MAP_PRIVATE? — MAP_SHARED: изменения видны другим процессам и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны и не попадают в файл на диске
- base | Что делает msync? — принудительно сбрасывает изменения MAP_SHARED-области на диск, не дожидаясь ядра
- core | Чем minor page fault отличается от major? — minor: страница уже в страничном кэше, только добавляется отображение (дёшево); major: страница реально читается с диска (дорого)
- core | Когда mmap проигрывает read по скорости? — при однократном последовательном чтении небольшого файла — накладные расходы на отображение и page fault не амортизируются
- deep | Что ограничивает mmap на 32-битных системах? — размер доступного виртуального адресного пространства (порядка нескольких гигабайт) — файл целиком отобразить может не получиться
Почему дальше: и read/write, и mmap работают с одним дескриптором за раз — сервер, обслуживающий тысячи соединений одним потоком, должен уметь ждать готовности сразу многих дескрипторов, отсюда select/poll/epoll.
## 4. select/poll/epoll: сигнатуры и стоимость
`int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval
*timeout)` — `nfds` это максимальный номер дескриптора в наборах плюс один, `fd_set` —
битовая маска дескрипторов. Жёсткий лимит — `FD_SETSIZE`, **1024** дескриптора: номер
дескриптора больше этого значения `select` обработать не может. При каждом вызове ядро
проходит весь набор целиком, чтобы определить, какие дескрипторы готовы, — сложность `O(n)`
от общего числа отслеживаемых дескрипторов на каждый вызов, независимо от того, сколько из
них реально готовы. `fd_set` к тому же модифицируется вызовом на месте — перед следующим
вызовом набор нужно пересобирать заново.
`int poll(struct pollfd *fds, nfds_t nfds, int timeout)` убирает лимит `FD_SETSIZE` — набор
это обычный массив структур `pollfd` произвольной длины, — но сложность та же: ядро всё равно
проходит по всем `nfds` элементам массива на каждый вызов, `O(n)`.
`epoll` разделяет операции регистрации и ожидания на разные вызовы:
- `int epoll_create1(int flags)` создаёт инстанс epoll (возвращает дескриптор самого epoll);
- `int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)` с `op` равным
`EPOLL_CTL_ADD`/`EPOLL_CTL_MOD`/`EPOLL_CTL_DEL` добавляет, изменяет или убирает дескриптор
из **списка интереса**, который ядро хранит между вызовами в красно-чёрном дереве;
- `int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)` просто
возвращает содержимое отдельного **списка готовых** — ядро само добавляет туда дескриптор,
когда его состояние меняется, без участия вызывающего кода.
Отсюда сложность `epoll_wait` — `O(1)` на каждое готовое событие: работа пропорциональна
числу реально готовых дескрипторов, а не общему числу зарегистрированных, потому что
фильтрация уже произошла в ядре асинхронно, а не в момент вызова.
**Факты для карточек**
- base | Жёсткий лимит числа дескрипторов у select и его значение? — FD_SETSIZE, 1024
- base | Сложность select и poll на один вызов? — O(n) от общего числа отслеживаемых дескрипторов, независимо от того, сколько готовы
- core | Три функции epoll и их роль? — epoll_create1 (создать инстанс), epoll_ctl (ADD/MOD/DEL в списке интереса), epoll_wait (забрать готовые из списка готовых)
- core | На чём построен список интереса epoll внутри ядра? — на красно-чёрном дереве
- core | Сложность epoll_wait на одно готовое событие? — O(1) — ядро уже отфильтровало готовые дескрипторы заранее, работа не зависит от общего числа зарегистрированных
Почему дальше: epoll сообщает, что дескриптор готов, но не гарантирует, что напомнит об этом снова, если не вычитать данные полностью — отсюда разница между level-triggered и edge-triggered режимами.
## 5. Edge-triggered vs level-triggered
**Level-triggered (LT)** — поведение по умолчанию у `epoll`, единственный режим у `select`
и `poll`: событие сообщается, **пока условие остаётся истинным**. Если в буфере сокета есть
непрочитанные данные, каждый следующий `epoll_wait` снова покажет этот дескриптор готовым,
даже если в прошлый раз данные вычитали не полностью.
**Edge-triggered (ET)**, флаг `EPOLLET` при `epoll_ctl` — событие сообщается **один раз**, в
момент перехода состояния из неготового в готовое. Если после этого не вычитать все
доступные данные (не дойти до `EAGAIN`), а прерваться раньше, оставшиеся данные никак не
будут сигнализированы повторно — дескриптор может «зависнуть» с непрочитанными данными до
следующего изменения состояния (например, до прихода новых данных).
Из этого прямо следует требование: **ET обязателен на неблокирующих дескрипторах** и требует
цикла чтения/записи до `EAGAIN`. Причина — на блокирующем дескрипторе цикл «читать, пока не
кончатся данные» на последнем вызове заблокировался бы навсегда в ожидании новых данных
вместо того, чтобы сразу вернуть `EAGAIN` и позволить перейти к другому дескриптору.
**Факты для карточек**
- base | В чём разница level-triggered и edge-triggered? — LT: событие повторяется, пока условие истинно; ET: событие сообщается один раз, в момент перехода в готовое состояние
- core | Почему ET требует неблокирующих дескрипторов? — на блокирующем дескрипторе цикл чтения до исчерпания данных на последнем вызове заблокируется навсегда вместо возврата EAGAIN
- core | Что произойдёт, если в ET-режиме не дочитать данные до EAGAIN? — оставшиеся данные не будут сигнализированы повторно, пока состояние дескриптора не изменится снова (например, не придут новые данные)
- deep | Какой флаг epoll_ctl включает edge-triggered режим? — EPOLLET
Почему дальше: раз ET требует вычитывать всё до конца, нужен точный протокол — что означает EAGAIN, чем он отличается от настоящей ошибки, и как устроен retry.
## 6. EAGAIN/EWOULDBLOCK/EINTR и retry-цикл
Неблокирующий сокет (`O_NONBLOCK`) не ждёт готовности: `read`/`recv`/`write`/`send`
немедленно возвращают управление, даже если данных нет или буфер записи полон. В этом случае
вызов возвращает `-1`, а `errno` выставляется в `EAGAIN` (на Linux синоним `EWOULDBLOCK`,
то же числовое значение) — это не ошибка в смысле сбоя, а сигнал «сейчас нечего делать,
попробуй позже». `EINTR` — другой случай: вызов был прерван доставкой сигнала до завершения,
и его нужно просто повторить теми же аргументами, а не считать ошибкой.
```c++
for (;;) {
ssize_t n = read(fd, buf, sizeof buf);
if (n > 0) { /* обработать n байт */ continue; }
if (n == 0) { /* EOF, закрыть соединение */ break; }
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // данных больше нет сейчас — выходим из цикла
if (errno == EINTR) continue; // прервано сигналом — повторить read
/* иначе настоящая ошибка */ break;
}
```
Ошибочная трактовка `EAGAIN` как сбоя (например, закрытие соединения при его получении) рвёт
рабочие соединения просто потому, что в момент проверки данные ещё не пришли — типичный
симптом под нагрузкой: случайные обрывы соединений, которых не должно быть.
**Ловушки**
- Закрыть соединение при получении EAGAIN вместо того, чтобы просто выйти из цикла чтения → рабочие соединения обрываются под нагрузкой без реальной причины.
- Использовать ET без цикла до EAGAIN → часть данных остаётся невычитанной и не сигнализируется повторно → дескриптор «зависает» до следующего изменения состояния.
- Не обработать EINTR отдельно от прочих ошибок → сигнал, пришедший во время вызова, обрывает обработку соединения вместо простого повтора.
**Факты для карточек**
- base | Что означает EAGAIN/EWOULDBLOCK на неблокирующем дескрипторе? — данных для чтения нет (или буфер записи полон) прямо сейчас — не ошибка, повторить позже
- base | Что означает EINTR и как на него реагировать? — вызов прерван сигналом — повторить тот же вызов немедленно
- core | Совпадают ли числовые значения EAGAIN и EWOULDBLOCK на Linux? — да, это синонимы с одним и тем же значением
Почему дальше: retry-цикл нужен на уровне отдельного сокета, но сами сокеты сервер создаёт и закрывает постоянно — здесь важно, что происходит с портом сразу после закрытия соединения.
## 7. SO_REUSEADDR и TIME_WAIT
При закрытии TCP-соединения сторона, отправившая последний ACK (первая начавшая закрытие),
переходит в состояние **TIME_WAIT** и ждёт там время, равное удвоенному MSL (Maximum Segment
Lifetime), прежде чем окончательно освободить сокет и порт. Ожидание нужно, чтобы поймать
задержавшиеся в сети дубликаты сегментов старого соединения: если бы порт освобождался сразу
и тут же переиспользовался новым соединением, устаревший сегмент из старого соединения мог бы
быть по ошибке принят как часть новой сессии.
Практическое следствие — сервер, часто пересоздающий слушающий сокет на одном и том же порту
(например, при рестарте), может получить ошибку `bind`: «Address already in use», пока
предыдущие сокеты не выйдут из TIME_WAIT. Опция `SO_REUSEADDR`, выставляемая на сокете перед
`bind`, разрешает повторно привязаться к адресу и порту, у которого есть сокеты в TIME_WAIT —
это стандартная практика для серверов, которые должны уметь быстро перезапускаться.
**Факты для карточек**
- base | Сколько времени сокет проводит в TIME_WAIT? — 2×MSL (удвоенное время жизни сегмента в сети)
- core | Зачем нужно состояние TIME_WAIT? — чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения и не спутать их с новым, если порт переиспользуют слишком быстро
- core | Что даёт SO_REUSEADDR? — разрешает bind на адрес/порт, у которого уже есть сокеты в состоянии TIME_WAIT — иначе bind вернёт «Address already in use»
## Проверь себя
<details>
<summary>1. Почему write() может записать меньше байт, чем передали, и как с этим работать правильно?</summary>
Буфер ядра для сокета/pipe может быть заполнен, вызов может быть прерван сигналом, или для
сокетов частичная запись — штатное поведение при большом объёме данных. Правильный код
дописывает оставшиеся байты в цикле, обрабатывая EINTR отдельно от настоящих ошибок.
</details>
<details>
<summary>2. Почему select ограничен 1024 дескрипторами, а epoll — нет?</summary>
select передаёт в ядро набор дескрипторов как битовую маску fd_set фиксированного размера
FD_SETSIZE (1024); номер дескриптора больше этого значения физически не помещается в маску.
epoll хранит список интереса внутри ядра между вызовами (в красно-чёрном дереве), а не
передаёт весь набор при каждом вызове, поэтому ограничения по числу дескрипторов на уровне
самого API нет.
</details>
<details>
<summary>3. Почему epoll_wait даёт O(1) на событие, а select и poll — O(n) на вызов?</summary>
select и poll на каждом вызове заново проходят весь переданный набор дескрипторов, чтобы
определить готовые — работа пропорциональна общему числу отслеживаемых дескрипторов. epoll
поддерживает отдельный список готовых, который ядро заполняет асинхронно по мере изменения
состояния дескрипторов; epoll_wait просто отдаёт содержимое этого списка — работа
пропорциональна числу реально готовых событий.
</details>
<details>
<summary>4. Почему edge-triggered режим epoll требует неблокирующих дескрипторов?</summary>
ET сообщает о готовности один раз за переход состояния, поэтому нужно вычитывать данные в
цикле до EAGAIN, чтобы не пропустить оставшиеся данные. На блокирующем дескрипторе последний
вызов такого цикла заблокировался бы навсегда в ожидании новых данных вместо немедленного
возврата EAGAIN.
</details>
<details>
<summary>5. Зачем нужен SO_REUSEADDR и с чем он связан?</summary>
Сервер после закрытия соединения оставляет сокет в TIME_WAIT на 2×MSL, чтобы поймать
задержавшиеся дубликаты сегментов. Без SO_REUSEADDR повторный bind на тот же адрес/порт,
пока есть сокеты в TIME_WAIT, вернёт ошибку «Address already in use» — опция явно разрешает
это игнорировать.
</details>
## Задачи дня
- `tasks/07_epoll` — TCP-эхо-сервер на epoll: неблокирующие сокеты, `EAGAIN`/`EINTR`,
частичные чтения и записи, `SO_REUSEADDR`, до 64 одновременных соединений, аккуратное
завершение по SIGTERM/SIGINT.
## Материалы
- man 2 open — https://man7.org/linux/man-pages/man2/open.2.html
- man 2 mmap — https://man7.org/linux/man-pages/man2/mmap.2.html
- man 7 epoll — https://man7.org/linux/man-pages/man7/epoll.7.html
- man 2 select — https://man7.org/linux/man-pages/man2/select.2.html
- man 7 tcp (TIME_WAIT, SO_REUSEADDR) — https://man7.org/linux/man-pages/man7/tcp.7.html
- man 7 signal (SIGTERM/SIGINT) — https://man7.org/linux/man-pages/man7/signal.7.html
-307
View File
@@ -1,307 +0,0 @@
# D3, часть 1. Списки, стек, очередь, монотонный стек (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Односвязный и двусвязный список: сложности
```c
struct Node { int value; Node* next; }; // односвязный
struct DNode { int value; DNode* prev, *next; }; // двусвязный
```
Вставка и удаление узла, когда указатель на нужное место уже есть, — O(1): операция это
перелинковка 2–3 указателей соседей, без сдвига остальных элементов. Поиск по значению или
по номеру — всегда O(n): у списка нет арифметики адреса, только последовательный проход по
`next` от головы.
Двусвязный список хранит ещё и `prev`, поэтому удаление узла по указателю на него самого не
требует отдельно знать предыдущий узел — это цена дополнительного указателя (8 байт на 64-бит
системе) в каждом узле. Узлы списка разбросаны по куче отдельными аллокациями — отсюда плохая
локальность памяти и частые промахи кэша процессора по сравнению с массивом, где элементы
лежат подряд.
**Факты для карточек**
- base | Сложность вставки в список, если указатель на место уже есть? — O(1)
- base | Сложность поиска элемента в связном списке? — O(n)
- core | Почему список медленнее массива на практике, хотя вставка O(1) на бумаге? — узлы разбросаны по куче, промахи кэша при обходе
- core | Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего? — лишним указателем `prev` в каждом узле
Почему дальше: раз вставка/удаление — это перелинковка указателей, логично разобрать
классическую операцию на этих указателях — разворот списка.
## 2. Разворот списка тремя указателями
```c
Node* reverse_list(Node* head) {
Node* prev = nullptr;
Node* curr = head;
while (curr) {
Node* next = curr->next; // сохранить хвост ДО перезаписи
curr->next = prev;
prev = curr;
curr = next;
}
return prev; // новая голова
}
```
Три указателя нужны потому, что `curr->next` перезаписывается на `prev`, а без сохранённого
`next` связь с остальной частью списка потеряется безвозвратно. Один проход, O(n) по времени,
O(1) дополнительной памяти — ни одной аллокации, только перестановка указателей.
**Ловушки**
- Забыть сохранить `next` до перезаписи `curr->next` → потеря хвоста списка → утечка памяти,
видна по ASAN/LeakSanitizer.
- Вернуть `curr` вместо `prev` как новую голову → `curr` в конце цикла равен `nullptr`,
функция вернёт пустой список.
**Факты для карточек**
- base | Сложность разворота списка тремя указателями? — O(n) по времени, O(1) по памяти
- core | Зачем нужен третий указатель `next`? — сохранить связь с остатком списка до перезаписи `curr->next`
Почему дальше: раз можно пройти список одним указателем, следующий вопрос — как найти
элемент на заданном расстоянии от конца за один проход, не зная длины заранее.
## 3. k-й элемент с конца
Идея: два указателя с фиксированным сдвигом в k узлов. Сначала продвигаем «ведущий» указатель
на k шагов вперёд от головы, затем двигаем оба указателя одновременно по одному шагу, пока
ведущий не дойдёт до конца — «отстающий» указатель в этот момент стоит на k-м узле с конца.
Один проход, O(n) по времени (ведущий указатель проходит список один раз, с задержкой в k
шагов для отстающего), O(1) дополнительной памяти — длину списка знать заранее не нужно.
**Факты для карточек**
- base | Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины? — один
- core | На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения? — на k шагов
Почему дальше: тот же приём с двумя указателями разной скорости, а не разного стартового
сдвига, даёт середину списка и обнаружение цикла — логично разобрать оба.
## 4. Середина списка: `find_middle` через slow/fast
```c
Node* find_middle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) { // fast не может продвинуться на 2 — стоп
slow = slow->next;
fast = fast->next->next;
}
return slow; // для чётной длины — второй из двух средних
}
```
`fast` движется на 2 узла за шаг, `slow` — на 1: когда `fast` доходит до конца, `slow`
прошёл ровно половину пути. Для списка из 4 узлов (1→2→3→4) цикл даёт `slow` на узле 3 — это
второй из двух средних (2 и 3), что совпадает с требованием задачи `02_list`. Условие цикла
`fast && fast->next` — единственно верное: без проверки `fast->next` обращение
`fast->next->next` на последнем узле разыменует `nullptr`.
**Факты для карточек**
- base | На сколько узлов за шаг двигается `fast` в поиске середины? — на 2, `slow` — на 1
- core | Что вернёт `find_middle` для списка из 4 узлов (1→2→3→4)? — узел 3 (второй из двух средних)
- deep | Почему условие цикла — `fast && fast->next`, а не только `fast`? — без `fast->next` обращение `fast->next->next` на последнем узле разыменует `nullptr`
Почему дальше: та же пара slow/fast, если список зацикленный, не выходит из цикла вовсе —
это и есть способ обнаружить цикл в списке.
## 5. Цикл Флойда: обнаружение и вход в цикл
```c
bool has_cycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // встретились внутри цикла
}
return false;
}
```
Почему указатели вообще встречаются: если в списке есть цикл, `fast` внутри цикла обгоняет
`slow` с относительной скоростью 1 узел за шаг (двигаясь на 2, а `slow` — на 1), поэтому
расстояние между ними в цикле сокращается на 1 каждый шаг и обязательно дойдёт до 0 — не
позже чем за `c` шагов, где `c` — длина цикла. Без цикла `fast` просто первым дойдёт до
`nullptr` и цикл остановится по условию `fast && fast->next`.
Поиск входа в цикл — отдельный шаг после обнаружения встречи. Пусть `a` — расстояние от
головы до входа в цикл, `b` — от входа до точки встречи, `c` — длина цикла. В момент встречи
`slow` прошёл `a+b`, а `fast` — вдвое больше шагов, чем `slow`, то есть `2(a+b) = a+b + n·c`
для какого-то целого `n` (лишние `n` полных обхода цикла). Отсюда `a = (n-1)·c + (c-b)` — то
есть путь длины `a` от головы совпадает по конечной точке с путём длины `c-b` от точки
встречи (остаток цикла до входа). Практический вывод: после обнаружения встречи ставим один
указатель обратно на голову, второй оставляем в точке встречи, двигаем оба по одному узлу за
шаг — они встретятся ровно на входе в цикл.
**Факты для карточек**
- base | С какой относительной скоростью `fast` догоняет `slow` внутри цикла? — 1 узел за шаг
- core | Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи? — один, второй остаётся в точке встречи, оба идут дальше по 1 шагу
- deep | Почему после первой встречи путь от головы длиной `a` и путь от точки встречи длиной `c-b` сходятся в одной точке? — из равенства `2(a+b) = a+b+n·c`, откуда `a = (n-1)c + (c-b)`
**Ловушки**
- Сравнивать значения узлов вместо указателей (`slow->value == fast->value`) → ложное
срабатывание на списке с повторяющимися значениями без реального цикла.
- Забыть проверку `fast && fast->next` перед `fast->next->next` → падение по `nullptr` на
списке без цикла нечётной/чётной длины на границе.
Почему дальше: и разворот, и середина, и обнаружение цикла работают без единой аллокации —
логично разобрать приём, который избавляет от лишних проверок на границах списка ещё до
самого алгоритма — dummy-узел.
## 6. Зачем dummy-узел
Dummy (sentinel) — фиктивный узел перед настоящей головой списка, который никогда не несёт
полезных данных. Механизм: без dummy операции вставки/удаления в начало списка требуют
отдельной ветки кода («если это голова — обнови указатель head отдельно»), а с dummy у любого
настоящего узла всегда есть предшественник, и вставка/удаление в начало становится тем же
кодом, что и вставка/удаление в середину — специальный случай исчезает.
Типичное применение — задачи слияния двух списков и удаления узлов по условию: результат
собирают, привязывая новые узлы к `dummy->next`, а в конце возвращают `dummy->next` как
настоящую голову.
**Факты для карточек**
- base | Что решает dummy-узел? — убирает отдельную ветку кода для вставки/удаления в начало списка
- core | Что возвращают в конце вместо dummy? — `dummy->next` как настоящую голову результата
Почему дальше: список — это структура с O(1) вставкой по указателю, но что если нужен доступ
только с одного (или двух) концов — это уже стек и очередь.
## 7. Стек и очередь на массиве; очередь на двух стеках
Стек на массиве — индекс вершины `top` плюс сам массив: `push` пишет по `top` и увеличивает
индекс, `pop` уменьшает индекс и отдаёт значение — O(1), без аллокаций, LIFO (last in, first
out).
Очередь на массиве наивно требует сдвига элементов при каждом `pop` с начала — O(n). Решение
без сдвигов — два индекса `head`/`tail` с заворотом по модулю ёмкости (разбор в следующем
разделе, кольцевой буфер).
Альтернативная конструкция без ёмкости заранее — **очередь на двух стеках** (`in`, `out`):
`push` всегда кладёт в `in` — O(1). `pop`: если `out` пуст, целиком переливаем `in` в `out`
(это разворачивает порядок — самый старый элемент `in` окажется на вершине `out`), затем
берём с вершины `out`; если `out` не пуст — берём сразу. Каждый элемент физически
перекладывается из `in` в `out` не более одного раза за всё время своей жизни в очереди,
поэтому суммарная стоимость `n` операций — O(n), то есть **амортизированная O(1)** на
операцию, хотя отдельный вызов `pop` с переливом стоит O(n).
**Факты для карточек**
- base | Сложность push/pop у стека на массиве? — O(1)
- core | Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между `in` и `out`? — не более одного раза
- core | Средняя (амортизированная) сложность pop в очереди на двух стеках? — O(1), несмотря на то что отдельный вызов с переливом стоит O(n)
Почему дальше: очередь на массиве без сдвигов элементов — это ровно то, что нужно для
сетевых буферов, где сдвигать байты на каждый пакет непозволительно дорого. Это кольцевой
буфер.
## 8. Кольцевой буфер: head/tail, wrap-around, полный/пустой
Массив фиксированного размера `capacity` плюс два индекса: `head` — куда пишем следующим,
`tail` — откуда читаем следующим. Когда индекс доходит до конца массива, он возвращается в
начало: `idx = (idx + 1) % capacity`, либо быстрее без деления — `if (++idx == capacity) idx = 0;`.
Сдвигать элементы не нужно никогда — в этом весь смысл структуры: O(1) на `push`/`pop`.
Ключевая ловушка, из-за которой структура — частый вопрос на собеседовании: если `head` и
`tail` совпали, буфер пуст или полон? Оба состояния дают одинаковое совпадение индексов.
Решения — либо отдельный счётчик `size` (тогда `empty()` — это `size == 0`, `full()` — это
`size == capacity`), либо сознательно держать одну ячейку всегда свободной и не использовать
её для данных.
Именно эта структура лежит в основе буферов приёма/передачи пакетов и буферов DMA в
драйверах: аппаратура пишет в буфер по одному индексу, программа читает по другому, оба
двигаются по кругу независимо, без перемещения самих данных в памяти. В однопоточном
варианте `head`/`tail` — обычные переменные; если буфер общий между потоком и прерыванием
или между двумя потоками, индексы нужно защищать (атомарными операциями или мьютексом) —
это разбирается в уроке про многопоточность.
**Факты для карточек**
- base | Формула перехода индекса на начало массива в кольцевом буфере? — `idx = (idx + 1) % capacity`
- base | Сложность push/pop в кольцевом буфере? — O(1), без сдвига элементов
- core | Как отличить полный кольцевой буфер от пустого, если `head == tail`? — счётчик `size`, либо держать одну ячейку всегда свободной
- core | Более быстрая замена `% capacity` без деления? — `if (++idx == capacity) idx = 0;`
- deep | Где кольцевой буфер встречается в драйверах? — буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных
**Ловушки**
- Сдвиг элементов вместо движения индексов → теряется весь смысл O(1), фактически O(n) на
операцию.
- Путаница «пусто/полно» при совпавших `head`/`tail` без отдельного счётчика → неверный
ответ `empty()`/`full()` на границе.
- Инкремент индекса до записи/чтения вместо после → значения идут в неправильном порядке
после первого же заворота.
Почему дальше: если элементов много и нужно быстро находить «следующий больший» для каждого
из них за один проход, наивный перебор даёт O(n²) — монотонный стек снижает это до O(n).
## 9. Монотонный стек: Next Greater Element, Daily Temperatures
Задача Next Greater Element: для каждого элемента массива найти первый элемент справа от
него, который больше. Наивно — вложенный цикл, O(n²). Монотонный стек решает за O(n): идём
слева направо, в стеке храним индексы элементов, для которых ответ ещё не найден (значения в
стеке убывают снизу вверх). Для каждого нового элемента `a[i]`: пока стек не пуст и
`a[i] > a[top]` — это и есть «следующий больший» для индекса на вершине, снимаем его со
стека и записываем ответ; затем кладём индекс `i` в стек.
Daily Temperatures — та же схема, только вместо значения элемента в ответ пишут расстояние
(число дней) до дня с более высокой температурой.
Почему это O(n), хотя внутри есть вложенный `while`: каждый индекс попадает в стек ровно один
раз (`push` вызывается n раз суммарно) и снимается со стека не более одного раза (`pop`
вызывается максимум n раз суммарно за весь проход) — суммарно операций со стеком не больше
2n, поэтому общая стоимость всех итераций внешнего цикла вместе с внутренним `while` — O(n).
Это классический пример **амортизированного анализа**: отдельная итерация внешнего цикла
может выполнить несколько `pop`, но в сумме по всему проходу лишних операций не набегает.
**Факты для карточек**
- base | Наивная сложность Next Greater Element вложенным циклом? — O(n²)
- base | Сложность Next Greater Element через монотонный стек? — O(n)
- core | Почему монотонный стек даёт O(n), если внутри есть вложенный `while`? — каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход
- core | Что хранит монотонный стек в задаче Next Greater Element — значения или индексы? — индексы (значения по ним убывают снизу вверх)
- deep | Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма? — тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение
Почему дальше: списки и кольцевые буферы из этого урока становятся общими структурами между
несколькими потоками в задаче `06_threads` — там начинается вопрос синхронизации доступа к
ним.
Ссылки на задачи этого дня: `tasks/02_list` (разворот, середина, цикл), `tasks/03_ring`
(кольцевой буфер, ступенями).
<details>
<summary>Проверь себя</summary>
1. Список из 5 узлов (1→2→3→4→5). Что вернёт `find_middle` по алгоритму slow/fast из этого
урока?
<details><summary>Ответ</summary>Узел 3 — ровно середина при нечётной длине 5.</details>
2. Почему `pop` в очереди на двух стеках всё равно считается амортизированной O(1), если
конкретный вызов с переливом стоит O(n)?
<details><summary>Ответ</summary>Каждый элемент переливается из `in` в `out` не более
одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n),
то есть в среднем O(1) на операцию.</details>
3. В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз
`head` за это время перешёл через границу массива (сделал wrap-around) при последовательной
индексации с 0?
<details><summary>Ответ</summary>Один раз: после 4 записей `head` дошёл до конца и вернулся
в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно
проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`).</details>
4. Почему монотонный стек для Next Greater Element хранит убывающую последовательность
значений, а не произвольную?
<details><summary>Ответ</summary>Как только встречается элемент больше вершины стека, это
и есть ответ для всех подходящих элементов на вершине — их снимают со стека; то, что
остаётся в стеке, по построению всегда убывает сверху вниз, иначе элемент уже был бы снят
раньше.</details>
</details>
## Материалы
- Clang: документация AddressSanitizer (утечки при потере хвоста списка) — https://clang.llvm.org/docs/AddressSanitizer.html
- cppreference: `std::mutex` — https://en.cppreference.com/w/cpp/thread/mutex
- cppreference: `std::atomic` и `memory_order` — https://en.cppreference.com/w/cpp/atomic/memory_order
- Linux Kernel Module Programming Guide (кольцевые буферы в драйверах) — https://sysprog21.github.io/lkmpg/
- Linux kernel: Driver APIs (DMA-буферы) — https://docs.kernel.org/driver-api/
-254
View File
@@ -1,254 +0,0 @@
# D3, часть 3. Отладка: gdb, core dump, санитайзеры (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/09_gdb`. Задача
дня — не переписать падающую программу с нуля, а найти дефекты отладчиком и починить
минимально.
## 1. Что нужно для отладки: `-g -O0`, DWARF
Флаг `-g` встраивает в бинарник отладочную информацию в формате **DWARF** — соответствие
машинных адресов номерам строк исходника, именам переменных и их типам. Без `-g` gdb видит
только адреса и ассемблер: `bt` покажет голые адреса вместо имён функций и номеров строк.
`-O0` отключает оптимизации компилятора и обязателен вместе с `-g` для комфортной отладки:
оптимизатор переставляет и удаляет инструкции, инлайнит функции и переиспользует регистры под
разные переменные, из-за чего отладочная информация перестаёт однозначно соответствовать
исходному коду — строки «прыгают», переменные показывают не то значение. Типичная ошибка —
попытаться отладить прод-бинарник, собранный с `-O2`: получится рассинхронизация строк и
пропущенные шаги.
**Факты для карточек**
- base | Какие два флага компиляции нужны для комфортной отладки в gdb? — `-g -O0`
- base | Как называется формат отладочной информации, который встраивает `-g`? — DWARF
- core | Почему `-O2` мешает отладке даже при наличии `-g`? — оптимизатор переставляет/удаляет инструкции и переиспользует регистры, отладочная информация перестаёт однозначно совпадать с исходником
Почему дальше: раз бинарник собран правильно, следующий шаг — реально запустить его под
отладчиком и разобраться с базовыми командами.
## 2. Запуск и базовые команды
`gdb ./prog` запускает отладчик с указанным бинарником, `run args` внутри gdb передаёт
управление процессу с аргументами командной строки, останавливаясь на точках останова или
при сигнале.
- `bt` — печатает стек вызовов (backtrace): цепочку кадров от текущей функции до `main`.
- `frame N` — переключает контекст `print`/`list` на конкретный кадр этого стека.
- `info locals` / `info args` — печатают локальные переменные и аргументы текущего кадра.
- `print x` (или `p x`) — печатает текущее значение переменной по её типу из отладочной
информации.
- `x/16xb ptr` — команда examine memory: печатает 16 (`N`) единиц в формате hex (`x`),
единица — байт (`b`), начиная с адреса `ptr`; формат `x/NFU addr`.
**Факты для карточек**
- base | Какая команда печатает стек вызовов в gdb? — `bt`
- base | Что означает `x/16xb ptr`? — 16 байт в hex начиная с адреса `ptr` (формат `x/NFU addr`)
- core | Чем `frame N` отличается от `bt`? — `bt` печатает весь стек, `frame N` переключает текущий контекст `print`/`info locals` на конкретный кадр из этого стека
Почему дальше: чтобы остановиться в нужном месте, а не просто дойти до конца программы,
нужны точки останова.
## 3. Точки останова
`break file:line` или `break func` ставит точку останова — адрес, на котором выполнение
приостанавливается при каждом попадании. `tbreak` — то же самое, но точка автоматически
удаляется после первого срабатывания, удобно для одноразовой остановки без ручной очистки.
`watch var` — точка наблюдения (watchpoint): останавливает выполнение при каждом изменении
значения переменной, а не в конкретной строке кода. `rwatch var` — остановка при чтении,
`awatch var` — при чтении или записи (access). Механизм — аппаратные регистры отладки
процессора: на x86 их 4 (`DR0`–`DR3`), поэтому одновременно можно держать лишь ограниченное
число аппаратных watchpoint; gdb использует их, если может, иначе откатывается на медленный
программный watchpoint (построчное выполнение с проверкой значения после каждой инструкции).
`watch` незаменим, когда переменная меняется как будто сама по себе из другого места кода —
без него пришлось бы вручную расставлять точки останова по подозрению.
**Факты для карточек**
- base | Чем `tbreak` отличается от `break`? — `tbreak` удаляется автоматически после первого срабатывания
- core | Чем `rwatch` отличается от `watch`? — `watch` реагирует на изменение значения, `rwatch` — на чтение переменной
- deep | Сколько аппаратных регистров отладки на x86 ограничивают число одновременных аппаратных watchpoint? — 4 (`DR0`–`DR3`)
Почему дальше: точки останова дают место остановки, но дальше нужно управлять именно ходом
выполнения — построчно или до конца функции.
## 4. Степание: `next`/`step`/`finish`/`until`
- `next` — выполняет текущую строку целиком, включая вызовы функций внутри неё, не заходя
внутрь них.
- `step` — заходит внутрь вызываемой функции, если для неё есть отладочная информация.
- `finish` — выполняет до возврата из текущей функции, печатает возвращаемое значение.
- `until` (без аргумента) — продолжает выполнение до строки с номером больше текущей в
текущем кадре, удобно чтобы выйти из цикла, не проходя его пошагово итерацию за итерацией.
Если бы `step` всегда заходил внутрь, отладка кода с вызовами библиотечных функций без
отладочной информации была бы мучительной — `next` даёт способ пропустить неинтересную
функцию, не теряя контроль над остальным ходом программы.
**Факты для карточек**
- base | Чем `next` отличается от `step`? — `next` не заходит внутрь вызываемых функций, `step` заходит
- core | Что делает `finish`? — выполняет до возврата из текущей функции и печатает возвращаемое значение
- core | Зачем нужен `until` внутри цикла? — продолжить до строки с номером больше текущей, не проходя цикл пошагово
Почему дальше: все эти команды одинаково работают и при разборе уже случившегося падения —
после срабатывания сигнала или по сохранённому снимку памяти (core dump).
## 5. Разбор падения: core dump, `ulimit -c`, `bt` по кадрам
Первый путь — запустить программу прямо под gdb и дождаться сигнала (обычно `SIGSEGV`, код
139 = 128+11): gdb сам остановится на инструкции, вызвавшей сбой, `bt` покажет полный стек
вызовов, а `print` значений указателей и переменных в нужных кадрах обычно сразу показывает,
например, что указатель равен `nullptr` или мусорному значению.
Второй путь — по **core dump**: файл-снимок памяти процесса, который ядро ОС сохраняет в
момент сигнала, приводящего к аварийному завершению, — все сегменты адресного пространства
(стек, куча, регистры процессора, список загруженных библиотек). Открывают его отдельно:
`gdb prog core` — и получают тот же `bt`/`print`, но постфактум, без необходимости
воспроизводить падение заново. Core dump — единственный способ разобрать баг, который
воспроизводится редко или только под нагрузкой в проде, куда заранее интерактивный отладчик
не прицепишь.
По умолчанию система часто отключает сохранение core-файлов — перед тем как ждать падение,
выставляют `ulimit -c unlimited`. Типичная ошибка — забыть это сделать и потерять
единственный шанс поймать редкий баг; вторая типичная ошибка — пересобрать бинарник (даже
без изменения логики) перед анализом core — адреса не совпадут с записанными в core, и gdb
покажет несогласованный стек вместо реального.
**Факты для карточек**
- base | Каким кодом завершается процесс при SIGSEGV? — 139 (128+11)
- base | Какой командой разрешить сохранение core-файлов перед ожиданием падения? — `ulimit -c unlimited`
- core | Как открыть core dump вместе с бинарником в gdb? — `gdb prog core`
- core | Почему пересборка бинарника перед анализом core ломает разбор? — адреса в новом бинарнике не совпадают с адресами, записанными в core, gdb покажет несогласованный стек
Почему дальше: падение может быть не одиночным потоком — если программа многопоточная, нужно
отдельно смотреть, какой именно поток и в каком состоянии упал или завис.
## 6. Отладка многопоточности
`info threads` показывает все потоки процесса и их текущее состояние (на какой строке/в
какой функции остановлен каждый). `thread N` переключает текущий контекст отладчика (для
`bt`, `frame`, `print`) на поток с номером `N`.
Это основной способ диагностировать зависший дедлок: программа не падает и не пишет ничего в
лог, просто стоит — `info threads` и просмотр стека (`bt`) каждого потока по очереди
показывают, какой поток на каком мьютексе застрял и кого он, в свою очередь, ждёт.
**Факты для карточек**
- base | Какая команда gdb показывает все потоки процесса разом? — `info threads`
- core | Как переключиться на конкретный поток по номеру для `bt`/`print`? — `thread N`
- core | Как gdb помогает диагностировать дедлок, если программа просто зависла без вывода? — `info threads` + `bt` по каждому потоку показывают, кто на каком мьютексе застрял
Почему дальше: не каждый краш находится ровно там, где gdb его показывает, — иногда точка
остановки и точка реальной ошибки в коде далеко друг от друга.
## 7. Почему краш «далеко от причины»: ASAN против gdb
Переполнение буфера или запись по неверному указателю портит чужую память, но крах может
случиться значительно позже — например, когда программа попытается использовать уже
испорченный указатель совсем в другом месте кода. gdb в момент самого краша покажет именно
**точку симптома** (где программа реально упала), а не точку, где память была испорчена
изначально.
Санитайзеры (ASAN — `-fsanitize=address`, UBSAN — `-fsanitize=undefined`) инструментируют
каждое обращение к памяти на этапе компиляции и останавливают программу с диагностикой в
момент **нарушения**, а не когда испорченные данные позже вызовут крах в неожиданном месте —
то есть показывают точку причины напрямую. ASAN дополнительно ловит выход за границы,
use-after-free и двойное освобождение через «красные зоны» вокруг выделенных блоков и
теневую карту памяти; в связке с LeakSanitizer он же в конце работы программы репортит
утечки. Санитайзеры — это перекомпиляция с проверками, а не отдельная программа поверх
готового бинарника, и включаются одним флагом на этапе сборки.
**Факты для карточек**
- core | В чём разница между точкой, которую покажет gdb при краше, и точкой, которую покажет ASAN? — gdb показывает точку симптома (где реально упало), ASAN — точку причины (момент нарушения)
- base | Каким флагом компиляции включается ASAN? — `-fsanitize=address`
- base | Каким флагом компиляции включается UBSAN? — `-fsanitize=undefined`
Почему дальше: санитайзеры требуют пересборки с флагами — если такой возможности нет
(например, готовый чужой бинарник или библиотека), есть альтернатива без пересборки.
## 8. `valgrind --tool=memcheck` как альтернатива
Valgrind ищет ошибки работы с памятью и утечки без пересборки программы с особыми флагами.
Модуль **memcheck** запускает бинарник внутри собственной виртуальной машины — эмулирует
каждую машинную инструкцию на лету, отслеживая состояние памяти (инициализирована/не
инициализирована, выделена/освобождена), и на каждое подозрительное обращение выводит
диагностику со стеком вызовов.
Эмуляция каждой инструкции в софтверной VM принципиально тяжелее, чем компиляторная
инструментация ASAN (которая добавляет проверки только вокруг реальных обращений к памяти
уже в нативном коде) — valgrind ощутимо медленнее санитайзеров, счёт идёт на кратное
замедление, а не на проценты накладных расходов. Главное преимущество — не нужен доступ к
исходникам и пересборка, поэтому его применяют на готовых бинарниках или сторонних
библиотеках, где ASAN не подключить; в собственном проекте с доступом к сборке обычно
предпочитают санитайзеры именно из-за скорости на CI.
**Факты для карточек**
- base | Какой модуль valgrind ищет ошибки памяти? — `memcheck` (`valgrind --tool=memcheck`)
- core | Почему valgrind медленнее ASAN? — эмулирует каждую машинную инструкцию в софтверной VM, а не добавляет проверки только вокруг обращений к памяти в нативном коде
- core | Когда valgrind предпочтительнее санитайзеров? — когда нет доступа к исходникам/пересборке (готовый бинарник, сторонняя библиотека)
## 9. Задача дня: `crash.c` (`tasks/09_gdb`)
`crash.c` — программа, которая падает на части входов и портит память; задача — найти
дефекты отладчиком и починить минимально, не переписывая с нуля. Ожидаемое поведение после
починки:
- `./crash` (без аргумента) печатает `len=5`, код возврата 0;
- `./crash <слово>` печатает `len=<длина слова>`, код возврата 0;
- `./crash ""` печатает `len=0`, код возврата 0;
- сборка с `-fsanitize=address,undefined` не даёт ни одного сообщения об ошибке.
Порядок работы: собрать `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`; под
`gdb ./crash` командами `run`, `bt`, `frame`, `info locals`, `watch` разобраться, что именно
портит память, а что приводит к падению при выходе — это разные дефекты, и падение при
выходе из программы обычно означает испорченный служебный указатель (например, порчу стека
или метаданных кучи), который проявляется только в момент разрушения объекта или выхода из
функции. Проверка — `python3 grade.py 09`: компилирует `crash.c` компилятором C (gcc) с
ASAN/UBSAN и прогоняет 5 входов; `answer.txt` проверяется ревью — важен ход разбора
командами отладчика, а не только итоговый результат.
**Факты для карточек**
- base | Какой командой собирают `crash.c` для отладки с санитайзерами? — `gcc -g -O0 -fsanitize=address,undefined crash.c -o crash`
- base | Что печатает `./crash` без аргументов после починки? — `len=5`, код возврата 0
- core | Почему падение может произойти не в строке с самой ошибкой, а при выходе из программы? — испорченный служебный указатель (стек/метаданные кучи) проявляется только в момент разрушения объекта, а не в момент самой порчи
Ссылка на задачу этого дня: `tasks/09_gdb` — проверка `python3 grade.py 09`, 5 входов, ASAN/UBSAN
чистые.
<details>
<summary>Проверь себя</summary>
1. Собрал бинарник с `-g -O2` и заметил, что `next` в gdb «перепрыгивает» через строки не по
порядку. В чём причина и что нужно изменить в сборке?
<details><summary>Ответ</summary>Оптимизатор при `-O2` переставляет и инлайнит инструкции,
отладочная информация перестаёт однозначно соответствовать исходнику; нужно пересобрать с
`-O0` (оставив `-g`).</details>
2. Программа падает по `SIGSEGV` только раз в несколько дней под нагрузкой в проде. Какие две
вещи нужно сделать заранее, чтобы разобрать этот краш постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited`, чтобы ядро сохранило core
dump при падении, и не пересобирать бинарник между падением и анализом — иначе адреса в
core не совпадут с бинарником и `gdb prog core` покажет несогласованный стек.</details>
3. ASAN указывает на строку записи за границу массива, а обычный gdb без санитайзеров на этом
же баге падал бы совсем в другом месте кода. Почему так?
<details><summary>Ответ</summary>Порча памяти повреждает чужие данные, но видимый крash
может случиться значительно позже, когда программа использует уже испорченные данные в
другом месте — gdb без санитайзера показывает точку симптома (где реально упало), ASAN
инструментирует каждое обращение и останавливает программу прямо в момент нарушения,
то есть в точке причины.</details>
4. Нужно проверить на утечки готовую стороннюю библиотеку без исходников и без возможности
пересобрать её с ASAN. Какой инструмент подходит и почему не санитайзер?
<details><summary>Ответ</summary>`valgrind --tool=memcheck` — он эмулирует уже готовый
бинарник в софтверной VM и не требует пересборки с флагами `-fsanitize=...`, в отличие от
санитайзеров, которые требуют перекомпиляции исходного кода.</details>
</details>
## Материалы
- man 1 gdb — https://man7.org/linux/man-pages/man1/gdb.1.html
- Документация GDB (sourceware) — https://sourceware.org/gdb/current/onlinedocs/gdb.html/
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html
- Clang: документация AddressSanitizer — https://clang.llvm.org/docs/AddressSanitizer.html
- man 2 setrlimit (ulimit -c / RLIMIT_CORE) — https://man7.org/linux/man-pages/man2/setrlimit.2.html
- man 7 signal (SIGSEGV) — https://man7.org/linux/man-pages/man7/signal.7.html
-348
View File
@@ -1,348 +0,0 @@
# D3, часть 2. Многопоточность (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/06_threads`. На
собеседовании тут спрашивают не «знаешь ли ты `std::thread`», а «умеешь ли ты не сломать
счётчик под нагрузкой» — то есть понимание синхронизации, а не перечисление API.
## 1. `std::thread`: создание, join/detach, стек
`std::thread t(f, args...)` запускает переданную функцию в новом потоке операционной системы
немедленно, в момент вызова конструктора, а не при отдельной команде «старт». Создание —
это системный вызов (на Linux — `clone`), ядру нужно выделить новому потоку собственный стек
(по умолчанию порядка 8 МБ на поток на типичной Linux-системе) и завести отдельный набор
регистров; адресное пространство, куча и таблица файловых дескрипторов остаются общими с
процессом.
С объектом `std::thread` после создания есть ровно два законных пути: `t.join()` — дождаться
завершения потока, блокируя вызывающий код до его окончания, или `t.detach()` — отсоединить
поток, чтобы он жил и завершался независимо от объекта. Если объект `std::thread`,
представляющий ещё не завершённый и не присоединённый поток, уничтожается (выходит из
области видимости) — стандарт требует вызвать `std::terminate`: поток — ресурс ОС, и его
нельзя «тихо потерять». При `detach()` нужно отдельно следить за временем жизни всего, что
поток использует по ссылке/указателю — если основной поток уничтожит эти объекты раньше, чем
завершится отсоединённый поток, это use-after-free.
**Факты для карточек**
- base | Когда именно стартует новый поток при `std::thread t(f)`? — сразу, в конструкторе
- base | Примерный размер стека потока на типичной Linux-системе? — ~8 МБ
- core | Что произойдёт, если объект `std::thread` с незавершённым и не присоединённым потоком уничтожится? — `std::terminate`, программа аварийно завершится
- core | Каким системным вызовом Linux создаёт новый поток? — `clone`
Почему дальше: раз несколько потоков делят одно адресное пространство, следующий вопрос —
что происходит, если они одновременно трогают одни и те же данные без защиты.
## 2. Data race и почему это UB уже для `int`
Гонка данных (data race) — одновременный доступ двух и более потоков к одной и той же ячейке
памяти без синхронизации, где хотя бы один из доступов — запись. По стандарту C++ это
неопределённое поведение (UB) — причём для **любого** типа, включая обычный `int`, а не
только для сложных структур.
Причина строгости правила не в том, что чтение/запись `int` физически не атомарны на
конкретном железе (выровненный `int` большинство процессоров читает и пишет одной шиной за
раз) — причина в том, что компилятор без синхронизации вправе кэшировать значение переменной
в регистре, переупорядочивать обращения к памяти и предполагать отсутствие гонки при
оптимизациях. UB здесь означает, что компилятор формально может сгенерировать любой код,
включая код, ломающий программу способом, не связанным напрямую с «неправильным числом» —
поэтому полагаться на «на моём железе `int` всё равно атомарен» неверно и небезопасно.
**Факты для карточек**
- base | Что такое data race? — одновременный доступ к одной памяти минимум с одной записью без синхронизации
- core | Является ли гонка на обычном `int` без атомиков UB по стандарту C++? — да, даже если на конкретном железе чтение/запись `int` физически атомарны
- core | Почему компилятору мало того, что чтение `int` атомарно на железе? — без синхронизации он вправе кэшировать значение в регистре и переупорядочивать обращения к памяти
Почему дальше: раз гонка данных — это UB, нужен механизм, который гарантирует, что в
критическую секцию кода одновременно входит только один поток, — мьютекс.
## 3. `std::mutex` и RAII-обёртки
`std::mutex` гарантирует взаимное исключение: только один поток одновременно может держать
его захваченным. На практике мьютекс почти никогда не захватывают вручную через
`lock()`/`unlock()` — их оборачивают в RAII-объект, потому что ручной `unlock()` легко забыть
на пути исключения или раннего `return`, и тогда мьютекс останется захваченным навсегда.
- `std::lock_guard` — простая блокировка на время текущей области видимости, без возможности
разблокировать раньше.
- `std::unique_lock` — то же самое, но с ручной разблокировкой/повторным захватом,
возможностью передать владение и обязательный тип для работы с `condition_variable::wait`.
- `std::scoped_lock` (C++17) — захватывает **несколько** мьютексов одной атомарной операцией,
без риска, что между захватом первого и второго вклинится другой поток с обратным порядком
захвата (это прямая защита от дедлока при захвате нескольких мьютексов в одном месте).
Дедлок из-за неверного порядка захвата компилятор не ловит — это не синтаксическая, а
динамическая ошибка, которая проявляется только в рантайме на конкретной раскладке потоков.
Обнаруживают его ThreadSanitizer (детектирует инверсию порядка захвата, даже если фактического
зависания в конкретном прогоне не случилось) либо наблюдением зависшей программы через
`gdb`/`info threads`.
**Факты для карточек**
- base | Какая RAII-обёртка нужна для работы с `condition_variable::wait`? — `std::unique_lock`
- base | Что делает `std::scoped_lock`? — атомарно захватывает несколько мьютексов сразу
- core | Почему дедлок ловится ThreadSanitizer, а не компилятором? — это динамическая ошибка, зависящая от конкретной раскладки потоков в рантайме, а не от статической структуры кода
Почему дальше: мьютекс защищает данные, но поток часто должен ещё и **ждать** какое-то
событие (данные появились, место освободилось) — для этого нужен `condition_variable`.
## 4. `condition_variable` и predicate-цикл
`condition_variable::wait(lock)` атомарно освобождает мьютекс и усыпляет поток одной
неделимой операцией. Атомарность здесь принципиальна: если бы освобождение мьютекса и уход в
сон были двумя раздельными шагами, между ними мог бы вклиниться другой поток, изменить
состояние и вызвать `notify` — тогда это уведомление потерялось бы (lost wakeup), потому что
ждущий поток ещё не успел реально зайти в состояние ожидания. При пробуждении `wait`
повторно захватывает тот же мьютекс перед тем, как вернуть управление — весь код после
`wait` уже выполняется под защитой.
Стандарт C++ прямо разрешает **ложные пробуждения** (spurious wakeup) — `wait` может
вернуться без единого вызова `notify_one`/`notify_all`, просто по решению ОС (на Linux
`condition_variable` реализован поверх `futex`, который иногда пробуждается по внутренним
причинам платформы). Из-за этого одиночный `if (!ready) cv.wait(lock);` недостаточен —
правильный паттерн:
```c++
std::unique_lock<std::mutex> lock(mtx);
while (!ready) // predicate-цикл, не if
cv.wait(lock);
```
Изменение состояния (`ready = true;`) обязательно делают под тем же мьютексом, которым
защищена сама проверка предиката, — иначе между проверкой предиката и входом в `wait` может
вклиниться другой поток. А вот сам вызов `notify_one`/`notify_all` можно делать как под
мьютексом, так и сразу после `unlock()` — на корректность это не влияет, потому что проверка
предиката и уход в `wait` у ждущего потока в любом случае происходят атомарно под общим
мьютексом. На производительность разница есть: `notify` под захваченным мьютексом иногда
будит поток, который тут же снова блокируется на этом же мьютексе в ожидании его
освобождения — вызов `notify` после `unlock()` избавляет от этого лишнего пробуждения-и-сна.
**Ловушки**
- `if` вместо `while` вокруг `wait` → поток продолжает работу до реального выполнения условия
→ трудновоспроизводимый баг под нагрузкой, не ловится обычными юнит-тестами.
- Изменение состояния (`size_++`, `ready = true`) вне захваченного мьютекса → гонка данных →
ловится ThreadSanitizer как data race.
- Один `condition_variable` на два разных предиката (например «не пусто» и «не полно») вместе
с `notify_one` → можно разбудить не тот поток, а нужный останется ждать → зависание.
**Факты для карточек**
- base | Что атомарно делает `cv.wait(lock)` при входе? — освобождает мьютекс и усыпляет поток одной неделимой операцией
- core | Почему `while`, а не `if`, вокруг `wait`? — из-за spurious wakeup: `wait` может вернуться без единого вызова `notify`
- core | Обязательно ли вызывать `notify` под захваченным мьютексом? — нет, обязательно лишь менять состояние под мьютексом; `notify` после `unlock()` — оптимизация, не требование корректности
- deep | На каком примитиве ОС реализован `condition_variable` на Linux? — `futex`
Почему дальше: мьютекс и `condition_variable` могут заблокировать программу навсегда, если
их захватывать в разном порядке в разных местах кода, — это дедлок.
## 5. Deadlock: 4 условия и порядок захвата
Дедлок — взаимная блокировка, когда каждый из нескольких потоков ждёт ресурс, захваченный
другим, и никто не может продолжить. Классический сценарий на двух мьютексах: поток A держит
мьютекс 1 и ждёт мьютекс 2, поток B держит мьютекс 2 и ждёт мьютекс 1 — оба висят навсегда.
Четыре условия Коффмана, необходимые одновременно для дедлока:
1. Взаимное исключение — ресурс занят не более чем одним потоком.
2. Удержание и ожидание — поток держит один ресурс и ждёт другой.
3. Невозможность принудительного отбора — ресурс нельзя отобрать у держащего потока.
4. Круговое ожидание — цикл потоков, каждый ждёт ресурс следующего.
Разрушить достаточно одно условие. На практике проще всего разрушить круговое ожидание:
всегда захватывать несколько мьютексов в едином порядке во всей программе (например, по
адресу объекта или по заранее заданному номеру) — тогда цикл ожидания просто не может
образоваться. Там, где несколько мьютексов захватываются в одном месте кода, для этого есть
`std::lock` или `std::scoped_lock` — атомарный захват сразу нескольких без риска, что между
захватом первого и второго вклинится поток с обратным порядком.
Дедлок обычно не падает с ошибкой — это зависшая программа без вывода в лог; диагностируют
через `gdb`, `info threads` и просмотр стеков всех потоков, чтобы увидеть, кто на каком
мьютексе застрял.
**Факты для карточек**
- core | Назови 4 условия Коффмана для дедлока? — взаимное исключение, удержание-и-ожидание, невозможность отбора, круговое ожидание
- core | Какое условие обычно разрушают на практике фиксированным порядком захвата мьютексов? — круговое ожидание
- base | Какой командой gdb смотрят, на каком мьютексе застрял каждый поток при зависании? — `info threads` (и стек каждого потока)
Почему дальше: помимо мьютекса, для простых операций вроде счётчика есть более дешёвая
альтернатива без перехода в ядро — атомарные типы.
## 6. `std::atomic` и `memory_order`
`std::atomic<T>` для простых типов (счётчики, флаги, указатели) дешевле мьютекса: операции
выполняются одной аппаратной атомарной инструкцией процессора (например compare-and-swap),
без системного вызова и без усыпления потока — тогда как мьютекс при конкуренции может
потребовать перехода в ядро и контекстного переключения.
`memory_order` управляет тем, какие перестановки чтений/записей вокруг атомарной операции
разрешены компилятору и процессору:
- `relaxed` — гарантирует только атомарность самой операции, никакого порядка относительно
других обращений к памяти не задаёт.
- `acquire` (на загрузке/чтении) — запрещает переносить более поздние по коду обращения к
памяти ДО этой операции.
- `release` (на сохранении/записи) — запрещает переносить более ранние по коду обращения к
памяти ПОСЛЕ этой операции; `release`-запись синхронизируется-с последующим `acquire`-чтением
того же атомика в другом потоке.
- `seq_cst` (значение по умолчанию для всех операций `std::atomic`) — то же, что `acquire`
и `release` вместе, плюс единый глобальный порядок для всех `seq_cst`-операций во всей
программе — самый строгий и самый дорогой по производительности вариант.
Видимость изменений между ядрами процессора на аппаратном уровне обеспечивает протокол
когерентности кэша **MESI** (Modified / Exclusive / Shared / Invalid) — каждая кэш-линия в
каждом ядре находится в одном из этих 4 состояний, и запись в линию на одном ядре инвалидирует
копии этой же линии в кэшах других ядер, заставляя их перечитать актуальное значение при
следующем обращении. `memory_order` — это про то, какие перестановки инструкций разрешены
компилятору и ядру процессора вокруг атомарной операции; MESI — это про то, как аппаратно
гарантируется, что после разрешённого порядка операций другое ядро увидит актуальное значение
кэш-линии, а не устаревшую локальную копию.
**Факты для карточек**
- base | Чем `std::atomic` дешевле мьютекса для простого счётчика? — одна аппаратная инструкция (например CAS), без перехода в ядро и усыпления потока
- core | Что гарантирует `memory_order_relaxed`? — только атомарность операции, без ограничений порядка с другими обращениями к памяти
- core | Что запрещает `acquire`, а что — `release`? — acquire запрещает переносить более поздние обращения ДО себя; release запрещает переносить более ранние обращения ПОСЛЕ себя
- deep | Сколько состояний у кэш-линии в протоколе MESI? — 4 (Modified, Exclusive, Shared, Invalid)
- deep | Какой memory_order используется по умолчанию у операций `std::atomic`? — `seq_cst`
Почему дальше: у синхронизации через мьютекс и `condition_variable` есть готовый типовой
паттерн, где всё это применяется вместе, — producer/consumer.
## 7. Producer/consumer и потокобезопасная очередь (`tasks/06_threads`)
Задача `06_threads` — ограниченная блокирующая очередь:
```c++
class BlockingQueue {
public:
explicit BlockingQueue(size_t capacity);
~BlockingQueue();
void push(int v); // блокируется, пока очередь полна
bool pop(int& out); // блокируется, пока пуста; false — если закрыта и пуста
void close(); // после close: pop() опустошает остаток и отдаёт false
size_t size() const;
};
```
Требования: `push` после `close()` бросает `std::runtime_error`; закрытие разблокирует все
ждущие потоки (никакого вечного ожидания и busy-wait); размер очереди никогда не превышает
`capacity`; ни одной гонки, включая `size()`, который тоже вызывается из другого потока.
Механизм — общее состояние (буфер, счётчик, флаг `closed`) защищено одним `std::mutex`.
Для `push` и `pop` нужны разные условия ожидания («не полна» и «не пуста или закрыта») —
такое возможно с одним `condition_variable`, только если использовать `notify_all` (каждый
разбуженный поток сам перепроверяет свой предикат в цикле и снова засыпает, если условие не
его), либо завести два раздельных `condition_variable`.
Почему `size()` тоже требует мьютекса: инкремент/декремент счётчика — это не одна
процессорная операция, а последовательность «прочитать — изменить — записать»
(read-modify-write); без синхронизации с `push`/`pop`, которые пишут в тот же счётчик, это
гонка данных даже для «безобидного» чтения одного числа — UB по стандарту, а не просто
«иногда неверное число».
**Ловушки**
- Забыть разбудить всех потоков при `close()` → часть потоков навсегда висит в `wait` →
зависание процесса, тест не завершается за отведённое время.
- Защищать `size_` отдельным от данных мьютексом → `size()` может вернуть значение, уже не
соответствующее реальному состоянию буфера.
**Факты для карточек**
- base | Каким исключением отвечает `push` после `close()`? — `std::runtime_error`
- core | Почему `size()` в этой задаче требует того же мьютекса, что и данные очереди? — инкремент/декремент — не атомарная операция read-modify-write, без синхронизации это гонка данных
Почему дальше: если под каждую входящую задачу создавать отдельный `std::thread`, накладные
расходы на создание потока (системный вызов, выделение стека) быстро перевешивают полезную
работу — логично перейти к переиспользуемому пулу потоков.
## 8. Пул потоков
Пул — заранее созданный набор из N рабочих потоков (обычно порядка числа ядер процессора),
которые постоянно забирают задачи из общей очереди (той же природы, что producer/consumer
выше) вместо создания нового `std::thread` под каждую задачу. Причина — цена создания потока:
системный вызов, выделение стека, регистрация в планировщике ОС; при коротких и частых
задачах эти накладные расходы легко превышают время самой полезной работы. Пул амортизирует
эту цену — потоки создаются один раз при старте и переиспользуются для множества задач, а
фиксированное их число не даёт программе бесконтрольно наплодить тысячи потоков под наплывом
запросов и не утопить систему в переключениях контекста.
**Факты для карточек**
- base | Примерно сколько потоков создают в пуле относительно ядер CPU? — порядка числа ядер процессора
- core | Какую конкретно цену амортизирует пул потоков? — стоимость создания потока (системный вызов, стек, регистрация в планировщике) на каждую отдельную короткую задачу
Почему дальше: не всякую параллельную задачу удобно оформлять вручную через
`std::thread`/очередь — для запуска функции и получения её результата есть более короткий
интерфейс, `std::async`/`std::future`.
## 9. `std::async` и `std::future`
`std::async(policy, f, args...)` запускает функцию `f` и сразу возвращает `std::future` —
объект-обещание будущего результата. Политика запуска (`std::launch::async` — обязательно в
новом потоке, `std::launch::deferred` — отложенный вызов при первом обращении к результату,
или их комбинация по умолчанию — реализация вправе выбрать любой вариант) определяет, когда
и где реально выполнится `f`.
`future.get()` блокирует вызывающий поток до готовности результата и возвращает его. `get()`
можно вызвать только один раз: после первого вызова `future` становится невалидным
(`valid() == false`), и повторный вызов `get()` — неопределённое поведение по стандарту.
`std::async` избавляет от ручного управления мьютексом/`condition_variable`, когда нужен
именно единичный результат одной асинхронной операции, а не постоянный поток задач через
очередь.
**Факты для карточек**
- base | Что возвращает `std::async` сразу после вызова? — `std::future` с будущим результатом
- core | Сколько раз можно вызвать `future.get()` для одного результата? — один; второй вызов — неопределённое поведение (`valid() == false`)
Почему дальше: всё, что описано в этом уроке, — мьютексы, `condition_variable`, атомики,
дедлоки — проверяемо инструментом, который ловит гонки по факту исполнения, а не на глаз.
## 10. ThreadSanitizer
ThreadSanitizer (`-fsanitize=thread`, флаг компиляции — то есть перекомпиляция с
инструментацией, а не отдельная программа поверх готового бинарника) инструментирует каждое
обращение к памяти и синхронизирующие примитивы, отслеживая порядок happens-before между
потоками во время конкретного исполнения — то есть ловит гонку по факту того, что реально
произошло в этом запуске.
Из этого следует практическое ограничение: «прогнать разок и не увидеть ошибки» не равно
«гонки в коде нет» — конкретная раскладка потоков в конкретном запуске могла просто не
проявить проблему, которая есть в коде и выстрелит на другой машине или под другой нагрузкой.
**Факты для карточек**
- base | Каким флагом компиляции включается ThreadSanitizer? — `-fsanitize=thread`
- core | Почему чистый прогон под TSan не гарантирует отсутствие гонки в коде вообще? — TSan ловит гонку по факту конкретной раскладки потоков в конкретном запуске, а не статическим анализом всех возможных раскладок
Ссылка на задачу этого дня: `tasks/06_threads` — потокобезопасная ограниченная очередь,
проверяется `python3 grade.py 06` (все `ok`, TSan чистый, нет зависания).
<details>
<summary>Проверь себя</summary>
1. Почему `cv.wait(lock)` обязан принимать `unique_lock`, уже захвативший тот же мьютекс, что
защищает разделяемое состояние, а не произвольный лок?
<details><summary>Ответ</summary>`wait` должен атомарно освободить именно этот мьютекс
перед сном и захватить его же при пробуждении — иначе разные потоки проверяли бы предикат
под разными блокировками, и защита состояния перестала бы работать.</details>
2. Два потока держат мьютексы A и B в противоположном порядке (A→B и B→A). Что нужно
изменить, чтобы устранить дедлок, не трогая логику самой критической секции?
<details><summary>Ответ</summary>Привести захват к единому порядку во всей программе
(например, всегда A перед B) — это разрушает условие кругового ожидания; либо захватывать
оба мьютекса разом через `std::scoped_lock`.</details>
3. Почему data race на обычном `int` без `std::atomic` считается UB, даже если на конкретном
процессоре чтение/запись `int` физически выполняются одной инструкцией?
<details><summary>Ответ</summary>Потому что стандарт C++ формально не гарантирует
атомарность для обычных типов — без синхронизации компилятор вправе кэшировать значение в
регистре и переупорядочивать обращения к памяти, а не потому что конкретное железо
действительно рвёт запись на части.</details>
4. Чем `memory_order_acquire` отличается от `memory_order_relaxed` по факту разрешённых
перестановок кода?
<details><summary>Ответ</summary>`relaxed` гарантирует только атомарность самой операции;
`acquire` дополнительно запрещает переносить более поздние по коду обращения к памяти до
этой операции — то есть даёт порядок, а не только неделимость.</details>
</details>
## Материалы
- cppreference: `std::mutex` — https://en.cppreference.com/w/cpp/thread/mutex
- cppreference: `std::condition_variable` — https://en.cppreference.com/w/cpp/thread/condition_variable
- cppreference: `std::atomic` и `memory_order` — https://en.cppreference.com/w/cpp/atomic/memory_order
- Clang: документация ThreadSanitizer — https://clang.llvm.org/docs/ThreadSanitizer.html
- cppreference: undefined behavior (data race) — https://en.cppreference.com/w/cpp/language/ub
- Linux kernel: "volatile considered harmful" — https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html
-410
View File
@@ -1,410 +0,0 @@
# D4, часть 1. Графы: обход, топологическая сортировка, кратчайшие пути (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/12_dijkstra`. Граф —
это не абстракция «для собеседований», а модель любой сети связей: маршрутизаторы и линки,
процессы и их зависимости, файлы и их include-цепочки.
## 1. Представления графа: матрица смежности vs список смежности
Граф — это набор вершин `V` и рёбер `E`. Два способа хранить связи в памяти:
```c++
// матрица смежности: adj[u][v] = вес ребра (или 0/inf, если ребра нет)
std::vector<std::vector<int>> adj(n, std::vector<int>(n, 0));
// список смежности: adj[u] = список (сосед, вес)
std::vector<std::vector<std::pair<int,int>>> adj(n);
```
**Матрица смежности:** память O(V²) независимо от числа рёбер, проверка «есть ли ребро u→v» —
O(1) прямым обращением по индексу, но перебор всех соседей вершины — всегда O(V), даже если
соседей всего два. Подходит для **плотных** графов (E близко к V²) и когда нужна частая
проверка «есть ли ребро».
**Список смежности:** память O(V+E) — платишь только за реально существующие рёбра, перебор
соседей вершины — O(deg(v)), то есть ровно столько операций, сколько соседей. Подходит для
**разреженных** графов (типичный случай: сети, графы зависимостей), где E ≈ V, а не V².
`tasks/12_dijkstra` — 100 000 вершин и 200 000 рёбер: матрица заняла бы 10¹⁰ ячеек и не влезла
бы в память, поэтому единственный вариант — список смежности.
**Факты для карточек**
- base | Память матрицы смежности для V вершин? — O(V²)
- base | Память списка смежности? — O(V+E)
- core | Сложность перебора всех соседей вершины в списке смежности? — O(deg(v)), в матрице — всегда O(V)
- core | Почему для графа 100 000 вершин нужен список смежности, а не матрица? — матрица заняла бы 10¹⁰ ячеек, не влезает в память
Почему дальше: раз связи хранятся как список соседей, естественный первый вопрос — как обойти
весь граф, начиная с одной вершины.
## 2. BFS: обход в ширину и кратчайший путь по числу рёбер
```c++
std::vector<int> bfs(int src, const std::vector<std::vector<int>>& adj) {
std::vector<int> dist(adj.size(), -1);
std::queue<int> q;
dist[src] = 0;
q.push(src);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u])
if (dist[v] == -1) { // ещё не посещена
dist[v] = dist[u] + 1;
q.push(v);
}
}
return dist;
}
```
BFS раскрывает граф слоями: сначала все вершины на расстоянии 1 ребро от источника, потом на
расстоянии 2, и так далее — это следствие того, что структура данных — **очередь** (FIFO):
вершина, добавленная раньше, обрабатывается раньше, поэтому к моменту, когда очередь дошла до
вершин расстояния k, все вершины расстояния <k уже обработаны. Отсюда ключевое свойство: **в
невзвешенном графе BFS даёт кратчайший путь по числу рёбер** — первый раз, когда вершина
помечена посещённой, это гарантированно по кратчайшему пути.
Каждая вершина кладётся в очередь ровно один раз (проверка `dist[v] == -1` это гарантирует), и
для каждой вершины перебираются все её соседи один раз — суммарно **O(V+E)**: O(V) на
инициализацию и постановку/снятие каждой вершины из очереди, O(E) на суммарный перебор всех
рёбер по всем вершинам (каждое ребро просматривается один или два раза, в зависимости от
ориентированности).
**Факты для карточек**
- base | Сложность BFS на списке смежности? — O(V+E)
- base | Какую структуру данных использует BFS? — очередь (FIFO)
- core | Что гарантированно находит BFS в невзвешенном графе? — кратчайший путь по числу рёбер от источника
- core | Почему BFS даёт кратчайший путь именно из-за очереди, а не из-за чего-то ещё? — FIFO-порядок раскрывает граф строго по слоям расстояния, вершина расстояния k не может обработаться раньше всех вершин расстояния <k
Почему дальше: если вместо очереди использовать стек (или рекурсию), обход идёт не слоями, а
«вглубь» одной ветки до конца — это DFS, и он даёт другие гарантии.
## 3. DFS: обход в глубину
```c++
void dfs(int u, const std::vector<std::vector<int>>& adj, std::vector<bool>& visited) {
visited[u] = true;
for (int v : adj[u])
if (!visited[v]) dfs(v, adj, visited); // рекурсия = неявный стек вызовов
}
```
DFS идёт максимально глубоко по одной ветке, прежде чем откатиться и попробовать соседнюю —
это следствие того, что структура данных — **стек** (LIFO), явный или неявный (стек вызовов
рекурсии). Сложность та же, что у BFS — **O(V+E)**: те же рассуждения про однократное
посещение каждой вершины и однократный перебор рёбер, разница только в порядке обхода, не в
асимптотике.
DFS не гарантирует кратчайший путь (может найти путь в 10 рёбер, когда есть путь в 2), зато
даёт естественный механизм для задач, где важен порядок «сначала потомки, потом сам узел» —
топологическая сортировка через постфиксный обход, обнаружение циклов, поиск компонент
связности.
**Ловушки**
- Рекурсивный DFS на графе с длинной цепочкой (например, список из 100 000 вершин,
выстроенных в цепь) → глубина рекурсии 100 000 → переполнение стека вызовов (stack
overflow), не ловится компилятором, падает в рантайме. Решение — итеративный DFS с явным
`std::stack`.
- Забыть пометить вершину посещённой до рекурсивного спуска, а не после → на графе с циклом
уйдёт в бесконечную рекурсию.
**Факты для карточек**
- base | Сложность DFS на списке смежности? — O(V+E)
- base | Какую структуру данных использует DFS? — стек (явный или стек вызовов рекурсии)
- core | Гарантирует ли DFS кратчайший путь? — нет, в отличие от BFS
- deep | Почему рекурсивный DFS опасен на графе-цепочке из 100 000 вершин? — глубина рекурсии равна длине цепочки, стек вызовов переполняется
Почему дальше: раз BFS/DFS помечают вершины посещёнными за один проход, естественно
использовать это для подсчёта изолированных друг от друга частей графа — компонент
связности.
## 4. Связные компоненты
Механизм: перебираем все вершины `0..n-1`; если вершина ещё не посещена — запускаем от неё
BFS или DFS, это помечает посещёнными всю компоненту, к которой она принадлежит, и
увеличиваем счётчик компонент на 1. Суммарная сложность по всем запускам всё равно
**O(V+E)** — каждая вершина и каждое ребро обрабатываются ровно один раз за весь перебор,
несмотря на то что BFS/DFS запускается несколько раз (по числу компонент), а не один.
Задача **Number of Islands** — это ровно связные компоненты на сетке: клетка `'1'` —
вершина, соседние по 4 направлениям (вверх/вниз/влево/вправо) клетки `'1'` — рёбра. BFS/DFS
(flood fill) от каждой ещё не посещённой клетки `'1'` закрашивает весь остров, число запусков
= число островов. Граф здесь не хранится явно списком смежности — соседи вычисляются на лету
по координатам, но асимптотика та же: O(rows·cols).
**Факты для карточек**
- base | Сложность подсчёта связных компонент через BFS/DFS от каждой непосещённой вершины? — O(V+E), суммарно по всем запускам
- core | Что в задаче Number of Islands является «вершиной» и «ребром»? — вершина — клетка `'1'`, ребро — соседство по 4 направлениям
Почему дальше: связные компоненты не различают направление рёбер. В ориентированном графе
зависимостей («B зависит от A») важен порядок — какую вершину обработать раньше другой. Это
топологическая сортировка.
## 5. Топологическая сортировка: алгоритм Кана
Топологический порядок существует только для **направленного ациклического графа (DAG)** —
если есть цикл (A зависит от B, B зависит от A), непротиворечивого порядка «раньше/позже»
построить нельзя в принципе.
```c++
std::vector<int> kahn_toposort(int n, const std::vector<std::vector<int>>& adj) {
std::vector<int> indeg(n, 0);
for (int u = 0; u < n; ++u)
for (int v : adj[u]) indeg[v]++; // степень входа: сколько рёбер входит в v
std::queue<int> q;
for (int u = 0; u < n; ++u)
if (indeg[u] == 0) q.push(u); // вершины без зависимостей — стартовые
std::vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u])
if (--indeg[v] == 0) q.push(v); // все зависимости v уже обработаны
}
return order; // order.size() < n -> в графе есть цикл
}
```
Механизм: **степень входа** вершины — число рёбер, входящих в неё, то есть число
нерассмотренных зависимостей. Вершина с `indeg == 0` не зависит ни от кого необработанного —
её можно поставить в порядок прямо сейчас. Когда вершина обработана, она «снимает»
зависимость со всех своих соседей (`--indeg[v]`); как только у соседа не осталось
необработанных зависимостей, он тоже готов — кладём его в очередь. Сложность **O(V+E)**: та
же логика, что у BFS — каждая вершина обрабатывается один раз, каждое ребро уменьшает
`indeg` один раз.
**Как проявляется цикл:** если в графе есть цикл, все вершины цикла имеют `indeg > 0` до
конца работы алгоритма (каждая ждёт кого-то из цикла, а тот ждёт её) — они никогда не попадут
в очередь. Проверка: **`order.size() < n`** после завершения — значит, часть вершин не
обработана, в графе цикл. Это и есть способ решить задачу **Course Schedule**: курсы —
вершины, пререквизит «A перед B» — ребро A→B; если топологический порядок покрывает все
курсы, все курсы можно пройти, иначе где-то циклическая зависимость.
**Факты для карточек**
- base | Для какого типа графа определена топологическая сортировка? — направленный ациклический граф (DAG)
- base | Сложность алгоритма Кана? — O(V+E)
- core | Что такое степень входа вершины в алгоритме Кана? — число входящих в неё рёбер (нерассмотренных зависимостей)
- core | Как алгоритм Кана обнаруживает цикл? — счётчик обработанных вершин `order.size()` меньше `n` после завершения
- core | Как задача Course Schedule сводится к топологической сортировке? — курсы — вершины, пререквизит — направленное ребро, цикл = невозможно пройти все курсы
**Ловушки**
- Забыть, что `order.size() < n` — единственный надёжный признак цикла (пустая очередь сама
по себе не значит успех) → код считает граф с циклом корректно отсортированным.
- Спутать степень входа со степенью выхода при инициализации очереди → в очередь попадут не
те вершины, порядок будет неверным с первого шага.
Почему дальше: топологическая сортировка отвечает на вопрос «в каком порядке», но не «на
каком расстоянии». Если рёбрам приписать веса, следующий естественный вопрос — кратчайший
путь по сумме весов, а не по числу рёбер.
## 6. Дейкстра: кратчайшие пути с неотрицательными весами
Условие применимости — **все веса рёбер ≥ 0** (`tasks/12_dijkstra` явно требует `w >= 0`,
при этом вес 0 допустим). Причина ограничения — алгоритм жадный: как только вершина извлечена
с минимальным текущим расстоянием, она считается **окончательно решённой** и больше не
пересматривается. Это верно только если из невыбранных вершин путь до неё не может стать
короче — а если есть отрицательное ребро, путь через ещё не рассмотренную вершину теоретически
мог бы уменьшить уже «зафиксированное» расстояние, и жадность ломается: алгоритм даст неверный
ответ, а не просто отработает медленнее.
**Наивная реализация** (без очереди с приоритетом): на каждом из V шагов ищем среди
непосещённых вершину с минимальным `dist` линейным перебором — O(V) на шаг, всего **O(V²)**.
Годится для плотных графов, где E ≈ V².
**С `priority_queue`** (мин-куча по `dist`): каждое расслабление ребра — это `push` в кучу
O(log V), таких расслаблений всего O(E), плюс O(V) извлечений минимума — итого **O((V+E) log V)**.
Для `tasks/12_dijkstra` (100 000 вершин, 200 000 рёбер) это единственный вариант, укладывающийся
в 2 секунды: V² здесь — 10¹⁰ операций, а (V+E)·log V — около 300 000 · 17 ≈ 5·10⁶.
```c++
long long shortest_path(int n,
const std::vector<std::tuple<int,int,int>>& edges, // (u, v, w)
int src, int dst) {
if (src == dst) return 0;
std::vector<std::vector<std::pair<int,long long>>> adj(n);
for (auto& [u, v, w] : edges) adj[u].push_back({v, w}); // ориентированное ребро
std::vector<long long> dist(n, -1);
using P = std::pair<long long,int>; // (расстояние, вершина)
std::priority_queue<P, std::vector<P>, std::greater<P>> pq; // мин-куча
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (dist[u] != -1 && d != dist[u]) continue; // устаревшая запись — "ленивое" удаление
if (u == dst) return d;
for (auto& [v, w] : adj[u]) {
long long nd = d + w;
if (dist[v] == -1 || nd < dist[v]) {
dist[v] = nd;
pq.push({nd, v});
}
}
}
return -1; // недостижима
}
```
**«Ленивое» удаление устаревших записей** — `std::priority_queue` не умеет напрямую
уменьшать ключ уже лежащего элемента (нет операции decrease-key), поэтому вместо обновления
кладут в кучу новую пару `(nd, v)` при каждом улучшении расстояния — старая запись остаётся в
куче. Когда до неё доходит очередь на извлечение, проверка `if (d != dist[v]) continue;`
находит, что для `v` уже известно расстояние лучше, чем в этой записи, и просто пропускает
её. Куча может временно раздуться до O(E) записей вместо O(V), но асимптотика O((V+E) log V)
не меняется, потому что каждая запись — это одна операция `push` в ответ на одно расслабление
ребра.
**Факты для карточек**
- base | Обязательное условие применимости Дейкстры? — все веса рёбер неотрицательны (w ≥ 0)
- base | Сложность наивной Дейкстры (без кучи)? — O(V²)
- core | Сложность Дейкстры с `priority_queue`? — O((V+E) log V)
- core | Почему отрицательное ребро ломает Дейкстру? — вершина считается решённой сразу после извлечения минимума, а отрицательное ребро из ещё не рассмотренной вершины теоретически могло бы уменьшить это «зафиксированное» расстояние
- core | Что проверяет `if (d != dist[v]) continue;` в реализации на куче? — что извлечённая запись не устарела (для v уже нашли путь короче)
- deep | Почему куча может держать до O(E) записей вместо O(V)? — при каждом улучшении расстояния в кучу кладётся новая запись вместо обновления старой (нет decrease-key)
**Ловушки**
- Забыть проверку устаревшей записи (`if (d != dist[v]) continue;`) → для одной вершины
расслабление соседей выполняется несколько раз лишний раз, результат остаётся верным, но
сложность деградирует к худшему случаю.
- Применить Дейкстру к графу с отрицательным ребром без проверки условия → неверный
(заниженный или завышенный, в зависимости от структуры) ответ без явной ошибки —
тестами не всегда ловится, если отрицательное ребро не лежит на кратчайшем пути.
Почему дальше: если в графе всё же есть отрицательные веса, нужен алгоритм без жадного
«фиксирования» результата — Беллман-Форд.
## 7. Беллман-Форд: отрицательные веса
Механизм: вместо жадного выбора минимума **расслабляем все E рёбер `V-1` раз подряд**. После
k-й полной итерации гарантированно найдены все кратчайшие пути, использующие не более k
рёбер; кратчайший путь без отрицательных циклов не может состоять больше чем из `V-1` ребра
(иначе он повторял бы вершину), поэтому `V-1` итераций достаточно, чтобы расстояния
стабилизировались. Сложность — **O(V·E)**: `V-1` проходов по всем E рёбрам.
Дополнительная V-я итерация по всем рёбрам служит детектором **отрицательного цикла**: если
после `V-1` итераций расстояние для какого-то ребра всё ещё можно уменьшить, значит в графе
есть цикл с отрицательной суммой весов — кратчайший путь в принципе не определён (можно
крутиться по циклу бесконечно, уменьшая сумму). Дейкстра такой цикл не заметит и просто даст
неверный ответ; Беллман-Форд явно сигнализирует о его существовании.
**Факты для карточек**
- base | Сложность Беллман-Форда? — O(V·E)
- base | Сколько раз алгоритм проходит по всем рёбрам в основном цикле? — V-1 раз
- core | Как Беллман-Форд обнаруживает отрицательный цикл? — если расстояние ещё можно уменьшить на дополнительной V-й итерации, в графе есть отрицательный цикл
- core | Почему V-1 итераций достаточно? — кратчайший путь без отрицательных циклов не может содержать больше V-1 ребра (иначе повторяет вершину)
Почему дальше: и Дейкстра, и Беллман-Форд работают на ориентированных рёбрах с весами. Для
неориентированных рёбер есть отдельная задача — построить минимальный связывающий набор
рёбер (остовное дерево) без циклов; для неё нужна структура, которая быстро отвечает «эти две
вершины уже соединены?» — union-find.
## 8. Union-Find (Disjoint Set Union): ранг, сжатие пути, Kruskal
Структура хранит разбиение вершин на непересекающиеся множества (компоненты) и поддерживает
две операции: `find(x)` — найти представителя множества, `union(x, y)` — объединить два
множества.
```c++
struct DSU {
std::vector<int> parent, rank_;
DSU(int n) : parent(n), rank_(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // сжатие пути (path compression)
return parent[x];
}
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false; // уже в одном множестве — цикл
if (rank_[x] < rank_[y]) std::swap(x, y);
parent[y] = x;
if (rank_[x] == rank_[y]) rank_[x]++; // объединение по рангу
return true;
}
};
```
**Сжатие пути:** при каждом `find` все вершины на пути до корня перепривязываются напрямую к
корню — следующий `find` для любой из них займёт один шаг вместо повторного прохода всей
цепочки. **Объединение по рангу:** при слиянии корень с меньшим рангом (примерной оценкой
высоты дерева) подвешивается под корень с большим — это не даёт деревьям расти в глубину без
необходимости. По отдельности каждый приём даёт логарифмическое улучшение, вместе — амортизи-
рованная сложность операции становится **O(α(n))**, где α — обратная функция Аккермана: она
растёт настолько медленно, что для любого практически представимого n (меньше числа атомов во
вселенной) α(n) ≤ 4. На практике это и называют «почти O(1)».
**Kruskal** (минимальное остовное дерево): сортируем все рёбра по весу — O(E log E); идём по
рёбрам от меньшего веса к большему, для каждого ребра `(u,v)` проверяем `find(u) != find(v)` —
если вершины ещё не в одной компоненте, добавляем ребро в остов и делаем `unite(u,v)` (иначе
ребро создало бы цикл — пропускаем). Итоговая сложность — **O(E log E)** (сортировка
доминирует над почти O(1) операциями union-find).
Задача **Redundant Connection**: дан граф-дерево из n вершин и n рёбер (то есть ровно одно
лишнее ребро, замыкающее единственный цикл) — нужно найти это лишнее ребро. Решение —
union-find: идём по рёбрам по порядку, для каждого делаем `unite`; первое ребро, для которого
`unite` вернул `false` (обе вершины уже в одной компоненте до объединения), и есть искомое —
оно замыкает цикл.
Задача **Network Delay Time**: рёбра с весами (время передачи сигнала), нужно время, за
которое сигнал от вершины k дойдёт до всех остальных. Это ровно Дейкстра от источника k;
ответ — максимум из всех найденных кратчайших расстояний (если какая-то вершина осталась
недостижима — ответ -1).
**Факты для карточек**
- base | Какие два приёма дают почти-константную сложность union-find? — сжатие пути и объединение по рангу
- base | Амортизированная сложность операции union-find с обоими приёмами? — O(α(n)), обратная функция Аккермана, практически O(1)
- core | Сложность алгоритма Kruskal и что в ней доминирует? — O(E log E), доминирует сортировка рёбер
- core | Как union-find решает Redundant Connection? — первое ребро, для которого `unite` вернул false (вершины уже в одной компоненте), и есть лишнее
- core | Как Network Delay Time сводится к Дейкстре? — вершина k — источник, ответ — максимум из всех кратчайших расстояний, -1 если что-то недостижимо
- deep | Каково верхнее ограничение обратной функции Аккермана для практических n? — α(n) ≤ 4 для любого n, меньшего числа атомов во вселенной
**Ловушки**
- Забыть сжатие пути в `find` → дерево может выродиться в цепочку, `find` деградирует к O(n)
на несбалансированных входных данных.
- В Kruskal забыть проверку `find(u) != find(v)` перед добавлением ребра → в остов попадёт
ребро, замыкающее цикл, результат перестанет быть деревом.
Ссылки на задачи этого дня: `tasks/12_dijkstra` (Дейкстра на `priority_queue`, ленивое
удаление, условие неотрицательных весов).
<details>
<summary>Проверь себя</summary>
1. Граф на 100 000 вершин и 200 000 рёбер. Почему для него используют список смежности, а не
матрицу, и во сколько раз (порядок величины) список экономнее по памяти?
<details><summary>Ответ</summary>Матрица заняла бы V²=10¹⁰ ячеек, список — V+E≈300 000
ячеек: разница на порядки (≈33 000 раз), матрица физически не влезает в разумную память.</details>
2. Почему BFS, а не DFS, используют для поиска кратчайшего пути в невзвешенном графе?
<details><summary>Ответ</summary>BFS раскрывает граф слоями через очередь (FIFO) — все
вершины расстояния k обрабатываются раньше вершин расстояния k+1, поэтому первое посещение
вершины гарантированно происходит по кратчайшему пути. DFS идёт вглубь одной ветки и такой
гарантии не даёт.</details>
3. В алгоритме Кана после обработки все вершины кроме трёх остались с `indeg > 0`. Что это
означает и как это связано с количеством обработанных вершин?
<details><summary>Ответ</summary>Эти три (и, возможно, другие зависимые от них) вершины
образуют цикл — они никогда не наберут `indeg == 0`, поэтому `order.size() < n`, что и есть
признак цикла в графе.</details>
4. Граф с одним ребром веса -5 в остальном с неотрицательными весами. Почему нельзя просто
«запустить Дейкстру и она сработает, если это ребро не на пути к целевой вершине»?
<details><summary>Ответ</summary>Нельзя гарантировать заранее, какие вершины уже
«зафиксированы» жадным выбором к моменту, когда алгоритм дойдёт до отрицательного ребра —
если оно ведёт в уже решённую вершину, её расстояние должно было бы уменьшиться, но
Дейкстра его не пересмотрит; корректность в общем случае не гарантирована, поэтому условие
w ≥ 0 обязательно для всех рёбер, а не только на пути к конкретной вершине.</details>
5. Почему сложность Kruskal — O(E log E), если union-find работает почти за O(1)?
<details><summary>Ответ</summary>Потому что перед объединением рёбра нужно отсортировать по
весу — это O(E log E) и доминирует над суммарной почти константной стоимостью всех
union-find операций O(E·α(V)).</details>
</details>
## Материалы
- Документация `std::priority_queue` (мин-куча в реализации Дейкстры) — https://en.cppreference.com/w/cpp/container/priority_queue
-483
View File
@@ -1,483 +0,0 @@
# D4, часть 2. L2/L3: Ethernet, IP, подсети (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь `tasks/04_ipv4` и
`tasks/13_subnet`. Тема дня — то, что физически лежит в байтах пакета от момента, когда
приложение вызвало `send`, до момента, когда кадр ушёл в провод.
## 1. Модель OSI и модель TCP/IP
OSI — эталонная модель из **семи** независимых уровней: физический, канальный, сетевой,
транспортный, сеансовый, представления, прикладной. Каждый уровень решает свою задачу и
разговаривает только с соседними уровнями через фиксированный интерфейс, не зная деталей их
реализации — физическую среду можно сменить с меди на оптику, ничего не трогая в IP и TCP,
потому что канальный уровень скрывает эту деталь от сетевого. На практике верхние три уровня
(сеансовый, представления, прикладной) отдельно не реализуют — приложение само решает вопросы
сессии и кодирования данных.
**TCP/IP** — практическая **четырёхуровневая** модель, которой реально пользуется стек Linux:
канальный, интернет, транспортный, прикладной. Соответствие: канальный уровень TCP/IP = L1+L2
OSI (доставка кадра внутри сегмента), интернет-уровень = L3 OSI (IP, маршрутизация между
сетями), транспортный = L4 OSI (TCP/UDP, порты), прикладной уровень TCP/IP поглощает сразу
L5–L7 OSI. Сокет создаётся на транспортном уровне — отдельного программного «уровня сессии»
в реальном стеке не существует.
**Факты для карточек**
- base | Сколько уровней в модели OSI? — 7
- base | Сколько уровней в модели TCP/IP? — 4
- core | Какие три уровня OSI объединяет прикладной уровень TCP/IP? — сеансовый, представления, прикладной
- core | На каком уровне создаётся сокет? — транспортном (L4 OSI / транспортный TCP/IP)
Почему дальше: раз данные проходят через уровни сверху вниз при отправке, нужно понять, что
физически происходит с байтами на каждом уровне — это инкапсуляция.
## 2. Инкапсуляция: во что заворачиваются данные
Каждый нижний уровень оборачивает данные верхнего уровня в свой заголовок (а канальный —
ещё и в трейлер) при передаче:
```
данные приложения
→ сегмент TCP (заголовок 20 байт)
→ пакет IP (заголовок 20 байт)
→ кадр Ethernet (заголовок 14 байт + трейлер FCS 4 байта)
```
На приёмной стороне процесс идёт в обратном порядке: каждый уровень снимает свой заголовок и
передаёт содержимое выше, ориентируясь на поле типа протокола (EtherType в Ethernet-кадре
говорит, что внутри IP; поле `protocol` в IP-заголовке говорит, что внутри TCP/UDP/ICMP).
Так устроено потому, что каждый уровень должен работать независимо от содержимого —
коммутатору не нужно знать про TCP, чтобы передать кадр дальше, ему хватает MAC-адреса в
заголовке L2. В дампе `tcpdump` эта вложенность видна буквально как последовательность байт:
Ethernet, затем IP, затем TCP, затем данные.
**Факты для карточек**
- base | Размер заголовка TCP-сегмента (без опций)? — 20 байт
- base | Размер заголовка IP-пакета (без опций)? — 20 байт
- base | Размер заголовка Ethernet-кадра и трейлера FCS? — 14 байт заголовок + 4 байта FCS
- core | По какому полю верхний уровень при приёме узнаёт, что лежит внутри нижнего? — по полю типа протокола (EtherType в Ethernet, `protocol` в IP)
Почему дальше: раз данные заворачиваются в Ethernet-кадр последними перед проводом, логично
разобрать этот заголовок по байтам первым.
## 3. Ethernet и MAC-адрес
Ethernet-заголовок — **14 байт**: 6 байт MAC-адрес получателя, 6 байт MAC-адрес отправителя,
2 байта EtherType (тип содержимого кадра — например, IPv4 или ARP). После полезной нагрузки
кадр завершается 4-байтовым трейлером **FCS** (frame check sequence) — контрольной суммой для
проверки целостности при приёме; трейлер частью заголовка не считается. Фиксированная и
компактная структура нужна для того, чтобы коммутатор мог разобрать заголовок на аппаратной
скорости без анализа содержимого выше — все поля имеют строго фиксированную длину и позицию.
**MAC-адрес** — 48-битный (6-байтный) физический адрес интерфейса, действующий только внутри
одного сегмента (широковещательного домена). Первые **3 байта (OUI)** назначаются
производителю оборудования организацией IEEE, оставшиеся 3 байта производитель присваивает
конкретному интерфейсу. Когда пакет покидает сегмент через маршрутизатор, MAC-адрес
получателя в заголовке меняется на MAC следующего узла на пути, а IP-адрес остаётся прежним —
L2 отвечает за доставку «из рук в руки» внутри сегмента, L3 — за доставку через множество
сегментов.
**Факты для карточек**
- base | Длина Ethernet-заголовка? — 14 байт
- base | Длина трейлера FCS? — 4 байта
- base | Длина MAC-адреса в битах и байтах? — 48 бит, 6 байт
- core | Из скольки байт состоит OUI в MAC-адресе и кто его назначает? — 3 байта, назначает IEEE производителю
- core | Что меняется в заголовках при переходе пакета через маршрутизатор — MAC или IP получателя? — MAC-адрес получателя, IP остаётся прежним
Почему дальше: чтобы отправить кадр внутри сегмента, нужен MAC получателя, а известен обычно
только его IP — протокол, который их связывает, это ARP.
## 4. ARP: разрешение IP в MAC
ARP (Address Resolution Protocol) по известному IP-адресу узла в локальном сегменте находит
его MAC-адрес. Механизм: отправитель рассылает **широковещательный (broadcast)** кадр «кто
владеет IP X, сообщите свой MAC»; получают его все узлы сегмента, но отвечает только владелец
адреса — уже адресным **unicast**-пакетом со своим MAC. Полученную пару IP-MAC отправитель
кладёт в локальный **ARP-кэш (таблицу)**, чтобы не повторять broadcast на каждый пакет —
именно поэтому первый пакет к новому соседу в сети чуть медленнее последующих, он ждёт
ARP-ответа.
**Gratuitous ARP** — ARP-запрос или ответ, который узел рассылает не в ответ на чужой запрос,
а сам, без повода, про собственный IP-адрес (запрашивает MAC для своего же IP). Назначение —
объявить остальным узлам сегмента свою пару IP-MAC заранее (обновить их кэши до первого
реального обмена) или обнаружить конфликт IP-адресов: если кто-то в сегменте уже отвечает за
тот же IP, это сразу видно. Используется при старте интерфейса и при переключении на резервный
узел (failover), чтобы соседи сразу обновили устаревшую запись в ARP-кэше на MAC нового узла.
Если ARP не проходит (узел выключен, неверная подсеть, фильтрация), это видно в `tcpdump` как
повторяющиеся «who has X» без ответа, а приложение получит таймаут соединения без объяснения
причины.
**Факты для карточек**
- base | Как рассылается ARP-запрос — broadcast или unicast? — broadcast
- base | Как отправляется ARP-ответ? — unicast, от владельца адреса
- core | Зачем нужен ARP-кэш? — не повторять broadcast-запрос для каждого исходящего пакета к уже известному узлу
- core | Что такое gratuitous ARP и для чего он нужен? — незапрошенный ARP про собственный IP; обновление чужих кэшей заранее и обнаружение конфликта адресов
Почему дальше: ARP работает внутри одного L2-сегмента. Если в сети настроены виртуальные
сегменты поверх одной физической — VLAN, — это меняет сам Ethernet-заголовок.
## 5. VLAN 802.1Q
VLAN (Virtual LAN) по стандарту **802.1Q** делит один физический сегмент на несколько
логически изолированных широковещательных доменов — кадры из разных VLAN не видят
широковещательный трафик друг друга, хотя идут по одному кабелю и через один коммутатор.
Механизм — тег из **4 дополнительных байт**, вставляемый в Ethernet-заголовок между
MAC-адресом отправителя и полем EtherType:
- **TPID** (Tag Protocol Identifier) — 2 байта, фиксированное значение **0x8100**, сигнализирует
коммутатору, что дальше идёт VLAN-тег, а не обычный EtherType;
- **TCI** (Tag Control Information) — 2 байта, из них: **PCP** (Priority Code Point, 3 бита) —
приоритет кадра для QoS, **DEI** (Drop Eligible Indicator, 1 бит) — кандидат на отбрасывание
первым при перегрузке, **VID** (VLAN ID, **12 бит**) — номер VLAN, диапазон 0–4095 (0 и
4095 зарезервированы, реально используется 1–4094).
Из-за тега заголовок Ethernet-кадра с VLAN вырастает с 14 до **18 байт**. Если это не
учесть при расчёте MTU или максимального размера кадра, получится ошибка ровно на 4 байта —
типично ловится сравнением дампа `tcpdump` с ожидаемой длиной.
**Факты для карточек**
- base | Сколько байт добавляет VLAN-тег 802.1Q к Ethernet-заголовку? — 4 байта (14 → 18)
- base | Значение поля TPID для VLAN-тега? — 0x8100
- core | Из каких трёх полей состоит TCI и сколько бит занимает VID? — PCP (3 бита), DEI (1 бит), VID (12 бит)
- core | Диапазон реально используемых VLAN ID? — 1–4094 (0 и 4095 зарезервированы)
Почему дальше: заголовок Ethernet ограничивает не только формат, но и максимальный размер
полезной нагрузки в одном кадре — MTU.
## 6. MTU и фрагментация
MTU (Maximum Transmission Unit) — максимальный размер полезной нагрузки, который канальный
уровень передаёт в одном кадре; для стандартного Ethernet это **1500 байт**. Если IP-пакет
крупнее MTU исходящего интерфейса, происходит одно из двух:
- **фрагментация** — пакет режется на несколько IP-пакетов меньшего размера, каждый со своим
IP-заголовком, собираются они уже на узле-получателе;
- если в заголовке выставлен флаг **DF** (Don't Fragment) — узел на пути отбрасывает пакет и
отправляет источнику ICMP-сообщение о необходимости фрагментации (см. раздел про ICMP).
Ограничение в 1500 байт исторически идёт из спецификации Ethernet и балансирует накладные
расходы заголовка против задержки и вероятности ошибки на длинном кадре: чем крупнее кадр,
тем дороже обходится его повторная передача при ошибке. Фрагментация — дорогая операция:
она нагружает маршрутизаторы на пути и делает передачу уязвимой к потере одного фрагмента, из-
за которого теряется весь исходный пакет целиком. Поэтому современные стеки заранее подбирают
размер пакета под MTU всего пути — механизм **PMTUD** (Path MTU Discovery), разобранный
дальше вместе с ICMP.
**Факты для карточек**
- base | Значение MTU для стандартного Ethernet? — 1500 байт
- core | Что происходит с IP-пакетом крупнее MTU, если флаг DF не выставлен? — фрагментируется на несколько IP-пакетов меньшего размера
- core | Что происходит, если пакет крупнее MTU и выставлен флаг DF? — пакет отбрасывается, отправителю летит ICMP о необходимости фрагментации
- deep | Почему фрагментация считается дорогой операцией? — нагружает маршрутизаторы на пути, и потеря одного фрагмента роняет весь исходный пакет
Почему дальше: и флаг DF, и размер, и адреса — это конкретные поля одной структуры,
IP-заголовка. Разберём его целиком по байтам.
## 7. IPv4-заголовок по полям
IPv4-заголовок — минимум **20 байт** (без опций), опции могут увеличить его до 60 байт.
Поля в порядке следования:
| Поле | Размер | Смысл |
|---|---|---|
| Version | 4 бита | версия протокола, для IPv4 всегда 4 |
| IHL | 4 бита | длина заголовка в 32-битных словах (минимум 5 → 20 байт) |
| TOS (Type of Service) | 8 бит | приоритет/качество обслуживания пакета |
| Total Length | 16 бит | общая длина пакета (заголовок + данные), байты |
| Identification | 16 бит | идентификатор для сборки фрагментов одного исходного пакета |
| Flags | 3 бита | бит DF (не фрагментировать), бит MF (есть ещё фрагменты) |
| Fragment Offset | 13 бит | смещение этого фрагмента в исходном пакете |
| TTL | 8 бит | время жизни пакета в хопах |
| Protocol | 8 бит | протокол следующего уровня: 6 = TCP, 17 = UDP, 1 = ICMP |
| Header Checksum | 16 бит | контрольная сумма заголовка (не данных) |
| Source / Destination IP | по 32 бита | адреса отправителя и получателя |
Это ровно поля структуры `Ipv4Header` из `tasks/04_ipv4`. Задача требует парсить их из сырого
буфера побайтово (через сдвиги и маски), а не приведением указателя `reinterpret_cast` — на
невыровненных адресах и при другом порядке байт это UB. Обязательные проверки при разборе:
буфер короче 20 байт, `version != 4`, `ihl < 5`, `ihl*4 > len`, `total_length < ihl*4` или
`total_length > len` — пакет с любым из этих условий отбрасывается как некорректный, ещё до
попытки читать поля выше заголовка.
**Факты для карточек**
- base | Минимальный размер IPv4-заголовка? — 20 байт
- base | Значение поля Protocol для TCP / UDP / ICMP? — 6 / 17 / 1
- core | Сколько бит занимает поле IHL и что оно означает? — 4 бита, длина заголовка в 32-битных словах
- core | Какие условия делают IPv4-пакет некорректным при разборе (по `tasks/04_ipv4`)? — буфер <20 байт, version≠4, ihl<5, ihl·4>len, total_length<ihl·4 или >len
- deep | Почему в `tasks/04_ipv4` запрещён `reinterpret_cast` на буфер? — невыровненный адрес и другой порядок байт на проводе дают UB при чтении полей как структуры напрямую
Почему дальше: одно из полей заголовка — TTL — существует специально для защиты от
зацикленной маршрутизации, и с ним напрямую связан отдельный протокол ошибок, ICMP.
## 8. TTL и ICMP Time Exceeded: как работает traceroute
**TTL** (Time To Live) уменьшается на единицу **на каждом маршрутизаторе**, через который
проходит пакет. Это сделано специально: чтобы зацикленный по ошибке маршрут не гонял пакет по
сети бесконечно. Когда TTL достигает нуля, пакет отбрасывается, а отправителю отправляется
**ICMP Time Exceeded** (тип 11).
Именно на этом механизме построен **traceroute**: он последовательно отправляет пакеты с
TTL 1, 2, 3, ... Пакет с TTL 1 гарантированно "умирает" на первом же маршрутизаторе — тот
шлёт ICMP Time Exceeded с собственным адресом, это и есть первая строка вывода traceroute.
Пакет с TTL 2 доходит до второго маршрутизатора и умирает там, и так далее, пока пакет не
дойдёт до конечного получателя. Так по цепочке ICMP-ответов восстанавливается список всех
промежуточных маршрутизаторов на пути, без какого-либо специального протокола обнаружения
маршрута — только манипуляция TTL и стандартный побочный эффект его истечения.
**Факты для карточек**
- base | На сколько уменьшается TTL на каждом маршрутизаторе? — на 1
- base | Какой ICMP-тип отправляется при обнулении TTL? — Time Exceeded, тип 11
- core | Как traceroute находит промежуточные маршрутизаторы, не имея отдельного протокола обнаружения пути? — последовательно шлёт пакеты с TTL=1,2,3..., каждый умирает на очередном хопе и присылает ICMP Time Exceeded с адресом этого хопа
Почему дальше: Time Exceeded — лишь один из типов ICMP-сообщений. Остальные закрывают другие
сценарии ошибок доставки и обнаружения MTU пути.
## 9. ICMP: echo, destination unreachable, PMTUD
ICMP (Internet Control Message Protocol) — протокол сетевого уровня для диагностических
сообщений; у него нет портов, он не переносит пользовательские данные приложений. Ключевые
типы:
- **Echo Request / Echo Reply** (тип 8 / тип 0) — основа команды `ping`;
- **Time Exceeded** (тип 11) — TTL обнулился (раздел выше, основа traceroute);
- **Destination Unreachable** (тип 3) — пакет физически не может быть доставлен; код внутри
этого типа уточняет причину, в частности код **"fragmentation needed and DF set"** —
посылается, когда пакет крупнее MTU промежуточного линка, а флаг DF запрещает
фрагментацию.
Именно код "fragmentation needed" лежит в основе **PMTUD** (Path MTU Discovery): отправитель
шлёт пакеты с выставленным DF, начиная с MTU своего интерфейса; если по пути встречается
линк с меньшим MTU, тот роутер отбрасывает пакет и присылает ICMP Destination Unreachable с
этим кодом (в современных реализациях — вместе со значением MTU узкого места); отправитель
уменьшает размер пакета и повторяет попытку. Так стек заранее подбирает размер, не полагаясь
на фрагментацию по пути.
ICMP существует отдельно от TCP/UDP потому, что диагностика нужна на уровне, где ещё нет
понятия соединения или порта — маршрутизатор должен уметь сообщить об ошибке доставки, даже не
зная, что за протокол был внутри. Именно поэтому `ping` и `traceroute` работают даже к узлу,
на котором не открыт ни один сервис поверх TCP/UDP. Если ICMP заблокирован файрволом
(частая практика), `ping` не пройдёт, хотя TCP-соединение на конкретный порт может работать
нормально — это видно, если сравнить `ping host` и `curl host` с разным результатом.
**Факты для карточек**
- base | Номера типов Echo Request и Echo Reply? — 8 и 0
- base | Тип ICMP-сообщения Destination Unreachable? — тип 3
- core | Какой код Destination Unreachable запускает PMTUD? — "fragmentation needed and DF set"
- core | Почему ICMP не имеет портов? — диагностика работает на сетевом уровне, где ещё нет понятия транспортного соединения
- deep | Почему `ping` может не проходить, а `curl` на тот же хост — работать? — ICMP заблокирован файрволом отдельно от TCP-порта, это два разных уровня фильтрации
**Ловушки**
- Судить о доступности хоста только по `ping` → файрвол может глушить ICMP, не трогая
реальный TCP-сервис — вывод «хост недоступен» будет ложным.
- Не учитывать PMTUD при жёстко заданном MTU в туннеле (VPN, GRE) → пакеты с DF молча
теряются на узком месте, если ICMP-ответы блокируются по пути — проявляется как
«маленькие пакеты проходят, большие — зависают».
Почему дальше: контрольная сумма заголовка из раздела про IPv4-поля устроена нетривиально —
разберём отдельно, как она считается и почему покрывает только заголовок.
## 10. Контрольная сумма IPv4-заголовка
Алгоритм — сумма в **дополнительном коде (one's complement)** по 16-битным словам заголовка,
затем **инверсия** результата (побитовое НЕ) — это и есть значение, которое пишут в поле
`header_checksum`. При сложении в дополнительном коде перенос из старшего бита не отбрасывается,
а прибавляется обратно к младшему биту суммы (end-around carry).
`tasks/04_ipv4` требует ровно эту схему в двух функциях:
```c++
// сумма в дополнительном коде по len байтам, затем инверсия — значение для записи в поле
// (вызывается на буфере, где поле контрольной суммы уже обнулено)
uint16_t compute_checksum(const uint8_t* buf, size_t len);
// true, если контрольная сумма заголовка верна: сумма в дополнительном коде по ihl*4 байтам
// заголовка (вместе с полем контрольной суммы) равна 0xFFFF; нагрузка не участвует
bool checksum_valid(const uint8_t* buf, size_t len);
```
Асимметрия проверки и вычисления не случайна: при **вычислении** поле суммы ещё не заполнено
(обнулено), поэтому оно не участвует своим значением; при **проверке** поле уже содержит
записанную сумму, и если она верна, повторное суммирование всех 16-битных слов заголовка
(включая это поле) в дополнительном коде обязано дать **все единицы — 0xFFFF**. Это свойство
дополнительного кода: сумма X и инверсии X всегда даёт все единицы.
**Почему сумма считается только по заголовку, а не по всему пакету с данными**: заголовок
меняется на каждом хопе — как минимум TTL уменьшается на 1 на каждом маршрутизаторе, а значит
контрольную сумму заголовка пришлось бы пересчитывать на каждом хопе заново. Пересчитывать её
ещё и по всей полезной нагрузке на каждом маршрутизаторе было бы избыточно дорого и не нужно:
целостность самих данных уже проверяется отдельно контрольными суммами более высокого уровня
(TCP/UDP-заголовок содержит свою контрольную сумму, покрывающую данные) и трейлером FCS на
канальном уровне. Если контрольная сумма заголовка не сходится, пакет молча отбрасывается —
ошибка ловится счётчиками ошибок интерфейса или отсутствием ожидаемого ответа в `tcpdump`.
**Факты для карточек**
- base | Алгоритм контрольной суммы IPv4-заголовка? — сумма в дополнительном коде по 16-битным словам, затем инверсия
- core | Какое значение должна давать сумма при проверке валидности (с учётом поля суммы)? — 0xFFFF
- core | Почему контрольная сумма покрывает только заголовок, а не данные? — заголовок (минимум TTL) меняется на каждом хопе и пересчитывается заново; целостность данных проверяют TCP/UDP-checksum и FCS канального уровня
- deep | Что такое end-around carry в one's complement сложении? — перенос из старшего бита не отбрасывается, а прибавляется обратно к младшему биту суммы
**Ловушки**
- Считать контрольную сумму по буферу с уже заполненным полем `header_checksum` вместо
обнулённого → результат `compute_checksum` окажется неверным, потому что старое значение
поля участвует в сумме.
- Включить в сумму данные после заголовка (payload) → сумма не сойдётся ни у отправителя, ни
при проверке — контрольная сумма IPv4 считается строго по `ihl*4` байтам, не по всей длине
пакета.
Почему дальше: сами IP-адреса в заголовке не существуют сами по себе — узел должен понимать,
какая их часть определяет сеть, а какая — конкретный хост. Это маска и подсеть.
## 11. Маски, подсети и CIDR
Маска подсети делит 32-битный IPv4-адрес на две части: номер сети и номер узла внутри неё —
это определяет, какие адреса «свои» для локальной доставки внутри сегмента, а какие требуют
выхода через шлюз. Запись `/N` (**CIDR**, Classless Inter-Domain Routing) — это и есть длина
префикса сети в битах вместо устаревшей классовой адресации (A/B/C), что позволяет выделять
подсети произвольного размера, а не только фиксированных 8/16/24 бит.
**Широковещательный адрес** подсети — адрес, где все биты хостовой части выставлены в 1; он
зарезервирован для рассылки всем узлам сегмента и не выдаётся конкретному устройству. Первый
адрес подсети (все хостовые биты — 0) зарезервирован как адрес самой сети.
Число доступных хостов по маске (для обычных подсетей — минус 2 служебных адреса, сеть и
broadcast):
| Маска | Хостовых бит | Всего адресов | Доступно хостам |
|---|---|---|---|
| /24 | 8 | 256 | **254** |
| /26 | 6 | 64 | **62** |
| /30 | 2 | 4 | **2** |
| /31 | 1 | 2 | **2** (RFC 3021, оба адреса — хосты, без сети/broadcast) |
| /32 | 0 | 1 | **1** (сеть = broadcast = сам адрес) |
`/31` и `/32` — исключения из общего правила «минус 2»: `/31` (RFC 3021) используется на
линках точка-точка между двумя маршрутизаторами, где резервировать сеть и broadcast из
всего двух адресов расточительно — оба адреса становятся хостовыми; `/32` — адрес единственного
узла, маршрут на конкретный хост.
По `tasks/13_subnet`: для `prefix <= 30` — `host_count = 2^(32-prefix) - 2`, `first_host =
network + 1`, `last_host = broadcast - 1`. Эталонный пример: `10.0.1.130/26` → сеть
`10.0.1.128`, broadcast `10.0.1.191`, диапазон хостов `129..190`. Обратная задача —
`prefix_for_hosts(h)`: найти **самую узкую** (наибольший `prefix`) подсеть, вмещающую `h`
хостов — считается как `32 - ceil(log2(h+2))` за O(1), без перебора. Примеры: `62` → `/26`,
`63` → `/25`, `254` → `/24`, `1` → `/30` (`/31` и `/32` в подборе не участвуют — они не для
произвольного числа хостов, а под конкретные сценарии линка и одиночного адреса).
**Факты для карточек**
- base | Сколько хостов доступно в подсети /24? — 254
- base | Сколько хостов доступно в подсети /26? — 62
- base | Сколько хостов доступно в подсети /30? — 2
- core | Чем /31 отличается от остальных масок по числу служебных адресов? — оба адреса хостовые (RFC 3021), нет отдельного сетевого/broadcast адреса
- core | Что означает запись `/N` в CIDR? — длина префикса сети в битах вместо классовой адресации A/B/C
- deep | Формула подбора самой узкой подсети под h хостов за O(1)? — `32 - ceil(log2(h+2))`
**Ловушки**
- Забыть исключить /31 и /32 из общего расчёта `2^(32-prefix) - 2` → для /31 формула даст 0
хостов вместо верных 2, для /32 — отрицательное число.
- Спутать адрес сети (все хостовые биты 0) с первым доступным хостом (`network + 1`) → off-by-one
в диапазоне выдаваемых адресов.
Почему дальше: маска определяет, доставлять ли пакет напрямую внутри сегмента или через
устройство более высокого уровня. Логично развести устройства L1/L2/L3 по тому, что каждое
из них реально делает с кадром/пакетом.
## 12. Хаб, коммутатор, маршрутизатор; таблица MAC-адресов (FDB)
Три устройства работают на разных уровнях и с разным объёмом понимания трафика:
- **Хаб (L1)** — просто повторитель: любой бит, пришедший на один порт, электрически
копируется на все остальные порты без какого-либо разбора кадра. Все порты хаба — один
общий домен коллизий; трафик одной пары узлов виден всем остальным. Практически вытеснен
коммутаторами.
- **Коммутатор (L2)** — разбирает Ethernet-заголовок и пересылает кадр только на порт, где
находится MAC-адрес получателя, а не на все порты сразу. Каждый порт — отдельный домен
коллизий, но все порты (без VLAN) остаются одним широковещательным доменом.
- **Маршрутизатор (L3)** — разбирает IP-заголовок и пересылает пакет между разными подсетями
по таблице маршрутов, уменьшая TTL на 1 на каждом пересланном пакете. Разделяет
широковещательные домены — broadcast из одной подсети не проходит через маршрутизатор в
другую.
Коммутатор знает, на какой порт слать кадр, благодаря **таблице MAC-адресов** (FDB, Forwarding
Database, также называют CAM-таблицей) — по сути хеш-таблице, где ключ — MAC-адрес, значение —
номер порта. Заполняется она самообучением: коммутатор смотрит **source MAC** каждого
входящего кадра и запоминает пару (MAC, порт, на который кадр пришёл) — так через обычный
трафик таблица заполняется без отдельного протокола объявления адресов. Если адреса
получателя нет в таблице (адрес ещё не встречался как источник), коммутатор пересылает кадр
на все порты, кроме входного (flooding) — точно так же, как хаб, но только для этого одного
кадра, пока адрес не станет известен.
Записи в FDB не хранятся вечно — у каждой запись есть **aging**: таймер, который сбрасывается
при каждом новом кадре с этим source MAC, и если запись не обновлялась дольше таймаута
(типичное значение в реализациях — 300 секунд, 5 минут), она удаляется из таблицы. Это нужно,
чтобы таблица не разрасталась записями отключённых или перемещённых на другой порт устройств
— переключить сетевой кабель с одного порта на другой без aging означало бы, что коммутатор
продолжал бы слать кадры на старый, уже неверный порт.
**Факты для карточек**
- base | На каком уровне работает хаб / коммутатор / маршрутизатор? — L1 / L2 / L3
- base | Что такое FDB коммутатора по структуре данных? — хеш-таблица MAC-адрес → порт
- core | Как коммутатор заполняет FDB без отдельного протокола объявления? — самообучением по source MAC каждого входящего кадра
- core | Что делает коммутатор с кадром, чей MAC получателя не найден в FDB? — рассылает на все порты кроме входного (flooding)
- core | Зачем нужен aging записей FDB? — удалять устаревшие пары MAC-порт, если устройство отключилось или переехало на другой порт
- deep | Что разделяет маршрутизатор, чего не делает коммутатор? — широковещательные домены (домены коллизий разделяет уже коммутатор)
**Ловушки**
- Ожидать, что коммутатор ограничивает broadcast-трафик → он передаёт broadcast-кадры на все
порты одного широковещательного домена так же, как хаб; ограничивает его только
маршрутизатор (или разбиение на VLAN).
- Перепутать основание пересылки: коммутатор решает по MAC (L2), маршрутизатор — по IP (L3) →
попытка «настроить маршрутизацию на коммутаторе» без функции L3 физически невозможна.
Ссылки на задачи этого дня: `tasks/04_ipv4` (разбор IPv4-заголовка и контрольная сумма),
`tasks/13_subnet` (арифметика подсетей).
<details>
<summary>Проверь себя</summary>
1. Кадр Ethernet с VLAN-тегом занимает 18 байт заголовка вместо 14. Откуда взялись эти лишние
4 байта и что конкретно в них закодировано?
<details><summary>Ответ</summary>VLAN-тег 802.1Q: 2 байта TPID (фиксированное значение
0x8100) + 2 байта TCI, где TCI = PCP (3 бита приоритета) + DEI (1 бит) + VID (12 бит номер
VLAN, диапазон 1–4094 реально используемых значений).</details>
2. Почему PMTUD зависит от ICMP, и что происходит, если файрвол на пути блокирует ICMP
целиком?
<details><summary>Ответ</summary>PMTUD узнаёт об узком месте по ICMP Destination
Unreachable с кодом «fragmentation needed and DF set» от промежуточного маршрутизатора;
если ICMP заблокирован, отправитель никогда не получит этот сигнал и будет слать пакеты,
которые молча теряются на узком месте — классическая причина «маленькие пакеты проходят,
большие зависают» через VPN/туннель.</details>
3. При проверке контрольной суммы IPv4-заголовка сумма всех 16-битных слов (включая само
поле суммы) даёт 0xFFFF. Почему именно это число, а не 0?
<details><summary>Ответ</summary>Поле контрольной суммы — это инверсия суммы остальных
слов; сумма числа и его инверсии в дополнительном коде всегда даёт все единицы (0xFFFF),
это математическое свойство one's complement, а не произвольный выбор.</details>
4. Подсеть `10.0.1.130/26`. Назови адрес сети, broadcast и диапазон хостов, не считая заново
с нуля — по правилу из этого урока.
<details><summary>Ответ</summary>Сеть `10.0.1.128`, broadcast `10.0.1.191`, хосты
`10.0.1.129`–`10.0.1.190` (62 адреса, /26 = 64 адреса минус 2 служебных).</details>
5. Почему `/31` — единственная маска (кроме /32) с `host_count`, который не считается по
формуле `2^(32-prefix) - 2`?
<details><summary>Ответ</summary>RFC 3021: на линке точка-точка из всего двух адресов
резервировать отдельно сеть и broadcast бессмысленно — оба адреса используют как хостовые,
поэтому `host_count = 2`, а не `2^1 - 2 = 0`.</details>
6. Коммутатор получил кадр с MAC получателя, которого нет в его FDB. Что он сделает и чем
это временно похоже на поведение хаба?
<details><summary>Ответ</summary>Разошлёт кадр на все порты, кроме входного (flooding) —
как и хаб, который всегда рассылает на все порты; разница в том, что у коммутатора это
разовое поведение для конкретного неизвестного адреса, а не постоянный режим работы для
всего трафика.</details>
</details>
## Материалы
- RFC 791, Internet Protocol (IPv4) — https://datatracker.ietf.org/doc/html/rfc791
- RFC 826, An Ethernet Address Resolution Protocol (ARP) — https://datatracker.ietf.org/doc/html/rfc826
- RFC 792, Internet Control Message Protocol (ICMP) — https://datatracker.ietf.org/doc/html/rfc792
- RFC 4632, Classless Inter-domain Routing (CIDR) — https://datatracker.ietf.org/doc/html/rfc4632
- RFC 3021, Using 31-Bit Prefixes on IPv4 Point-to-Point Links — https://datatracker.ietf.org/doc/html/rfc3021
- man7.org, `ip(7)` — https://man7.org/linux/man-pages/man7/ip.7.html
-391
View File
@@ -1,391 +0,0 @@
# D5, часть 1. Динамическое программирование (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Когда ДП вообще применимо
ДП применимо, если задача одновременно даёт два свойства:
- **Оптимальная подструктура** — оптимальное решение всей задачи собирается из оптимальных
решений подзадач (для LCS: если последние символы совпали, LCS всей строки — это LCS без
последних символов плюс 1; оптимум подзадачи не пересчитывается заново).
- **Перекрывающиеся подзадачи** — одни и те же подзадачи встречаются многократно при наивной
рекурсии (для чисел Фибоначчи `fib(5)` вызывает `fib(3)` дважды, `fib(2)` трижды и так
далее — без запоминания результат пересчитывается экспоненциально много раз).
Если подзадачи не пересекаются (например, обычное бинарное дерево решений без общих
поддеревьев), рекурсия остаётся рекурсией — мемоизация не ускоряет её, кэшировать нечего.
Если нет оптимальной подструктуры (жадный локальный выбор не гарантирует глобальный оптимум),
ДП даёт неверный ответ — тогда нужен либо перебор, либо доказанный жадный алгоритм.
**Факты для карточек**
- base | Два условия применимости ДП? — оптимальная подструктура + перекрывающиеся подзадачи
- core | Почему наивный рекурсивный `fib(n)` работает за экспоненциальное время? — одни и те же подзадачи (`fib(k)` для одного и того же k) пересчитываются заново много раз
- core | Что ломается, если применить ДП-переход к задаче без оптимальной подструктуры? — переход не отражает реальную зависимость оптимумов, ответ будет неверным независимо от таблицы
Почему дальше: раз задача сводится к подзадачам, нужно определить, что именно является
«состоянием» подзадачи и как один переход выражается через предыдущие.
## 2. Состояние, переход, мемоизация vs табуляция
**Состояние** — минимальный набор параметров, однозначно определяющий подзадачу (для рюкзака:
номер предмета + оставшийся вес; для LCS: позиции в обеих строках). **Переход** — формула,
выражающая ответ для состояния через ответы уже решённых состояний.
Два способа посчитать одно и то же:
- **Мемоизация (top-down)** — обычная рекурсия по формуле перехода, но перед вычислением
проверяем кэш (`unordered_map` или массив), а после вычисления кладём результат в кэш.
Считает только реально нужные состояния, но каждый вызов — это кадр стека.
- **Табуляция (bottom-up)** — заполняем таблицу итеративно от базовых случаев к целевому,
без рекурсии вообще. Требует явно определить порядок заполнения (что должно быть посчитано
раньше, чем понадобится).
```c++
// мемоизация
long long fib_memo(int n, std::vector<long long>& cache) {
if (n <= 1) return n;
if (cache[n] != -1) return cache[n];
return cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache);
}
// табуляция
long long fib_tab(int n) {
std::vector<long long> dp(n + 1);
dp[0] = 0; if (n >= 1) dp[1] = 1;
for (int i = 2; i <= n; ++i) dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
```
Оба варианта — O(число состояний) по времени (каждое состояние считается ровно один раз, а
не экспоненциально много) и O(число состояний) по памяти (нужно где-то хранить ответ для
каждого состояния). У мемоизации есть дополнительный риск, которого нет у табуляции:
рекурсия на входе с большим n (например, `fib_memo(1'000'000, ...)`) кладёт по кадру на
каждый уровень — стек ограничен (типично несколько МБ), и глубокая рекурсия падает с
переполнением стека (SIGSEGV) там, где итеративная табуляция отработает без проблем.
**Ловушки**
- Забыть проверить кэш перед вычислением в мемоизации → рекурсия становится обычной наивной
→ возврат к экспоненциальному времени, видно по таймауту на больших n.
- Взять слишком глубокую рекурсию для мемоизации (n порядка 10⁵–10⁶) → переполнение стека →
падение по SIGSEGV, а не по логической ошибке.
**Факты для карточек**
- base | Сложность по времени мемоизации/табуляции? — O(число состояний)
- core | Чем мемоизация рискует, а табуляция — нет? — переполнением стека при большой глубине рекурсии
- core | Что нужно определить в ДП-задаче до написания кода? — состояние (параметры подзадачи) и переход (формула через уже решённые состояния)
Почему дальше: раз табуляция явно хранит таблицу, логично спросить, всегда ли нужна вся
таблица целиком, или память можно ужать.
## 3. Уменьшение памяти: O(n) → O(1) или O(n)
Если переход использует только последние 1–2 строки/значения таблицы, всю таблицу хранить не
нужно — достаточно «скользящего окна» из нужного числа последних значений.
Лестница (Climbing Stairs: сколько способов подняться на n ступеней шагами по 1 или 2):
переход `dp[i] = dp[i-1] + dp[i-2]` использует только два предыдущих значения — O(1) памяти.
```c++
int climb_stairs(int n) { // способов дойти до ступени n
if (n <= 2) return n;
int prev2 = 1, prev1 = 2;
for (int i = 3; i <= n; ++i) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
House Robber (нельзя грабить два соседних дома подряд, максимизировать сумму): тот же приём.
`dp[i] = max(dp[i-1], dp[i-2] + nums[i])` — не ограбить дом i (взять лучший результат без
него) или ограбить (взять лучший результат через один плюс текущий дом).
```c++
int rob(const std::vector<int>& nums) {
int prev2 = 0, prev1 = 0;
for (int x : nums) {
int curr = std::max(prev1, prev2 + x);
prev2 = prev1;
prev1 = curr;
}
return prev1; // O(n) время, O(1) память
}
```
Рюкзак 0/1 ужимается не до O(1), а до O(W) (одна строка по весу вместо таблицы n×W) — переход
там зависит от целой предыдущей строки, а не от 1–2 чисел; подробно в следующем разделе.
**Факты для карточек**
- base | Сложность по памяти Climbing Stairs при развёрнутых `prev1`/`prev2`? — O(1)
- core | Почему рюкзак 0/1 нельзя ужать до O(1), только до O(W)? — переход `dp[i][w]` зависит от целой предыдущей строки по весу, а не от 1–2 соседних чисел
Почему дальше: рюкзак — задача, где переход по весу нельзя писать «вперёд» без потери
корректности; разберём, почему именно назад.
## 4. Рюкзак 0/1: почему обратный проход по весу
Задача: n предметов с весом `weight[i]` и ценностью `value[i]`, вместимость W, каждый предмет
берётся не более одного раза, максимизировать суммарную ценность.
```c++
int knapsack01(const std::vector<int>& weight, const std::vector<int>& value, int W) {
std::vector<int> dp(W + 1, 0);
for (size_t i = 0; i < weight.size(); ++i)
for (int w = W; w >= weight[i]; --w) // обратный проход!
dp[w] = std::max(dp[w], dp[w - weight[i]] + value[i]);
return dp[W]; // O(n·W) время, O(W) память
}
```
Если одномерный массив `dp[w]` переиспользуется для всех предметов подряд (без второго
измерения по номеру предмета), то при проходе весов **вперёд** (`w` от `weight[i]` до `W`)
значение `dp[w - weight[i]]`, использованное в переходе, могло уже быть обновлено этим же
предметом i на текущей итерации — то есть предмет i фактически используется дважды, и задача
незаметно превращается в рюкзак с неограниченным числом копий предмета (unbounded knapsack).
Проход **назад** гарантирует, что `dp[w - weight[i]]` берётся из состояния «до предмета i» —
старое значение ещё не тронуто текущей итерацией внешнего цикла.
**Ловушки**
- Пройти веса вперёд в одномерном 0/1-рюкзаке → предмет учитывается несколько раз → ответ
завышен относительно эталона, видно на тесте с одним дорогим предметом.
- Перепутать размер массива (`W` вместо `W+1`) → индекс `dp[W]` вне границ → UB/ASAN heap-buffer-overflow.
**Факты для карточек**
- base | Временная сложность 0/1-рюкзака с одномерным массивом? — O(n·W)
- core | Почему в одномерном 0/1-рюкзаке веса обходят от W к weight[i], а не наоборот? — иначе `dp[w-weight[i]]` уже обновлён текущим предметом в этой же итерации, предмет посчитается дважды
- deep | Как называется вариант рюкзака, в который случайно превращается 0/1-рюкзак при прямом проходе весов? — unbounded knapsack (неограниченное число копий предмета)
Почему дальше: та же ловушка с порядком циклов — не по направлению, а по тому, что снаружи, а
что внутри — по-другому проявляется в задаче про размен монет.
## 5. Монеты: порядок циклов меняет смысл ответа
Coin Change (минимальное число монет для суммы `amount`, каждая монета берётся неограниченное
число раз — это уже unbounded knapsack):
```c++
int coin_change(const std::vector<int>& coins, int amount) {
std::vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int a = 1; a <= amount; ++a)
for (int c : coins)
if (c <= a && dp[a - c] != INT_MAX)
dp[a] = std::min(dp[a], dp[a - c] + 1);
return dp[amount] == INT_MAX ? -1 : dp[amount]; // O(amount · coins.size())
}
```
Для минимума порядок циклов не влияет на корректность — минимум не зависит от того, в каком
порядке предметы разрешено переиспользовать. Но для **подсчёта числа способов** (Coin Change
II) порядок циклов меняет сам смысл ответа:
```c++
// количество КОМБИНАЦИЙ: {1,2} и {2,1} — один и тот же способ
long long change_combinations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int c : coins) // внешний цикл — монета
for (int a = c; a <= amount; ++a)
dp[a] += dp[a - c];
return dp[amount];
}
// количество ПЕРЕСТАНОВОК: {1,2} и {2,1} — разные способы
long long change_permutations(int amount, const std::vector<int>& coins) {
std::vector<long long> dp(amount + 1, 0);
dp[0] = 1;
for (int a = 1; a <= amount; ++a) // внешний цикл — сумма
for (int c : coins)
if (c <= a) dp[a] += dp[a - c];
return dp[amount];
}
```
Монета снаружи цикла фиксирует «в каком порядке монеты рассматриваются» раз и навсегда для
всех сумм — эквивалентные по составу, но переставленные последовательности монет схлопываются
в один и тот же результат, отсюда комбинации. Сумма снаружи цикла для каждого `a` заново
перебирает все монеты как «последнюю добавленную» — одна и та же комбинация монет, добавленная
в разном порядке, считается несколько раз, отсюда перестановки.
**Ловушки**
- Перепутать порядок циклов в задаче «сколько способов» → вместо количества комбинаций
получается количество перестановок (число сильно больше ожидаемого) → расходится с
эталонным ответом на тесте с 2+ разными монетами.
**Факты для карточек**
- base | Сложность Coin Change (минимум монет) по времени? — O(amount · число_номиналов)
- core | Какой порядок циклов в Coin Change II даёт число комбинаций, а какой — перестановок? — монета снаружи/сумма внутри → комбинации; сумма снаружи/монета внутри → перестановки
Почему дальше: рюкзак и монеты — одномерные ДП по числу. Следующий класс задач — ДП по двум
строкам сразу, с двумерной таблицей.
## 6. LCS и Edit Distance: таблица (n+1)×(m+1)
**LCS** (длиннейшая общая подпоследовательность двух строк, не обязательно непрерывная):
```c++
int lcs_length(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
dp[i][j] = (a[i - 1] == b[j - 1])
? dp[i - 1][j - 1] + 1
: std::max(dp[i - 1][j], dp[i][j - 1]);
return dp[n][m]; // O(n·m) время и память
}
```
Строка 0 и столбец 0 — базовый случай «одна из строк пустая», отсюда размер `(n+1)×(m+1)`, а
не `n×m`. Если последние символы совпадают, они точно входят в оптимальную LCS — переход к
`dp[i-1][j-1]+1`. Если нет — общая подпоследовательность не может использовать оба последних
символа одновременно, берём лучшее из «отбросить последний символ a» и «отбросить последний
символ b».
**Edit Distance** (минимум вставок/удалений/замен, чтобы превратить строку a в b):
```c++
int edit_distance(const std::string& a, const std::string& b) {
int n = a.size(), m = b.size();
std::vector<std::vector<int>> dp(n + 1, std::vector<int>(m + 1));
for (int i = 0; i <= n; ++i) dp[i][0] = i; // удалить все i символов
for (int j = 0; j <= m; ++j) dp[0][j] = j; // вставить все j символов
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
dp[i][j] = (a[i - 1] == b[j - 1])
? dp[i - 1][j - 1]
: 1 + std::min({dp[i - 1][j - 1], // замена
dp[i - 1][j], // удаление
dp[i][j - 1]}); // вставка
return dp[n][m]; // O(n·m) время и память
}
```
Тот же размер таблицы `(n+1)×(m+1)` и та же причина: строка/столбец 0 — превращение пустой
строки в префикс другой строки чисто вставками или удалениями.
**Ловушки**
- Завести таблицу `n×m` вместо `(n+1)×(m+1)` → нет места для базового случая «пустой префикс»
→ неверные значения на границе или выход за границы массива.
- В Edit Distance забыть инициализировать нулевую строку/столбец → сравнение с мусорными
значениями → неверный ответ без падения программы (тихая ошибка).
**Факты для карточек**
- base | Размер таблицы LCS/Edit Distance для строк длины n и m? — (n+1)×(m+1)
- base | Временная сложность LCS и Edit Distance? — O(n·m)
- core | Почему при несовпадении последних символов в LCS берут max(dp[i-1][j], dp[i][j-1])? — оба последних символа одновременно в общую подпоследовательность войти не могут, значит хотя бы один из них можно отбросить без потери оптимальности
Почему дальше: LCS/Edit Distance решают за O(n·m) через явную таблицу. Следующая классическая
задача — LIS — решается за O(n²) той же схемой, но улучшается до O(n log n) совсем другим
приёмом.
## 7. LIS (НВП): O(n²) и O(n log n)
Длиннейшая строго возрастающая подпоследовательность массива (не обязательно непрерывная).
Сигнатура из `tasks/05_lis`: `int lis_length(const std::vector<int>& a)`.
**Наивное O(n²)**: `dp[i]` — длина LIS, заканчивающейся ровно на элементе `a[i]`.
```c++
int lis_naive(const std::vector<int>& a) {
int n = a.size();
std::vector<int> dp(n, 1);
int best = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j)
if (a[j] < a[i]) dp[i] = std::max(dp[i], dp[j] + 1);
best = std::max(best, dp[i]);
}
return best; // O(n²)
}
```
На 100 000 элементах (ограничение задачи `05_lis`) это 10¹⁰ операций — не укладывается в 2
секунды внутреннего теста, нужен другой алгоритм.
**O(n log n) через массив «хвостов»**:
```c++
int lis_length(const std::vector<int>& a) {
std::vector<int> tails; // tails[k] = минимальный возможный последний
// элемент возрастающей подпоследовательности длины k+1
for (int x : a) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x); // x больше всех хвостов — новая длина
else *it = x; // заменить первый хвост ≥ x на x
}
return tails.size(); // O(n log n)
}
```
`tails` не хранит саму подпоследовательность — только минимально возможное значение
последнего элемента для каждой достижимой длины. `tails` всегда отсортирован по построению:
если `x` заменяет элемент на позиции `it`, новое значение `x` меньше старого (иначе
`lower_bound` нашёл бы другую позицию) и больше всех элементов слева от `it` (иначе
`lower_bound` остановился бы раньше) — порядок не нарушается ни при замене, ни при добавлении
в конец. `lower_bound` ищет первый элемент ≥ x, что даёт строго возрастающую LIS (`task.md`
требует именно строгую); для нестрогой (неубывающей) подпоследовательности нужен
`upper_bound`. Длина `tails` в конце равна длине LIS, хотя сам массив LIS обычно не является.
**Факты для карточек**
- base | Наивная сложность LIS через dp[i]? — O(n²)
- base | Сложность LIS через массив хвостов и бинарный поиск? — O(n log n)
- core | Что хранится в `tails[k]`? — минимально возможный последний элемент возрастающей подпоследовательности длины k+1
- core | Почему `tails` остаётся отсортированным после каждой замены/добавления? — новое значение всегда меньше заменяемого и больше всех элементов левее позиции, найденной `lower_bound`
- core | `lower_bound` или `upper_bound` нужен для строго возрастающей LIS? — `lower_bound`
- deep | Сколько операций у наивного O(n²) LIS на 100 000 элементах и почему это не укладывается в тест? — порядка 10¹⁰, тест роняет прогон при времени > 2 с
Почему дальше: LIS через хвосты — пример, где ДП-таблица заменяется одним отсортированным
массивом и бинарным поиском; та же идея (жертвовать явной таблицей ради log-фактора)
встречается и в других задачах на подпоследовательности.
Ссылки на задачи этого дня: `tasks/05_lis` — реализовать `lis_length` за O(n log n); наивная
O(n²) версия не пройдёт по времени на 100 000 элементах.
<details>
<summary>Проверь себя</summary>
1. Массив весов `[1, 3, 4]`, ценностей `[15, 20, 30]`, вместимость `W=4`. Какой ответ даст
0/1-рюкзак и почему это не «взять предметы 1 и 2» (вес 1+3=4)?
<details><summary>Ответ</summary>35 — рюкзак действительно может выбрать предметы с весами
1 и 3 (суммарный вес 4, ценность 15+20=35); предмет весом 4 отдельно даёт только 30, что
меньше. Ответ 35, а не 30 — если получилось 30, значит в переборе не сравнили оба варианта
заполнения веса 4.</details>
2. Почему для Coin Change II (подсчёт числа способов, не минимума) важно, какой цикл
снаружи — монета или сумма, — а для Coin Change (минимум монет) не важно?
<details><summary>Ответ</summary>Минимум не зависит от порядка, в котором монеты
рассматриваются — это просто оптимум по всем комбинациям. Подсчёт способов чувствителен к
порядку: монета снаружи фиксирует относительный порядок монет и схлопывает перестановки
одной комбинации в один результат (комбинации); сумма снаружи пересчитывает каждую монету
как потенциально «последнюю» для каждой суммы заново, из-за чего одна комбинация
монет считается один раз на каждую перестановку (перестановки).</details>
3. Для массива `[3, 1, 4, 1, 5, 9, 2, 6]` пройдите LIS через `tails` вручную. Чему равен
`tails` после обработки первых пяти элементов (`3, 1, 4, 1, 5`)?
<details><summary>Ответ</summary>`[1, 4, 5]`: 3 → `[3]`; 1 заменяет 3 → `[1]`; 4 больше
всех → `[1,4]`; 1 заменяет первый элемент ≥1 (сам 1) → `[1,4]` без изменений; 5 больше
всех → `[1,4,5]`.</details>
4. Мемоизация `fib_memo(n, cache)` вызвана с n = 200 000. Почему это скорее упадёт по
SIGSEGV, чем отработает медленно?
<details><summary>Ответ</summary>Каждый рекурсивный вызов держит кадр стека, пока не
вернётся его результат; глубина рекурсии здесь равна n, а размер стека потока ограничен
(типично несколько МБ) — при n=200 000 кадров стек переполняется раньше, чем вычисление
успевает завершиться. Табуляция (`fib_tab`) того же n отработает без проблем — там нет
рекурсии, только цикл.</details>
</details>
## Материалы
- cppreference, `std::vector` — https://en.cppreference.com/w/cpp/container/vector
- cppreference, `std::lower_bound` — https://en.cppreference.com/w/cpp/algorithm/lower_bound
- cppreference, `std::max` — https://en.cppreference.com/w/cpp/algorithm/max
- cppreference, `std::min` — https://en.cppreference.com/w/cpp/algorithm/min
- cppreference, лимиты `<climits>` (`INT_MAX`) — https://en.cppreference.com/w/cpp/types/climits
-387
View File
@@ -1,387 +0,0 @@
# D5, часть 3. Bash, awk/sed, /proc (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах
можно копировать и запускать — это образец, а не готовый ответ на задачу.
## 1. Пайплайны и код возврата
`cmd1 | cmd2 | cmd3` запускает три отдельных процесса одновременно, соединённых анонимными
каналами (pipe) — `cmd2` начинает читать данные, ещё пока `cmd1` их пишет, ничего не
буферизуется целиком в памяти между шагами. `$?` после пайплайна — это код возврата
**последней** команды (`cmd3`), а не всего конвейера: если `cmd1` упал с ошибкой, а `cmd3`
завершилась успешно, `$?` покажет 0 — ошибка `cmd1` потеряется молча.
Чтобы увидеть код возврата каждой команды пайплайна отдельно, есть массив `PIPESTATUS`:
`echo "${PIPESTATUS[@]}"` после `cmd1 | cmd2` печатает, например, `1 0` — `cmd1` упал,
`cmd2` отработал нормально, хотя `$?` был бы 0.
**Факты для карточек**
- base | Чей код возврата хранит `$?` после `cmd1 | cmd2 | cmd3`? — только последней команды (`cmd3`)
- core | Как узнать код возврата каждой команды пайплайна отдельно? — массив `PIPESTATUS` (`${PIPESTATUS[@]}`)
Почему дальше: раз `$?` по умолчанию скрывает ошибки середины пайплайна, логично разобрать
режимы `set`, которые меняют это поведение по умолчанию.
## 2. `set -euo pipefail`: что каждая буква включает и где ломается
- **`-e`** — скрипт немедленно завершается, если любая команда вернула ненулевой код.
- **`-u`** — обращение к неопределённой переменной — ошибка, а не пустая строка.
- **`-o pipefail`** — код возврата пайплайна становится кодом **первой** упавшей команды в
нём, а не только последней (закрывает дыру из раздела 1).
`-e` не так надёжен, как кажется, — есть места, где он **не срабатывает**:
- Команда внутри условия (`if cmd; then`, `while cmd; do`, после `&&`/`||`) — код возврата там
ожидаем и проверяется явно, `-e` эту команду не трогает даже при ошибке.
- Команда в подстановке `$(cmd)`, присвоенной переменной без немедленного использования
результата в условии — ошибка внутри подстановки сама по себе не всегда останавливает
внешний скрипт, поведение зависит от контекста использования результата.
- Любая команда как часть пайплайна, кроме последней, — без `pipefail` `-e` реагирует только
на код последней команды пайплайна (та же дыра, что и в разделе 1).
**Ловушки**
- Полагаться на `-e` внутри `if some_check; then ... fi` для остановки скрипта при ошибке
`some_check` → скрипт не остановится, потому что команда — часть условия → ошибка
проглатывается и обнаруживается заметно позже, в неожиданном месте.
- Забыть `pipefail` → `grep pattern file | sort` при отсутствующем `file` вернёт 0 (потому что
`sort` пустого потока отрабатывает успешно) → скрипт продолжит работу, как будто файл найден.
**Факты для карточек**
- base | Что делает `-e` в `set -euo pipefail`? — скрипт завершается сразу при ненулевом коде возврата любой команды
- base | Что делает `-u`? — обращение к неопределённой переменной — ошибка вместо пустой строки
- core | Что делает `pipefail` и какую проблему из раздела 1 это закрывает? — код возврата пайплайна = код первой упавшей команды, а не только последней; закрывает потерю ошибок середины пайплайна
- core | В каком месте `-e` не остановит скрипт при ошибке команды? — если команда — часть условия (`if`, `while`, после `&&`/`||`) или не последняя команда пайплайна без `pipefail`
Почему дальше: `set -u` ловит неопределённые переменные, но не спасает от другой частой
причины сюрпризов — как bash разбивает строку на слова при подстановке без кавычек.
## 3. Кавычки, word splitting, IFS, массивы
Без двойных кавычек bash после подстановки переменной делает **word splitting** — разбивает
результат на отдельные слова по символам из `IFS` (по умолчанию пробел, таб, перевод строки) и
затем **globbing** — раскрывает символы `*`, `?`, `[...]` как маски файлов. `"$var"` в двойных
кавычках отключает оба шага — переменная передаётся как одна строка целиком, что почти всегда
и нужно.
```bash
file="my file.txt"
rm $file # word splitting: rm воспринимает это как rm "my" "file.txt" — два аргумента
rm "$file" # правильно: один аргумент "my file.txt"
```
`IFS` можно временно переопределить, чтобы разбить строку по нужному разделителю:
```bash
IFS=',' read -ra parts <<< "a,b,c" # parts=(a b c)
```
Массивы: `arr=(a b c)`, обращение по индексу `${arr[1]}`, все элементы — `${arr[@]}` (каждый
элемент — отдельное слово при развёртывании в кавычках, `"${arr[@]}"`), длина — `${#arr[@]}`.
**Ловушки**
- Забыть кавычки вокруг переменной с путём, содержащим пробел, → word splitting режет путь на
несколько аргументов → «No such file or directory» на файле, который на самом деле есть.
- `${arr[@]}` без кавычек при переборе `for x in ${arr[@]}` → элементы с пробелами внутри сами
разбиваются на несколько слов → цикл обрабатывает не те элементы, что были в массиве.
**Факты для карточек**
- base | Что по умолчанию входит в IFS? — пробел, таб, перевод строки
- core | Что отключают двойные кавычки вокруг `"$var"`? — word splitting и globbing (`*`/`?`/`[...]` не раскрываются)
- core | Как правильно перебрать массив с элементами, содержащими пробелы? — `for x in "${arr[@]}"` (с кавычками)
Почему дальше: помимо кода возврата команды и переменных окружения, у самого запущенного
скрипта и его процессов есть ещё несколько специальных переменных и способ отреагировать на
сигнал — `trap`.
## 4. `$?`, `$!`, `$$`, `trap`
- **`$?`** — код возврата последней выполненной команды (см. раздел 1 про пайплайны).
- **`$!`** — PID последнего запущенного в фоне процесса (`cmd &` затем `pid=$!`), нужен,
чтобы потом сделать `wait $pid` или `kill $pid`.
- **`$$`** — PID самого текущего скрипта/шелла, часто используется для уникальных временных
файлов: `tmpfile="/tmp/out.$$"`.
- **`trap`** — регистрирует обработчик на сигнал или на выход из скрипта:
`trap 'rm -f "$tmpfile"' EXIT` гарантированно удалит временный файл при любом завершении
скрипта — обычном, по ошибке (`-e`) или по сигналу (если сигнал перечислен), без ручного
дублирования `rm` в каждой точке выхода.
**Факты для карточек**
- base | Что хранит `$!`? — PID последнего фонового процесса
- base | Что хранит `$$`? — PID текущего скрипта/шелла
- core | Зачем `trap ... EXIT` используют для временных файлов? — гарантирует очистку при любом пути завершения скрипта (успех, ошибка, сигнал), без дублирования кода в каждой точке выхода
Почему дальше: `$$` в имени временного файла — это про один процесс; когда данные для команды
нужно передать через аргументы, а не через имя файла, и данных много (список файлов, список
PID), на сцену выходит `xargs`.
## 5. `xargs -0`
`xargs` берёт поток строк со стандартного ввода и подставляет их как аргументы в конец
указанной команды, разбивая по частям, если список слишком длинный для одного вызова
(ограничение ОС на длину аргументов). По умолчанию `xargs`, как и bash, разбивает вход по
пробельным символам — имена файлов с пробелами или переводами строк внутри (редко, но
бывает) ломают такой разбор.
`-0` — вход разделён не пробелами/переводами строк, а нулевыми байтами (`\0`), которые не
могут встретиться внутри легального имени файла в Unix. Источник таких нулевых разделителей —
`find ... -print0`:
```bash
find . -name '*.tmp' -print0 | xargs -0 rm -f
```
Это единственная безопасная пара для случая, когда имена файлов заранее не гарантированы
«простыми» (без пробелов, кавычек, переводов строк).
**Ловушки**
- `find ... | xargs rm` без `-print0`/`-0` на именах файлов с пробелами → имя разбивается на
несколько «аргументов» → `rm` пытается удалить несуществующие файлы или удаляет не то.
**Факты для карточек**
- base | Чем `-0` в `xargs -0` отличается от поведения по умолчанию? — вход разделяется нулевыми байтами `\0` вместо пробелов/переводов строк
- core | С какой опцией `find` обычно комбинируют `xargs -0`? — `-print0`
Почему дальше: `xargs` передаёт список аргументов другой команде; сами команды для поиска и
агрегации данных в потоке — отдельный набор утилит с конкретными флагами.
## 6. Поиск и агрегация: grep, sort, uniq
- **`grep -c PATTERN`** — печатает не совпавшие строки, а их количество.
- **`grep -o PATTERN`** — печатает только совпавшую часть строки, а не строку целиком (удобно
вместе с `wc -l`, чтобы посчитать число вхождений, а не строк с хотя бы одним вхождением).
- **`grep -v PATTERN`** — инвертирует фильтр, печатает строки, которые **не** совпали.
- **`sort -k N`** — сортирует по N-му полю (по умолчанию разделитель — пробел), а не по всей
строке целиком.
- **`sort -n`** — числовая сортировка (`10` идёт после `9`, а не перед `2`, как при
лексикографической сортировке строк по умолчанию).
- **`sort -u`** — сортировка с одновременным удалением дубликатов (эквивалент `sort | uniq`
в один проход).
- **`uniq -c`** — схлопывает соседние повторяющиеся строки, добавляя счётчик повторов перед
каждой строкой; работает только на **уже отсортированном** входе — несмежные повторы
`uniq` не видит.
```bash
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -3 # топ-3 IP по числу запросов
```
**Ловушки**
- Вызвать `uniq -c` без предварительного `sort` → повторы, не идущие подряд в исходном файле,
не схлопнутся → счётчики занижены относительно реального числа повторов.
- `sort` без `-n` на числовых полях (порты, счётчики) → `10 < 9` лексикографически → строки
идут не в том порядке, который ожидался.
**Факты для карточек**
- base | Что печатает `grep -c`? — количество совпавших строк, а не сами строки
- base | Почему `uniq -c` нужно применять после `sort`? — `uniq` схлопывает только соседние одинаковые строки, несмежные повторы не видит
- core | Чем `sort -u` эквивалентен по результату? — `sort | uniq`, но за один проход
Почему дальше: `grep`/`sort`/`uniq` работают со строками целиком или по одному полю через
внешний ключ; когда нужна произвольная логика по колонкам с накоплением состояния (суммы,
счётчики по ключу), нужен `awk`.
## 7. awk: поля, NR/NF, ассоциативные массивы
`awk` читает вход построчно и автоматически разбивает каждую строку на поля по разделителю
(`FS`, по умолчанию — пробел/таб): `$1`, `$2`, … — поля, `$0` — строка целиком, `NR` — номер
текущей строки от начала потока, `NF` — число полей в текущей строке.
```bash
awk '{sum[$1] += $2} END {for (k in sum) print k, sum[k]}' data.txt
```
`sum` здесь — ассоциативный массив: ключ `$1` (первое поле), значение — накапливаемая сумма
второго поля. `awk` — полноценный язык с переменными, циклами и такими массивами, а не просто
фильтр, поэтому в нём можно агрегировать данные по ключу за один проход, без промежуточных
файлов.
Почему `awk` на файле в 10⁶ строк быстрее bash-цикла построчного чтения с внешними командами
внутри: `awk` — один процесс, который читает поток и делает линейный проход по строкам целиком
внутри себя. Bash-цикл вида `while read -r line; do grep ... <<< "$line"; done < file`
порождает (форкает) отдельный новый процесс **на каждую строку** — на миллионе строк это
миллион `fork`+`exec`, каждый из которых на порядки дороже одной итерации внутреннего цикла
`awk`. Разница — не в разы, а на порядки по времени выполнения.
**Ловушки**
- Обрабатывать большой лог циклом `for`/`while` с построчным чтением и вызовом `grep`/`awk`
внутри цикла → минуты вместо секунд на файле в миллионы строк → видно по `time` на запуске
скрипта; правильный подход — один проход `awk`/`sort` на весь поток целиком.
**Факты для карточек**
- base | Что означает `$1` в awk? — первое поле текущей строки
- base | Что содержит `NR`? — номер текущей строки от начала потока
- core | Почему awk на 1e6 строк быстрее bash-цикла с grep внутри? — awk — один процесс с линейным проходом по строкам, bash-цикл форкает новый процесс на каждую итерацию (миллион fork+exec против одного процесса)
Почему дальше: `awk` агрегирует и печатает; когда нужно не посчитать, а точечно
отредактировать текст по шаблону прямо в потоке, для этого — `sed`.
## 8. sed
`sed` применяет команды редактирования к каждой строке потока и печатает результат — потоковый
редактор, не интерактивный. Самая частая команда — замена по шаблону:
`sed 's/OLD/NEW/'` (заменяет первое вхождение в строке), `sed 's/OLD/NEW/g'` (все вхождения в
строке, флаг `g` = global). Без флага `-i` `sed` не меняет исходный файл, а печатает результат
в stdout — `sed -i 's/OLD/NEW/g' file` редактирует файл на месте.
```bash
sed -n '5,10p' file # напечатать только строки с 5 по 10 (-n подавляет вывод по умолчанию)
sed '/^#/d' file # удалить строки, начинающиеся с #
```
**Факты для карточек**
- base | Что делает флаг `g` в `s/OLD/NEW/g`? — заменяет все вхождения в строке, а не только первое
- core | Чем `sed -i` отличается от `sed` без флага? — редактирует файл на месте вместо печати результата в stdout
Почему дальше: `grep`/`awk`/`sed` работают с текстовыми потоками из файлов и команд; отдельный
источник текстовых данных в Linux — это `/proc`, где ядро публикует состояние процессов и
ресурсов в виде текстовых файлов.
## 9. `/proc`: что там реально лежит
`/proc` — виртуальная файловая система: файлы в ней не лежат на диске, ядро генерирует их
содержимое в реальном времени по запросу чтения. Ключевые пути:
- **`/proc/<pid>/status`** — читаемая человеком сводка о процессе: состояние, использование
памяти (VmRSS и другие), UID/GID, число потоков.
- **`/proc/<pid>/cmdline`** — команда запуска процесса с аргументами, разделёнными нулевыми
байтами `\0` (не пробелами — тот же принцип, что у `find -print0`, аргумент с пробелом
внутри не спутать с границей между аргументами).
- **`/proc/<pid>/fd/`** — каталог, где каждый файл — символическая ссылка на открытый этим
процессом файловый дескриптор (обычный файл, сокет, pipe); число ссылок в этом каталоге —
реальное число открытых дескрипторов процесса прямо сейчас.
- **`/proc/<pid>/maps`** — карта отображений памяти процесса: диапазоны адресов, права доступа
(r/w/x), какому файлу или сегменту (куча, стек, конкретная библиотека) принадлежит каждый
диапазон.
- **`/proc/cpuinfo`** — параметры процессора (модель, число ядер, флаги поддерживаемых
инструкций).
- **`/proc/meminfo`** — распределение оперативной памяти: занято, свободно, в кэше, в буферах
(источник данных для `free -h`).
- **`/proc/<pid>/stat`** — машинно-читаемая (не для человека) строка с числовыми полями
состояния процесса, включая, например, время в user/kernel-режиме; источник данных для
`ps`/`top`.
Утилиты вроде `ps`, `top`, `free`, `iostat` — это не отдельный механизм сбора данных, а тонкая
обёртка над чтением этих же файлов `/proc`; ядро уже держит эти данные готовыми, специально
«собирать» их не нужно.
**Ловушки**
- Читать `/proc/<pid>/cmdline` как обычную строку с пробелами в качестве разделителей
аргументов → аргумент вида `"two words"` неотличим от двух отдельных аргументов → нужен
именно разбор по `\0`.
**Факты для карточек**
- base | Что такое /proc с точки зрения хранения данных? — виртуальная файловая система, содержимое генерируется ядром на лету, не хранится на диске
- base | Чем разделены аргументы в /proc/<pid>/cmdline? — нулевыми байтами `\0`
- core | Что можно узнать из /proc/<pid>/maps? — диапазоны адресов памяти процесса, права доступа (r/w/x) и к какому файлу/сегменту относится каждый диапазон
- core | Откуда `free -h` берёт данные о памяти? — из /proc/meminfo
Почему дальше: `/proc/<pid>/fd/` показывает текущее число открытых дескрипторов процесса;
верхнюю границу на это число задаёт `ulimit`.
## 10. `ulimit`: дескрипторы, core, стек
`ulimit` задаёт лимиты ресурсов для текущего шелла и процессов, запущенных из него:
- **`ulimit -n`** — максимальное число одновременно открытых файловых дескрипторов на
процесс; упереться в этот лимит на нагруженном сервере — типичная причина ошибки
«Too many open files».
- **`ulimit -c`** — максимальный размер core dump файла; по умолчанию часто `0` (core dump
отключён), поэтому перед тем как ловить редкий краш, выставляют `ulimit -c unlimited` —
иначе программа упадёт, а файла с состоянием памяти на момент падения просто не будет.
- **`ulimit -s`** — размер стека потока; именно этот лимит определяет, на какой глубине
рекурсии процесс упадёт с переполнением стека (см. риск глубокой рекурсии при мемоизации в
теме ДП).
**Ловушки**
- Ждать редкий краш под нагрузкой без `ulimit -c unlimited` заранее → программа падает, core
dump не создаётся (лимит 0 по умолчанию) → единственный шанс поймать причину падения
упущен, воспроизвести заново может быть нечем.
**Факты для карточек**
- base | Что ограничивает `ulimit -n`? — максимальное число открытых файловых дескрипторов на процесс
- core | Почему перед отладкой редкого краша выставляют `ulimit -c unlimited`? — по умолчанию размер core dump часто ограничен нулём, без этого файл с состоянием памяти на момент падения не создастся
Почему дальше: лимиты `ulimit` — это про ресурсы процесса; отдельная система ограничений — про
то, кому вообще разрешено читать, писать и исполнять конкретный файл.
## 11. Права: `chmod`/`chown`, `umask`
Права файла — три триады бит (владелец/группа/остальные) × (чтение r / запись w / выполнение
x), каждая триада кодируется одной восьмеричной цифрой: r=4, w=2, x=1, сумма даёт цифру для
триады. `755` = `rwxr-xr-x`: владелец — 7 (4+2+1, полный доступ), группа — 5 (4+1, чтение и
выполнение без записи), остальные — 5 (то же самое). `chmod 755 file` выставляет эти права
явно; `chown user:group file` меняет владельца и группу файла.
`umask` — маска, которая **вычитается** из прав по умолчанию при создании нового файла/каталога
(обычно каталоги создаются с базовым 777, файлы — с 666, дальше действует `umask`): при
`umask 022` новый файл получает `666 & ~022 = 644` (`rw-r--r--`), новый каталог — `777 & ~022 =
755`. `umask` не меняет права существующих файлов — только права по умолчанию для новых.
**Факты для карточек**
- base | Расшифровка прав 755? — rwxr-xr-x (владелец: полный доступ, группа и остальные: чтение+выполнение)
- base | Какие числа кодируют r, w, x? — r=4, w=2, x=1
- core | Что делает `umask 022` с правами нового файла по умолчанию (666)? — вычитает 022, получается 644 (rw-r--r--)
Почему дальше: права управляют доступом к файлам статически; когда нужно понять, что процесс
делает с файлами/сетью прямо сейчас, в динамике, нужны отдельные диагностические утилиты.
## 12. `strace`, `lsof`, `ss` как инструменты
- **`strace -p PID`** (или `strace command`) — трассирует системные вызовы процесса построчно:
какой syscall, с какими аргументами, что вернул. Способ увидеть, например, что процесс
зависает именно на `read()` конкретного файлового дескриптора, а не где-то в логике
приложения.
- **`lsof -p PID`** — список файлов (в широком unix-смысле — включая сокеты, pipe), открытых
процессом; тот же смысл, что и `/proc/<pid>/fd/`, но в удобном для чтения формате с
дополнительной информацией.
- **`ss -tan`** — состояние TCP-сокетов (замена устаревшему `netstat`): адреса, порты,
состояние соединения (`ESTABLISHED`, `TIME_WAIT` и так далее — см. состояния TCP).
**Факты для карточек**
- base | Что показывает `strace`? — системные вызовы процесса с аргументами и результатом, построчно
- core | Чем `lsof -p PID` и `/proc/<pid>/fd/` пересекаются по смыслу? — оба показывают список файловых дескрипторов, открытых процессом
Ссылки на задачи этого дня: `tasks/08_bash` — разбор access-лога (`TOTAL`/`TOP`/`5XX`) на
файле до миллиона строк; построчный bash-цикл не проходит по времени (раздел 7), нужен
`awk`/`sort` за один-два прохода.
<details>
<summary>Проверь себя</summary>
1. Скрипт с `set -e` содержит `if grep -q pattern file; then echo found; fi`, и `grep` не
находит `pattern` (возвращает 1). Остановится ли скрипт на этой строке?
<details><summary>Ответ</summary>Нет — команда внутри условия `if` не подчиняется `-e`,
даже если она вернула ненулевой код; это одно из исключений `-e`, скрипт продолжит
выполнение дальше.</details>
2. `grep pattern missing_file.txt | wc -l` при отсутствующем файле напечатает `0`, а `$?`
пайплайна без `pipefail` будет `0`. Как обнаружить, что реальная проблема — отсутствующий
файл, а не отсутствие совпадений?
<details><summary>Ответ</summary>Включить `set -o pipefail` — тогда код возврата пайплайна
станет кодом первой упавшей команды (`grep` на несуществующем файле), а не только `wc -l`;
либо проверить `${PIPESTATUS[0]}` явно после пайплайна.</details>
3. На файле в миллион строк нужно посчитать число запросов на каждый IP-адрес. Почему решение
через `while read -r line; do ...; done < access.log` с вызовом `awk`/`grep` внутри цикла
будет на порядки медленнее, чем `awk '{print $1}' access.log | sort | uniq -c`?
<details><summary>Ответ</summary>Цикл на bash с внешней командой внутри порождает (fork+exec)
новый процесс на каждую из миллиона строк; связка `awk | sort | uniq -c` — это фиксированное
малое число процессов (по одному на каждый элемент конвейера), каждый из которых делает
один линейный проход по всему потоку целиком.</details>
4. Процесс с `ulimit -c 0` неожиданно падает по SIGSEGV под нагрузкой, воспроизвести падение
по требованию не получается. Что нужно было сделать заранее, чтобы разобрать причину
постфактум?
<details><summary>Ответ</summary>Выставить `ulimit -c unlimited` до запуска процесса —
тогда при падении ядро сохранит core dump со снимком памяти на момент сбоя, и его можно
будет открыть позже (`gdb prog core`), не дожидаясь повторного воспроизведения
краша.</details>
</details>
## Материалы
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html
- man7.org, `getrlimit(2)` — https://man7.org/linux/man-pages/man2/getrlimit.2.html
- man7.org, `ptrace(2)` — https://man7.org/linux/man-pages/man2/ptrace.2.html
- man7.org, `chmod(2)` — https://man7.org/linux/man-pages/man2/chmod.2.html
- man7.org, `umask(2)` — https://man7.org/linux/man-pages/man2/umask.2.html
- man7.org, `signal(7)` — https://man7.org/linux/man-pages/man7/signal.7.html
-446
View File
@@ -1,446 +0,0 @@
# D5, часть 2. TCP глубоко (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Разбор идёт от
байтов заголовка к состояниям соединения, затем к механизмам надёжности и скорости, и в конце
к соседним протоколам (UDP, DNS, DHCP, NAT), которые решают то, что TCP не решает.
## 1. Заголовок TCP: 20 байт, поле за полем
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Offset| Rsvd|C E U A P R S F| Window |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```
Фиксированная часть — ровно **20 байт**, это минимум заголовка TCP-сегмента (тот же минимум
20 байт, что и у IP-заголовка, вложенного на уровень ниже). Поля:
- **Source Port / Destination Port** — по 16 бит каждый (порты 0–65535).
- **Sequence Number** — 32 бита, номер первого байта данных в этом сегменте.
- **Acknowledgment Number** — 32 бита, номер следующего ожидаемого байта от собеседника.
- **Data Offset** — 4 бита, длина заголовка в 32-битных словах (максимум 15×4 = 60 байт,
значит опции — максимум 60 − 20 = 40 байт).
- **Флаги** — по одному биту: **SYN** (установка соединения), **ACK** (подтверждение), **FIN**
(корректное закрытие направления), **RST** (аварийный сброс), **PSH** (передать данные
приложению немедленно, не буферизуя), **URG** (часть данных помечена срочной через Urgent
Pointer); плюс **ECE**/**CWR** — сигнализация перегрузки сети (ECN, RFC 3168), делит
6 «резервных» бит исходного RFC 793 на 4 действительно резервных и 2 флаговых.
- **Window** — 16 бит, объявляемый размер приёмного окна (flow control, раздел 6).
- **Checksum** — 16 бит, контрольная сумма заголовка+данных+псевдозаголовка IP.
- **Urgent Pointer** — 16 бит, действителен только при выставленном URG.
- **Options** — переменная длина, до 40 байт; сюда входит **MSS** (Maximum Segment Size) —
опция, которой стороны обмениваются только в SYN-сегментах, сообщая максимальный размер
сегмента, который готовы принять.
**Факты для карточек**
- base | Минимальный размер заголовка TCP? — 20 байт
- base | Сколько бит занимает порт в заголовке TCP? — 16 бит (диапазон 0–65535)
- core | Максимальный размер опций TCP-заголовка и почему именно столько? — 40 байт, потому что Data Offset (4 бита) кодирует длину заголовка в 32-битных словах максимум 15×4=60 байт, минус 20 байт фиксированной части
- core | В каких сегментах передаётся опция MSS? — только в сегментах с флагом SYN, при установке соединения
- deep | Какие два флага TCP появились позже исходного RFC 793 и для чего? — ECE и CWR (RFC 3168), сигнализация перегрузки сети (ECN) вместо/вместе с потерей пакета
Почему дальше: поля Sequence/ACK Number и флаг SYN используются в первую очередь при
установке соединения — разберём этот обмен по шагам.
## 2. Установка соединения: three-way handshake
1. Клиент → серверу: сегмент с флагом **SYN** и собственным начальным порядковым номером
(**ISN**, Initial Sequence Number).
2. Сервер → клиенту: сегмент с флагами **SYN+ACK** — подтверждает ISN клиента (Ack = ISN_клиента + 1)
и присылает собственный ISN.
3. Клиент → серверу: сегмент с флагом **ACK**, подтверждающим ISN сервера.
Три шага, а не два, нужны потому, что соединение TCP полнодуплексное — у каждого направления
свой независимый ISN, и каждая сторона должна не только сообщить свой ISN, но и получить
подтверждение, что собеседник его получил. Два шага (SYN → SYN-ACK) недостаточно: сервер не
может быть уверен, что его SYN-ACK дошёл до клиента, пока не получит финальный ACK. После
рукопожатия обе стороны знают начальные номера друг друга и могут независимо отслеживать
доставку и порядок байт в каждом направлении.
**Ловушки**
- Файрвол блокирует ответный ACK клиента → сервер зависает в состоянии `SYN_RECEIVED`
(полуоткрытое соединение) → видно как одинокий `SYN` без завершающего `ACK` в tcpdump и
запись в `ss -tan`.
**Факты для карточек**
- base | Сколько сегментов в three-way handshake? — 3 (SYN, SYN+ACK, ACK)
- core | Почему для установки TCP-соединения недостаточно двух сегментов? — соединение полнодуплексное, серверу нужно подтверждение, что его SYN-ACK (и его ISN) реально дошёл до клиента
- core | Что означает ISN и синхронизируется ли он в одном экземпляре на оба направления? — начальный порядковый номер; нет, у каждого направления свой собственный ISN
Почему дальше: раз соединение открывается тремя сегментами и переходит через промежуточные
состояния (`SYN_SENT`, `SYN_RECEIVED`), логично разобрать полный набор состояний TCP как
конечный автомат.
## 3. Состояние-машина TCP
- **CLOSED** — соединения нет.
- **LISTEN** — сервер ждёт входящих SYN.
- **SYN_SENT** — клиент отправил SYN, ждёт SYN-ACK.
- **SYN_RECEIVED** — сервер получил SYN, отправил SYN-ACK, ждёт финальный ACK.
- **ESTABLISHED** — соединение открыто, идёт обмен данными.
- **FIN_WAIT_1** — эта сторона отправила FIN, ждёт ACK на него.
- **FIN_WAIT_2** — FIN подтверждён, эта сторона ждёт FIN от собеседника.
- **CLOSE_WAIT** — получен FIN от собеседника, эта сторона ещё может досылать данные.
- **LAST_ACK** — эта сторона отправила свой FIN (после CLOSE_WAIT), ждёт последний ACK.
- **TIME_WAIT** — сторона, отправившая финальный ACK, ждёт 2×MSL перед освобождением сокета.
- **CLOSING** — оба конца отправили FIN почти одновременно, редкий путь одновременного закрытия.
Автомат асимметричен по конструкции: клиент и сервер проходят разные пути (`SYN_SENT` только
у инициатора, `SYN_RECEIVED`/`LISTEN` только у принимающей стороны), потому что роли в
рукопожатии разные. `ss -tan` показывает текущее состояние сокета в столбце State — это прямое
отражение позиции в этом автомате, а не абстракция.
**Факты для карточек**
- base | В каком состоянии сервер ждёт входящие подключения? — LISTEN
- core | Чем отличаются пути клиента и сервера в конечном автомате TCP? — клиент проходит SYN_SENT, сервер — LISTEN и SYN_RECEIVED; роли в рукопожатии асимметричны
- core | Какой командой в Linux видно текущее состояние TCP-сокета? — `ss -tan` (столбец State)
Почему дальше: часть состояний (`FIN_WAIT_*`, `CLOSE_WAIT`, `LAST_ACK`, `TIME_WAIT`) относится
к закрытию соединения — разберём эту последовательность отдельно, она сложнее открытия.
## 4. Закрытие в четыре шага и TIME_WAIT
1. Сторона A, завершившая передачу, шлёт **FIN**.
2. Сторона B подтверждает его **ACK** (A уходит в `FIN_WAIT_2`, B — в `CLOSE_WAIT`).
3. Когда сторона B тоже готова закрыться, она шлёт свой **FIN**.
4. Сторона A подтверждает финальным **ACK** (A уходит в `TIME_WAIT`, B — в `CLOSED` сразу
после получения этого ACK).
Четыре сегмента, а не два, — потому что закрытие каждого направления независимо
(**полузакрытие**, half-close): получение FIN от B означает только «B больше не пришлёт
данных», но A может продолжать досылать данные в обратном направлении, прежде чем закрыть
свою половину. FIN идёт в одну сторону за раз именно поэтому — это закрытие конкретного
направления потока, а не всего соединения разом.
Сторона, отправившая последний ACK (то есть первой инициировавшая закрытие), уходит в
**TIME_WAIT** и ждёт там **2×MSL** (Maximum Segment Life) перед освобождением сокета. Зачем:
эта сторона должна поймать задержавшиеся в сети дубликаты старых сегментов (если порт
освободить сразу и тут же переиспользовать для нового соединения, устаревший сегмент может
быть по ошибке принят как часть новой сессии) и быть готовой повторно отправить последний ACK,
если он потерялся и партнёр повторяет свой FIN. RFC 793 определяет номинальный MSL = 2 минуты
(отсюда 2×MSL = 4 минуты), но в Linux TIME_WAIT реализован как фиксированный таймаут **60
секунд** (константа `TCP_TIMEWAIT_LEN` в ядре) независимо от настраиваемого MSL. Куча
накопившихся `TIME_WAIT`-сокетов на активном сервере — видимая проблема (`ss -tan state
time-wait`), решается через `SO_REUSEADDR` или снижением частоты пересоздания соединений.
**Ловушки**
- Считать TIME_WAIT «багом» и убирать его целиком (агрессивные настройки reuse) → сервер
начинает принимать дубликаты старых сегментов как часть новых соединений → редкие, трудно
воспроизводимые повреждения данных на высоконагруженных коротких соединениях.
**Факты для карточек**
- base | Сколько сегментов нужно для полного закрытия TCP-соединения? — 4 (FIN, ACK, FIN, ACK)
- base | Формула длительности TIME_WAIT? — 2×MSL
- core | Почему закрытие TCP асимметрично («полузакрытие»), а не мгновенное закрытие по первому FIN? — соединение дуплексное, получение FIN означает только «собеседник больше не пришлёт данные», но сама сторона может ещё дописывать данные в обратном направлении
- deep | Сколько секунд реально длится TIME_WAIT в Linux и совпадает ли это с 2×MSL по RFC 793? — 60 секунд, фиксированная константа ядра; не совпадает с номинальными 4 минутами (2×2 мин) по RFC 793
Почему дальше: FIN — это вежливое закрытие. Разберём флаг, который сигнализирует не закрытие
по согласию, а ошибку или невозможность продолжить, — RST.
## 5. RST: аварийный сброс, а не закрытие
**RST** сигнализирует ошибку или невозможность продолжить соединение — в отличие от вежливого
FIN, тишины не будет. Типичный случай: попытка подключиться к закрытому порту получает в ответ
именно RST, а не молчание — так клиент сразу узнаёт, что порт не слушает, вместо ожидания
таймаута. Если приложение получает RST там, где ожидало нормальное закрытие через FIN, это
обычно означает, что сокет на другой стороне был закрыт грубо (например, процесс убит) —
классический симптом «connection reset by peer» в логах, подтверждается флагом RST в
последнем сегменте дампа tcpdump.
**Факты для карточек**
- base | Что получает клиент в ответ на попытку подключиться к закрытому порту? — RST
- core | Чем RST принципиально отличается от FIN по смыслу? — RST — аварийный немедленный сброс (ошибка/невозможность продолжить), FIN — согласованное закрытие направления
Почему дальше: SYN, FIN, RST — это управление соединением. Отдельный вопрос — как TCP поверх
этого управления гарантирует, что данные точно дойдут и в правильном порядке.
## 6. Надёжность: ACK, окно, flow control против congestion control
TCP строит надёжный упорядоченный байтовый поток поверх ненадёжной доставки IP:
- Каждый байт данных нумеруется порядковым номером (Sequence Number).
- Получатель подтверждает принятые данные **кумулятивным ACK** — Ack Number означает «я
получил всё непрерывно вплоть до этого байта», а не «я получил именно этот сегмент».
- Если подтверждение не пришло за таймаут (RTO, раздел 7) — отправитель ретранслирует.
- Сегменты, пришедшие не по порядку, получатель буферизует и переупорядочивает перед тем, как
отдать данные приложению.
- **Окно скольжения** (sliding window) — отправитель держит в полёте сразу много
неподтверждённых байт, не дожидаясь ACK на каждый сегмент отдельно; иначе пришлось бы ждать
полный RTT на каждый пакет.
TCP регулирует скорость передачи **двумя разными** механизмами, которые легко перепутать:
- **Flow control** (управление получателем, поле Window) — сколько байт получатель готов
принять прямо сейчас. Защищает получателя от переполнения его приёмного буфера: если
приложение читает данные медленно, окно сужается, вплоть до нуля («TCP Zero Window» в
tcpdump — передача полностью останавливается).
- **Congestion control** (управление сетью, `cwnd` — congestion window) — сколько отправитель
может слать, не перегружая сеть между узлами, о состоянии которой напрямую ничего не
известно. Работает по схеме **AIMD** (Additive Increase, Multiplicative Decrease):
- **Slow start** — `cwnd` стартует с малого значения (исторически 1 MSS, современный RFC
6928 разрешает стартовое окно до 10 MSS) и удваивается каждый RTT, пока не достигнет
порога `ssthresh` или не случится потеря.
- **Congestion avoidance** — после `ssthresh` рост становится линейным: `cwnd` растёт
примерно на 1 MSS за RTT (аддитивное увеличение).
- **Fast retransmit** — 3 повторных (дублирующих) ACK на один и тот же номер сегмента
трактуются как сигнал потери без ожидания полного таймаута RTO — ретрансмиссия начинается
немедленно.
- При потере: `ssthresh = cwnd / 2`, `cwnd` тоже уменьшается (мультипликативное уменьшение).
Потеря вдвое режет `cwnd`, а не сбрасывает в ноль, потому что потеря одного сегмента —
сигнал «сеть перегружена сейчас», а не «сеть недоступна»; резкое падение до минимума
впустую потратило бы уже проверенную пропускную способность. Полный сброс `cwnd` к
минимуму происходит отдельно — при таймауте RTO (более серьёзный сигнал, чем
дублирующие ACK).
Действующий по умолчанию в Linux алгоритм congestion control — **CUBIC** (с ядра 2.6.19),
более сложная функция роста `cwnd` от времени, чем классический AIMD Reno, но сама идея
«расти, пока не потеряли, резко сократиться при потере» сохраняется.
**Ловушки**
- Перепутать flow control (Window) с congestion control (`cwnd`) → неверный ответ на вопрос
«почему передача остановилась»: Zero Window — проблема медленного читателя на приёмнике,
просевший `cwnd` — проблема сети между узлами, у них разная диагностика и разное решение.
**Факты для карточек**
- base | Чем измеряется flow control в заголовке TCP? — полем Window
- core | Чем отличается flow control от congestion control по цели? — flow control защищает получателя от переполнения буфера, congestion control защищает сеть от перегрузки
- core | Во сколько раз падает cwnd при обнаруженной потере? — вдвое (ssthresh = cwnd/2)
- core | Что такое fast retransmit? — ретрансмиссия по 3 дублирующим ACK без ожидания полного таймаута RTO
- deep | Какой алгоритм congestion control используется в Linux по умолчанию? — CUBIC
Почему дальше: и ретрансмиссия по таймауту, и fast retransmit опираются на измеренное время
кругового пути — разберём, как считается сам таймаут RTO.
## 7. Таймеры ретрансмиссии: RTO и RTTVAR
Таймаут ретрансмиссии (RTO) не фиксированное число — он адаптируется под измеренный round-trip
time (RTT) конкретного соединения. По алгоритму Джекобсона/Карелса (RFC 6298):
- `SRTT` (сглаженный RTT) обновляется как экспоненциальное скользящее среднее с коэффициентом
`α = 1/8` от нового измерения.
- `RTTVAR` (вариация RTT) обновляется как скользящее среднее отклонения `|SRTT − RTT_sample|`
с коэффициентом `β = 1/4`.
- `RTO = SRTT + 4 × RTTVAR`.
Множитель 4 у `RTTVAR` — запас на случай, если сеть внезапно станет менее стабильной (большой
разброс задержек), чтобы не срабатывать ложно на обычный джиттер, но и не ждать избыточно
долго при реальной потере. Фиксированный RTO (без адаптации под RTT) не работает — RTT
локальной сети и RTT через несколько континентов различаются на порядки, единое число либо
слишком долго ждёт в быстрой сети, либо слишком рано ретранслирует в медленной.
**Факты для карточек**
- core | Формула RTO по Джекобсону/Карелсу? — SRTT + 4×RTTVAR
- deep | Какие коэффициенты сглаживания используются для SRTT и RTTVAR? — α=1/8 для SRTT, β=1/4 для RTTVAR
Почему дальше: RTO касается решения «когда переслать заново». Отдельный, более локальный
таймер решает более мелкий вопрос — отправлять ли данные прямо сейчас маленьким куском или
подождать и накопить.
## 8. Nagle и TCP_NODELAY
Алгоритм Нейгла по умолчанию задерживает отправку маленьких сегментов, пока не придёт ACK на
предыдущие неподтверждённые данные или не накопится достаточно данных для полного сегмента —
цель в том, чтобы не засорять сеть множеством мелких пакетов (несколько байт полезной нагрузки
на 20 байт TCP-заголовка — плохое соотношение). Флаг сокета **`TCP_NODELAY`** отключает эту
задержку — данные уходят сразу, как только приложение вызвало `write`/`send`.
Nagle плохо сочетается с **delayed ACK** (получатель тоже не спешит слать ACK, ждёт немного —
вдруг появятся данные для отправки в обратную сторону, тогда ACK можно приклеить к ним) —
классический сценарий из статьи Кларка 1982 года даёт задержки порядка сотен миллисекунд:
отправитель ждёт ACK, чтобы послать следующий маленький кусок, получатель ждёт данные, чтобы
не слать ACK отдельно — оба ждут друг друга. Для интерактивных протоколов с мелкими,
чувствительными к задержке сообщениями (например, построчный ввод в интерактивном сеансе)
`TCP_NODELAY` — стандартная практика.
**Факты для карточек**
- base | Что делает флаг TCP_NODELAY? — отключает алгоритм Нейгла, данные отправляются сразу без задержки на накопление
- core | Почему Nagle + delayed ACK вместе дают заметные задержки? — обе стороны ждут друг друга: отправитель — ACK перед следующей мелкой отправкой, получатель — данные для отправки в обратную сторону, чтобы не слать ACK отдельно
Почему дальше: TCP — не единственный транспортный протокол. Разберём его прямую
противоположность по философии — UDP, у которого почти ничего из разобранного выше просто нет.
## 9. UDP: 8 байт, без гарантий
Заголовок UDP — всего **8 байт**: порт источника, порт назначения, длина, контрольная сумма —
и всё; никаких порядковых номеров, подтверждений или окна, как у TCP. Нет установки
соединения, нет гарантии доставки, порядка или отсутствия дублей — датаграмма либо доходит,
либо нет, молча. Такая простота осознанная: приложениям, которым важнее низкая задержка и
минимум накладных расходов, чем гарантия каждого байта, не нужен вес состояния соединения и
ретрансмиссий.
Где уместен UDP:
- **DNS** — короткий запрос-ответ, переспросить целиком дешевле, чем ждать TCP-ретрансмиссию.
- **RTP** (голос/видео реального времени) — устаревший потерянный кадр всё равно бесполезен,
ждать его повторной доставки хуже, чем пропустить.
- **QUIC** — строит собственную надёжность и порядок поверх UDP на прикладном уровне, обходя
то, что классический TCP жёстко зашивает в ядро (head-of-line blocking на уровне сегментов).
**Факты для карточек**
- base | Размер заголовка UDP? — 8 байт
- base | Какие поля есть в заголовке UDP? — порт источника, порт назначения, длина, контрольная сумма
- core | Почему DNS исторически использует UDP, а не TCP? — типичный запрос-ответ короткий и умещается в одну датаграмму, устанавливать TCP-соединение ради одного маленького обмена избыточно медленно
Почему дальше: TCP и UDP используют один и тот же числовой идентификатор приложения на узле —
порт. Разберём, как устроено адресное пространство портов.
## 10. Порты: три диапазона
- **0–1023** — Well-known / System Ports: закреплены за стандартными службами (22 SSH, 53
DNS, 80 HTTP, 443 HTTPS) — клиент заранее знает, куда стучаться.
- **1024–49151** — Registered Ports: регистрируются IANA за конкретными приложениями, но без
такой строгой резервации, как первый диапазон.
- **49152–65535** — Dynamic/Private Ports (ephemeral): ОС временно выделяет их клиентским
сокетам на время соединения, не привязывая ни к какой конкретной службе.
Два процесса не могут одновременно слушать один и тот же порт на одном интерфейсе — второй
вызов `bind` завершится ошибкой «Address already in use»; частая причина, по которой сервис
не поднимается после аварийного перезапуска — старый процесс ещё держит порт (либо сокет
всё ещё в `TIME_WAIT`).
**Факты для карточек**
- base | Диапазон well-known портов? — 0–1023
- base | В каком диапазоне ОС обычно выделяет эфемерные порты клиентским соединениям? — 49152–65535
- core | Что вернёт второй `bind` на уже занятый порт? — ошибку «Address already in use»
Почему дальше: порт определяет приложение на узле, но не решает проблему нехватки IPv4-адресов
для самих узлов — этим занимается NAT.
## 11. NAT: таблица трансляций и почему ломается P2P
Маршрутизатор на исходящем пакете подменяет внутренний адрес источника на свой внешний и
запоминает в **таблице трансляций** соответствие «внутренний IP:порт — внешний IP:порт»; на
входящий ответный пакет ищет по этой таблице нужного внутреннего получателя и подменяет адрес
обратно. NAT возник как практическое решение нехватки публичных IPv4-адресов: 32-битного
пространства не хватает на все устройства мира, а один внешний адрес может обслуживать целую
локальную сеть, различая внутренние узлы по номеру порта (**PAT**, Port Address Translation).
Почему ломается P2P: узел за NAT не имеет собственного публичного адреса и не может принимать
входящие соединения без явной настройки (проброс портов, `-p` в Docker — тот же принцип) —
входящий пакет от нового, незнакомого узла просто не с чем сопоставить в таблице трансляций,
её запись создаётся только исходящим трафиком. Диагностируется тем, что снаружи виден только
внешний адрес роутера, а не внутренний узел.
**Факты для карточек**
- base | Что делает NAT с исходящим пакетом? — подменяет внутренний IP:порт источника на внешний IP:порт, запоминая соответствие в таблице трансляций
- core | Почему NAT ломает входящие P2P-соединения без проброса портов? — запись в таблице трансляций создаётся только исходящим трафиком, входящему от незнакомого узла не с чем сопоставиться
Почему дальше: NAT решает адресацию узлов, но перед этим узел нужно ещё найти по имени —
разберём DNS.
## 12. DNS: порт 53, рекурсия, типы записей, TTL
Клиент отправляет запрос резолверу через **UDP на порт 53** (типичный ответ умещается в один
пакет, соединение не нужно); резолвер либо отвечает из кэша, либо рекурсивно опрашивает
корневые, затем доменные (TLD), затем авторитативные серверы, пока не получит финальный ответ.
Если ответ не помещается в стандартный размер UDP-датаграммы (передача зоны, большие
DNSSEC-записи), DNS переключается на **TCP/53**, где нет ограничения на размер одного пакета.
Основные типы записей: **A** (имя → IPv4-адрес), **AAAA** (имя → IPv6-адрес), **MX** (почтовый
сервер домена, с приоритетом). Каждая запись несёт **TTL** — время в секундах, на которое
резолверам разрешено кэшировать запись без повторного запроса к авторитативному серверу;
меньший TTL — быстрее распространяются изменения (например, при смене IP сервиса), но больше
нагрузка на DNS-инфраструктуру повторными запросами.
**Факты для карточек**
- base | Порт DNS по умолчанию и протокол? — 53, UDP (переключение на TCP/53 для больших ответов)
- base | Что хранит запись типа A? — соответствие имени домена IPv4-адресу
- core | Зачем у DNS-записи есть TTL? — ограничивает время кэширования резолверами; компромисс между скоростью распространения изменений и нагрузкой повторными запросами
Почему дальше: DNS резолвит имя в адрес, но сам адрес узлу тоже нужно откуда-то получить при
подключении к сети — этим занимается DHCP.
## 13. DHCP: порты 67/68, схема DORA
DHCP-сервер слушает **UDP-порт 67**, клиент — **UDP-порт 68**. Обмен идёт по схеме **DORA**:
**D**iscover (узел широковещательно ищет сервер) → **O**ffer (сервер предлагает адрес) →
**R**equest (узел подтверждает выбор) → **A**ck (сервер закрепляет адрес на ограниченный срок
аренды — lease, который нужно периодически продлевать). Автоматизация нужна потому, что вручную
прописывать уникальный IP на каждое устройство в сети из сотен узлов неуправляемо и чревато
конфликтами адресов при ошибке администратора.
**Факты для карточек**
- base | Порты DHCP-сервера и клиента? — сервер 67/UDP, клиент 68/UDP
- core | Из каких четырёх шагов состоит DORA? — Discover, Offer, Request, Ack
Почему дальше: всё разобранное выше — это то, что реально видно в байтах на проводе; разберём
инструмент, которым эти байты читают напрямую.
## 14. tcpdump и Wireshark: как читать дамп
`tcpdump` захватывает пакеты на интерфейсе и печатает их построчно (или пишет в файл для
Wireshark). Базовые приёмы:
- Фильтр по порту: `tcpdump tcp port 80` — только TCP-трафик на порту 80 в любую сторону.
- Сохранение в файл для последующего анализа в Wireshark: `tcpdump -w capture.pcap`.
- Чтение handshake в выводе: строка с флагом `[S]` (SYN) от клиента, `[S.]` (SYN-ACK) от
сервера, `[.]` (ACK) от клиента — три строки подряд с растущими seq/ack номерами это и есть
three-way handshake, ровно как в разделе 2.
- Закрытие видно как пара `[F.]` (FIN+ACK) с обеих сторон, каждый подтверждён отдельным `[.]`.
- Одинокий `[S]` без ответа — недоступный порт или заблокированный файрволом ACK (раздел 2,
ловушка `SYN_RECEIVED`).
- Флаг `[R]` — RST, аварийный сброс (раздел 5).
**Факты для карточек**
- base | Команда для захвата TCP-трафика на 80 порту? — `tcpdump tcp port 80`
- base | Флаг tcpdump для сохранения дампа в файл? — `-w`
- core | Как в выводе tcpdump выглядит three-way handshake? — три строки подряд: `[S]` от клиента, `[S.]` от сервера, `[.]` от клиента
Ссылки на задачи этого дня: `tasks/04_ipv4` — разбор IPv4-заголовка и контрольной суммы
(нижний уровень относительно TCP, инкапсулирующий его); задачи чтения дампов — применение
раздела 14 на реальных `.pcap`.
<details>
<summary>Проверь себя</summary>
1. В tcpdump видно: `[S]` от клиента, `[S.]` от сервера, дальше тишина — ACK от клиента не
приходит. В каком состоянии завис сервер и почему?
<details><summary>Ответ</summary>`SYN_RECEIVED` — сервер получил SYN, отправил SYN-ACK, но
финальный ACK не дошёл (например, заблокирован файрволом), поэтому рукопожатие не
завершилось и сервер ждёт третий сегмент.</details>
2. Почему TIME_WAIT длится именно 2×MSL, а не произвольное короткое время вроде 1 секунды?
<details><summary>Ответ</summary>Сторона, закрывшая соединение последней, должна успеть
поймать задержавшиеся в сети дубликаты старых сегментов (которым отводится время жизни
MSL на путь туда и обратно — отсюда удвоение) и быть готовой повторно отправить последний
ACK, если его потеря заставит партнёра повторить FIN. Слишком короткий таймаут рискует
освободить порт раньше, чем дубликат добежит и будет ошибочно принят новым
соединением.</details>
3. Клиент шлёт данные маленькими кусками через `write()` без `TCP_NODELAY`, сервер использует
delayed ACK. Почему передача может ощутимо тормозить, хотя пропускной способности канала
достаточно?
<details><summary>Ответ</summary>Алгоритм Нейгла на клиенте задерживает отправку
следующего маленького куска, пока не придёт ACK на предыдущий; сервер с delayed ACK не
спешит слать этот ACK отдельно, ожидая данных для отправки в обратную сторону — обе
стороны ждут друг друга, и задержка растёт не от нехватки полосы, а от этого
взаимного ожидания.</details>
4. При передаче по сети с потерями каждые несколько RTT срабатывает fast retransmit. Что
происходит с `cwnd` при этом и почему не сбрасывается до минимального значения, как при
таймауте RTO?
<details><summary>Ответ</summary>`ssthresh` и `cwnd` уменьшаются вдвое (`cwnd/2`), а не до
минимума — 3 дублирующих ACK означают, что сеть в целом жива и часть сегментов всё же
доходит, это более мягкий сигнал перегрузки, чем полное отсутствие ответа при таймауте
RTO, поэтому реакция мягче.</details>
</details>
## Материалы
- RFC 793, Transmission Control Protocol — https://datatracker.ietf.org/doc/html/rfc793
- RFC 6298, Computing TCP's Retransmission Timer — https://datatracker.ietf.org/doc/html/rfc6298
- RFC 3168, The Addition of Explicit Congestion Notification (ECN) to IP — https://datatracker.ietf.org/doc/html/rfc3168
- RFC 768, User Datagram Protocol (UDP) — https://datatracker.ietf.org/doc/html/rfc768
- RFC 1035, Domain Names — Implementation and Specification (DNS) — https://datatracker.ietf.org/doc/html/rfc1035
- RFC 2131, Dynamic Host Configuration Protocol (DHCP) — https://datatracker.ietf.org/doc/html/rfc2131
- man7.org, `tcp(7)` — https://man7.org/linux/man-pages/man7/tcp.7.html
-325
View File
@@ -1,325 +0,0 @@
# D6, часть 2. Docker минимум под сборку и тесты (урок)
Это не про эксплуатацию продакшн-кластеров, а про Docker как инструмент воспроизводимой
сборки и тестового окружения для C++: одна и та же среда сборки — компилятор, тулчейн,
библиотеки — у каждого разработчика и на CI, вместо «у меня собирается, у тебя нет».
## 1. Контейнер — это процесс, а не виртуальная машина
Контейнер — обычный процесс хостовой ОС, изолированный двумя независимыми механизмами ядра
Linux:
- **namespaces** — отдельное пространство имён для каждого вида ресурса: **pid** (свой список
процессов, процесс внутри контейнера видит себя как PID 1), **mnt** (своя точка монтирования
файловой системы), **net** (свой сетевой стек — интерфейсы, адреса, таблица маршрутизации),
**uts** (свой hostname), **ipc** (изолированные механизмы межпроцессного взаимодействия —
очереди сообщений, семафоры), **user** (отображение UID/GID контейнера на другие UID/GID
хоста).
- **cgroups** (control groups) — ограничение и учёт потребления ресурсов: сколько CPU, памяти,
дискового ввода-вывода разрешено процессу и его потомкам.
Ключевое отличие от виртуальной машины: контейнеру не нужно собственное ядро — он использует
ядро хоста напрямую, тогда как VM через гипервизор эмулирует виртуальное железо, поверх
которого грузится отдельное ядро гостевой ОС. Отсюда напрямую следуют два практических
эффекта: старт контейнера — это по сути `fork`/`exec` с применёнными namespaces, то есть
доли секунды и накладные расходы, близкие к нулю (VM грузит собственное ядро — секунды и
проценты CPU/памяти на гипервизор); и системные вызовы контейнера выполняет то же самое
ядро хоста без эмуляции, то есть без потери производительности на виртуализацию — но и без
изоляции на уровне ядра: уязвимость ядра или неверно настроенные capabilities способны дать
выход из контейнера на хост, чего с отдельным ядром VM добиться сложнее.
**Факты для карточек**
- base | Из каких двух механизмов ядра Linux состоит изоляция контейнера? — namespaces (изоляция видимости ресурсов) и cgroups (ограничение потребления ресурсов)
- base | Перечисли namespaces, разбираемые в этом разделе? — pid, mnt, net, uts, ipc, user
- core | Почему контейнер стартует за доли секунды, а VM — за секунды? — контейнер не грузит собственное ядро, старт — это fork/exec с применёнными namespaces; VM грузит через гипервизор целое гостевое ядро
- core | Почему изоляция контейнера слабее, чем у VM, на уровне безопасности? — контейнер и хост используют одно и то же ядро; уязвимость ядра или неверные capabilities могут дать выход на хост, а у VM с отдельным ядром такой прямой путь отсутствует
Почему дальше: контейнер запускается из образа — разберём, из чего состоит сам образ и почему
порядок команд при его сборке влияет на скорость пересборки.
## 2. Слои образа и кэш сборки
Образ Docker — неизменяемый набор **слоёв**: каждая инструкция Dockerfile (`RUN`, `COPY`)
порождает отдельный слой — diff файловой системы относительно предыдущего слоя, слои
read-only и кэшируются по хешу содержимого. При сборке Docker идёт по инструкциям сверху вниз
и для каждой проверяет кэш: если инструкция и её входные данные не изменились с прошлой
сборки (для `COPY` — содержимое копируемых файлов), слой берётся из кэша без выполнения; как
только один слой не совпал с кэшем, **все последующие слои пересобираются заново**, даже если
сами по себе не менялись — кэш линеен и рвётся в первой же точке расхождения.
Отсюда практическое правило порядка инструкций: сначала копировать и устанавливать
зависимости (меняются редко), и только потом копировать исходный код (меняется на каждом
коммите). Если сделать наоборот — любая правка одной строки кода инвалидирует кэш
зависимостей, и сборка каждый раз заново качает пакеты из сети, что на CI ощутимо по времени.
**Ловушки**
- `COPY . .` перед установкой зависимостей → любое изменение кода инвалидирует и слой с
зависимостями → пересборка образа качает все пакеты заново на каждый коммит, видно по
резко выросшему времени сборки в CI.
**Факты для карточек**
- base | Что порождает каждая инструкция `RUN`/`COPY` в Dockerfile? — отдельный слой (diff файловой системы)
- core | Что происходит с последующими слоями, если один слой не совпал с кэшем? — все последующие слои пересобираются заново, даже если сами по себе не менялись
- core | Какой порядок инструкций Dockerfile правильный для скорости пересборки? — сначала зависимости (меняются редко), потом исходный код (меняется часто)
Почему дальше: применим это правило к конкретному Dockerfile для сборки C++-проекта.
## 3. Dockerfile для C++ сборки
```dockerfile
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY CMakeLists.txt .
COPY src/ src/
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
```
Установку пакетов делают **одной инструкцией `RUN`** (`apt-get update && apt-get install ...
&& rm -rf /var/lib/apt/lists/*`), а не отдельными командами — потому что слой фиксирует
файловую систему на момент завершения именно этой инструкции. Если `rm -rf
/var/lib/apt/lists/*` вынести в отдельный `RUN` после установки, скачанный кеш списков
пакетов уже необратимо запечён в предыдущем слое и продолжает занимать место в итоговом
образе — слой нельзя «похудеть» задним числом последующим слоем, можно только скрыть файл в
новом слое поверх старого.
`WORKDIR` задаёт и фиксирует рабочую директорию для всех последующих инструкций; `COPY`
переносит файлы с хоста в образ отдельным слоем — именно поэтому в примере сначала копируется
`CMakeLists.txt`, а исходники — вторым `COPY`: правка кода не трогает слой с конфигурацией
сборки. Каталог `build/`, уже собранный на хосте разработчика, копировать в образ **нельзя**:
он собран под окружение хоста (другая версия компилятора, другие пути, возможно другая
архитектура) и не гарантированно совместим с окружением внутри контейнера — сборка должна
проходить внутри самого образа, чтобы результат был воспроизводим одинаково у всех.
**Ловушки**
- `apt-get install` и `rm -rf /var/lib/apt/lists/*` в разных `RUN` → кеш списков пакетов
необратимо остаётся в промежуточном слое → итоговый образ ощутимо больше, чем при
объединении в одну инструкцию, видно по `docker history`.
- Копирование готового `build/` с хоста в образ вместо сборки внутри контейнера → бинарник
собран под окружение хоста, а не образа → несовместимость версий библиотек или архитектуры,
«works on my machine» переносится прямо в контейнер.
**Факты для карточек**
- base | Почему `apt-get install` и `rm -rf /var/lib/apt/lists/*` объединяют в одну инструкцию `RUN`? — слой фиксирует файловую систему на момент завершения инструкции; в отдельном RUN кеш пакетов уже необратимо запечён в предыдущем слое
- core | Почему нельзя копировать в образ каталог `build/`, собранный на хосте? — он собран под окружение хоста (версия компилятора, пути, архитектура), не гарантированно совместим с окружением контейнера; сборка должна идти внутри образа
Почему дальше: если собирать всё в одном образе, финальный образ несёт в себе весь тулчейн
сборки (компилятор, cmake, заголовки) — для рантайма это лишний вес и лишняя поверхность
атаки. Разберём, как это разделить.
## 4. Multi-stage build
```dockerfile
FROM debian:bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends build-essential cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j
FROM debian:bookworm-slim
COPY --from=builder /src/build/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
```
Первый `FROM ... AS builder` — образ со всем тулчейном сборки; второй `FROM` начинает
**новый, независимый** образ, в который командой `COPY --from=builder` переносится только
готовый результат — скомпилированный бинарник. Слои со всем тулчейном сборки в финальный
образ не попадают вовсе. Итоговый рантайм-образ для этого выбирают минимальным:
`debian:bookworm-slim` (урезанный Debian с базовыми библиотеками) — если бинарник собран
динамически и нужна libc; либо **`scratch`** (полностью пустой образ, без единого файла) —
подходит только для полностью статически слинкованного бинарника, которому вообще не нужна
никакая библиотека окружения.
**Факты для карточек**
- base | Что переносит `COPY --from=builder` во второй `FROM`? — только указанные готовые файлы (например бинарник) из первого этапа, без слоёв тулчейна
- core | Когда для финального этапа multi-stage можно использовать `FROM scratch`? — когда бинарник собран полностью статически и не нуждается ни в одной библиотеке окружения
- core | Чем `debian:bookworm-slim` в качестве финального образа лучше полного `debian:bookworm` для рантайма? — не несёт тулчейн сборки и лишние пакеты, меньше размер и меньше поверхность атаки
Почему дальше: образ — это шаблон файловой системы, но данные, которые должны пережить
пересоздание контейнера (например, база данных теста), нельзя держать в самом
read-write-слое контейнера — для этого есть volume и bind mount.
## 5. Volume и bind mount
Оба механизма монтируют в контейнер хранилище, которое живёт отдельно от read-write-слоя
контейнера (тот слой стирается командой `docker rm`).
- **Volume** — область, которой управляет сам Docker, физически хранится в его служебной
директории на хосте, не привязана к конкретному пути на диске разработчика — переносима
между машинами, подходит для персистентных данных вроде базы данных теста.
- **Bind mount** — прямое монтирование конкретного каталога хоста внутрь контейнера по
заданному пути: контейнер и хост видят один и тот же каталог одновременно и правки на хосте
сразу видны внутри без пересборки образа — используется при разработке, когда исходники
редактируются на хосте, а собираются/тестируются внутри контейнера.
**Ловушки**
- Держать данные в обычном read-write-слое контейнера без volume → `docker rm` или
пересоздание контейнера безвозвратно стирает данные → «тесты вчера прошли, база сегодня
пустая» без единой ошибки при удалении.
**Факты для карточек**
- base | Кто физически управляет расположением volume на диске? — сам Docker, служебная директория, не путь, выбранный вручную
- core | Почему bind mount, а не volume, используют для разработки с редактированием кода на хосте? — bind mount даёт прямой одновременный доступ к каталогу хоста, правки на хосте сразу видны в контейнере без пересборки образа
Почему дальше: то, как контейнер запускается и как к нему обращаются после старта, задаётся
флагами `docker run` и парой соседних команд — разберём их.
## 6. `docker run`, `docker exec`, `docker logs`
Частые флаги `docker run`:
- **`--rm`** — автоматически удалить контейнер (его read-write-слой) сразу после завершения
процесса; без него остановленные контейнеры копятся и занимают место на диске.
- **`-v host_path:container_path`** (или `-v volume_name:container_path`) — bind mount или
named volume (раздел 5).
- **`-p host_port:container_port`** — пробросить порт с хоста на порт внутри контейнера
(контейнер по умолчанию в изолированной bridge-сети не виден снаружи без явного проброса).
- **`--network=host`** — контейнер использует сетевой стек хоста напрямую, без собственного
сетевого namespace и без проброса портов, но и без сетевой изоляции.
**`docker exec <container> <cmd>`** запускает дополнительную команду внутри уже работающего
контейнера — типичный случай: открыть интерактивный shell (`docker exec -it <container>
bash`) для отладки живого процесса без его остановки. **`docker logs <container>`** показывает
вывод stdout/stderr, который написал процесс с PID 1 внутри контейнера, — то, что видно в
терминале при `docker run` без `-d`, доступно так же и после отсоединения.
**Факты для карточек**
- base | Что делает флаг `--rm` у `docker run`? — автоматически удаляет контейнер и его read-write-слой сразу после завершения процесса
- base | Какая команда даёт интерактивный shell в уже запущенном контейнере? — `docker exec -it <container> bash`
- core | Чем `--network=host` отличается от обычного режима с `-p`? — контейнер напрямую использует сетевой стек хоста без собственного network namespace и без проброса портов, но и без сетевой изоляции
Почему дальше: реальная задача редко исчерпывается одним контейнером — обычно нужен ещё
тестовый брокер, база данных или несколько сервисов сразу; управлять их сетью и порядком
запуска вручную неудобно — для этого docker compose.
## 7. `docker compose`
`docker-compose.yml` — декларативное описание нескольких связанных контейнеров как одного
приложения: сервисы с указанием образа или пути к Dockerfile, портов, volume, переменных
окружения и зависимостей между сервисами. `docker compose up -d` поднимает все сервисы разом
и автоматически создаёт для них общую сеть, где сервисы видят друг друга по имени из YAML как
по hostname — не нужно вручную создавать сеть и связывать контейнеры по IP-адресам, которые
могут меняться при пересоздании.
`docker compose down` без флага `-v` останавливает и удаляет контейнеры, но **сохраняет
именованные volume**; `-v` удаляет и их. Типичная ошибка — предположить, что обычный `down`
стирает данные тестовой базы (нет, если она в именованном volume), либо наоборот случайно
потерять данные, добавив `-v` не задумавшись.
**Факты для карточек**
- base | Какая команда поднимает все сервисы из `docker-compose.yml` разом? — `docker compose up -d`
- core | Что удаляет `docker compose down -v`, чего не удаляет `docker compose down` без флага? — именованные volume и данные в них
Почему дальше: сервисы в compose ссылаются на образы по имени и тегу — разберём, что означает
тег и почему один из них считается плохой практикой.
## 8. Реестры и теги
Образ идентифицируется именем и тегом (`myapp:1.4.0`); реестр (например Docker Hub или
приватный registry) хранит образы, `docker pull`/`docker push` их скачивают/загружают.
Тег **`:latest`** — не «самая новая версия» в смысле гарантии, а обычный мутируемый тег,
который каждый `docker push` без явного тега перезаписывает: `myapp:latest`, скачанный сегодня
и через месяц, может указывать на совершенно разное содержимое образа. Это ломает
воспроизводимость сборки и тестов — CI, зафиксировавший `myapp:latest`, через месяц может
неожиданно тянуть другой код без единой изменённой строчки в собственном конфиге. Практика —
фиксировать конкретную версию тега или дайджест образа (`myapp@sha256:...`), который
неизменяем по определению хеша.
**Факты для карточек**
- base | Что физически происходит с тегом `:latest` при каждом `docker push` без явного тега? — он перезаписывается на новый образ, становится мутируемым указателем, а не фиксированной версией
- core | Почему фиксация `myapp@sha256:...` вместо тега `:latest` важна для воспроизводимости CI? — дайджест неизменяем по определению хеша, а тег `:latest` может незаметно указывать на другое содержимое образа в разное время
Почему дальше: помимо версии образа, есть ещё вопрос — от чьего имени процесс исполняется
внутри контейнера, и почему это не всё равно.
## 9. Права: `--user` и почему root в контейнере опасен
По умолчанию процесс внутри контейнера, если не указано иное, запускается от **root**
(UID 0) — того же UID 0, что и root на хосте, если не настроен user namespace с ремаппингом
UID. Раз у контейнера нет отдельного ядра (раздел 1), эскалация из контейнерного root до
root на хосте — через уязвимость ядра, неверно выданные capabilities или смонтированный внутрь
`docker.sock` — гораздо ближе и реальнее, чем аналогичный побег из виртуальной машины с
отдельным ядром.
Флаг **`--user uid:gid`** запускает процесс контейнера с непривилегированным UID/GID вместо
root — снижает ущерб от компрометации процесса внутри контейнера: даже получив контроль над
процессом, атакующий не имеет привилегий root ни внутри контейнера, ни тем более на хосте.
**Факты для карточек**
- base | От какого пользователя запускается процесс в контейнере по умолчанию, если не указано иное? — root (UID 0)
- core | Почему root в контейнере опаснее, чем root в отдельной VM? — контейнер не имеет отдельного ядра; эскалация до root хоста возможна через уязвимость общего ядра, неверные capabilities или смонтированный docker.sock, чего с отдельным ядром VM добиться сложнее
- core | Что делает флаг `--user uid:gid`? — запускает процесс контейнера от непривилегированного UID/GID вместо root
Почему дальше: помимо прав, отдельный вопрос — сколько ресурсов хоста контейнеру вообще
разрешено потреблять, чтобы один тестовый контейнер не положил всю машину.
## 10. Ограничения ресурсов: `--memory`, `--cpus`
- **`--memory=512m`** — жёсткий лимит памяти через cgroups; при превышении лимита OOM-killer
убивает процесс контейнера сигналом `SIGKILL` — тот же механизм и тот же код завершения
**137 = 128 + 9** (128 — соглашение shell/wait о сигнальном завершении, 9 — номер SIGKILL),
что и при обычном `kill -9` вне контейнера, но здесь его вызывает превышение
cgroup-лимита, а не человек.
- **`--cpus=1.5`** — ограничение через CPU-контроллер cgroups: контейнер не может использовать
больше эквивалента 1.5 ядра процессорного времени, даже если на хосте простаивают
дополнительные ядра.
Ограничения задаются той же cgroups-инфраструктурой, что обеспечивает саму изоляцию
контейнера (раздел 1) — это не отдельный механизм Docker, а применение уже существующего
механизма ядра с конкретными числами.
**Факты для карточек**
- base | Каким сигналом и с каким кодом завершения убивает процесс превышение лимита `--memory`? — SIGKILL, код завершения 137 (128 + 9)
- core | Что ограничивает `--cpus=1.5` технически? — квоту CPU-контроллера cgroups, эквивалент 1.5 ядра процессорного времени вне зависимости от простаивающих ядер хоста
Ссылки на задачи этого набора: `tasks/06_threads` — типичный кандидат на тестирование внутри
контейнера с ограничением `--cpus`, чтобы гонки данных проявлялись стабильнее под реальным
давлением на планировщик; `tasks/09_gdb` — отладка бинарника внутри контейнера требует флага
`--cap-add=SYS_PTRACE` (по умолчанию Docker урезает capabilities, и `ptrace`, на котором
работает `gdb`/`strace`, без этого флага запрещён).
<details>
<summary>Проверь себя</summary>
1. В Dockerfile сначала `COPY . .` копирует весь исходный код, а уже потом идёт установка
зависимостей через `apt-get`. Что произойдёт со временем пересборки при правке одной
строки кода и почему?
<details><summary>Ответ</summary>Слой с `COPY . .` изменится при любой правке кода, и по
правилу линейного кэша все последующие слои — включая установку зависимостей —
пересоберутся заново, заново скачивая пакеты из сети. Правильный порядок — сначала
зависимости (меняются редко), потом код.</details>
2. Финальный образ после multi-stage build весит существенно меньше, чем промежуточный
`builder`-образ, хотя бинарник тот же самый. За счёт чего?
<details><summary>Ответ</summary>Второй `FROM` начинает независимый образ, в который
`COPY --from=builder` переносит только сам готовый бинарник — весь тулчейн сборки
(компилятор, cmake, заголовки и их слои) остаётся только в промежуточном `builder`-образе
и в финальный не попадает.</details>
3. Тестовый контейнер убит с кодом завершения 137 после превышения лимита `--memory=256m`.
Что произошло механически и с чем ещё встречается тот же код завершения вне контейнеров?
<details><summary>Ответ</summary>cgroups-контроллер памяти зафиксировал превышение лимита
и OOM-killer прислал процессу `SIGKILL` (сигнал 9); shell/wait-конвенция кодирует
завершение по сигналу как 128 + номер сигнала = 137. Тот же код 137 виден при обычном
`kill -9` процесса вне всякого контейнера — механизм кодирования тот же.</details>
4. Почему root внутри контейнера — больший риск, чем root внутри виртуальной машины,
изолирующей тот же процесс?
<details><summary>Ответ</summary>Контейнер не имеет собственного ядра — он использует ядро
хоста напрямую, поэтому уязвимость в этом общем ядре, неверно выданные capabilities или
смонтированный `docker.sock` могут дать выход из контейнерного root прямо в root хоста.
VM с отдельным гостевым ядром не даёт такого прямого пути.</details>
</details>
## Материалы
- docs.docker.com, Dockerfile reference — https://docs.docker.com/engine/reference/builder/
- docs.docker.com, Multi-stage builds — https://docs.docker.com/build/building/multi-stage/
- docs.docker.com, `docker run` CLI reference — https://docs.docker.com/engine/reference/commandline/run/
- docs.docker.com, Volumes — https://docs.docker.com/storage/volumes/
- docs.docker.com, Compose file reference — https://docs.docker.com/compose/compose-file/
- man7.org, `namespaces(7)` — https://man7.org/linux/man-pages/man7/namespaces.7.html
- man7.org, `cgroups(7)` — https://man7.org/linux/man-pages/man7/cgroups.7.html
-380
View File
@@ -1,380 +0,0 @@
# D6, часть 1. Ядро и embedded обзорно (урок)
Уровень этого урока — «уверенно отвечать словами на собеседовании», а не «написать драйвер
с нуля». Идём от границы user space/kernel space к модулю, от модуля к драйверу символьного
устройства, от драйвера к железу (device tree, cross-compile, загрузка платы). Опорные
источники для самостоятельного углубления: docs.kernel.org, The Linux Kernel Module
Programming Guide (LKMPG), `man 2`/`man 3`/`man 7` (системные вызовы, библиотечные функции,
конвенции ядра).
## 1. User space vs kernel space и системный вызов как переход границы
Процессор x86 поддерживает уровни привилегий (кольца защиты): **кольцо 0** — режим ядра,
полный доступ к железу и памяти; **кольцо 3** — режим пользователя, обычные программы, без
прямого доступа к физической памяти чужих процессов или портам ввода-вывода. Ядро работает в
кольце 0, все обычные процессы — в кольце 3. Разделение существует ради защиты и стабильности:
если бы любая программа могла напрямую писать в память другого процесса или в регистры
диска, ошибка или злой умысел в одной программе обрушивали бы всю систему.
Чтобы попросить ядро что-то сделать (открыть файл, выделить память, создать процесс),
пользовательская программа не может просто вызвать функцию ядра — она делает **системный
вызов**: специальную инструкцию процессора, которая переключает CPU в привилегированный режим
и передаёт управление фиксированному обработчику в ядре, а после выполнения запроса управление
возвращается программе обратно в непривилегированном режиме. На x86-64 эту инструкцию зовут
**`syscall`**, на ARM — **`svc`** (supervisor call). Оба случая — не обычный вызов функции
(`call`), а специальная инструкция именно потому, что она обязана сменить уровень привилегий
процессора, а не просто передать управление по адресу.
**Факты для карточек**
- base | В каком кольце защиты x86 работает ядро Linux? — в кольце 0 (пользовательские процессы — в кольце 3)
- base | Какая инструкция делает системный вызов на x86-64? — `syscall` (на ARM — `svc`)
- core | Почему системный вызов — отдельная инструкция процессора, а не обычный `call`? — обычный `call` не меняет уровень привилегий CPU, а переход в кольцо 0 требует именно смены режима процессора
Почему дальше: код, работающий в кольце 0, можно добавлять в ядро двумя разными способами —
разберём, чем модуль ядра отличается от кода, встроенного в ядро на этапе сборки.
## 2. Модуль ядра vs встроенный в ядро
Функциональность можно либо **встроить в ядро** на этапе сборки (код компилируется прямо в
образ ядра, доступен сразу при загрузке, но требует пересборки и перезагрузки при любом
изменении), либо оформить как **загружаемый модуль ядра** (`.ko`-файл, подключается и
отключается в работающей системе без перезагрузки). Встроенным делают то, что нужно с самого
первого момента загрузки (например, драйвер корневой файловой системы); модулем — то, что
может понадобиться позже или не понадобиться вовсе (драйвер конкретного периферийного
устройства), чтобы не раздувать образ ядра и не грузить лишний код.
Инструменты управления модулями:
- **`insmod <path.ko>`** — загружает модуль по прямому пути к файлу, без разрешения
зависимостей от других модулей.
- **`rmmod <name>`** — выгружает модуль по имени (если счётчик использования равен нулю).
- **`modprobe <name>`** — загружает модуль по имени, сам находит файл в
`/lib/modules/$(uname -r)/` и подгружает зависимости по карте `modules.dep` (строится
утилитой `depmod`) — на практике используется чаще `insmod`, именно из-за автоматических
зависимостей.
- **`lsmod`** — список загруженных сейчас модулей (по сути читает `/proc/modules`), с
размером и счётчиком использования.
- **`modinfo <name|path>`** — метаданные модуля (лицензия, описание, параметры, зависимости)
без его загрузки в ядро.
**Факты для карточек**
- base | Чем `insmod` отличается от `modprobe`? — `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости
- base | Какая команда показывает метаданные модуля, не загружая его? — `modinfo`
- core | Откуда `modprobe` берёт карту зависимостей модулей? — из файла `modules.dep`, который строит утилита `depmod`
Почему дальше: раз модуль — это отдельно загружаемый код, у него должна быть точка входа и
точка выхода из ядра — разберём структуру простейшего модуля.
## 3. Структура простейшего модуля
```c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "hello: module loaded\n");
return 0; // 0 — успех; ненулевой код отменяет загрузку модуля
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "hello: module unloaded\n");
}
module_init(hello_init); // вызывается ядром при insmod/modprobe
module_exit(hello_exit); // вызывается ядром при rmmod
MODULE_LICENSE("GPL");
```
`module_init`/`module_exit` — не обычные вызовы функций, а макросы, которые регистрируют
функции как точки входа/выхода: сам код внутри них ядро вызывает автоматически в момент
`insmod`/`rmmod`, программист их напрямую не зовёт. `MODULE_LICENSE("GPL")` обязателен: без
него или с иной лицензией ядро помечает себя как **tainted** (загрязнённое) и закрывает
модулю доступ к символам, экспортированным только для GPL-кода (`EXPORT_SYMBOL_GPL`).
`printk` — это `printf` уровня ядра, пишет не в консоль процесса, а в кольцевой буфер ядра.
Первым аргументом обычно идёт уровень важности — восемь уровней от `KERN_EMERG` (0,
критично) до `KERN_DEBUG` (7, отладочная информация); сообщения с уровнем выше порога
консоли попадают только в буфер, но не печатаются на экран сразу. Прочитать буфер целиком —
команда **`dmesg`**.
**Факты для карточек**
- base | Какой макрос ядра — аналог `printf`? — `printk`
- base | Команда для чтения буфера сообщений ядра? — `dmesg`
- core | Сколько уровней важности у `printk` и какие крайние? — 8 уровней, от `KERN_EMERG` (0) до `KERN_DEBUG` (7)
- core | Что произойдёт с модулем без `MODULE_LICENSE("GPL")`? — ядро станет tainted и закроет модулю доступ к символам `EXPORT_SYMBOL_GPL`
- core | Кто вызывает функции, зарегистрированные `module_init`/`module_exit`? — ядро автоматически, при `insmod`/`modprobe` и `rmmod` соответственно, а не сам программист
Почему дальше: модулю часто нужно принимать настройки снаружи ещё до его собственной логики —
разберём, как модуль объявляет параметры запуска.
## 4. Параметры модуля: `module_param`
```c
static int count = 1;
module_param(count, int, S_IRUGO); // третий аргумент — права доступа в sysfs
```
`module_param(имя, тип, права)` (заголовок `<linux/moduleparam.h>`) делает переменную
настраиваемой при загрузке модуля — значение можно передать прямо в `insmod modname.ko
count=5`. Третий аргумент — режим доступа файла в sysfs: `0` означает, что параметр доступен
только при загрузке и не публикуется отдельным файлом, ненулевое значение (например
`S_IRUGO` — чтение всем) создаёт файл `/sys/module/<имя_модуля>/parameters/<count>`, из
которого параметр можно прочитать (а при подходящих правах — и переписать) уже после загрузки.
**Факты для карточек**
- base | Как передать параметр модулю при загрузке? — `insmod modname.ko имя_параметра=значение`
- core | Что означает третий аргумент `module_param`, если он ненулевой? — параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа
Почему дальше: файл параметра в sysfs — частный случай общего механизма, которым ядро вообще
разговаривает с пользовательским пространством через файловую систему, — `/proc` и `/sys`.
## 5. `/proc` и `/sys` как интерфейс к ядру
**`/proc`** (procfs) — виртуальная файловая система, изначально созданная для информации о
процессах (`/proc/<pid>/...`), позже расширенная общей информацией о ядре (`/proc/cpuinfo`,
`/proc/meminfo`, `/proc/modules`); файлы внутри часто содержат несколько значений свободным
текстом. **`/sys`** (sysfs, с ядра 2.6) — более новый и структурированный интерфейс,
отражающий модель устройств и драйверов ядра (шины, устройства, классы), с соглашением «один
файл — одно значение», что упрощает и чтение скриптами, и программную запись настроек. Оба
это не диски, а генерируются ядром на лету при каждом обращении — `cat /proc/meminfo`
формирует ответ в момент чтения, а не читает файл с диска.
**Факты для карточек**
- base | Чем отличается соглашение о содержимом файлов `/sys` от `/proc`? — в `/sys` одно значение на файл, в `/proc` файл может содержать несколько значений свободным текстом
- core | Откуда `cat /proc/meminfo` берёт данные? — ядро формирует ответ на лету в момент чтения, это не файл на диске
Почему дальше: `/proc` и `/sys` — интерфейсы общего назначения; когда нужно управлять
конкретным устройством операциями `open`/`read`/`write`, пишут символьный драйвер.
## 6. Символьный драйвер: `file_operations`, major/minor, барьер user/kernel
Драйвер символьного устройства регистрирует набор функций-обработчиков в структуре
`struct file_operations` — как минимум `.open`, `.read`, `.write`, `.release` (плюс,
например, `.unlocked_ioctl`); ядро вызывает нужный обработчик, когда пользовательский процесс
делает соответствующий системный вызов над файлом устройства в `/dev`. Регистрация —
`register_chrdev(major, name, &fops)`: если `major` передан как `0`, ядро само подбирает
свободный старший номер.
Устройство идентифицируется парой **major:minor**: **major** (старший номер) определяет
драйвер, обслуживающий устройство, **minor** (младший) — конкретный экземпляр внутри этого
драйвера (например, второй последовательный порт того же типа). `dev_t` в современном ядре —
32-битное число: 12 бит под major (до 4095) и 20 бит под minor (до 1 048 575).
Внутри `.read`/`.write` драйвер **не может напрямую разыменовать указатель, пришедший из
пользовательского пространства** — это чужое адресное пространство, страница может быть не
загружена в память или указатель вообще некорректен, а прямое разыменование либо уронит
ядро (kernel oops), либо (на CPU с SMAP/SMEP) вызовет аппаратный запрет. Вместо этого
обязательны **`copy_to_user`**/**`copy_from_user`** — они безопасно копируют данные через
границу, сами обрабатывают отсутствующую страницу и возвращают число байт, которые
**не** удалось скопировать (0 — полный успех).
**Ловушки**
- Разыменовать пользовательский указатель напрямую в `.read`/`.write` → крах ядра (oops) или
аппаратный запрет на SMAP/SMEP-системах → видно как немедленный крэш при обращении к
устройству, а не как «иногда неверные данные».
- Забыть проверить возвращаемое значение `copy_to_user`/`copy_from_user` → часть данных не
скопирована, а код считает операцию успешной → приложение получает частично мусорный буфер
без явной ошибки.
**Факты для карточек**
- base | Какие 4 обработчика минимально нужны в `file_operations` символьного драйвера? — `.open`, `.read`, `.write`, `.release`
- core | Что означают major и minor номера устройства? — major определяет драйвер, minor — конкретный экземпляр устройства внутри этого драйвера
- core | Сколько бит под major и minor в `dev_t`? — 12 бит major, 20 бит minor (32-битное число целиком)
- core | Почему в драйвере нельзя напрямую разыменовать указатель из user space? — это чужое адресное пространство, страница может быть не загружена или указатель некорректен; прямое разыменование роняет ядро или блокируется SMAP/SMEP
Почему дальше: `read`/`write` подходят для потока байт, но не для команд, которые не
укладываются в чтение/запись (настроить режим устройства, запросить статус) — для этого есть
`ioctl`.
## 7. `ioctl`: команды, которые не являются чтением или записью
`ioctl` — обработчик `.unlocked_ioctl` в `file_operations`, принимающий числовой код команды
и один аргумент (`unsigned long`, часто указатель на структуру в user space). Используется,
когда операция над устройством не укладывается в модель «поток байт» — например, «сообщи
текущую скорость порта» или «переведи устройство в другой режим». Коды команд собирают
макросами **`_IO`**, **`_IOR`**, **`_IOW`**, **`_IOWR`** (кодируют направление передачи данных,
«магическое число» драйвера и номер команды) — это соглашение снижает риск, что два разных
драйвера случайно используют одинаковый числовой код команды.
**Факты для карточек**
- base | В какой функции `file_operations` реализуется `ioctl`? — `.unlocked_ioctl`
- core | Зачем коды ioctl-команд собирают через `_IO`/`_IOR`/`_IOW`/`_IOWR`, а не пишут произвольным числом? — макросы кодируют направление передачи, магическое число драйвера и номер команды, снижая риск коллизии кодов между разными драйверами
Почему дальше: и `file_operations`, и `ioctl` работают с уже существующим, известным
устройством — а откуда ядро вообще узнаёт, какое железо есть на конкретной плате, особенно в
embedded, где плат много и они разные?
## 8. Device tree: описание железа без хардкода
**Device tree** — текстовое описание аппаратной конфигурации платы (`.dts`, компилируется
утилитой `dtc` в бинарный `.dtb`): адреса регистров периферии, линии прерываний, доступные
шины, строки `compatible`, по которым ядро сопоставляет узел дерева с подходящим драйвером.
Загрузчик передаёт `.dtb` ядру вместе с образом ядра при старте.
Смысл — один и тот же бинарник ядра должен уметь работать на разных платах с разной
периферией и разными адресами регистров без перекомпиляции под каждую плату: без device tree
адреса и конфигурация железа были бы зашиты прямо в код ядра (так называемые board files),
и под каждую новую плату требовалась бы правка и пересборка самого ядра. Device tree выносит
это описание из кода в данные, которые загрузчик просто подкладывает рядом с ядром.
**Факты для карточек**
- base | Во что компилируется `.dts` и какой утилитой? — в бинарный `.dtb`, утилитой `dtc`
- core | Зачем device tree вообще нужен, если можно было бы прописать адреса регистров прямо в коде драйвера? — один и тот же бинарник ядра работает на разных платах без пересборки под каждую; хардкод адресов требовал бы правки и компиляции ядра под каждую конкретную плату
Почему дальше: чтобы вообще собрать ядро (и модуль) под плату, у которой процессор отличается
от машины разработчика, обычную сборку компилятором хоста использовать нельзя — нужен
кросс-компилятор.
## 9. Cross-compile: тулчейн под целевую архитектуру
Сборка ведётся на машине разработчика (обычно x86-64), а результат должен исполняться на
целевом процессоре платы (например, ARM) — обычный компилятор хоста генерирует машинный код
под архитектуру хоста, целевая плата такой код исполнить не сможет. Нужен **кросс-компилятор**
— тулчейн, генерирующий код именно под целевую архитектуру: например `arm-linux-gnueabihf-gcc`.
- **`CROSS_COMPILE`** — переменная окружения/аргумент сборки (используется, например, в
Makefile ядра и Buildroot), задающая префикс имени инструментов тулчейна
(`CROSS_COMPILE=arm-linux-gnueabihf-`), чтобы вызывался `arm-linux-gnueabihf-gcc`, а не
системный `gcc`.
- **`-march`** — флаг компилятора, задающий конкретный набор инструкций целевого процессора
(например `-march=armv7-a`); несовпадение с реальным железом даёт крах «illegal
instruction» на плате или отказ собраться, если код использует расширения, которых у
целевого процессора нет.
- **Статическая сборка** — линковка всех библиотек прямо в бинарник вместо динамических
`.so`; в embedded это снимает риск несовпадения версии библиотек (например, glibc) в
минимальном корневом ФС платы с версией, под которую собирался бинарник, ценой большего
размера самого файла.
**Факты для карточек**
- base | Что задаёт переменная `CROSS_COMPILE`? — префикс имени инструментов тулчейна (например `arm-linux-gnueabihf-`)
- core | Что произойдёт при запуске бинарника, собранного с неверным `-march`, на реальной плате? — крах «illegal instruction» (процессор не поддерживает часть использованных инструкций) или отказ сборки
- core | Какую проблему в embedded снимает статическая линковка? — несовпадение версии динамических библиотек (например glibc) на целевой плате с версией сборки
Почему дальше: тулчейн даёт бинарники ядра и модулей под плату, но сама плата должна ещё
дойти от включения питания до работающей системы — разберём цепочку загрузки.
## 10. Загрузка платы: u-boot → ядро+dtb → initramfs → init
Типичная последовательность:
1. Boot ROM процессора (зашит в кристалл, неизменяем) загружает первый этап загрузчика.
2. **U-Boot** — распространённый загрузчик embedded-плат — инициализирует минимально
необходимое железо (память, консоль) и находит образ ядра и `.dtb` (раздел 8) на носителе.
3. U-Boot загружает **ядро** и **`.dtb`** в память и передаёт управление точке входа ядра.
4. Ядро инициализируется и монтирует **initramfs** — временную корневую файловую систему,
целиком находящуюся в оперативной памяти, — и запускает из неё `/init`.
5. `/init` в initramfs делает раннюю настройку (загружает нужные модули, находит настоящий
диск), затем переключается на настоящую корневую файловую систему (`pivot_root`/
`switch_root`) и запускает уже настоящий **init** (PID 1 — например `systemd` или
BusyBox init) на постоянном разделе.
initramfs нужен потому, что к моменту, когда ядро только загрузилось, оно ещё может не знать,
как смонтировать настоящий диск (нужный драйвер файловой системы или контроллера диска может
сам быть модулем, который ещё не загружен) — временная ФС в памяти даёт минимальную среду,
достаточную, чтобы загрузить эти модули и уже потом перейти на постоянное хранилище.
**Факты для карточек**
- base | В каком порядке идёт цепочка загрузки платы? — Boot ROM → U-Boot → ядро+dtb → initramfs → переключение на настоящий rootfs → init (PID 1)
- core | Зачем нужен initramfs, если ядро уже загружено и работает? — ядру для монтирования настоящего диска может понадобиться драйвер, который сам является модулем и ещё не загружен; initramfs даёт минимальную среду в памяти, чтобы его подгрузить перед переходом на постоянный rootfs
Почему дальше: после того как система загрузилась, embedded-разработчик работает с реальным
железом напрямую через память — а компилятор по умолчанию считает, что содержимое памяти
меняется только его собственным кодом. Разберём, что с этим не так.
## 11. `volatile`, `mmap` регистров и барьеры памяти
Обычная оптимизация компилятора — закэшировать значение переменной в регистре процессора и не
перечитывать его из памяти повторно, если по коду программы оно «не могло измениться».
Регистр памяти-отображённого устройства (MMIO) может измениться сам, независимо от кода
программы (например, статус-регистр меняется самим железом) — без `volatile` компилятор
законно уберёт «повторное» чтение как избыточное, и драйвер будет вечно видеть устаревшее
значение. `volatile` заставляет компилятор реально выполнять каждое обращение к памяти по
этому адресу, не кэшируя и не убирая «дублирующиеся» чтения/записи.
Важная ловушка: `volatile` **не даёт атомарности и не расставляет барьеры памяти** — он
касается только компилятора и только одной переменной, а не порядка операций между
несколькими адресами и не защищает от гонки между несколькими ядрами процессора (для этого в
пользовательском C++ есть `std::atomic`, раздел про многопоточность).
Доступ к регистрам устройства из драйвера обычно идёт не через прямой физический адрес, а
через **`ioremap()`** — функция ядра, отображающая физический адрес MMIO-региона в виртуальное
адресное пространство ядра, после чего к регистру обращаются как к обычному указателю.
**Барьеры памяти** (`mb()`, `rmb()`, `wmb()`) принудительно фиксируют порядок операций чтения
и записи в памяти, как его видит железо — нужны потому, что и компилятор, и сам процессор
вправе переставлять инструкции местами, если это не меняет результат с точки зрения
однопоточной программы, а DMA-контроллер или другое устройство читает память независимо от
этого порядка. Типичный случай: драйвер записывает данные в дескриптор DMA-кольца
(`tasks/03_ring`, задача этого набора про кольцевой буфер — та же структура head/tail лежит
в основе DMA-колец), а затем «звонит в дверной звонок» — пишет в регистр, запускающий
устройство; без `wmb()` между этими двумя записями устройство может увидеть сигнал запуска
раньше, чем реально записанные данные дескриптора, и прочитать старое содержимое.
**Ловушки**
- Забыть `volatile` на указателе на регистр устройства → компилятор кэширует значение в
регистре CPU и не видит изменений железа → драйвер «зависает», читая одно и то же старое
значение, хотя устройство давно сменило статус.
- Считать, что `volatile` защищает от гонки между несколькими ядрами процессора → в
многоядерном коде это не так, нужны барьеры памяти или атомики → гонка, не ловится
однопоточным тестированием.
**Факты для карточек**
- base | Что делает `volatile` с точки зрения компилятора? — заставляет реально выполнять каждое обращение к памяти по адресу, не кэшируя значение в регистре и не убирая повторные чтения/записи
- core | Даёт ли `volatile` атомарность или барьер памяти между несколькими ядрами CPU? — нет, только запрещает компилятору кэшировать/убирать обращения к конкретной переменной
- core | Какая функция ядра отображает физический адрес MMIO-региона в виртуальный адрес для доступа как к указателю? — `ioremap()`
- deep | Зачем нужен `wmb()` между записью DMA-дескриптора и записью в регистр запуска устройства? — без барьера порядок этих двух записей, как его видит железо, не гарантирован, и устройство может прочитать старое содержимое дескриптора раньше, чем увидит новые данные
Ссылки на задачи этого набора: `tasks/07_epoll` — то, как ядро уведомляет процесс о готовых
событиях на файловых дескрипторах (тот же принцип «пользовательский процесс не опрашивает
железо/сеть напрямую, а получает уведомление от ядра», что и в syscall-границе раздела 1);
`tasks/03_ring` — кольцевой буфер head/tail, структурно совпадающий с DMA-кольцами в
драйверах (раздел 11).
<details>
<summary>Проверь себя</summary>
1. Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы,
прямо внутри `.read` драйвера?
<details><summary>Ответ</summary>Это указатель в чужом (пользовательском) адресном
пространстве: соответствующая страница памяти может быть не загружена или указатель
вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется
аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые
безопасно обрабатывают этот переход.</details>
2. На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без
пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это
возможно и что бы сломалось без него?
<details><summary>Ответ</summary>Device tree: адреса регистров и конфигурация железа
вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без
device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение
конфигурации платы пришлось бы переписывать и пересобирать сам код ядра.</details>
3. Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но
переменная объявлена без `volatile` — цикл не завершается, хотя железо статус давно
сменило. В чём причина и как это чинится?
<details><summary>Ответ</summary>Компилятор решил, что раз в теле цикла переменная кодом
программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с
закэшированным в регистре значением — оптимизация, законная для обычной переменной, но
ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на
регистр как `volatile`, чтобы компилятор перечитывал память при каждой
итерации.</details>
4. Почему для системного вызова на x86-64 нужна специальная инструкция `syscall`, а не
обычный вызов функции ядра через `call` по известному адресу?
<details><summary>Ответ</summary>Обычный `call` не меняет уровень привилегий процессора —
пользовательская программа в кольце 3 так и осталась бы в кольце 3, не получив доступа к
привилегированным операциям ядра. `syscall` — специальная инструкция, которая одновременно
переключает CPU в кольцо 0 и передаёт управление фиксированному обработчику ядра, что
обычный переход по адресу сделать не может.</details>
</details>
## Материалы
- sysprog21.github.io, Linux Kernel Module Programming Guide (LKMPG) — https://sysprog21.github.io/lkmpg/
- docs.kernel.org, sysfs — https://docs.kernel.org/filesystems/sysfs.html
- docs.kernel.org, "volatile" Considered Harmful — https://docs.kernel.org/process/volatile-considered-harmful.html
- man7.org, `syscall(2)` — https://man7.org/linux/man-pages/man2/syscall.2.html
- man7.org, `ioctl(2)` — https://man7.org/linux/man-pages/man2/ioctl.2.html
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html
-648
View File
@@ -1,648 +0,0 @@
# D7 (30.09). Повтор и мок-интервью
Последний день — не новая тема, а тренировка в формате реального собеседования: 3 часа
алгоритмов вслух с таймером и 2 часа системных вопросов "объясни механизм за 30 секунд".
Ниже — карта, куда смотреть по слабым местам, и два мок-сценария с эталонными ответами.
## 1. Карта слабых мест
| Домен | Что спрашивают | Куда смотреть |
|---|---|---|
| O-нотация, хеш-таблица | сложности операций, устройство хеш-таблицы, коллизии, rehash | `D1_algo` §1–5 |
| fork/exec/wait/сигналы/errno | зомби, коды 137/139, `sigaction`, `EINTR`/`EAGAIN` | `D1_linux` |
| Деревья, BST, куча, top-K | обходы, инвариант BST, `sift-up`/`sift-down`, `nth_element` | `D2_algo` §1–6 |
| open/mmap/epoll/select/TIME_WAIT | буферизация, page fault, `FD_SETSIZE`, ET vs LT | `D2_linux` §1–7 |
| padding, правило 0/3/5, virtual-деструктор, move, UB | `sizeof`/`offsetof`, double-free, vtable, ASAN/UBSAN | `D2_cpp` §1–5 |
| Списки, стек/очередь, кольцевой буфер | разворот, цикл Флойда, dummy-узел, монотонный стек | `D3_algo` |
| Потоки, mutex, CV, deadlock, atomic | data race, 4 условия deadlock, `memory_order`, TSan | `D3_threads` |
| gdb, core dump, valgrind | точки останова, `bt`, ASAN vs gdb, `ulimit -c` | `D3_gdb` |
| Графы: BFS/DFS, топосортировка, Дейкстра, DSU | сложности `O(V+E)`, `O((V+E) log V)`, ранг+сжатие пути | `D4_algo` |
| Ethernet/ARP/VLAN/MTU/IPv4/TTL/ICMP/CIDR | заголовки по байтам, checksum, /24 vs /26 | `D4_net` |
| ДП: рюкзак, монеты, LCS, Edit Distance, LIS | таблица `(n+1)×(m+1)`, обратный проход по весу, `O(n log n)` LIS | `D5_algo` |
| TCP: заголовок, handshake, состояния, TIME_WAIT, UDP, NAT, DNS, DHCP | 20/8 байт заголовков, `2×MSL`, `tcpdump` | `D5_net` |
| bash: pipefail, xargs, awk/sed, `/proc`, ulimit, strace | коды возврата, `set -euo pipefail`, дескрипторы | `D5_bash` |
| Ядро обзорно: syscall, модуль, `/proc`/`/sys`, chardev, ioctl, device tree | user space vs kernel space, `copy_to_user` | `D6_kernel` |
Механизмы уровня "откуда это следует" (ООП, STL, Git/Docker/GDB детальнее) — в
`HR_BASE_deep.md`, если конкретный урок дня даёт ответ слишком коротко.
**Факты для карточек**
- base | Сколько уроков покрывает план перед D7? — 13 файлов (`D1_algo`…`D6_docker`) по алгоритмам, Linux, C++, сетям, потокам, отладке, bash и ядру
- core | Где искать формулировки механизмов, если в уроке дня их не хватает? — `HR_BASE_deep.md`
- base | Сколько часов длится каждый мок сегодня? — мок №1 (алгоритмы) 3 часа, мок №2 (системное) 2 часа
Почему дальше: карта показывает, где искать теорию; дальше — тренировка в формате реального
таймера, начиная с алгоритмов.
## 2. Мок №1. Алгоритмы (3 часа, 6 задач × 25 минут)
Формат: 25 минут на задачу — сначала вслух проговорить план и уточняющие вопросы (первые
3–5 минут), потом решение. На реальном скрининге читаемый план ценится не меньше кода: если
план верный, а код не дописан за 25 минут, это всё ещё сильный ответ. Ниже — не готовый код,
а эталонная последовательность шагов для каждой задачи; сам код — в соответствующем уроке дня.
### Задача 1. Хеш-таблица с нуля (`tasks/10_hash`, теория — `D1_algo` §3, §5)
**Что проверяет интервьюер:** понимание механизма, а не вызов готового `std::unordered_map` —
обоснование выбора разрешения коллизий и сложности, а не память формул.
**Критерии сильного ответа:** называет компоненты (массив корзин, хеш-функция, коллизии,
load factor) без подсказки; сразу разделяет среднюю сложность `O(1)` и худший случай `O(n)`;
осознанно выбирает chaining или open addressing и объясняет цену выбора; упоминает rehash при
превышении порога загрузки.
**Ловушки**
- Путают среднюю и худшую сложность операций → на вопрос "а что если все ключи попали в одну корзину" отвечают "всё равно O(1)" → сразу видно, что структура не понята, а заучена.
- При open addressing не знают про удаление через tombstone → предлагают просто очищать ячейку → следующий поиск обрывается раньше времени, не находя элемент дальше по цепочке зондирования.
- Не проговаривают степень двойки для capacity → используют деление по модулю вместо `hash & (cap - 1)`, не объясняя, зачем вообще нужна степень двойки.
**Эталонный план по шагам:**
1. Уточнить тип ключей (строки/числа), нужен ли `erase`, ожидаемый порядок `N`.
2. Взять `capacity` степенью двойки, задать порог load factor (например, 0.7).
3. Выбрать хеш-функцию: для строк — FNV-1a или `std::hash<std::string>`.
4. Выбрать разрешение коллизий: chaining — проще и безопаснее уложить в 25 минут.
5. `insert`: посчитать `idx = hash & (cap - 1)`, добавить в корзину; если load factor превышен — rehash в массив вдвое больше, перенести все элементы.
6. `get`/`erase`: пройти цепочку по индексу; для open addressing `erase` — пометить tombstone, не очищать ячейку.
7. Проговорить итоговые сложности и триггер rehash.
**Сложность:** амортизированно `O(1)` на вставку/поиск/удаление; худший случай (плохой хеш,
все ключи в одной корзине) — `O(n)`; сам rehash — `O(n)`, но происходит редко, поэтому не
портит амортизированную оценку.
**Уточняющие вопросы кандидата:** нужна ли потокобезопасность? Ключи известны заранее (тогда
возможен perfect hashing)? Важен ли порядок вставки при обходе?
**Факты для карточек**
- base | Средняя сложность операций хеш-таблицы? — амортизированное O(1)
- core | При каком load factor обычно триггерят rehash при open addressing? — около 0.7
- core | Зачем capacity берут степенью двойки? — `idx = hash & (cap - 1)` вместо дорогого деления по модулю
- deep | Каков стандартный max_load_factor по умолчанию у `std::unordered_map` в большинстве реализаций (libstdc++, MSVC)? — 1.0
Почему дальше: хеш-таблица даёт O(1) доступ по ключу без порядка; следующая задача — структура
с O(1) доступом к экстремуму и осмысленным порядком по величине.
### Задача 2. Куча / top-K (`tasks/11_heap`, теория — `D2_algo` §5–6)
**Что проверяет интервьюер:** различие между построением кучи и потоковым top-K; выбор между
полной кучей, min-heap размера k и `nth_element` по контексту задачи.
**Критерии сильного ответа:** первым делом спрашивает про размер данных (влезают ли в
память); знает сложности всех трёх подходов; не путает направление кучи для потокового
top-K (для top-K наибольших нужен именно min-heap размера k).
**Ловушки**
- Строят кучу через `push` в цикле вместо bottom-up `heapify` → результат корректный, но `O(n log n)` вместо `O(n)` → на миллионах элементов заметно медленнее, видно по времени выполнения.
- В потоковом top-K сравнивают новый элемент с максимумом кучи, а не с минимумом → кандидаты вытесняются в обратном порядке → итоговый top-K оказывается неверным набором.
- Используют `nth_element` там, где нужен отсортированный результат, и забывают досортировать k-элементный префикс → набор элементов верный, порядок — нет.
**Эталонный план по шагам:**
1. Уточнить `k`, `N`, влезают ли данные в память целиком, нужен ли отсортированный результат.
2. Если данные помещаются в память — `heapify` за `O(n)`, затем `k` раз `pop` по `O(log n)`.
3. Если поток / не влезает — держать min-heap размера `k`: сравнивать новый элемент с `top()` кучи (O(1)); если новый больше минимума — вытеснить минимум, добавить новый.
4. Альтернатива без построения кучи — `nth_element` (quickselect), в среднем `O(n)`, но без готового порядка внутри половин.
5. Если нужен отсортированный top-K через `nth_element` — досортировать k-элементный префикс отдельно.
6. Проговорить, почему выбран именно этот вариант.
**Сложность:** полная куча — `O(n + k log n)` время, `O(n)` память; потоковый min-heap —
`O(n log k)` время, `O(k)` память; `nth_element` — среднее `O(n)`, худшее `O(n²)`.
**Уточняющие вопросы кандидата:** данные статичны или стримятся? Нужен ли устойчивый порядок
при равных ключах? Есть ли жёсткое ограничение по памяти?
**Факты для карточек**
- base | Сложность top-K через полную кучу? — O(n + k log n)
- core | Какую кучу держат для потокового top-K наибольших элементов размера k? — min-heap размера k
- core | Средняя и худшая сложность nth_element? — среднее O(n), худшее O(n²)
- deep | Как получить top-K частых элементов за O(n) без log-множителя? — подсчёт хеш-таблицей O(n) + bucket sort по частоте (массив корзин размера n+1)
Почему дальше: куча и top-K работают со случайным доступом по значению; следующая задача —
структура, где доступ строго последовательный через указатели, и там есть собственный класс
ошибок — циклы.
### Задача 3. Связный список с циклом (`tasks/02_list`, теория — `D3_algo` §5)
**Что проверяет интервьюер:** понимание, почему указатели вообще встречаются (а не просто
память алгоритма Флойда), корректная обработка граничных случаев.
**Критерии сильного ответа:** объясняет механизм через относительную скорость (`fast`
догоняет `slow` на 1 узел за шаг внутри цикла), а не просто произносит "медленный и быстрый
указатель"; корректно достраивает поиск входа в цикл вторым проходом.
**Ловушки**
- Сравнивают значения узлов вместо указателей → на списке с повторяющимися значениями без реального цикла даёт ложное срабатывание.
- Забывают проверку `fast && fast->next` перед `fast->next->next` → падение на `nullptr` для списка без цикла чётной/нечётной длины на границе.
- Не могут объяснить вторую часть (поиск входа в цикл) без домашней заготовки → интервьюер спрашивает "почему это работает", а не "какой код писать".
**Эталонный план по шагам:**
1. Уточнить: список одно- или двусвязный, возможны ли пустой список и self-loop (узел ссылается сам на себя).
2. `slow = head`, `fast = head`.
3. Пока `fast && fast->next`: `slow` на 1 шаг, `fast` на 2 шага; если `slow == fast` — цикл найден, выйти из цикла проверки.
4. Если `fast` дошёл до `nullptr` — цикла нет.
5. Для входа в цикл: сбросить один указатель на `head`, второй оставить в точке встречи; двигать оба по 1 шагу — встретятся ровно на входе в цикл.
6. Кратко обосновать почему (из `2(a+b) = a+b+n·c` следует `a = (n-1)c + (c-b)`).
**Сложность:** время `O(n)`, память `O(1)` — без хеш-множества посещённых узлов.
**Уточняющие вопросы кандидата:** нужна ли длина цикла отдельно от факта его наличия? Список
гарантированно не пуст?
**Факты для карточек**
- base | Сложность обнаружения цикла алгоритмом Флойда по времени и памяти? — O(n) время, O(1) память
- core | С какой относительной скоростью fast догоняет slow внутри цикла? — 1 узел за шаг
- core | Как найти вход в цикл после первой встречи указателей? — сбросить один указатель на head, оба двигать по 1 шагу — встретятся на входе
Почему дальше: список — структура с одним "соседом" на узел; граф — структура с произвольным
числом соседей, и обход там даёт другие гарантии в зависимости от порядка обхода.
### Задача 4. BFS по матрице/графу (теория — `D4_algo` §2)
**Что проверяет интервьюер:** умение свести сетку к неявному графу соседей, правильный момент
пометки `visited`, вывод сложности через размеры входа.
**Критерии сильного ответа:** явно проговаривает, что кратчайший путь по рёбрам гарантирован
именно порядком FIFO очереди, а не самим фактом использования BFS "потому что так учили";
помечает узел посещённым в момент постановки в очередь, а не при извлечении.
**Ловушки**
- Помечают `visited` при извлечении из очереди, а не при добавлении → один и тот же узел кладётся в очередь несколько раз → деградация по времени, иногда неверный `dist`.
- Не проверяют границы сетки перед обращением к соседней клетке → выход за границы массива.
- Считают диагональных соседей, хотя задача просила 4-связность (или наоборот) → неверный граф с самого начала, весь дальнейший разбор бессмыслен.
**Эталонный план по шагам:**
1. Уточнить: сетка или произвольный граф; 4 или 8 соседей; есть ли препятствия или веса рёбер.
2. Представление: список смежности для явного графа; прямой обход соседних клеток для сетки.
3. `dist[] = -1` (или `visited[][] = false`), очередь, `dist[src] = 0`, `push(src)`.
4. Пока очередь не пуста: `pop u`; для каждого непосещённого соседа `v` — `dist[v] = dist[u] + 1`, `push(v)`, пометить как посещённый сразу здесь.
5. Ответ — `dist[target]` (или число посещённых клеток для задач на компоненты/острова).
**Сложность:** `O(V+E)` для графа; для сетки `R×C` — `O(R·C)`, так как `V = R·C`, а `E`
пропорционально `V` при фиксированном числе соседей на клетку (4 или 8).
**Уточняющие вопросы кандидата:** веса рёбер одинаковы? Граф ориентированный? Нужен сам путь
или только его длина?
**Факты для карточек**
- base | Сложность BFS на сетке R×C? — O(R·C)
- core | Почему BFS гарантирует кратчайший путь по числу рёбер? — FIFO-очередь раскрывает граф строго по слоям расстояния
- core | В какой момент нужно помечать узел посещённым, чтобы избежать повторных вставок в очередь? — в момент постановки в очередь, а не при извлечении
- deep | Как обойти граф со весами рёбер только 0 и 1 за O(V+E) без полноценной Дейкстры? — 0-1 BFS: deque, вес 0 — push_front, вес 1 — push_back
Почему дальше: BFS решает граф с одинаковой "ценой" каждого ребра; следующая задача — с
одномерным массивом, где решение каждого следующего элемента зависит от предыдущих
подрешений, а не от соседей в структуре — это динамическое программирование.
### Задача 5. ДП на строки: LCS / Edit Distance (теория — `D5_algo` §6)
**Что проверяет интервьюер:** умение построить таблицу `(n+1)×(m+1)`, объяснить переходы
словами, а не просто написать формулу по памяти.
**Критерии сильного ответа:** явно объясняет базовый случай (пустая строка/префикс) и почему
размер таблицы `(n+1)×(m+1)`, а не `n×m`; для Edit Distance проговаривает все три операции
(замена/удаление/вставка) и почему берётся минимум трёх соседних ячеек.
**Ловушки**
- Заводят таблицу `n×m` вместо `(n+1)×(m+1)` → нет места под базовый случай "один из префиксов пуст" → неверные значения на границе или выход за границы массива.
- В Edit Distance забывают инициализировать нулевую строку/столбец → сравнение с мусорными значениями → неверный ответ без падения программы (тихая ошибка).
- Путают направление переходов LCS (берут `min` вместо `max` при несовпадении символов) → результат заведомо меньше правильного.
**Эталонный план по шагам:**
1. Уточнить: нужна длина LCS, сама подпоследовательность (восстановление пути) или Edit Distance.
2. Завести таблицу `dp[(n+1)][(m+1)]`; строка/столбец 0 — базовый случай "один из префиксов пуст".
3. LCS: при совпадении символов — `dp[i-1][j-1] + 1`; иначе — `max(dp[i-1][j], dp[i][j-1])`.
4. Edit Distance: `dp[i][0] = i`, `dp[0][j] = j`; при совпадении — `dp[i-1][j-1]`; иначе — `1 + min(замена, удаление, вставка)`.
5. Ответ — `dp[n][m]`.
6. Если нужна экономия памяти и не нужно восстановление пути — держать только 2 строки таблицы.
**Сложность:** время и память `O(n·m)`; при экономии памяти (без восстановления пути) —
`O(min(n,m))` память.
**Уточняющие вопросы кандидата:** нужна ли сама подпоследовательность/путь операций, или
только число? Укладывается ли `n·m` в лимит по времени при заданных ограничениях на длину строк?
**Факты для карточек**
- base | Размер таблицы LCS/Edit Distance для строк длины n и m? — (n+1)×(m+1)
- base | Временная и пространственная сложность LCS/Edit Distance? — O(n·m) и по времени, и по памяти
- core | Три операции, между которыми выбирают минимум в Edit Distance? — замена, удаление, вставка
Почему дальше: LCS и Edit Distance решают задачу за O(n·m) явной таблицей; следующая
классическая задача решается той же схемой за O(n²), но улучшается до O(n log n) совсем
другим приёмом — это повод спросить про компромисс между простотой и асимптотикой.
### Задача 6. ДП/НВП — LIS (`tasks/05_lis`, теория — `D5_algo` §7)
**Что проверяет интервьюер:** знание наивного `O(n²)` и продвинутого `O(n log n)` подходов,
умение оценить ограничения по `N` и сразу выбрать нужный алгоритм.
**Критерии сильного ответа:** сразу считает, влезает ли наивный `O(n²)` в лимит времени при
данном `N` (например, на `N = 100 000` это `10¹⁰` операций — не укладывается в 2 секунды);
понимает, что массив `tails[]` — не сама LIS, а вспомогательная структура минимальных хвостов.
**Ловушки**
- Путают длину массива `tails[]` (результат) с самой LIS-последовательностью → на просьбу "выведите саму подпоследовательность" отвечают неверно, потому что `tails[]` не хранит реальный путь.
- Делают линейный поиск позиции вместо `lower_bound` в `tails[]` → теряют весь выигрыш от `O(n log n)`, получают снова `O(n²)`.
- Не уточняют строгое или нестрогое возрастание → готовое решение может не соответствовать формулировке задачи.
**Эталонный план по шагам:**
1. Уточнить `N` и лимит по времени/памяти, строгое или нестрогое возрастание.
2. Если `N` мало — наивный `O(n²)`: `dp[i]` — длина LIS, заканчивающейся на `a[i]`, перебор `j < i` с `a[j] < a[i]`.
3. Если `N` большое — массив `tails[]`: для каждого нового элемента бинарным поиском (`lower_bound`) найти позицию замены или расширения `tails`.
4. Ответ — текущая длина `tails[]`.
5. Проговорить: для восстановления самой подпоследовательности нужен отдельный массив индексов-предков, `tails[]` для этого не подходит.
**Сложность:** наивный подход — `O(n²)` время, `O(n)` память; продвинутый — `O(n log n)`
время, `O(n)` память.
**Уточняющие вопросы кандидата:** нужна ли сама подпоследовательность или только длина?
Строгое возрастание или нестрогое (допускаются равные соседние значения)?
**Факты для карточек**
- base | Сложность наивного LIS и продвинутого через tails[]? — O(n²) и O(n log n) соответственно
- core | Почему наивный O(n²) не проходит на N=100 000 за 2 секунды? — 10¹⁰ операций, не укладывается в типичный лимит времени внутреннего теста
- core | Что хранит tails[k]? — минимальный возможный последний элемент возрастающей подпоследовательности длины k+1
- deep | Как называется классический приём построения tails[] с заменой через бинарный поиск? — patience sorting (раскладка карт по кучкам)
Почему дальше: алгоритмическая часть проверяет чистые структуры данных с однозначным
эталонным ответом; системная часть мока проверяет то же самое качество понимания, но в формате
устного объяснения без кода — и там интервьюер чаще всего задаёт один и тот же вопрос дважды,
разными словами, чтобы проверить глубину.
## 3. Мок №2. Системное (2 часа, 12 вопросов)
Формат: интервьюер задаёт короткий вопрос, ждёт ответ на 20–40 секунд, затем "углубляет" —
задаёт уточняющий вопрос, который проверяет, понята ли причина, а не просто выучен факт.
Ниже — по 4 вопроса на C/C++, Linux и сети.
### C/C++
**Вопрос 1. Посчитайте `sizeof(struct { char a; int b; char c; })` на x86-64 и объясните откуда взялось число.**
Чего ждёт интервьюер: конкретное число (12) и раскладку по смещениям, а не "наверное, 6".
Заготовка ответа (20–40 сек): "Поля дают 6 байт, но `sizeof` будет 12. `a` на смещении 0;
дальше 3 байта паддинга, потому что `int b` требует выравнивания на 4 байта; `b` занимает
смещения 4–7; `c` на смещении 8. Полезные данные кончаются на 9 байте, но размер всей
структуры округляется вверх до кратного выравниванию самого строгого поля — здесь `int`,
выравнивание 4 — отсюда ещё 3 байта хвостового паддинга и итог 12."
Углубление: "А если переставить поля — `char a; char c; int b`?" → 8 байт: два `char` подряд
без паддинга между ними, потом `int` с выравниванием 4, конец уже кратен 4 — экономия 4
байта на объект. "Что делает `#pragma pack(1)`?" → убирает паддинг (`sizeof` станет 6), но
доступ к невыровненным полям дороже на x86 и может аппаратно упасть на платформах со строгим
выравниванием.
**Факты для карточек**
- base | sizeof(struct { char a; int b; char c; }) на x86-64? — 12 байт
- core | sizeof той же структуры с полями в порядке char a; char c; int b? — 8 байт
**Вопрос 2. Что такое правило 0/3/5 и когда его нужно применять?**
Чего ждёт интервьюер: связь между "класс владеет ресурсом" и необходимостью явно писать
копирование/перемещение.
Заготовка ответа: "Если класс сам управляет ресурсом (владеющий указатель, файловый
дескриптор) и поэтому определяет деструктор, он почти наверняка должен явно определить и
конструктор/оператор копирования (правило трёх), а с C++11 — ещё и перемещения (правило
пяти). Если ни одну из пяти функций не объявить, компилятор сгенерирует все сам, и
сгенерированное копирование — побитовое: для владеющего указателя это значит, что обе копии
получат один и тот же адрес, и оба деструктора вызовут `delete` на нём — double-free."
Углубление: "А правило нуля?" → не писать ни одну из пяти вручную, доверив владение готовой
RAII-обёртке (`unique_ptr`, `vector`, `string`) — её сгенерированные копирование/перемещение
уже корректны.
**Факты для карточек**
- base | Какие 5 функций входят в правило пяти? — деструктор, конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением
- core | Что делает сгенерированный компилятором конструктор копирования по умолчанию? — побитовое (memberwise) копирование каждого поля
**Вопрос 3. Что физически делает `std::move`?**
Чего ждёт интервьюер: развенчание мифа "move перемещает данные во время выполнения".
Заготовка ответа: "Ничего во время выполнения — это `static_cast<T&&>(x)`, явный каст к
rvalue-ссылке. Единственный эффект — при выборе перегрузки компилятор теперь предпочитает
move-конструктор, а не копирующий. Реальную работу делает move-конструктор конкретного типа:
для `vector` это перенос трёх указателей (начало, конец данных, конец ёмкости) и обнуление их
у источника — O(1), без копирования элементов."
Углубление: "В каком состоянии объект после `std::move`?" → в валидном, но неопределённом
состоянии по стандарту; использование старых данных — не UB, но логическая ошибка, не ловится
санитайзерами.
**Факты для карточек**
- base | Что физически делает std::move во время выполнения? — ничего: это static_cast к rvalue-ссылке
- core | За какое время перемещается std::vector и что именно переносится? — O(1): три внутренних указателя (начало, конец данных, конец ёмкости)
**Вопрос 4. Назовите 2–3 примера UB в C++ и объясните разницу ASAN/UBSAN.**
Чего ждёт интервьюер: конкретные примеры (не общее "неопределённое поведение — это плохо") и
чёткое разделение зон ответственности двух санитайзеров.
Заготовка ответа: "Знаковое переполнение `int` (`INT_MAX + 1`), сдвиг на число бит больше или
равное разрядности типа (`1 << 32` для 32-битного `int`), разыменование `nullptr` — всё UB.
ASAN проверяет ошибки работы с памятью: границы, use-after-free, double-free. UBSAN проверяет
сами операции языка, являющиеся UB по стандарту: переполнение, сдвиг, выравнивание. Они не
заменяют друг друга, включают вместе: `-fsanitize=address,undefined`."
Углубление: "Почему код может работать в `-O0` и падать в `-O2`?" → оптимизатор релизной
сборки строит код в предположении, что UB не происходит, и может убрать проверки, на которые
программист рассчитывал.
**Факты для карточек**
- base | Значение INT_MAX для 32-битного int? — 2147483647
- core | Флаг компилятора, включающий сразу ASAN и UBSAN? — -fsanitize=address,undefined
Почему дальше: C++ отвечает за корректность в пределах одного процесса; следующий блок
вопросов — про то, что происходит на границе процесса и ядра.
### Linux
**Вопрос 5. Процесс упал, echo $? показывает 139. Что произошло?**
Чего ждёт интервьюер: связь оболочечного кода возврата с сигналом, а не просто "процесс
упал".
Заготовка ответа: "Оболочка кодирует код возврата как 128 + номер сигнала. 139 = 128 + 11 —
`SIGSEGV`, падение по памяти. 137 = 128 + 9 — `SIGKILL`, часто это OOM-killer. 143 = 128 + 15
— `SIGTERM`, корректный запрос на завершение. В коде это разбирается макросами
`WIFSIGNALED`/`WTERMSIG` над `status` из `waitpid`, а не вручную арифметикой."
Углубление: "Можно ли перехватить SIGKILL обработчиком?" → нет, `SIGKILL` (9) и `SIGSTOP` (19)
нельзя ни перехватить, ни заблокировать — ядро обрабатывает их безусловно, это единственный
гарантированный способ остановить процесс, который сам игнорирует остальные сигналы.
**Факты для карточек**
- base | Откуда взялись коды возврата 137 и 139? — 128 + номер сигнала: 137 = SIGKILL(9), 139 = SIGSEGV(11)
- core | Какие два сигнала нельзя перехватить или заблокировать? — SIGKILL (9) и SIGSTOP (19)
**Вопрос 6. Что такое зомби-процесс?**
Чего ждёт интервьюер: точное понимание, что зомби не расходует память, а занимает запись в
таблице процессов.
Заготовка ответа: "Завершившийся ребёнок не исчезает полностью — ядро держит его запись (код
возврата) в таблице процессов, пока родитель не заберёт её через `wait`/`waitpid`. Такой
процесс — зомби, состояние `Z` в `ps`. Памяти он не занимает, но занимает слот в таблице
процессов; накопление зомби — это утечка слотов, а не утечка памяти."
Углубление: "Как избавиться от зомби, если родитель не вызывает wait?" → либо родитель обязан
вызывать `waitpid` (часто в обработчике `SIGCHLD`), либо если родитель сам умирает раньше
ребёнка — ребёнка усыновляет init/systemd (PID 1), который делает `wait` за него, и ребёнок
зомби не остаётся.
**Факты для карточек**
- base | Состояние зомби-процесса в выводе ps? — Z
- core | Что именно занимает зомби-процесс — память или что-то другое? — слот в таблице процессов, не память
**Вопрос 7. Чем epoll лучше select для сервера с тысячами соединений?**
Чего ждёт интервьюер: количественное сравнение сложности и лимитов, а не общую фразу "epoll
быстрее".
Заготовка ответа: "select ограничен `FD_SETSIZE` = 1024 дескриптора и на каждый вызов
проходит весь набор целиком — `O(n)` независимо от того, сколько реально готово, плюс
`fd_set` надо пересобирать перед каждым вызовом. epoll разносит регистрацию (`epoll_ctl`,
список интереса на красно-чёрном дереве в ядре) и ожидание (`epoll_wait`, который просто
возвращает уже готовые дескрипторы) — `O(1)` на каждое готовое событие, без лимита 1024."
Углубление: "Level-triggered или edge-triggered по умолчанию?" → level-triggered — у epoll по
умолчанию и всегда у select/poll: событие сообщается, пока условие истинно. Edge-triggered —
явный флаг `EPOLLET`, требует вычитывать данные до `EAGAIN` на неблокирующем дескрипторе,
иначе остаток данных не будет сигнализирован повторно.
**Факты для карточек**
- base | Жёсткий лимит select и его значение? — FD_SETSIZE, 1024
- core | Сложность epoll_wait на одно готовое событие против select/poll на весь набор? — epoll_wait O(1) на событие; select/poll O(n) на весь набор при каждом вызове
**Вопрос 8. Что делает mmap и чем MAP_SHARED отличается от MAP_PRIVATE?**
Чего ждёт интервьюер: механизм ленивого отображения через page fault, а не "mmap читает файл
в память".
Заготовка ответа: "mmap резервирует диапазон виртуальных адресов и связывает его со
страницами файла в страничном кэше ядра; физического чтения при самом вызове нет. При первом
обращении к странице — page fault: minor, если страница уже в кэше (дёшево), major, если её
нужно реально прочитать с диска (дорого). `MAP_SHARED` — изменения видны всем процессам и
попадают в файл на диске; `MAP_PRIVATE` — copy-on-write, как при `fork`: страницы общие, пока
не начинается запись, тогда процесс получает свою копию, а файл на диске не меняется."
Углубление: "Когда read быстрее mmap?" → при однократном последовательном чтении небольшого
файла — накладные расходы на настройку отображения и page fault на каждую новую страницу не
амортизируются, обычный `read` одним вызовом дешевле.
**Факты для карточек**
- base | Разница MAP_SHARED и MAP_PRIVATE? — MAP_SHARED: изменения видны другим и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны
- core | Чем minor page fault отличается от major? — minor: страница уже в кэше (дёшево); major: страница читается с диска (дорого)
Почему дальше: Linux управляет одним узлом; следующий блок — про то, как узлы вообще находят
друг друга и обмениваются байтами по проводу.
### Сети
**Вопрос 9. Из чего состоит Ethernet-заголовок и что меняет VLAN?**
Чего ждёт интервьюер: точные числа байт заголовка и понимание, зачем и куда вставляется тег.
Заготовка ответа: "Ethernet-заголовок — 14 байт: 6 байт MAC получателя, 6 байт MAC
отправителя, 2 байта EtherType. После полезной нагрузки — 4-байтовый трейлер FCS, в заголовок
не входит. VLAN-тег 802.1Q по стандарту вставляет 4 дополнительных байта между MAC
отправителя и EtherType: TPID (2 байта, фиксировано 0x8100) и TCI (2 байта: PCP 3 бита, DEI 1
бит, VID 12 бит, диапазон реально используемых значений 1–4094). Заголовок с тегом растёт с
14 до 18 байт."
Углубление: "Что будет, если не учесть эти 4 байта при расчёте MTU?" → ошибка ровно на 4
байта — типично ловится сравнением дампа `tcpdump` с ожидаемой длиной кадра.
**Факты для карточек**
- base | Длина Ethernet-заголовка и длина заголовка с VLAN-тегом? — 14 байт без тега, 18 байт с тегом 802.1Q
- core | Значение TPID и число бит поля VID? — TPID = 0x8100, VID = 12 бит (диапазон 1–4094)
**Вопрос 10. Дана подсеть 10.0.1.130/26. Назовите сеть, broadcast и диапазон хостов.**
Чего ждёт интервьюер: расчёт на лету, а не заученный пример из урока.
Заготовка ответа: "/26 — 6 бит под хосты, 64 адреса всего, 62 доступно хостам (минус адрес
сети и broadcast). Маска в десятичном виде — 255.255.255.192. Для 10.0.1.130/26: сеть —
10.0.1.128, broadcast — 10.0.1.191, диапазон хостов — 129..190."
Углубление: "Как быстро найти самую узкую подсеть под 100 хостов?" → формула `32 -
ceil(log2(h+2))`: для `h=100` это `32 - ceil(log2(102)) = 32 - 7 = /25` (126 хостов — ближайшая
подходящая степень двойки сверху, `/26` с 62 хостами уже не хватит).
**Факты для карточек**
- base | Сколько хостов доступно в подсети /26 и как выглядит маска в десятичном виде? — 62 хоста, 255.255.255.192
- core | Сеть, broadcast и диапазон хостов для 10.0.1.130/26? — сеть 10.0.1.128, broadcast 10.0.1.191, хосты 129–190
**Вопрос 11. Как работает traceroute и при чём тут TTL?**
Чего ждёт интервьюер: понимание, что traceroute не использует отдельный протокол обнаружения
маршрута, а эксплуатирует побочный эффект TTL.
Заготовка ответа: "TTL уменьшается на 1 на каждом маршрутизаторе на пути пакета; при
обнулении пакет отбрасывается, а отправителю уходит ICMP Time Exceeded (тип 11). traceroute
последовательно шлёт пакеты с TTL 1, 2, 3... — пакет с TTL 1 умирает на первом
маршрутизаторе, тот присылает Time Exceeded со своим адресом; пакет с TTL 2 умирает на
втором, и так далее, пока пакет не дойдёт до получателя. Список хопов восстанавливается по
цепочке ICMP-ответов без специального протокола обнаружения маршрута."
Углубление: "Почему ping иногда не проходит, а обычное TCP-соединение на тот же хост
работает?" → ICMP может быть заблокирован файрволом отдельно от TCP-порта — это два разных
уровня фильтрации, `ping host` и `curl host` в таком случае дают разный результат.
**Факты для карточек**
- base | На сколько уменьшается TTL на каждом маршрутизаторе и какой ICMP-тип шлётся при обнулении? — на 1; ICMP Time Exceeded, тип 11
- core | С какого TTL traceroute начинает зондирование и почему именно так восстанавливает маршрут? — с TTL=1, наращивая на 1 — каждый пакет умирает на очередном хопе и раскрывает его адрес
**Вопрос 12. Три сегмента TCP-рукопожатия и зачем нужен TIME_WAIT?**
Чего ждёт интервьюер: точная последовательность флагов и механическая причина TIME_WAIT, а не
"так положено по стандарту".
Заготовка ответа: "SYN от клиента со своим ISN → SYN+ACK от сервера (Ack = ISN клиента + 1, и
собственный ISN сервера) → ACK от клиента, подтверждающий ISN сервера. Двух шагов
недостаточно, потому что соединение полнодуплексное: сервер не может быть уверен, что его
SYN-ACK дошёл, пока не получит финальный ACK. Закрытие — уже 4 сегмента (FIN, ACK, FIN, ACK)
из-за полузакрытия каждого направления по отдельности. Сторона, отправившая финальный ACK,
уходит в TIME_WAIT на 2×MSL — она должна поймать задержавшиеся в сети дубликаты старых
сегментов и быть готова повторно отправить последний ACK, если партнёр повторит FIN."
Углубление: "Сколько реально длится TIME_WAIT в Linux?" → 60 секунд, фиксированная константа
ядра `TCP_TIMEWAIT_LEN`, а не номинальные 4 минуты (2×2 мин) по RFC 793.
**Факты для карточек**
- base | Сколько сегментов в three-way handshake и в полном закрытии соединения? — 3 (SYN, SYN+ACK, ACK) и 4 (FIN, ACK, FIN, ACK)
- deep | Сколько секунд реально длится TIME_WAIT в Linux? — 60 секунд (константа TCP_TIMEWAIT_LEN), не совпадает с номинальными 4 минутами по RFC 793
Почему дальше: 12 вопросов закрывают C/C++, Linux и сети по отдельности; дальше — не техника,
а формат самого разговора: что спросить у работодателя и как рассказать про себя, чтобы не
потерять баллы на ровном месте.
## 4. Вопросы работодателю
Задавать под конец интервью, когда спросят "есть ли у вас вопросы" — не сам факт вопроса
важен, а конкретность.
1. На каком стандарте C++ пишут в проекте (C++11/14/17/20), какой компилятор и целевая
архитектура (x86, ARM, embedded-таргет)?
2. Как устроено код-ревью: обязательно ли оно для джуна перед мерджем, кто ревьюит, сколько
итераций в среднем проходит один PR?
3. Что гоняет CI на каждый коммит — юнит-тесты, статический анализ, санитайзеры? Обязательно
ли покрытие тестами для мерджа?
4. Какие задачи обычно дают джуну в первый месяц — багфиксы, мелкие фичи, документация,
разбор legacy-кода?
5. Есть ли доступ к реальному стенду/железу для отладки, или разработка сначала идёт на
эмуляторе/симуляторе?
6. Кто ментор на испытательный срок и как часто идёт синхронизация — ежедневный созвон,
раз в неделю, по запросу?
7. Что для команды считается успехом джуна через год — конкретные вехи или метрики, а не
общие слова?
8. Какая доля времени уходит на поддержку и доработку существующего кода против новых задач
с нуля?
**Факты для карточек**
- base | Сколько вопросов работодателю стоит подготовить заранее на такое интервью? — 8, каждый — конкретный, не риторический
- core | Когда обычно задают вопросы работодателю на техническом скрининге? — в конце интервью, по приглашению интервьюера
Почему дальше: вопросы работодателю закрывают техническую часть разговора; последний пункт —
не техника вообще, а то, как честно и без потери баллов рассказать про собственный опыт.
## 5. Легенда по опыту
Задача — не приукрашивать, а структурировать то, что уже сделано руками, так, чтобы
интервьюер за 60–90 секунд понял уровень, не тратя время на наводящие вопросы. Рабочий каркас
для таких ответов — STAR (Situation, Task, Action, Result): сначала контекст в одной фразе,
потом конкретное действие, потом измеримый результат.
**Что говорить как есть, потому что это правда и это ценно:**
- Практика с C/C++ и Linux не в вакууме, а руками: написанный код, работа в терминале,
скрипты.
- Опыт сборки и CI на собственном проекте (например, сборка APK и настройка пайплайна) —
конкретный пример того, что такое "довести до работающего результата", даже если стек не
совпадает с C++/embedded напрямую: понимание пайплайна сборки, зависимостей, автоматизации
переносится.
- Текущая подготовка по плану — не "готовился неделю перед собеседованием для галочки", а
системный разбор конкретных тем с самопроверкой (карточки, мок-интервью) — это тоже сигнал
об умении учиться быстро и целенаправленно.
**Как объяснять пробелы, не оправдываясь:**
- "Алгоритмы — тема, которую я целенаправленно добираю последние недели: понимаю формальные
свойства структур (сложность, инварианты, границы применимости), но production-опыта
оптимизации под high-load нет — это зона роста, а не то, что я скрываю."
- Если спросят про конкретный API, которого не касался — честно "не работал с этим напрямую,
но по механизму ожидаю Y, потому что Z" — интервьюер оценивает способность рассуждать, а не
только базу знаний.
**Чего не говорить:**
- Не заявлять претензию на роль лида или архитектурные решения, если такого опыта не было —
на джуновском скрининге это резко расходится с реальным уровнём ответов на технические
вопросы и подрывает доверие ко всему остальному сказанному.
- Не преувеличивать глубину embedded/kernel-опыта: тема ядра в этом плане закрыта на уровне
"отвечать словами" (`D6_kernel`), а не "писал драйверы в проде" — если спросят "а сами
писали?", честный ответ "нет, разбирал теоретически и на учебных примерах" безопаснее любой
неправды, которая всплывёт на первом же уточняющем вопросе.
**Факты для карточек**
- base | Из каких 4 частей состоит структура STAR для ответа про опыт? — Situation, Task, Action, Result
- core | Как правильно называть пробел в алгоритмах на собеседовании — скрывать или называть прямо? — называть прямо: конкретная тема добирается сейчас, с указанием, что уже понятно (сложность, инварианты)
## Проверь себя
<details>
<summary>1. Почему для top-K наибольших элементов в потоковом режиме нужен именно min-heap размера k, а не max-heap?</summary>
Min-heap хранит k текущих кандидатов, и на вершине (O(1) доступ) всегда самый маленький из
них — именно с ним сравнивают каждый новый элемент: если новый больше минимума кучи, минимум
вытесняется, новый занимает его место. Max-heap размера k показывал бы на вершине самый
большой из текущих k кандидатов, что не даёт дешёвого способа проверить "стоит ли вытеснять
кого-то" — пришлось бы искать минимум отдельно, теряя весь выигрыш от кучи.
</details>
<details>
<summary>2. Процесс завершился с кодом 139. Что произошло и какой командой в коде это разбирают?</summary>
139 = 128 + 11, то есть процесс убит сигналом SIGSEGV — падение по памяти (например,
разыменование невалидного указателя). В коде это разбирается макросами WIFSIGNALED(status) и
WTERMSIG(status) над status, полученным из wait/waitpid, а не вычитанием 128 вручную.
</details>
<details>
<summary>3. Почему epoll_wait даёт O(1) на готовое событие, а select — O(n) на весь набор при каждом вызове?</summary>
select при каждом вызове проходит весь переданный набор дескрипторов целиком, чтобы понять,
какие из них готовы — работа пропорциональна общему числу отслеживаемых дескрипторов
независимо от того, сколько реально готовы. epoll разносит регистрацию (epoll_ctl, список
интереса на красно-чёрном дереве в ядре) и ожидание (epoll_wait): ядро само добавляет
дескриптор в список готовых асинхронно, когда его состояние меняется, и epoll_wait просто
отдаёт содержимое этого списка — работа пропорциональна числу реально готовых дескрипторов.
</details>
<details>
<summary>4. Для 10.0.1.130/26 назовите адрес сети и диапазон доступных хостов, объяснив расчёт.</summary>
/26 оставляет 6 бит под хосты (32-26=6), это 64 адреса всего, из них 62 доступны хостам
(минус адрес сети и broadcast). Маска — 255.255.255.192, последний октет сети получается
округлением 130 вниз до кратного 64: сеть 10.0.1.128, broadcast 10.0.1.191 (128+63), диапазон
хостов — 129..190.
</details>
<details>
<summary>5. Почему TCP-рукопожатие требует три сегмента, а не два?</summary>
Соединение TCP полнодуплексное — у каждого направления свой независимый ISN (начальный
порядковый номер), и обе стороны должны не только сообщить свой ISN, но и получить
подтверждение, что собеседник его получил. После SYN и SYN-ACK сервер ещё не знает, дошёл ли
его SYN-ACK до клиента — только финальный ACK клиента даёт это подтверждение и завершает
синхронизацию обоих направлений.
</details>
<details>
<summary>6. Чем зомби-процесс отличается от процесса-сироты и что физически занимает зомби?</summary>
Зомби — завершившийся процесс, чья запись (код возврата) ещё не забрана родителем через
wait/waitpid; он не занимает память, но занимает слот в таблице процессов. Сирота — процесс,
чей родитель умер раньше него; его усыновляет init/systemd (PID 1), и это состояние не
связано с зомби напрямую — сирота продолжает работать, зомби уже завершился и ждёт только
уборки записи.
</details>
## Материалы
- cppreference, `std::unordered_map` — https://en.cppreference.com/w/cpp/container/unordered_map
- cppreference, `std::move` — https://en.cppreference.com/w/cpp/utility/move
- man7.org, `epoll(7)` — https://man7.org/linux/man-pages/man7/epoll.7.html
- man7.org, `mmap(2)` — https://man7.org/linux/man-pages/man2/mmap.2.html
- man7.org, `wait(2)` — https://man7.org/linux/man-pages/man2/wait.2.html
- RFC 793, Transmission Control Protocol — https://datatracker.ietf.org/doc/html/rfc793