# База перед тех-скринингом — углублённая версия Расширенная версия `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 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`, то есть полноценный контейнер STL: у него есть итераторы, `size()`, `begin()`/`end()`, и данные лежат непрерывно в памяти, как у `vector`. Из непрерывности следует, что `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` — это не одна процессорная операция, а последовательность "прочитать значение — прибавить единицу — записать обратно" (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 печатает содержимое конкретного коммита — сообщение и 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.