23. Linux и ОС

«UNIX прост. Просто нужно быть гением, чтобы понять его простоту».
— Деннис Ритчи

  1. Что такое процесс?
  2. Что такое поток?
  3. Что такое системный вызов?
  4. Что такое epoll?
  5. Как работает сеть в Linux?
  6. Что такое select, poll, epoll отличия?
  7. Что такое inode?
  8. Что такое файловый дескриптор?
  9. Что такое page cache?
  10. Что такое buffer cache?
  11. Что такое виртуальная память?
  12. Что такое MMU (Memory Management Unit)?
  13. Что такое page fault?
  14. Что такое TLB (Translation Lookaside Buffer)?
  15. Что такое NUMA?
  16. Что такое сgroups (control groups)?
  17. Что такое namespaces?
  18. Как работает Docker с точки зрения Linux?
  19. Что такое OOM Killer?
  20. Как посмотреть использование памяти процессом (/proc/[pid]/smaps)?
  21. Как работает fork и exec?
  22. Что такое init процесс (PID 1) и его роль?
  23. Что такое daemon и как его создать?
  24. Как работают сигналы (SIGTERM, SIGKILL, SIGSEGV)?
  25. Что такое "зомби процесс" и как с ним бороться?
  26. Что такое "сирота" процесс?
  27. Что такое "контекст переключения" (context switch) и почему это дорого?
  28. Как работает планировщик задач в Linux (CFS, EEVDF)?
  29. Что такое nice и priority процесса?
  30. Как работает strace и как его использовать?
  31. Как работает lsof?
  32. Как работает tcpdump?
  33. Как работает iptables / nftables?
  34. Как работает TCP рукопожатие и TIME_WAIT состояние?
  35. Что такое backlog в TCP?
  36. Что такое SO_KEEPALIVE?
  37. Что такое SO_REUSEPORT?
  38. Что такое SO_RCVBUF / SO_SNDBUF?
  39. Как работает нагрузка (load average) в Linux?
  40. Что такое "kernel panic"?
  41. Как работает copy-on-write (COW)?
  42. Что такое "huge pages" и зачем они нужны?
  43. Как работает seccomp?
  44. Что такое "файловая система" и как работает VFS (Virtual File System)?
  45. Что такое "inotify" и как его использовать для отслеживания изменений?
  46. Как работает sysfs и procfs?
  47. Что такое "unix domain sockets" и их преимущества перед TCP?
  48. Как работает "memory mapping" (mmap) и зачем он нужен?
  49. Что такое "asynchronous IO" (AIO) и "io_uring"?
  50. Как работает "netlink" сокеты?
  51. Что такое os.Root в Go 1.24?

Что такое процесс?

Краткий ответ (30–60 секунд)

Процесс — это экземпляр исполняемой программы, обладающий собственным адресным пространством, одним или несколькими потоками, файловыми дескрипторами и контекстом выполнения. Ядро Linux представляет каждый процесс структурой task_struct, которая хранит всё его состояние. Процессы изолированы друг от друга средствами виртуальной памяти и могут взаимодействовать только через явные механизмы IPC.

Развёрнутый ответ

Процесс является основной единицей управления ресурсами в Linux. Он включает:

  • Идентификатор (PID), родительский PID, идентификаторы сессии и группы процессов.
  • Адресное пространство, описываемое mm_struct: сегменты кода, данных, кучи, стека, отображённых файлов, библиотек.
  • Таблицу файловых дескрипторов (files_struct), указывающую на открытые файлы, сокеты, каналы.
  • Контекст безопасности (credentials, capabilities, пространства имён).
  • Статистику использования ресурсов (время CPU, page faults, сигналы).

Жизненный цикл:

  1. Создание: fork() + exec() (или clone() с флагами). При fork дочерний процесс наследует копию родительского task_struct, но разделяет страницы памяти по механизму Copy-on-Write.
  2. Выполнение: планировщик ядра выделяет кванты времени в соответствии с политикой (CFS/EEVDF) и значением nice.
  3. Завершение: exit(), переход в состояние зомби до вызова wait() родителем.

В Go процессы запускаются через os/exec, который под капотом вызывает fork/exec (с предварительной блокировкой горутин и потоков для безопасного форка).

Под капотом (internals)

  • task_struct (определена в include/linux/sched.h) — одна из самых тяжеловесных структур ядра, содержит вложенные структуры mm_struct, fs_struct, files_struct, signal_struct.
  • Управление PID: pid_namespace позволяет изоляцию; поиск процесса по PID через radix tree или хеш-таблицу.
  • Создание: _do_fork() -> copy_process() копирует почти всё, но для памяти использует COW — страницы помечаются как read-only, а при записи do_wp_page() создаёт копии.
  • Планировщик: task_struct содержит sched_entity со значениями vruntime, которые определяют очерёдность выполнения.

Типичные ошибки кандидатов

  1. Путаница процесса и программы
    Процесс — динамический объект (исполняющийся экземпляр), программа — статический файл на диске. Ошибка в том, что при вопросе «что такое процесс» отвечают «это программа в исполнении», но не раскрывают свойства, ресурсы, структуры. Исправить: дать чёткое различие, упомянуть task_struct и жизненный цикл.

  2. Утверждение, что fork копирует всю память процесса
    На самом деле используется Copy-on-Write, иначе создание процесса было бы катастрофически медленным. Кандидат должен показать понимание COW. В контексте Go важно, что os/exec тоже опирается на fork, а горутины могут привести к проблемам, если форкать многопоточный процесс без немедленного exec.

  3. Игнорирование различий между потоками и процессами
    Часто кандидаты говорят, что процессы изолированы, а потоки разделяют всё, но не уточняют, что на уровне Linux и то и другое — задачи (task_struct), а разница в флагах clone. Надо объяснить, что потоки разделяют mm, files, fs, sighand и поэтому менее изолированы.

Возможные дополнительные вопросы интервьюера

  • Может ли процесс существовать без потоков? — По определению у каждого процесса есть как минимум один поток выполнения (главный поток). При его завершении процесс заканчивается.

  • Почему в Go нельзя безопасно использовать системный вызов Fork напрямую? — Потому что fork дублирует только вызвавший поток, оставляя другие горутины/потоки в странном состоянии. Горутины могут исполняться на других потоках, и после форка они исчезнут, что приведёт к deadlock’ам. Поэтому os/exec использует fork с последующим exec, минимизируя промежуток.

  • Чем отличается pid namespace от виртуального PID внутри контейнера? — pid namespace создаёт иерархию, в которой процесс имеет разные PID в разных пространствах имён. Процесс с PID 1 в контейнере видит только себя и потомков, изолированных от хостовой таблицы процессов.


Что такое поток?

Краткий ответ (30–60 секунд)

Поток (thread) — это легковесная единица выполнения, которая разделяет адресное пространство и другие ресурсы с другими потоками в рамках одного процесса. В Linux потоки реализованы через clone() с флагами CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND, т.е. являются такими же task_struct, но с общим mm_struct. Это снижает накладные расходы на переключение контекста по сравнению с процессами, но требует осторожной синхронизации.

Развёрнутый ответ

Потоки в Linux появились как «легковесные процессы» (LWP) в рамках стандарта POSIX-нитей (pthreads). Реализация NPTL (Native POSIX Thread Library) использует clone с нужными флагами:

  • CLONE_VM — разделение виртуальной памяти (тот же mm_struct).
  • CLONE_FILES — общая таблица файловых дескрипторов.
  • CLONE_FS — общий корень и текущий каталог.
  • CLONE_SIGHAND — общая таблица обработчиков сигналов.
  • CLONE_THREAD — помечает, что это поток одной thread-группы (тот же TGID, равный PID лидера).

Каждый поток имеет собственный стек, регистры, счётчик команд и отдельный task_struct, но разделяет heap, глобальные переменные, код. Переключение между потоками одного процесса дешевле, чем между процессами, потому что не нужно переключать адресное пространство (не сбрасывается TLB полностью, если нет смены процесса), но всё равно требует сохранения контекста регистров.

Go работает поверх потоков ОС, реализуя легковесные горутины. GOMAXPROCS потоков ОС (M) выполняют горутины, при блокирующих системных вызовах поток может быть временно отсоединён от горутины и заменён другим, минимизируя простой.

Под капотом (internals)

  • В ядре каждый поток — отдельный task_struct, но поле mm указывает на один и тот же mm_struct. Поле tgid равно PID лидера thread-группы (то, что пользователь видит как PID процесса).
  • При exit потока, если он последний, mm_struct и другие разделяемые ресурсы освобождаются.
  • Планировщик CFS/EEVDF оперирует задачами (sched_entity привязаны к каждому task_struct), без различия процессов и потоков; он просто распределяет CPU между всеми runnable задачами.
  • Контекст переключения: switch_to() сохраняет регистры, меняет task_struct, но при сохранении mm переключения TLB не требуется, если новый поток того же процесса.

Типичные ошибки кандидатов

  1. Считать, что потоки всегда быстрее процессов
    Хотя в Linux потоки создаются быстрее и переключаются с меньшими затратами (нет переключения адресного пространства), выигрыш не абсолютен. При большой конкуренции блокировки могут нивелировать преимущества. Исправить: обсудить конкретные механизмы планировщика и TLB.

  2. Незнание о tgid и thread-группах
    Многие говорят "поток — тоже процесс", но не указывают, как ОС группирует потоки. При опросе getpid() из потока возвращается tgid, а истинный PID потока — gettid(). Важно понимать, как это работает.

  3. Игнорирование проблем с сигналами в многопоточных программах
    Сигналы направляются всей thread-группе; обрабатываются любым потоком, не имеющим замаскированного сигнала. Система требует осторожности, особенно в Go, где среда выполнения перехватывает сигналы, чтобы корректно останавливать горутины или паниковать при SIGSEGV.

Возможные дополнительные вопросы интервьюера

  • Какой системный вызов скрывается под созданием потока в Linux? — clone с флагами, аналогичными тем, что я описал. Современные pthread_create используют clone внутри. Можно показать вывод strace -f.

  • Что такое M:N модель планирования и как она соотносится с горутинами Go? — Go использует модель M:N: M горутин распределяются на N потоков ОС. Планировщик Go сам решает, когда горутина выполняется, а когда блокируется, переключая контекст в пользовательском пространстве без задействования тяжёлых переключений ОС.

  • Почему слишком большое количество потоков ОС вредно? — Каждый поток потребляет память под стек (по умолчанию 8 МБ виртуальной памяти), task_struct (~10 КБ), и создаёт нагрузку на планировщик, увеличивая время выбора следующего потока. Горутины же стартуют с 2 КБ стека и динамически растут.


Что такое системный вызов?

Краткий ответ (30–60 секунд)

Системный вызов (syscall) — это интерфейс между пользовательским пространством и ядром, позволяющий программам запрашивать привилегированные операции: доступ к оборудованию, управление процессами, файловый ввод-вывод. Программа выполняет инструкцию syscall (или int 0x80 на старых x86), передавая номер вызова и аргументы через регистры, тем самым переключаясь в режим ядра, где диспетчер вызывает соответствующую функцию.

Развёрнутый ответ

Системные вызовы — фундамент безопасности и абстракции в UNIX. Они делятся на категории: файловые операции (open, read, write), управление процессами (fork, exec, exit), сеть (socket, bind, connect), управление памятью (mmap, brk). Пользовательская программа не может напрямую писать в порты ввода-вывода или менять таблицы страниц; всё это выполняется ядром.

Механизм:

  1. Приложение помещает номер syscall в регистр (x86-64: rax) и аргументы в rdi, rsi, rdx, r10, r8, r9.
  2. Вызывает инструкцию syscall (быстрый путь) или старый int 0x80.
  3. Процессор переключается в Ring 0, сохраняет контекст, выполняет handler из MSR-регистров.
  4. Ядро по номеру находит запись в таблице sys_call_table, вызывает функцию-обработчик.
  5. Результат помещается в rax, для ошибок устанавливается флаг переноса, а отрицательное значение -errno возвращается в rax.
  6. Возврат через sysret в Ring 3.

В Go стандартная библиотека (syscall, golang.org/x/sys/unix) оборачивает системные вызовы в функции Go. Планировщик Go маркирует горутину как «входящую в syscall» (entersyscall) и прикрепляет поток (M) к этому вызову; если syscall блокируется долго, runtime может отсоединить P и передать другому M, чтобы не простаивал.

flowchart TD
    classDef entry   fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    G1["Горутина G исполняется на M и P"]:::entry --> SysCall["Вызов системной функции (read/write/open)"]:::process
    SysCall --> Enter["runtime.entersyscall(): перевод G в _Gsyscall"]:::process
    Enter --> Block{"Блокировка > 10-20 мкс?"}:::warning

    Block -- "Да (долгий syscall)" --> Sysmon["Поток sysmon отсоединяет процессор P (handoffp)"]:::warning
    Sysmon --> NewM["P захватывается свободным потоком M для других G"]:::process

    Block -- "Нет (быстрый syscall)" --> Kernel["Ядро Linux исполняет вызов в Ring 0"]:::accent
    Sysmon --> Kernel

    Kernel --> RetSys["Возврат в userspace (Ring 3)"]:::process
    RetSys --> Exit["runtime.exitsyscall()"]:::process
    Exit --> CheckP{"Есть ли свободный P?"}:::warning
    CheckP -- "Да" --> Resume["G связывается с P и продолжает выполнение"]:::success
    CheckP -- "Нет" --> Park["G кладётся в глобальную очередь; поток M засыпает"]:::process
  

Под капотом (internals)

  • arch/x86/entry/entry_64.S: обработчик entry_SYSCALL_64, сохраняет регистры, вызывает do_syscall_64, затем выходит.
  • Таблица системных вызовов: массив sys_call_table, определённый в arch/x86/entry/syscalls/syscall_64.tbl. Каждый элемент — указатель на функцию.
  • Процесс трассировки: ptrace-основанные инструменты типа strace перехватывают syscall на входе и выходе, временно заменяя обработчик прерывания/точки останова.
  • Важно: системные вызовы могут долго не возвращаться, что влияет на планировщик. В Go для мониторинга затянувшихся syscall есть sysmon, который проверяет потоки и при необходимости открепляет P.

Типичные ошибки кандидатов

  1. Путаница между библиотечными функциями и системными вызовами
    Часто называют printf системным вызовом, хотя это библиотечная функция C, которая в итоге может вызвать write. Правильно провести границу: fread/fwrite — буферизованные библиотечные функции, read/write — обёртки над syscall.

  2. Мнение, что системные вызовы всегда медленные и этого следует избегать любой ценой
    Накладные расходы включают смену кольца, но современные процессоры имеют оптимизации (инструкция syscall ~ несколько сотен циклов). Вопрос в их амортизации: к примеру, буферизация в netpoller снижает частоту syscall. Но в Go системный вызов read с неблокирующим сокетом быстр, и его активно используют.

  3. Незнание, как Go обрабатывает ошибки syscall
    Go преобразует возврат ошибки (отрицательное число -errno) в error через syscall.Errno. Типичное непонимание, что EAGAIN и EINTR могут требовать повторного вызова, и как это делает рантайм.

Возможные дополнительные вопросы интервьюера

  • Что происходит в планировщике Go при системном вызове, который блокируется надолго? — Горутина переводится в состояние Gsyscall, P отпускается и может быть передан другому M. Sysmon отслеживает зависший M, отсоединяет P, может создать новый M.

  • Почему Go не предоставляет прямого доступа к системному вызову fork? — Это небезопасно в многопоточной среде. Пакет syscall содержит ForkExec для безопасного запуска процессов через fork + exec. Прямой fork отсутствует.

  • Как узнать, какой именно syscall упал, анализируя strace? — Нужно смотреть строки вида read(3, 0x..., 1024) = -1 EAGAIN; номер syscall можно выяснить по отладочным символам или макросу.


Что такое epoll?

Краткий ответ (30–60 секунд)

epoll — масштабируемый механизм ядра Linux для мультиплексирования ввода-вывода, позволяющий эффективно отслеживать готовность множества файловых дескрипторов. Он создаётся через epoll_create, а регистрация интересов — epoll_ctl, после чего epoll_wait блокируется до появления готовых событий. В отличие от select/poll, epoll имеет сложность O(1) по активным дескрипторам благодаря регистрации интересов и ожиданию на списке готовых событий.

Развёрнутый ответ

Мотивация: select и poll при каждом вызове передают полный набор наблюдаемых дескрипторов, ядро его перебирает, что приводит к O(n)-проблеме при большом количестве соединений. epoll решает эту проблему, разделяя регистрацию и ожидание.

API:

  • epoll_create1(flags) — создаёт экземпляр epoll, возвращает файловый дескриптор.
  • epoll_ctl(epfd, op, fd, event) — добавляет (EPOLL_CTL_ADD), модифицирует или удаляет отслеживание fd. В структуре epoll_event указывается интересующая маска (EPOLLIN, EPOLLOUT, EPOLLET и т.д.) и пользовательские данные.
  • epoll_wait(epfd, events, maxevents, timeout) — возвращает массив готовых событий, блокируясь до timeout миллисекунд.

Два режима:

  • Level-triggered (LT): если после обработки данные остались в буфере, epoll_wait будет снова сообщать о готовности. Проще, но потенциально лишние пробуждения.
  • Edge-triggered (ET): событие генерируется только при изменении состояния (например, приход новых данных). Требует неблокируемого чтения до EAGAIN и аккуратной обработки, чтобы не пропустить новый ввод.

Go runtime на Linux использует epoll для реализации netpoller. При вызове net.Listen или net.Dial создаётся неблокирующий сокет, который регистрируется в глобальном epoll-дескрипторе. Когда горутина выполняет операцию Read() на сокете без данных, она паркуется; как только epoll сообщает о готовности, сетевая поллерная горутина или sysmon пробуждает соответствующую горутину.

flowchart TD
    classDef entry   fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Init["Инициализация Netpoller: epoll_create1()"]:::entry --> Register["Регистрация сокета: epoll_ctl(EPOLL_CTL_ADD, fd, EPOLLIN|EPOLLOUT|EPOLLET)"]:::process
    Register --> GRead["Горутина вызывает conn.Read()"]:::process
    GRead --> NonBlock{"read() вернул EAGAIN?"}:::warning

    NonBlock -- "Нет (байты есть)" --> ReadOK["Данные скопированы в буфер; G продолжает работу"]:::success
    NonBlock -- "Да (нет данных)" --> ParkG["gopark(): G переходит в _Gwaiting и паркуется в pollDesc"]:::process

    ParkG --> FreeM["Поток M освобождается и выполняет другие горутины"]:::accent

    subgraph NetpollLoop ["Фоновый цикл Netpoller (runtime.netpoll / sysmon)"]
        PollWait["epoll_wait(epfd, events, max, timeout)"]:::process --> EventReady["Событие готовности I/O от ядра Linux"]:::accent
        EventReady --> Unpark["goready(g): перевод горутины в _Grunnable"]:::success
    end

    FreeM -.-> NetpollLoop
    Unpark --> Schedule["G возвращается в runq процессора P и продолжает чтение"]:::success
  

Под капотом (internals)

  • Структура eventpoll в ядре содержит: красно-чёрное дерево (rbr) для быстрого поиска/добавления отслеживаемых дескрипторов (по fd) и двусвязный список готовых (rdllist).
  • При добавлении fd через epoll_ctl, ядро регистрирует обратный вызов в структуре файла (file->f_op->poll). Когда файл (например, сокет) становится готовым, вызывается этот callback, который добавляет элемент в список готовых и пробуждает ожидающие epoll_wait.
  • epoll_wait проверяет список готовых; если он пуст, поток засыпает на очереди ожидания wq, встроенной в eventpoll.
  • Благодаря тому, что обратный вызов срабатывает при изменении состояния, epoll информирует только активные дескрипторы, что и даёт O(1) по активным, в отличие от O(n) всех отслеживаемых у select.

Типичные ошибки кандидатов

  1. Использование level-triggered по умолчанию без понимания edge-triggered
    В высоконагруженных серверах ET снижает количество системных вызовов epoll_wait, но требует аккуратного цикла чтения до EAGAIN. Ошибка — написать цикл, который не читает все данные, и потерять уведомление. Исправить: чётко контролировать цикл неблокируемого чтения.

  2. Добавление одного fd несколько раз
    epoll позволяет добавить fd лишь один раз. Повторная попытка с EPOLL_CTL_ADD возвращает EEXIST. Кандидаты часто забывают об этом. Нужно использовать EPOLL_CTL_MOD для изменения маски.

  3. Потокобезопасность
    Хотя epoll_wait потокобезопасен (можно вызывать из нескольких потоков, но одно событие доставляется только одному), при модификации через epoll_ctl из разных потоков нужна осторожность. Неправильное удаление fd, пока другой поток ожидает его, приводит к гонке. В Go netpoller решает это централизованным epoll-дескриптором под управлением одного мониторящего потока.

Возможные дополнительные вопросы интервьюера

  • Как Go использует epoll и почему в netpoller только один epoll-дескриптор? — Единый epoll дескриптор создаётся рантаймом (см. runtime/netpoll_epoll.go). Это экономит ресурсы и централизует polling. Сетевая poller-горутина (netpoll) вызывает epoll_wait и будит соответствующие горутины.

  • Что такое EPOLLONESHOT и когда применять? — Флаг, при котором после срабатывания события fd автоматически удаляется из мониторинга, и его нужно заново добавить через epoll_ctl. Применяется, когда один fd обрабатывается пулом потоков: гарантирует, что только один поток обработает событие.

  • Какая проблема с edge-triggered и многопоточностью? — Если несколько потоков ожидают на одном epoll в ET, одно уведомление может разбудить только один поток; остальные не увидят данных. Требуется аккуратный дизайн или использование EPOLLONESHOT.


Как работает сеть в Linux?

Краткий ответ (30–60 секунд)

Сетевой стек Linux обрабатывает пакеты от оборудования до приложения через многоуровневую архитектуру: драйвер сетевой карты, NAPI/softirq для приёма, netfilter для фильтрации, маршрутизация, транспортный уровень (TCP/UDP) и, наконец, сокет в пользовательском пространстве. Приложение использует системные вызовы socket, bind, listen, accept, connect, send/recv, взаимодействуя с буферами ядра, что реализует модель клиент-сервер.

Развёрнутый ответ

Сеть в Linux — это не монолитный блок, а слоёный стек:

  1. Аппаратный уровень: сетевая карта, драйвер (например, igb, ixgbe). При поступлении пакета DMA'и помещают его в кольцевые буферы (RX ring), генерируется прерывание.
  2. NAPI (New API): для снижения нагрузки прерываний драйвер отключает прерывания и использует механизм опроса (softirq NET_RX), планировщик softirq обрабатывает несколько пакетов за раз.
  3. Netfilter: после входа пакет проходит хуки NF_INET_PRE_ROUTING, затем маршрутизация, NF_INET_LOCAL_IN (если пакет адресован нам), и на выходе NF_INET_LOCAL_OUT, NF_INET_POST_ROUTING. iptables/nftables регистрируют правила именно на этих хуках.
  4. Транспортный уровень: протокол TCP или UDP обрабатывает сегменты. TCP собирает поток, управляет окнами, повторными передачами, состояниями. Пакет помещается в очередь приёма сокета (sk_receive_queue).
  5. Сокет: структура sock (часть inet_sock) связана с файловым дескриптором процесса. Системный вызов read/recv изымает данные из очереди, если они есть, иначе блокируется (до таймаута или события, управляемого epoll).

Go оборачивает сетевые операции в удобные библиотеки net. net.Listen("tcp", ":8080") создаёт сокет, устанавливает SO_REUSEADDR, неблокирующий режим и регистрирует в epoll. Горутина Accept() ожидает на epoll, при новом соединении создаётся новый fd, который также регистрируется в epoll для чтения/записи.

Под капотом (internals)

  • Структура sk_buff представляется пакетом, передаётся между уровнями. Содержит указатели на заголовки разных протоколов, данные, управляющую информацию.
  • Обработка softirq: do_softirq запускает net_rx_action, который вызывает poll() на драйвере. Драйвер передаёт полученные sk_buff в стек протоколов через netif_receive_skb.
  • TCP: tcp_v4_rcv вызывается после маршрутизации. Функция поддержания конечного автомата (FSM) обрабатывает состояние сокета (LISTEN, ESTABLISHED и т.д.). При приходе ACK на SYN для слушающего сокета создаётся новый сокет (tcp_v4_syn_recv_sock) и помещается в очередь accept.
  • Буферы сокета: sk->sk_receive_queue (входящие) и sk->sk_write_queue (исходящие). Их размеры контролируются через SO_RCVBUF и SO_SNDBUF, ядро автомасштабирует в зависимости от нагрузки.

Типичные ошибки кандидатов

  1. Путаница с уровнями: "драйвер кладёт пакет прямо в сокет"
    На самом деле между ними — NAPI, netfilter, протоколы TCP/IP. Описание должно отражать цепочку.

  2. Заблуждение, что epoll является частью сети как таковой
    epoll — это универсальный механизм опроса, а сеть использует его через API файлового дескриптора. Правильно: сокет как файловый дескриптор регистрируется в epoll, который затем оповещает о готовности.

  3. Непонимание, почему read на неблокирующем сокете может вернуть EAGAIN
    Это означает, что данных нет, и нужно ожидать готовности через epoll. Кандидаты иногда думают, что EAGAIN — это фатальная ошибка, а не норма в неблокирующем режиме.

Возможные дополнительные вопросы интервьюера

  • Какой путь проходит TCP-пакет в ядре от получения до пробуждения epoll? — Драйвер -> NAPI -> netif_receive_skb -> netfilter PREROUTING -> маршрутизация -> tcp_v4_rcv -> состояние ESTABLISHED -> tcp_data_queue -> добавление в sk_receive_queue -> вызов callback-функции, зарегистрированной в очереди ожидания сокета; epoll callback добавляет событие, будит epoll_wait.

  • Что происходит, когда заполняется sk_receive_queue? — TCP объявляет нулевое окно (window 0), отправитель переходит в режим persist-таймера. Данные в очереди обрабатываются приложением, после чего окно открывается.

  • Как работает sendfile и где он выгоден? — Системный вызов sendfile копирует данные из файлового дескриптора напрямую в сокет, не пересекая пользовательское пространство, используя механизм splice или page cache, уменьшая число копирований.


Что такое select, poll, epoll отличия?

Краткий ответ (30–60 секунд)

select, poll и epoll — это механизмы мультиплексирования ввода-вывода в Linux, позволяющие одному потоку ожидать готовности на множестве файловых дескрипторов. Ключевые отличия: select ограничен дескрипторами до 1024, poll лишён этого ограничения, но оба требуют при каждом вызове передавать полный список, что даёт O(n) сложность. epoll отделяет регистрацию от ожидания, обеспечивая O(1) относительно активных дескрипторов, и является предпочтительным для высоконагруженных сетевых сервисов.

Развёрнутый ответ

  • select: В аргументы передаются три битовые маски (read, write, except). Внутри ядро сканирует все дескрипторы в масках, проверяя готовность. Максимальное число дескрипторов задано FD_SETSIZE (обычно 1024), при большем количестве надо пересобирать ядро. После возврата приложение снова обходит все маски для поиска готовых. Неудобен при тысячах соединений.
  • poll: Вместо битовых масок использует массив структур pollfd, поэтому ограничений на количество дескрипторов нет. Однако алгоритм по-прежнему O(n), так как ядро обходит весь массив для проверки статуса.
  • epoll: Разделяет операции: однократная регистрация интересов (epoll_ctl), после чего ожидание только на активных событиях (epoll_wait). Ядро использует callback-механизм для пробуждения при изменении состояния fd. Это даёт эффективность O(1) по активным дескрипторам. Поддерживает Edge-Triggered и Level-Triggered режимы.

Сравнительная таблица:
| Характеристика | select | poll | epoll |
|-------------------|------------------|------------------|----------------|
| Ограничение FD | FD_SETSIZE (1024) | Нет | Нет |
| Сложность вызова | O(всех fd) | O(всех fd) | O(активных) |
| Передача интересов| При каждом вызове | При каждом вызове | Один раз |
| Режимы триггера | Только LT | Только LT | LT + ET |
| Сброс интересов | Не нужен | Не нужен | Автоматически, кроме EPOLLONESHOT |

Go выбирает наилучший доступный механизм: на Linux — epoll, на BSD/Darwin — kqueue. Это инкапсулировано в netpoller и невидимо разработчику.

Под капотом (internals)

  • Реализация select: do_select обходит маски и вызывает poll для каждого fd. Использует подход «наивного» ожидания на нескольких очередях.
  • poll: аналогично do_pollfd, перебор массива.
  • epoll: сложная структура с красно-чёрным деревом и списком готовых + файловые callback'и, зарегистрированные через poll_wait. При модификации состояния файла (например, при добавлении данных в сокет) вызывается ep_poll_callback, который добавляет элемент в список готовых и пробуждает ожидающий процесс.
  • Исторически epoll добавлен в 2.5.44 (2002 г.) именно для решения проблемы C10K.

Типичные ошибки кандидатов

  1. Сказать, что select хорош для небольшого числа дескрипторов
    Хотя это формально верно, на практике даже при небольших fd epoll предпочтительнее, так как системный вызов epoll_wait требует лишь ожидания, не повторяя регистрацию. При написании нового кода используют epoll, если не требуется портабельность.

  2. Не знать, что poll также копирует массив в ядро при каждом вызове
    Это важная деталь: при большом количестве fd копирование структуры ощутимо. Эффективность ниже, чем у epoll.

  3. Утверждение, что epoll автоматически масштабируется без настройки
    Системные параметры /proc/sys/fs/epoll/max_user_watches ограничивают количество регистраций. Игнорирование этого лимита ведёт к ошибкам.

Возможные дополнительные вопросы интервьюера

  • В каком случае select может быть предпочтительнее epoll? — В основном для портирования legacy Unix-кода или в учебных целях. Также в сценариях, где дескрипторы меняются очень часто, но это редкость; практически всегда используют epoll.

  • Как можно достичь эффекта epoll в Windows? — Используют IOCP (I/O Completion Ports). Go реализует netpoller с использованием IOCP на Windows, обеспечивая аналогичный асинхронный интерфейс.

  • Почему epoll не гарантирует, что событие не потеряется при ET+многопоточности? — Потому что при ET событие доставляется только один раз при изменении состояния. Если два потока ждут на одном epoll, один может разбудиться, прочитать часть данных, но не вызвать новое изменение, и другой поток не узнает о данных. Решение: EPOLLONESHOT или single-threaded обработка.


Что такое inode?

Краткий ответ (30–60 секунд)

Inode (индексный дескриптор) — это структура данных в файловой системе, содержащая метаданные файла (размер, права доступа, временные метки, ссылки на блоки данных), но не его имя. Каждый объект файловой системы (обычный файл, директория, символьная ссылка) имеет уникальный номер inode в пределах одной файловой системы. Жёсткие ссылки ссылаются на один и тот же inode, тогда как символические — на имя.

Развёрнутый ответ

Inode — ключевое понятие файловых систем UNIX. Он хранит:

  • Тип файла (обычный, директория, символьная/блочная ссылка, FIFO, сокет).
  • Размер в байтах.
  • Права доступа (mode), владелец (UID, GID).
  • Временные метки: access time (atime), modification time (mtime), change time (ctime).
  • Количество жёстких ссылок.
  • Указатели на блоки данных: прямые, косвенные, двойные/тройные косвенные (зависят от ФС).

Имя файла хранится не в inode, а в записях директорий (dentry), которые связывают имя с номером inode. Это позволяет реализовать жёсткие ссылки: несколько имён указывают на один inode, увеличивая счётчик ссылок. При удалении файла unlink уменьшает счётчик; когда он достигает 0, файловая система может освободить блоки.

В Go os.Stat("file") возвращает интерфейс os.FileInfo, который за кулисами получает поля из системного вызова stat, заполняющего syscall.Stat_t (структуру с inode-информацией, включая Ino — номер inode). Две жёсткие ссылки дадут одинаковый Ino.

Под капотом (internals)

  • В ядре inode представлен структурой struct inode (объявлена в include/linux/fs.h). Она содержит поля, отражающие он-диск структуру, плюс операционные поля (например, i_mapping — address_space для page cache).
  • В ext4 on-disk inode — struct ext4_inode (размер 256 байт по умолчанию). Таблица inode выделяется при форматировании; исчерпание inode делает невозможным создание новых файлов даже при наличии свободных блоков.
  • Inode cache (inode cache) в памяти ускоряет операции. Используются хеш-таблицы (inode_hashtable) для поиска по номеру inode и суперблоку.

Типичные ошибки кандидатов

  1. Путать inode с файлом или его именем
    Файл — это абстракция, имя — запись в директории, inode — метаданные. Ошибка: сказать, что inode содержит имя файла. Исправление: чётко разделять: имя хранит dentry, inode — метаданные и блоки.

  2. Не знать, что исчерпание inode может привести к No space left on device
    Сообщение одинаковое, но причина иная: закончились not блоки, а inode’ы. Команда df -i показывает использование inode. Кандидат должен это знать.

  3. Неверное представление о жёстких ссылках и счётчике
    Часто думают, что при удалении файла, на который есть жёсткая ссылка, удаляются данные. Данные удаляются только при падении link count до 0 и закрытии всех файловых дескрипторов.

Возможные дополнительные вопросы интервьюера

  • Как ядро находит inode по номеру в ext4? — Суперблок содержит указатель на таблицу inodes. Номер inode умножается на размер inode, вычисляется смещение в таблице. При включённом кэше сначала ищется в inode cache.

  • Что произойдёт с inode, если процесс держит файл открытым, а другой процесс удаляет его (unlink)? — Запись директории удаляется, счётчик ссылок уменьшается. Если он стал 0, но fd ещё открыт, inode остаётся в памяти и данные не освобождаются до закрытия последнего дескриптора. Это свойство используется для временных файлов: немедленно unlink после создания.

  • Как получить номер inode из Go-кода? — info, _ := os.Stat("file"); stat := info.Sys().(*syscall.Stat_t); ino := stat.Ino. Это даст номер inode, уникальный в пределах ФС.


Что такое файловый дескриптор?

Краткий ответ (30–60 секунд)

Файловый дескриптор — это целое неотрицательное число, служащее индексом в таблице открытых файлов процесса, указывающее на структуру file в ядре, которая представляет открытый файл, сокет, канал или устройство. Через дескриптор выполняются все операции ввода-вывода (read, write, close). После fork дочерний процесс наследует таблицу дескрипторов от родителя.

Развёрнутый ответ

Дескриптор — абстракция, скрывающая детали реализации объекта ввода-вывода. Каждый процесс имеет свою таблицу files_struct->fd_array, где каждый слот указывает на общий объект struct file. Общий объект содержит:

  • Флаги открытия (O_RDONLY, O_WRONLY, O_RDWR, O_NONBLOCK, O_APPEND).
  • Текущее смещение (file position) для последовательного доступа.
  • Указатель на file_operations — таблицу функций (read, write, llseek, poll), реализующих операции для данного типа файла.
  • Ссылку на dentry и inode.

Несколько дескрипторов (даже в разных процессах) могут ссылаться на один struct file, например через dup или наследование.

В Go *os.File оборачивает fd, предоставляя методы Read, Write, Close. При создании через os.Open вызывается системный вызов open, возвращающий fd. Важно закрывать файлы, иначе утечка дескрипторов может привести к исчерпанию лимита и ошибкам «too many open files».

Под капотом (internals)

  • struct files_struct (в include/linux/fdtable.h) — содержит указатель на массив указателей struct file *; fd — это индекс в этом массиве.
  • struct file (в include/linux/fs.h) — выделяется динамически при каждом open, использует счётчик ссылок (f_count). При close счётчик уменьшается, при 0 освобождается.
  • file_operations — таблица виртуальных методов, обеспечивающая полиморфизм для разных типов (ext4, сокет, pipe, epoll). Таким образом, read(fd, ...) превращается в fd->f_op->read(fd, ...), перенаправляя на нужную реализацию.
  • Системные ограничения: общий лимит на процесс RLIMIT_NOFILE (можно просмотреть через ulimit -n), глобальный лимит /proc/sys/fs/file-max.

Типичные ошибки кандидатов

  1. Утечка дескрипторов: забывают закрывать файлы/сокеты
    В Go это приводит к накоплению открытых fd и в конечном счёте к ошибке "too many open files". Правильное использование defer f.Close() сразу после открытия предотвращает проблему.

  2. Непонимание разницы между дескриптором и FILE*
    В C FILE* — это буферизованная библиотечная обёртка над дескриптором. В Go аналогов нет, но *os.File работает с дескриптором напрямую без буферизации пользовательского пространства. Отсюда путаница.

  3. После dup закрыть все копии
    Каждый dup увеличивает счётчик ссылок в struct file. Только когда будут закрыты все копии дескрипторов, счётчик станет 0 и ресурс освободится. Неправильно думать, что закрытие одного закрывает всё.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт, если создать pipe и сделать fork без закрытия ненужных концов? — Это классическая ошибка: если один конец не закрыт в нужном процессе, read может заблокироваться навсегда, ожидая, что кто-то ещё может записать. Правильно: в процессе-писателе закрыть конец чтения, в читателе — конец записи.

  • Как передать файловый дескриптор другому процессу через Unix domain socket? — Через сообщение SCM_RIGHTS, которое позволяет передать права на struct file через sendmsg/recvmsg, используя вспомогательные данные.

  • Как в Go получить количество открытых дескрипторов? — Можно прочитать содержимое /proc/self/fd или вызвать runtime.NumGoroutine неподходяще, но прямого метода нет. Обычно используют os.ReadDir("/proc/self/fd") или syscall.Getrlimit.


Что такое page cache?

Краткий ответ (30–60 секунд)

Page cache — это кэш в оперативной памяти, управляемый ядром Linux, хранящий страницы данных, считанных с блочных устройств (файлов), для ускорения повторного ввода-вывода. Когда приложение читает файл, страницы попадают в кэш; последующие чтения обслуживаются из памяти без обращения к диску. Грязные (модифицированные) страницы позже записываются на диск процессом writeback.

Развёрнутый ответ

Page cache — ключевой компонент производительности ввода-вывода. Он оперирует страницами фиксированной длины (обычно 4 КБ). Механизм:

  • При read(fd, buf, size) ядро определяет, какие страницы соответствуют смещению в файле. Если страницы отсутствуют в кэше (miss), они загружаются с диска (major page fault или прямой ввод), затем копируются в пользовательский буфер.
  • При write страницы могут быть загружены в кэш (read-modify-write) и помечены как грязные. Данные не пишутся сразу на диск (write-back кэш), что даёт высокую скорость записи ценой риска потерь при отказе питания без fsync.
  • Синхронизация: грязные страницы записываются на диск демоном pdflush (в ядрах 2.6+) или потоками flusher (современные ядра). Политика управляется через параметры dirty_ratio, dirty_background_ratio, истечение времени exprire (30 секунд по умолчанию).

Программа на Go, выполняющая f.Write(), попадает в системный вызов write, который работает с page cache. Чтобы гарантировать запись на диск, следует вызвать f.Sync() (соответствует fsync), который инициирует сброс грязных страниц.

Page cache также обслуживает mmap: отображённые страницы файла непосредственно отображаются в виртуальную память процесса через page cache, разделяя одни и те же физические страницы между процессами (если MAP_SHARED).

Под капотом (internals)

  • Каждый инод имеет address_space (объект struct address_space), который управляет отображением смещения файла -> страницы в кэше. Используется дерево (radix tree ранее, теперь XArray после 4.20) для быстрого поиска по индексу страницы.
  • Страница — struct page; счётчик ссылок показывает, сколько процессов её используют. Флаги определяют, является ли она грязной, подлежит ли записи.
  • read и write реализуются в VFS через file->f_op->read_iter/write_iter, которые в случае ext4 вызывают generic_file_read_iter, работающий с page cache.
  • Writeback управляется через struct bdi_writeback и отслеживает суперблоки. Поток ядра writeback периодически обходит и записывает грязные страницы.

Типичные ошибки кандидатов

  1. Путаница между free и доступной памятью
    Команда free -m показывает память, занятую page cache, как использованную (в графе used = MemTotal - MemFree - Buffers - Cached). Кандидаты часто думают, что память почти заполнена, игнорируя, что кэш можно быстро освободить при необходимости. Нужно различать available и free.

  2. Пренебрежение fsync после записи важных данных
    Утверждение, что write сразу записывает на диск, неверно. Данные остаются в page cache до сброса. В Go потеря данных может произойти при внезапном отключении, если не вызвано f.Sync(). Исправить: явно синхронизировать или использовать O_SYNC.

  3. Считать, что mmap вообще не использует page cache
    На самом деле mmap разделяет одни и те же страницы из page cache: изменения, сделанные через обычный write и через mmap, когерентны в рамках одного struct address_space. Ошибка думать, что это разные кэши.

Возможные дополнительные вопросы интервьюера

  • Как page cache влияет на производительность баз данных? — БД часто используют прямой ввод-вывод (O_DIRECT), минуя page cache, чтобы избежать двойной буферизации и иметь полный контроль над кэшированием.

  • Что произойдёт, если система испытывает нехватку памяти, а page cache велик? — Ядро начнёт вытеснять чистые страницы из кэша; грязные будут сначала записаны, а затем освобождены. Это может вызвать задержки.

  • Как Go runtime использует mmap и связан ли он с page cache? — Go выделяет память под кучи через анонимный mmap (не связанный с файлами), который не использует page cache в смысле файлового кэша, но всё равно манипулирует страницами. При выделении через mmap файлов, Go может отображать файлы в память (например, плагины).


Что такое buffer cache?

Краткий ответ (30–60 секунд)

Buffer cache — это исторически отдельный кэш блочных устройств в UNIX, хранящий необработанные блоки диска до того, как они представлены в виде файлов. В современном Linux (начиная с ядра 2.4) buffer cache объединён с page cache: буферы (метаданные блоков, суперблоки) используют те же страницы памяти, что и page cache, но учитываются отдельно в Buffers в выводе free. Таким образом, это не отдельный пул, а часть унифицированной подсистемы ввода-вывода.

Развёрнутый ответ

Исторически UNIX имели buffer cache для кэширования сырых блочных операций (чтение/запись с диска без файловой системы) и page cache для операций с файлами. В Linux 2.4 произошла унификация: всё работает через единый page cache. Однако:

  • Buffer heads всё ещё существуют: когда файловая система обращается к метаданным (например, блоки bitmap, таблицы inode, суперблок), она использует буферы, которые привязаны к страницам. Буферы описывают блоки размером меньше страницы (или равные блоку ФС) и ссылаются на страницы page cache.
  • В free -m колонка buffers отражает память, занимаемую буферными головами для метаданных, а cache — page cache для содержимого файлов.
  • При записи блочного устройства напрямую (например, dd if=/dev/sda of=...) также задействуется page cache, минуя ФС.

В Go прямого взаимодействия с buffer cache нет; операции ввода-вывода к файлам прозрачно проходят через унифицированный кэш. Однако понимание разницы помогает интерпретировать отчёты по использованию памяти.

Под капотом (internals)

  • struct buffer_head (упрощённо) содержит указатель на страницу (b_page), смещение внутри страницы, номер блока и флаги (грязный, заблокирован).
  • Унификация означает, что для страницы с данными файла не создаются буферные головы, управление идёт через struct page и radix tree/XArray. Для метаданных, которые адресуются поблочно, создаются буфера, привязанные к тем же страницам.
  • Операция submit_bh отправляет буферный ввод-вывод к блочному слою. Это лежит под файловыми системами, которые сами оперируют buffer_head или напрямую с bio (новые ФС вроде ext4).

Типичные ошибки кандидатов

  1. Считать, что buffer cache и page cache разделены физически
    Многие ошибочно думают, что память buffers и cache — это два разных пула, и очистка кэша через echo 3 > /proc/sys/vm/drop_caches сбросит и то и другое. На самом деле они взаимосвязаны, но drop_caches=1 освобождает page cache, =2 — dentries/inodes, =3 — и то и другое.

  2. Игнорировать buffer cache при анализе потребления памяти приложениями БД
    Базы данных часто обходят page cache через O_DIRECT, но метаданные ФС всё равно используют буферы. Небольшое увеличение буферов нормально.

  3. Заблуждение, что buffer cache совсем исчез в новых ядрах
    Хотя слой унифицирован, struct buffer_head жив, и ядро использует его в некоторых операциях, особенно для блочных устройств и старых драйверов. Не знать этого — пробел.

Возможные дополнительные вопросы интервьюера

  • Почему Linux отказался от полностью раздельных кэшей? — Чтобы избежать двойной буферизации, упростить управление памятью и повысить эффективность: одни и те же страницы могут кэшироваться и как файловые данные, и как блоки, экономя ОЗУ.

  • Как можно интерпретировать /proc/meminfo в отношении буферов и кэша? — Поля Buffers — объём памяти для буферов блочных устройств; Cached — page cache; SReclaimable — часть slab-кэша, которую можно восстановить (включает кэши inode/dentry).

  • Влияет ли очистка буферов через drop_caches на целостность данных? — Нет, сбрасываются только чистые (не грязные) кэши; ядро не позволит сбросить грязные страницы. Но производительность временно упадёт.


Что такое виртуальная память?

Краткий ответ (30–60 секунд)

Виртуальная память — это абстракция операционной системы, предоставляющая каждому процессу собственное адресное пространство, изолированное от других. Физические адреса скрыты за преобразованием через таблицы страниц с помощью MMU. Это позволяет выполнять программы, размер которых превышает физическую RAM, за счёт подкачки (paging), а также обеспечивает защиту памяти и возможность разделения кода/данных между процессами.

Развёрнутый ответ

Виртуальное адресное пространство процесса (обычно 4 ГБ на 32 битах, огромное на 64 битах) делится на страницы (стандартно 4 КБ). Каждая страница может находиться в физической памяти, в области подкачки (swap) или вообще не существовать до момента обращения. Основные механизмы:

  • Страничное отображение: таблицы страниц (PGD, PUD, PMD, PTE) преобразуют виртуальный адрес в физический. MMU аппаратно кэширует результаты в TLB.
  • Подкачка (demand paging): страницы загружаются по мере необходимости при page fault. Это позволяет экономить физическую память.
  • Свопинг (swapping): когда физической памяти не хватает, ядро может вытеснить редко используемые страницы в swap-раздел/файл, освобождая RAM.
  • Разделяемая память и копирование-при-записи (COW): страницы библиотек или разделяемой памяти могут быть отображены в адресные пространства нескольких процессов. При fork родительский и дочерний процессы разделяют страницы до первой записи.

Go использует виртуальную память через mmap для аллокации больших блоков (арен памяти, кучи). Сборщик мусора освобождает страницы через madvise или munmap. Поскольку Go управляет памятью в пользовательском пространстве, он меньше зависит от стандартного malloc, но всё равно находится в рамках виртуальной памяти процесса.

Под капотом (internals)

  • Каждый процесс имеет struct mm_struct, описывающую границы виртуальной памяти и список vm_area_struct (VMA), представляющих регионы (библиотеки, куча, стек, mmap-области). VMA содержит начальный/конечный виртуальные адреса, права доступа, информацию о файле (для file-backed областей).
  • Обработчик page fault (do_page_fault) находит VMA, проверяет права, затем выделяет физическую страницу и обновляет таблицу страниц.
  • Физические страницы управляются аллокатором buddy system, а также slab-аллокатором для мелких объектов ядра.
  • Go runtime резервирует виртуальное пространство через mmap(..., PROT_NONE) без немедленного выделения физической памяти, а затем по мере роста кучи через madvise активирует страницы, которые обрабатываются page fault’ами.

Типичные ошибки кандидатов

  1. Путать виртуальную память с swap’ом
    Многие говорят «виртуальная память — это swap». Swap — лишь один из механизмов, а виртуальная память охватывает всё адресное пространство и аппаратную поддержку. Ответ должен начинаться с изоляции и трансляции адресов.

  2. Игнорировать стоимость выделения физической памяти
    Выделение памяти через mmap резервирует только виртуальные адреса, фактическая память выделяется при первом касании страницы (major/minor fault). В Go аллокация большого слайса может быть быстрой, но первая запись может вызвать множество page faults, задерживая программу.

  3. Считать, что mmap с отображением файла всегда быстрее read
    Хотя mmap экономит одно копирование, при случайном доступе он может генерировать много page faults и задержек, тогда как read с явной предзагрузкой может быть более управляемым. Важно обсудить компромиссы.

Возможные дополнительные вопросы интервьюера

  • Как узнать, сколько виртуальной памяти использует процесс в Linux? — Утилита pmap -x PID показывает все VMA с размерами, RSS и режимами. Также можно посмотреть /proc/PID/status (VmSize, VmRSS, VmSwap) и /proc/PID/smaps для детализации.

  • Как работает memory overcommit в Linux и как он относится к виртуальной памяти? — Overcommit позволяет выделять больше виртуальной памяти, чем есть физической + swap, рассчитывая, что не вся будет востребована. При фактической нехватке вызывается OOM Killer. В Go overcommit обычно желателен, но надо следить за лимитами cgroups.

  • Зачем Go использует mmap без резервного файла (MAP_ANONYMOUS)? — Для выделения больших непрерывных областей виртуальной памяти под heap и стеки горутин. mmap даёт возможность выровнять память и управлять ростом прозрачно для GC.


Что такое MMU (Memory Management Unit)?

Краткий ответ (30–60 секунд)

MMU — это аппаратный компонент процессора (часть ядра или чипсета), обеспечивающий преобразование виртуальных адресов, используемых программами, в физические адреса ОЗУ. Он использует многоуровневые таблицы страниц и TLB для ускорения трансляции, а также обеспечивает защиту памяти: запрет доступа к чужим страницам и разграничение режимов (user/kernel). Без MMU невозможно реализовать изолированное адресное пространство процесса.

Развёрнутый ответ

MMU выполняет следующие функции:

  • Трансляция адресов: При каждом обращении к памяти виртуальный адрес делится на индексы для уровней таблиц страниц (например, на x86-64: PGD, PUD, PMD, PTE). Результатом является физический адрес страницы, к которому добавляется смещение.
  • Защита памяти: Каждая запись таблицы страниц содержит биты прав (Read/Write, Execute, User/Supervisor). Если процесс пытается записать в read-only страницу или выполнить код в области без Execute, MMU генерирует исключение (page fault / segmentation fault), которое ядро превращает в сигнал SIGSEGV.
  • Кэширование отображений: TLB (Translation Lookaside Buffer) хранит несколько последних трансляций, чтобы избежать прохода по многоуровневым таблицам при каждом доступе. При смене процесса ядро инвалидирует TLB, но современные процессоры используют ASID (address space identifiers) для частичного сохранения.
  • Поддержка больших страниц (huge pages): MMU может отображать страницы размером 2 МБ или 1 ГБ, что уменьшает нагрузку на TLB.

Go runtime полагается на MMU для изоляции и страничного распределения. При аллокации кучи через mmap виртуальные адреса возвращаются, но физическая память подключается именно через MMU при page fault. Без MMU Go не смог бы обеспечить стандартную модель памяти с горутинами.

Под капотом (internals)

  • В x86-64 MMU использует 4-уровневые таблицы страниц (с возможным 5-уровневым расширением). Каждая таблица состоит из 512 записей по 8 байт. Адрес разбивается: биты 47:39 для PGD, 38:30 для PUD, 29:21 для PMD, 20:12 для PTE, 11:0 — смещение.
  • Регистр CR3 указывает на физический адрес корневой таблицы (PGD) текущего процесса. При переключении задач ядро меняет CR3, заставляя MMU использовать новое адресное пространство.
  • В записи PTE есть биты присутствия (P), грязная (D), accessed (A), биты разрешений и PAT (тип кэширования). Когда страница выгружена в swap, P=0, и MMU генерирует page fault.
  • Page fault вызывает аппаратное прерывание, обрабатываемое ядром. Ядро проверяет причину (например, запись в COW-страницу) и либо выделяет/копирует страницу, либо убивает процесс.

Типичные ошибки кандидатов

  1. Путать MMU и контроллер памяти
    MMU отвечает за трансляцию адресов, а контроллер памяти — за физический доступ к DRAM (тайминги, чипы). Хотя в современных SoC они могут быть интегрированы в один блок, функционально это разные вещи.

  2. Заявлять, что MMU используется только для виртуальной памяти и свапа
    MMU обеспечивает также изоляцию и защиту памяти между процессами; даже при отключённом swap его роль критична.

  3. Не знать о ASID и сбросе TLB
    Без ASID каждое переключение процесса требовало бы полной очистки TLB, что дорого. ASID позволяет тэгировать записи TLB идентификатором процесса, сокращая промахи. Кандидат должен знать об этом.

Возможные дополнительные вопросы интервьюера

  • Какую роль играет MMU при прямом доступе к оборудованию (DMA)? — DMA контроллер использует физические адреса. Для взаимодействия с DMA из драйверов ядро преобразует виртуальные адреса ядра в физические через MMU (или таблицы отображения). Также IOMMU выполняет трансляцию и изоляцию для устройств.

  • Что такое IOMMU и зачем он нужен в виртуализации? — IOMMU (например, VT-d) выполняет трансляцию адресов для устройств, аналогично MMU для CPU, позволяя безопасно назначать устройства гостевым VM и изолировать доступ к памяти.

  • Можно ли обойтись без MMU в микроконтроллерах? — Да, многие микроконтроллеры (AVR, Cortex-M без MPU) не имеют полноценного MMU и работают с физическими адресами. Там нет изоляции процессов, вся система выполняется в едином адресном пространстве.


Что такое page fault?

Краткий ответ (30–60 секунд)

Page fault — это исключение, генерируемое MMU, когда процесс пытается обратиться к странице виртуальной памяти, для которой нет действительного отображения в физической памяти (либо она не выделена, либо выгружена в swap, либо нарушены права доступа). Ядро обрабатывает fault: выделяет новую страницу (minor fault), загружает её с диска (major fault) или сигнализирует о нарушении (SIGSEGV).

Развёрнутый ответ

Типы page faults:

  • Minor (soft) fault: виртуальная страница уже зарезервирована (например, в результате mmap или расширения кучи), но физическая страница ещё не выделена. Ядро выделяет физическую страницу, обновляет PTE и возвращает управление; операция быстрая.
  • Major (hard) fault: страница была выгружена в swap или ещё не загружена из файла (file-backed mapping). Требуется дисковый ввод-вывод; это заметно снижает производительность.
  • Invalid/protection fault: обращение к адресу, не принадлежащему ни одному VMA, или нарушение прав (запись в read-only). Ядро посылает процессу SIGSEGV. В Go такая ситуация превращается в панику (panic) со стектрейсом.

В Go page faults происходят при росте кучи: когда runtime-аллокатор решает расширить кучу, он вызывает mmap, затем страницы помечаются как доступные; при первом обращении к новым страницам возникают minor faults. Также при fork/exec стартующие процессы генерируют множество page faults для загрузки кода и данных. Сборщик мусора при сканировании может коснуться многих страниц, также вызывая faults.

Под капотом (internals)

  • Обработчик do_page_fault принимает адрес ошибки, контекст (user/kernel режим) и код ошибки (защищённая, запись, исполнение...). Он ищет VMA, содержащую адрес. Если VMA не найдена или доступ запрещён — SIGSEGV. Иначе:
    • Для анонимной (нефайловой) области: выделяет физический кадр через партнёрский аллокатор, настраивает PTE.
    • Для file-backed: вызывает filemap_fault, которая ищет страницу в page cache; если нет — инициирует чтение с диска.
    • Для swap: считывает из swap-раздела.
  • После установки PTE и загрузки данных выполняется инструкция iret или возврат из исключения, повторяя вызвавшую инструкцию.

Типичные ошибки кандидатов

  1. Путать page fault со сбоем приложения
    Не каждый page fault — катастрофа. Minor fault — нормальная часть ленивого выделения. Ошибка думать, что любое исключение страницы — признак деградации.

  2. Игнорировать разницу между major/minor при анализе производительности
    Программа, генерирующая много major faults (например, работа с большим swap’ом или интенсивный mmap файла), резко замедляется из-за I/O. Кандидат должен уметь различать их через getrusage(RUSAGE_SELF, &ru) (поля ru_minflt, ru_majflt) или /proc/pid/stat.

  3. Считать, что Go не генерирует page faults
    Горутины, растущий стек, выделение кучи — всё это вызывает minor faults. Кандидат может ошибочно полагать, что только C программы страдают от них.

Возможные дополнительные вопросы интервьюера

  • Как из Go-кода отследить количество page faults? — Используя syscall.Getrusage(syscall.RUSAGE_SELF, &ru); ru.Minflt, ru.Majflt. Или парсить /proc/self/stat.

  • Что произойдёт, если при обработке page fault возникнет нехватка памяти? — Ядерный аллокатор попытается освободить память (сжать кэш, swap). Если всё равно не хватает, может быть вызван OOM Killer, который завершит процесс, вызвавший fault, или другую жертву.

  • Почему при форке дочерний процесс не генерирует major faults для уже загруженных страниц? — Благодаря Copy-on-Write: страницы родителя разделяются (до первой записи). Чтение не вызывает копирования. При попытке записи происходит minor fault с выделением новой страницы.


Что такое TLB (Translation Lookaside Buffer)?

Краткий ответ (30–60 секунд)

TLB — это небольшой, чрезвычайно быстрый кэш внутри процессора, хранящий недавно использованные соответствия виртуальных и физических адресов. Его цель — избежать дорогостоящего обхода многоуровневых таблиц страниц при каждом обращении к памяти. Промах TLB (TLB miss) приводит к аппаратному или программному поиску в таблицах, что сильно замедляет выполнение.

Развёрнутый ответ

TLB работает как ассоциативный кэш с ключом — виртуальной страницей (VPN) и результатом — физической страницей (PFN) плюс флагами защиты. Современные процессоры имеют несколько уровней TLB: L1 TLB (раздельный для инструкций и данных, ~64 записей), L2 TLB (объединённый, ~1024–2048 записей). Промах L1 может быть обслужен L2; промах L2 требует обхода таблиц страниц (hardware page walker или программное прерывание).

Смена адресного пространства (переключение процессов) традиционно требовала сброса TLB (flush), что было очень дорого. Технология ASID (Address Space ID) или PCID (Process Context ID в x86) позволяет тэгировать записи TLB идентификатором процесса, сохраняя TLB при переключении и снижая потери.

Влияние на Go: частое переключение горутин, распределённых на несколько потоков ОС, может приводить к сбросу TLB, если потоки связаны с разными процессами (что не так, но есть влияние NUMA). Использование huge pages (2 МБ/1 ГБ) сокращает количество записей TLB, необходимых для покрытия больших областей памяти (скажем 1 ГБ), уменьшая промахи.

Под капотом (internals)

  • x86-64 предоставляет программный интерфейс: регистры CR3 (указывает на PGD), CR4.PCIDE (включает контекстные идентификаторы). При загрузке CR3 можно указать PCID для сброса только нужных записей.
  • Аппаратный page table walker: при промахе TLB процессор сам проходит по таблицам, используя CR3 и отказывается от прерывания, если страница присутствует. Если P-бит отсутствует, возбуждается page fault.
  • TLB shootdown: при изменении отображения (например, munmap) требуется инвалидация TLB на всех ядрах, на которых может исполняться процесс, что порождает межпроцессорные прерывания (IPI) и влияет на масштабируемость.

Типичные ошибки кандидатов

  1. Считать TLB неважным для производительности
    Промахи TLB могут добавлять до 50% к времени выполнения кода, интенсивно обращающегося к памяти. Игнорирование этого в высоконагруженных системах критично.

  2. Не учитывать эффект TLB при выборе размера страниц
    При работе с огромными структурами данных разумно использовать huge pages, чтобы одна TLB-запись покрывала 2 МБ вместо 512 4-КБ страниц. Голословные заявления, что стандартной страницы достаточно, — ошибка.

  3. Путать TLB с кэшем данных (L1/L2 cache)
    TLB кэширует трансляции адресов, а кэш данных — сами данные. Это разные аппаратные структуры. Некоторые думают, что TLB хранит копии данных.

Возможные дополнительные вопросы интервьюера

  • Как huge pages влияют на TLB? — Каждая huge page запись покрывает в 512 раз больше памяти (для 2 МБ страниц), значит, для того же рабочего набора требуется меньше TLB-записей, уменьшая промахи. Однако это увеличивает фрагментацию памяти.

  • Что такое TLB shootdown и почему он дорог? — При изменении таблиц страниц на одном ядре нужно уведомить другие ядра через IPI, чтобы они инвалидировали устаревшие записи TLB. Ожидание подтверждения от всех ядер задерживает работу.

  • Как Go-программист может снизить количество TLB промахов? — Использовать структуры данных, локализованные в памяти (локальность), по возможности выравнивать на размер кэш-линий/страниц, минимизировать количество используемых потоков ОС (GOMAXPROCS) для уменьшения параллельного доступа к разрозненной памяти.


Что такое NUMA?

Краткий ответ (30–60 секунд)

NUMA (Non-Uniform Memory Access) — архитектура памяти, в которой время доступа к памяти зависит от её расположения относительно ядра процессора. В многопроцессорных системах с несколькими сокетами каждое ядро имеет «локальную» память с малой задержкой и «удалённую» память другого сокета, доступ к которой медленнее. Операционная система и приложения могут оптимизировать размещение, чтобы максимизировать локальные обращения.

Развёрнутый ответ

В симметричной многопроцессорности (SMP) все ядра подключаются к общей шине памяти, что при масштабировании становится узким горлом. NUMA решает это, предоставляя каждому сокету/узлу собственную память и быстрый канал межсоединений (UPI у Intel, Infinity Fabric у AMD). Ядро может обратиться к удалённой памяти через межсоединения, но с большей задержкой и меньшей пропускной способностью.

Linux нумерует узлы NUMA (начиная с 0), отображая топологию в sysfs /sys/devices/system/node/node*/distance (матрица относительных «расстояний»). Стандартная политика размещения: numactl для управления. По умолчанию используется политика default — стараться выделять локальную память.

Go runtime не имеет полного NUMA-aware планирования, но можно настроить привязку потоков ОС к ядрам через GOMAXPROCS и taskset для повышения локальности. При больших нагрузках на многопроцессорных серверах целесообразно запускать несколько экземпляров Go-приложения, привязанных к NUMA-узлам, избегая межсокетных синхронизаций.

Под капотом (internals)

  • Ядро поддерживает NUMA через аллокатор страниц: зоны памяти (ZONE_NORMAL, ZONE_MOVABLE) привязаны к узлам. Аллокация через alloc_pages_node() принимает предпочтительный узел.
  • Структура pglist_data описывает узел памяти, содержит node_zones, список свободных областей.
  • Балансировка памяти между узлами осуществляется kswapd на каждом узле и глобальном уровне.
  • Планировщик процессов CFS/EEVDF учитывает топологию: пытается сохранять задачи на том же узле, где ранее выполнялись, и по возможности на том же ядре.

Типичные ошибки кандидатов

  1. Считать, что при наличии NUMA всё работает оптимально по умолчанию
    Ядро применяет эвристики, но они не всегда оптимальны для конкретного приложения. Без анализа может возникнуть «NUMA miss» — 80% доступов к удалённой памяти, ухудшая производительность на 20-40%.

  2. Игнорировать NUMA при проектировании микросервисов на Golang
    На многоядерных серверах часто запускают один процесс с GOMAXPROCS=все ядра, но блокировки межгорутинного общения и общие структуры могут вынудить обращаться к удалённой памяти. Иногда выгоднее запустить несколько экземпляров, привязанных к NUMA-узлам.

  3. Не использовать numastat для диагностики
    Неправильно диагностировать проблемы производительности, не проверяя статистику numastat или perf stat -e node-load-misses. Кандидат должен уметь предложить методику анализа.

Возможные дополнительные вопросы интервьюера

  • Как проверить топологию NUMA и расстояния? — Команды numactl --hardware, lscpu, ls /sys/devices/system/node/. Матрица расстояний показывает относительную стоимость доступа.

  • Влияет ли NUMA на производительность каналов в Go и как этого избежать? — Да, если горутины-писатель и читатель канала закреплены на разных узлах, передача данных будет дольше из-за копирования памяти. Привязка к общему узлу или использование lock-free структур с локальной памятью может помочь.

  • Что означает numa=fake в параметрах ядра и когда применяется? — Это эмуляция NUMA на UMA-машине для тестирования и разработки; полезно для проверки NUMA-aware приложений.


Что такое сgroups (control groups)?

Краткий ответ (30–60 секунд)

cgroups — механизм ядра Linux, позволяющий ограничивать, учитывать и изолировать использование ресурсов (CPU, память, ввод-вывод, сеть) для групп процессов. Он лежит в основе контейнеризации (Docker, Kubernetes) и управления системными службами (systemd). cgroups организованы в иерархию, а современная версия v2 предлагает унифицированную модель с единым деревом.

Развёрнутый ответ

cgroups решают задачу обеспечения гарантий ресурсов и предотвращения «жадного» потребления. Основные подсистемы (контроллеры):

  • cpu: ограничивает долю CPU (через CFS bandwith control) либо задаёт вес (cpu.shares).
  • memory: лимитирует пользовательскую память (включая page cache), при превышении вызывает OOM в группе.
  • blkio/io: ограничение пропускной способности/IOPS блочных устройств.
  • pids: ограничение на количество создаваемых процессов/потоков.
  • cpuset: привязка к конкретным ядрам и NUMA-узлам.
  • devices: контроль доступа к устройствам.

cgroup v2 (Linux 4.5+) использует единую иерархию, а контроллеры включаются в поддеревьях. Интерфейс — файловая система cgroupfs, обычно смонтированная в /sys/fs/cgroup.

Go runtime с версии 1.11 считывает cgroup ограничения (особенно cpu.cfs_quota_us) для автоматической настройки GOMAXPROCS — максимального числа одновременно исполняемых потоков. Если контейнеру выделено менее одного ядра, но квота больше периода, GOMAXPROCS округляется вверх от доли времени. Внутренние структуры runtime позволяют лучше использовать ресурсы в контейнерах.

Под капотом (internals)

  • Каждая cgroup представляется структурой cgroup с подсистемами в виде cgroup_subsys_state. Для отслеживания процессов используется css_set.
  • Контроллер памяти: mem_cgroup хранит счётчики использования, события давления, лимиты. При достижении лимита срабатывает OOM-киллер уровня cgroup.
  • Контроллер CPU: использует планировщик CFS, который с помощью cfs_bandwidth ограничивает время исполнения всех задач группы.
  • Иерархия реализована через cgroupfs — виртуальную ФС с файлами для управления. Чтение/запись в файлы вызывает соответствующие обработчики ядра.

Типичные ошибки кандидатов

  1. Недооценка влияния cgroup memory limit на Go
    При ограничении памяти контейнера runtime Go видит только этот лимит (через cgroup v2 memory.max). Но GC настраивается на % от лимита, однако без явного указания GOMEMLIMIT (с 1.19) он может пытаться использовать больше, что приведёт к OOMKilled. Кандидат должен предложить выставление GOMEMLIMIT и правильный расчёт пределов.

  2. Считать, что GOMAXPROCS всегда надо ставить по количеству ядер
    В контейнере с ограничением CPU cgroup часто дают долю ядра, и без явной установки GOMAXPROCS (или её автоматического определения) Go может запустить слишком много потоков, вызывая лишние переключения. Правильно: доверять автомагическому определению runtime или явно задавать на основе runtime.NumCPU() лимитов cgroup.

  3. Игнорирование cgroup v2 при миграции
    Многие инструменты до сих пор ориентированы на v1. Неправильно настроенные конфигурации могут привести к тому, что приложение не будет ограничено, хотя контейнер заявлен с лимитами.

Возможные дополнительные вопросы интервьюера

  • Как Go определяет количество доступных процессоров в контейнере? — Функция runtime.NumCPU() читает /proc/cpuinfo и смотрит на cgroup quotas (/sys/fs/cgroup/cpu/cpu.cfs_quota_us и period). Если квота меньше period*online_cpus, NumCPU корректируется.

  • Какие проблемы могут возникнуть при комбинировании GOMAXPROCS и CFS quota? — Если квота составляет 400ms из 100ms периода, NumCPU вернёт 4, но фактически доступно только 4*100% занятости = 400% времени CPU. Это может привести к избытку параллелизма; ранние версии Go (до 1.11) этого не учитывали.

  • Как ограничить память, используемую Go программой, без cgroups? — Установить GOMEMLIMIT (Go 1.19+) или использовать debug.SetMemoryLimit. Это внутренний механизм, не полагающийся на cgroup, но хорошо интегрируемый.


Что такое namespaces?

Краткий ответ (30–60 секунд)

Namespaces (пространства имён) — это механизм изоляции ресурсов ядра Linux, при котором процессы видят собственный независимый экземпляр глобальных системных ресурсов, таких как PID, сетевые интерфейсы, точки монтирования, имена хостов, IPC, пользователи. Namespaces являются строительными блоками контейнеризации: Docker использует их для изоляции контейнеров от хоста и друг друга.

Развёрнутый ответ

Каждый тип namespace отвечает за изоляцию конкретного ресурса:

  • PID (CLONE_NEWPID): процессы получают новый набор PID, внутри контейнера PID 1 видит только собственные процессы.
  • Network (CLONE_NEWNET): отдельные сетевые интерфейсы, таблицы маршрутизации, IP-адреса, iptables.
  • Mount (CLONE_NEWNS): собственные точки монтирования, позволяют контейнерам иметь отдельную файловую систему /proc, /sys, overlay.
  • UTS (CLONE_NEWUTS): отдельное имя хоста и домена.
  • IPC (CLONE_NEWIPC): изолированные очереди сообщений, семафоры, разделяемая память.
  • User (CLONE_NEWUSER): маппинг UID/GID, позволяющий процессу внутри быть root без привилегий на хосте.
  • Cgroup (CLONE_NEWCGROUP): изоляция cgroup-путей.

Создание namespace происходит через системный вызов unshare (изолирует текущий процесс) или clone с соответствующими флагами при создании потомка. В Go можно использовать syscall.Unshare (с осторожностью) или библиотеки, но обычно это зона ответственности контейнерного runtime (runc, containerd).

Под капотом (internals)

  • Каждый task_struct содержит указатель nsproxy, который ссылается на структуру nsproxy с указателями на конкретные пространства имён: pid_ns, net_ns, mnt_ns и т.д. При создании нового пространства копируются и модифицируются эти структуры.
  • Пример PID namespace: внутри pid_ns создаётся новая нумерация процессов, но на уровне ядра каждый процесс имеет глобальный PID. procfs показывает только процессы, принадлежащие текущему PID namespace.
  • Network namespace: структура net содержит отдельные сетевые структуры (таблицы маршрутизации, Netfilter, устройства), «старый» сетевой стек связывается через sk->sk_net.
  • Изоляция обеспечивается проверкой namespace во многих системных вызовах: например, getpid() возвращает PID в текущем pid_ns.

Типичные ошибки кандидатов

  1. Путать namespaces с cgroups
    Хотя оба используются для контейнеров, namespaces отвечают за видимость и изоляцию, а cgroups — за ограничение ресурсов. Ошибка: сказать, что namespaces ограничивают память. Исправить: чётко разграничить функции.

  2. Не знать о user namespace и его роли в безопасности
    Благодаря user namespace контейнеры могут запускаться от root внутри, не имея root-прав на хосте. Незнание этого — пробел, так как это критично для защиты.

  3. Игнорировать возможность утечки информации между namespaces
    Например, /proc/sysrq-trigger изолирован не полностью. В реальных deployments необходимо применять дополнительные средства (capabilities, seccomp).

Возможные дополнительные вопросы интервьюера

  • Как можно «войти» в namespace существующего процесса для отладки? — Команда nsenter -t PID -a входит во все namespace указанного процесса; например, можно войти в сетевой namespace контейнера для запуска tcpdump.

  • Как Docker изолирует сеть контейнера? — Создаёт два network namespace: один для контейнера, другой (промежуточный) с veth-парой. Один конец veth в контейнере, другой — в bridge на хосте. Плюс настройка iptables для NAT.

  • Можно ли в Go-программе создать собственный PID namespace? — Можно, но осторожно: после syscall.Unshare(syscall.CLONE_NEWPID) нужно немедленно fork (через syscall.ForkExec), чтобы новый процесс стал init в новом пространстве. Прямое использование чревато проблемами.


Как работает Docker с точки зрения Linux?

Краткий ответ (30–60 секунд)

Docker строит контейнеры, комбинируя ключевые возможности ядра Linux: namespaces (изоляция процессов, сети, файловой системы), cgroups (ограничение CPU, памяти, I/O) и многоуровневые файловые системы (OverlayFS) для образов. При запуске контейнера Docker передаёт инструкции containerd → runc, который делает системные вызовы clone, unshare, настраивает cgroups и применяет профили безопасности (seccomp, AppArmor).

Развёрнутый ответ

Архитектура (упрощённо): Docker CLI → dockerd → containerd → containerd-shim → runc. runc — легковесный рантайм, реализующий спецификацию OCI (Open Container Initiative). Его работа:

  1. Создаёт корневую файловую систему (rootfs), объединяя слои образа через OverlayFS (или другой union FS) в единую точку монтирования.
  2. Вызывает clone с флагами для создания новых namespace: CLONE_NEWNS, CLONE_NEWUTS, CLONE_NEWIPC, CLONE_NEWPID, CLONE_NEWNET, CLONE_NEWUSER (опционально). Получает изолированный процесс.
  3. В новом процессе настраивает pivot_root или chroot для переключения корня в подготовленную rootfs.
  4. Настраивает cgroup через запись в /sys/fs/cgroup/... для установки лимитов памяти, CPU, весов.
  5. Применяет профили безопасности: seccomp-фильтры ограничивают доступные системные вызовы, AppArmor/SELinux усиливают изоляцию.
  6. Запускает указанную команду (entrypoint).

Сеть Docker по умолчанию создаёт bridge-сеть, используя veth-пары и iptables для NAT, позволяя контейнерам выходить во внешнюю сеть и пробрасывать порты.

Go-приложения часто упаковываются в Docker-образы. При запуске в контейнере их среда ограничена cgroups; важно правильно настраивать GOMAXPROCS и GOMEMLIMIT, чтобы эффективно использовать ресурсы.

Под капотом (internals)

  • runc init — выполняется внутри нового namespace, настраивает монтирование /proc, /sys, монтирует специальные файлы, устанавливает caps, применяет seccomp.
  • OverlayFS: драйвер хранилища использует модули ядра overlay. Нижние слои (только для чтения) и верхний слой (read-write) объединяются. Ядро обеспечивает copy-up при записи.
  • Cgroup v2 в современных Docker: управляет через единый cgroupfs. docker run --memory транслируется в memory.max, а --cpus в cpu.max.
  • Сетевая изоляция: veth-пара соединяет контейнерный namespace с хостом. Один конец внутри как eth0, другой — в bridge docker0. Пакеты проходят через iptables (правила в цепочке DOCKER).

Типичные ошибки кандидатов

  1. Считать контейнер лёгкой виртуальной машиной
    Контейнер — это просто процесс (или группа процессов) с изолированным view, он разделяет ядро хоста. Отсутствие гипервизора делает старт быстрее, но открывает поверхность атаки на ядро.

  2. Не настраивать лимиты памяти и CPU
    Без лимитов контейнер может занять всю память хоста, вызывая глобальный OOM. В Go без GOMEMLIMIT GC ориентируется на всю память хоста, что в контейнере может привести к превышению cgroup лимита и убийству процесса.

  3. Игнорирование PID 1 проблемы в контейнере
    Если приложение в контейнере запущено как PID 1, оно должно корректно обрабатывать сигналы и осиротевших потомков, иначе накапливаются зомби. В образе Go простой CMD ["./app"] делает приложение PID 1; нужно либо встраивать обработку (например, через tini или самому ловить SIGCHLD).

Возможные дополнительные вопросы интервьюера

  • Какой механизм отвечает за то, что контейнер видит только свои процессы? — PID namespace. При clone(CLONE_NEWPID) процесс получает собственное дерево PID, и /proc внутри контейнера монтируется с фильтрацией.

  • Что произойдёт, если внутри контейнера вызвать reboot? — По умолчанию контейнер не имеет доступа к системным вызовам перезагрузки из-за ограничения capabilities и seccomp; попытка обычно вызывает ошибку или игнорируется, но может повлиять на хост только при серьёзных misconfiguration.

  • Как ограничить количество открытых файловых дескрипторов в контейнере? — Через docker run --ulimit nofile=..., что устанавливает RLIMIT_NOFILE через setrlimit в контейнере.


Что такое OOM Killer?

Краткий ответ (30–60 секунд)

OOM Killer (Out-Of-Memory Killer) — компонент ядра Linux, который принудительно завершает один или несколько процессов, когда система испытывает острую нехватку памяти и другие методы восстановления (сжатие кэша, swap) не помогают. Цель — освободить память и сохранить работоспособность системы. Выбор жертвы основан на oom_score процесса, который учитывает объём используемой памяти и настройки oom_score_adj.

Развёрнутый ответ

Активация OOM Killer происходит, когда ядру не удаётся выделить физическую страницу для удовлетворения page fault или потребности ядра, и все резервы исчерпаны. Процедура:

  • Ядро вызывает out_of_memory() (в mm/oom_kill.c), которая проходит все процессы, вычисляя badness («плохость») каждого.
  • Формула badness учитывает: анонимную и file-backed память RSS, размер процесса; oom_score_adj позволяет администратору повысить/понизить вероятность выбора.
  • Процесс с наивысшим счётом выбирается и посылается SIGKILL. Обычно это самый «жирный» потребитель памяти, но не всегда.
  • В современных ядрах OOM может действовать на уровне cgroup: если контейнер исчерпал свой лимит памяти, киллер уничтожает процесс только в этой cgroup (memory.oom.group).

Go-программы, работающие в контейнерах с лимитом памяти, часто становятся жертвами OOM, если не выставлен GOMEMLIMIT. Сборщик мусора может не освобождать память достаточно быстро или куча может расти, превышая лимит. Понимание OOM Killer критично для DevOps и разработчиков, деплоящих в Kubernetes.

Под капотом (internals)

  • Основная функция oom_kill_process выбирает цель, отправляет SIGKILL, затем вызывает oom_reaper для быстрого освобождения памяти жертвы (асинхронно).
  • cgroup v2: при включённом memory.oom.group убиваются все процессы в этой cgroup одновременно — аналог «взорвать» весь контейнер.
  • oom_score выводится в /proc/PID/oom_score, а oom_score_adj в /proc/PID/oom_score_adj. Значение oom_score_adj = -1000 полностью исключает процесс из выбора (например, для sshd).
  • oom_kill_allocating_task (sysctl) — при 1 убивает именно тот процесс, который запросил память, а не самого «плохого»; обычно 0.

Типичные ошибки кандидатов

  1. Полагать, что OOM Killer выбирает жертву только по размеру RSS
    Это основной фактор, но также учитываются трафик в page cache, OOM-счётчик и настройки adj. Утверждение, что самый большой процесс всегда убивается, неверно; надо упоминать badness-эвристику.

  2. Игнорирование возможности настройки oom_score_adj
    Кандидат, не предложивший защитить критичные процессы (например, Go-приложение с заданным oom_score_adj) для предотвращения случайного завершения, не показывает системного мышления.

  3. Не связывать OOM Killer и GC в Go
    В Go до 1.19 приложение в контейнере могло быть убито, хотя куча занимала меньше лимита, из-за фрагментации или отложенного возврата памяти ОС. Правильный ответ: использовать GOMEMLIMIT и debug.SetMemoryLimit для своевременного запуска GC.

Возможные дополнительные вопросы интервьюера

  • Как получить информацию о срабатывании OOM Killer? — Посмотреть в dmesg или journalctl -k | grep -i oom. Ядро выводит детали: PID и имя убитого процесса, причины, состояние памяти.

  • Можно ли полностью отключить OOM Killer? — Теоретически через sysctl vm.overcommit_memory=2 и отсутствие swap, при этом система откажет в выделении памяти, но это приведёт к другим отказам. Полное отключение не предусмотрено.

  • Как Docker обрабатывает OOM в контейнере? — По умолчанию docker использует cgroup ограничения памяти; при превышении ядро вызывает OOM Killer внутри cgroup, что убивает один из процессов контейнера (обычно entrypoint). Статус контейнера становится «OOMKilled», видно в docker inspect.


Как посмотреть использование памяти процессом (/proc/[pid]/smaps)?

Краткий ответ (30–60 секунд)

Файл /proc/[pid]/smaps предоставляет детальную информацию о каждом регионе виртуальной памяти процесса: размер, Resident Set Size (RSS), Private/Shared Dirty и Clean, PSS (Proportional Set Size). Это позволяет анализировать реальное потребление физической памяти, разделяемой с другими процессами, и выявлять утечки. Аналоги: pmap -x PID, /proc/[pid]/status для сводных показателей.

Развёрнутый ответ

smaps — расширенная версия maps. Для каждого VMA выводится блок:

TEXT
7f1234560000-7f1234570000 rw-p 00000000 00:00 0  [heap]
Size:               1000 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                 800 kB
Pss:                 500 kB
Shared_Clean:        200 kB
Shared_Dirty:        100 kB
Private_Clean:       200 kB
Private_Dirty:       300 kB
Referenced:          700 kB
Anonymous:           600 kB
...
  • Rss: физическая память, занятая процессом в этом регионе (включая shared).
  • Pss: доля процесса в shared страницах (Rss / количество разделяющих). Сумма Pss по всем процессам даст общее использование с учётом разделения, это точнее для анализа утилизации памяти.
  • Private: страницы, принадлежащие только этому процессу.
  • Shared: страницы, разделяемые с другими (обычно библиотеки).
  • Anonymous: память, не привязанная к файлу (heap, stack, mmap-анонимно).

В Go такие утилиты, как runtime.ReadMemStats(), дают сведения о heap изнутри, но не показывают системное представление. smaps позволяет выявить, что RSS сильно превышает heap in use, указывая на фрагментацию или утечку в native-аллокациях (CGO). При деплое в контейнерах можно периодически сохранять smaps для диагностики медленного роста памяти.

Под капотом (internals)

  • Ядро генерирует smaps через seq_file API, обходя VMA процесса (mm_struct->mmap). Для каждой VMA подсчитываются счётчики страниц: функция smaps_pte_range обходит PTE и суммирует RSS, разделяя на private/shared, clean/dirty.
  • Shared определяется по счётчику ссылок страницы (page->_mapcount): если >1, то shared.
  • Pss рассчитывается как сумма (размер страницы / mapcount) для каждой разделяемой страницы.
  • Доступ к /proc/pid/smaps требует прав на чтение task_struct (обычно тот же пользователь или root).

Типичные ошибки кандидатов

  1. Путать VIRT (виртуальная память) и RES (резидентная)
    Кандидаты часто смотрят на top/htop и обращают внимание на VIRT, которая включает зарезервированные, но не выделенные области. Ошибка: думать, что процесс потребляет 5 ГБ при VIRT=5G, а RES=100M. Правильно: оценивать RES и особенно PSS.

  2. Не учитывать PSS при анализе многопоточных приложений
    Использование RSS завышает потребление при разделяемых библиотеках. PSS даёт более реалистичную картину. Кандидат, не упоминающий PSS, не полностью оценивает память.

  3. Игнорирование Shared vs Private в контексте утечек
    Рост Private_Dirty обычно указывает на утечки в приложении. Утверждение, что утечка есть, но не смотреть на типы страниц — поверхностно.

Возможные дополнительные вопросы интервьюера

  • Как получить общее PSS процесса? — Проще всего grep Pss: /proc/PID/smaps | awk '{sum+=$2} END{print sum}' либо pmap -x PID и смотреть строку total в столбце PSS.

  • Почему RSS может быть меньше выделенной Go heap (HeapAlloc)? — Это нормально, если часть выделений ещё не коснулись (lazy allocation). RSS отражает только физически размещённые страницы.

  • Что такое KernelPageSize и MMUPageSize в smaps? — KernelPageSize — размер страницы, используемой ядром (обычно 4K). MMUPageSize — размер, которым MMU отображает регион (может быть 2M при huge pages). Расхождение бывает при transparent huge pages.


Как работает fork и exec?

Краткий ответ (30–60 секунд)

fork создаёт дочерний процесс, почти идентичный родительскому: копируются task_struct, адресное пространство (через COW), таблица дескрипторов, но возврат из fork разный (PID потомка в родителя, 0 в потомке). exec заменяет адресное пространство и контекст текущего процесса новым исполняемым файлом, запуская его точку входа. fork+exec — классический способ запуска новой программы в UNIX.

Развёрнутый ответ

fork (sys_fork, _do_fork, ныне clone с флагами SIGCHLD):

  • Ядро создаёт новую task_struct и mm_struct; копирует VMA родителя. Однако страницы памяти не копируются — устанавливается COW: PTE помечаются только для чтения для обоих процессов.
  • Файловые дескрипторы наследуются: указатели на те же struct file, увеличивается счётчик ссылок.
  • Дочернему процессу выделяется новый PID. В многопоточном приложении копируется только вызывавший поток (это проблема для более сложного окружения, например, Go).
  • Возврат: родитель получает PID ребёнка, потомок — 0.

execve (семейство exec):

  • Вызывается в процессе (обычно после fork). Ядро проверяет формат файла (ELF), загружает сегменты (.text, .data, .bss) в новое адресное пространство, настраивает стек, подготавливает аргументы и окружение.
  • Старые адресное пространство уничтожается; все mmap-области удаляются. Дескрипторы, если не помечены флагом FD_CLOEXEC, сохраняются.
  • Результат: процесс начинает выполнение кода из ELF.

Go безопасно запускает внешние процессы через os/exec, который внутри вызывает fork и немедленно exec в дочернем процессе, минимизируя потенциальные проблемы многопоточности.

Под капотом (internals)

  • fork() в современных ядрах Linux реализован через clone(SIGCHLD) — универсальный механизм.
  • copy_process(): настройка копий task_struct, mm, files, fs. Для mm вызывается dup_mmap, который устанавливает COW флаги для всех частных страниц.
  • При execve вызывается do_execve, который через бинарный обработчик load_elf_binary создаёт новые VMA, flush_old_exec освобождает старые.
  • COW: при первой записи в разделяемую страницу возникает page fault, вызывается do_wp_page, который выделяет новую физическую страницу и копирует данные.

Типичные ошибки кандидатов

  1. Вызов fork в многопоточном приложении без последующего exec
    Так как копируется только один поток, заблокированные мьютексы или состояние других потоков могут остаться в несогласованном состоянии в потомке. Это ведёт к deadlocks. Go не предоставляет fork напрямую именно поэтому.

  2. Предположение, что fork копирует всё адресное пространство немедленно
    Если бы каждая страница копировалась при форке, создание процесса было бы медленным и расходовало память. COW — критическая оптимизация, которую нужно знать.

  3. Забывать о FD_CLOEXEC
    После fork и перед exec могут быть утечки дескрипторов в новый образ. Установка CLOEXEC гарантирует, что дескриптор не перейдёт к новой программе. В Go os/exec по умолчанию устанавливает CLOEXEC для внутренних файлов, но стоит помнить при написании системного кода.

Возможные дополнительные вопросы интервьюера

  • Что такое vfork и почему он редко используется? — vfork приостанавливает родителя, а потомок разделяет адресное пространство до exec/exit, что экономит ресурсы, но небезопасен. В Linux его реализация оптимизирована, но практически везде используется fork.

  • Как clone позволяет создавать потоки и процессы одновременно? — clone принимает битовую маску флагов: если указать CLONE_VM, CLONE_FILES и т.д., получится поток. Без них — обычный процесс. Это унифицированный механизм.

  • Какие ограничения могут возникнуть при частом создании процессов и как их смягчить? — Исчерпание PID пространства, нагрузка на планировщик. Можно использовать пул процессов или переходить к горутинам.


Что такое init процесс (PID 1) и его роль?

Краткий ответ (30–60 секунд)

PID 1, или init, является первым пользовательским процессом, запускаемым ядром после загрузки. В современных системах это обычно systemd (или OpenRC, runit). Его особые обязанности: принимать осиротевшие процессы (становясь их родителем), избегать накопления зомби-процессов с помощью обработки SIGCHLD и выступать прародителем всех процессов системы. В контейнере приложение часто выступает как PID 1 и должно выполнять эти функции.

Развёрнутый ответ

Специфические роли init:

  1. Запуск системы: инициализация сети, демонов, монтирование файловых систем согласно конфигурации.
  2. Управление потомками: когда родитель умирает раньше потомка, ядро переназначает такого потомка к init. Init вызывает waitpid() в цикле или обрабатывает SIGCHLD, чтобы не оставлять зомби.
  3. Сигнальная диспетчеризация: по умолчанию сигналы, отправляемые на PID 1 (например, SIGTERM), игнорируются, если явно не перехвачены. Это требует специальной обработки для graceful shutdown.
  4. Защита от случайного завершения: PID 1 нельзя убить обычными сигналами (кроме SIGKILL, но тогда система паникует). Завершение init эквивалентно kernel panic.

В контейнерах, где приложение запущено как PID 1 (например, CMD ["./app"] в Docker), оно несёт эти обязательства. Если приложение не вызывает wait для потомков, зомби накапливаются. В Go нужно либо установить обработчик SIGCHLD и вызывать syscall.Wait4, либо использовать легковесный init-процесс, например tini, который выступает PID 1 и запускает приложение.

Под капотом (internals)

  • Ядро создаёт первый пользовательский процесс в функции kernel_init (после монтирования rootfs, запуска init-программы). В современных системах init обычно из initramfs.
  • Перепривязка родителя: когда родительский процесс умирает, ядро в exit_notify просматривает всех потомков и переназначает их ближайшему живому предку в том же PID namespace, или самому init.
  • PID 1 обладает дополнительной защитой: флаг PF_KTHREAD отсутствует, но is_only_task_is_init запрещает свойство SIGNAL_UNKILLABLE (флаг signal_struct->flags & SIGNAL_UNKILLABLE) игнорирует сигналы, для которых нет обработчика, и предотвращает завершение.

Типичные ошибки кандидатов

  1. Не обрабатывать SIGCHLD в приложении-PID 1
    Написанное на Go приложение, запущенное как PID 1 в контейнере, должно перехватывать SIGCHLD (или регулярно вызывать Wait), иначе зомби-процессы накапливаются. Часто забывают об этом и удивляются зомби.

  2. Пытаться послать SIGTERM PID 1 и ожидать немедленного завершения
    По умолчанию сигналы игнорируются, если не установлен обработчик. Поэтому при docker stop сигнал отправляется PID 1, и если приложение Go его не ловит, контейнер не остановится корректно (по истечении таймаута docker принудительно убьёт).

  3. Считать, что init отвечает за автозапуск демонов во всех дистрибутивах
    В современных systemd-based системах именно systemd выполняет роль init, но он также управляет службами. В контейнерах же часто просто запускают приложение, и роль init сводится к зомби-переработке.

Возможные дополнительные вопросы интервьюера

  • Как сделать так, чтобы Go-приложение в контейнере корректно реагировало на SIGTERM? — Захватить сигнал: signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) и в горутине <-sigCh инициировать graceful shutdown.

  • Что будет, если PID 1 в контейнере умрёт? — Ядро завершит все процессы в этом PID namespace (т.е. контейнер остановится), так как PID namespace существует только пока жив init. Это немедленно останавливает контейнер.

  • Как накопление зомби влияет на систему? — Они не потребляют память/CPU, но занимают слоты в таблице процессов, что может исчерпать лимит PID и предотвратить создание новых процессов.


Что такое daemon и как его создать?

Краткий ответ (30–60 секунд)

Daemon (демон) — фоновый процесс, не привязанный к какому-либо терминалу, работающий продолжительное время. Создание демона в UNIX включает: двойной fork, отсоединение от терминала через setsid, смену текущего каталога на /, закрытие стандартных дескрипторов (stdin, stdout, stderr) и перенаправление их в /dev/null, а также обработку сигналов. В современных системах чаще используют systemd или контейнеризацию, которые берут на себя демонизацию.

Развёрнутый ответ

Классическая последовательность (double-fork):

  1. fork(), родитель завершается. Потомок продолжает.
  2. В потомке setsid() — создаёт новую сессию и группу процессов, отрываясь от управляющего терминала.
  3. Второй fork(), родитель (лидер сессии) завершается, внук продолжает. Это гарантирует, что процесс не сможет случайно получить доступ к терминалу (не является лидером сессии).
  4. chdir("/") — освобождает точку монтирования исходного рабочего каталога.
  5. umask(0) — сброс маски прав на значения по умолчанию.
  6. Закрытие/перенаправление файловых дескрипторов 0,1,2, чтобы демон не удерживал терминал.

В Go написать демона можно, но в современных deployment обычно предпочитают использовать systemd-сервисы или запускать в контейнере, где PID 1 управляется оркестратором. Если требуется демонизация, используют os.StartProcess с флагами syscall.SysProcAttr{Setsid: true} и т.д., либо обёртки, но это может привести к сложностям с горутинами.

Под капотом (internals)

  • setsid создаёт новую сессию, делая вызвавший процесс лидером новой сессии и новой группы процессов, при этом он лишается управляющего терминала.
  • Двойной fork гарантирует, что процесс-лидер сессии завершается, а внук уже не может стать лидером и не сможет случайно открыть терминал.
  • Управляющий терминал: при открытии /dev/tty процессом-лидером сессии терминал становится управляющим; завершение лидера сессии отправляет SIGHUP на все процессы в foreground группе. Отсюда необходимость избежать лидерства.

Типичные ошибки кандидатов

  1. Пропуск второго fork
    Без него процесс остаётся лидером сессии и может случайно акквирировать терминал, что нарушает требование демона. Правильно: всегда второй fork.

  2. Не закрывать дескрипторы (0,1,2)
    Оставшиеся открытые дескрипторы могут удерживать pipe или терминал, мешая корректному завершению родителя. Исправить: открыть /dev/null и дуплицировать на 0,1,2.

  3. Пытаться выполнить демонизацию в Go без учёта среды выполнения
    После форка горутины, оставшиеся на других потоках, могут оказаться в нерабочем состоянии. Правильный подход: использовать системные механизмы systemd/контейнеры либо стартовать отдельный процесс.

Возможные дополнительные вопросы интервьюера

  • Как systemd упрощает создание демонов? — systemd берёт на себя демонизацию (Type=simple или forking), управление логами через stdout/stderr, перезапуск при сбоях, задание ресурсных ограничений.

  • Почему nohup не достаточно для демона? — nohup только игнорирует SIGHUP, но не отсоединяет от терминала полностью; процесс всё ещё может получать сигналы от терминала и быть остановлен.

  • Как превратить Go-сервер в демона с systemd? — Написать обычную программу без double-fork, настроить systemd unit с Type=simple (или notify с sd_notify), обеспечить логирование в stdout/stderr.


Как работают сигналы (SIGTERM, SIGKILL, SIGSEGV)?

Краткий ответ (30–60 секунд)

Сигналы — программные прерывания, доставляемые ядром процессу для уведомления о событиях: SIGTERM (запрос вежливого завершения), SIGKILL (немедленное безусловное уничтожение, не может быть перехвачен), SIGSEGV (нарушение сегментации из-за неверного доступа к памяти). Приложение может перехватывать и обрабатывать сигналы (кроме SIGKILL и SIGSTOP) или полагаться на действия по умолчанию, часто завершение с core dump.

Развёрнутый ответ

Жизненный цикл сигнала:

  1. Отправитель: ядро (из-за аппаратного исключения, например, page fault), другой процесс (kill, raise), терминал (Ctrl+C -> SIGINT).
  2. Доставка: ядро проверяет, не заблокирован ли сигнал (маска sigmask), если да — остаётся в ожидании (pending). Иначе ядро обновляет task_struct->pending и будит поток, который может обработать сигнал.
  3. Обработка: если установлен обработчик (sigaction), ядро формирует кадр на стеке пользователя и запускает обработчик. По завершении обработчик вызывает sigreturn. Для фатальных сигналов (SIGSEGV) ядро может выполнить действие по умолчанию: завершить процесс и записать core.

Важные сигналы:

  • SIGTERM (15): даёт программе время для graceful shutdown. В Go перехватывается через signal.Notify(ch, syscall.SIGTERM).
  • SIGKILL (9): не ловится, мгновенно уничтожает процесс; используется как последнее средство.
  • SIGSEGV (11): возникает при обращении по недопустимому адресу. В Go превращается в панику (panic), если не обрабатывается низкоуровнево.

Go runtime перехватывает многие сигналы для своих нужд (например, SIGURG для прерывания системных вызовов). При SIGSEGV естественно происходит паника с трассировкой горутины.

Под капотом (internals)

  • Доставка сигнала: do_signal вызывается при возврате из прерывания или системного вызова, проверяет флаги TIF_SIGPENDING. Если есть pending, вызывается get_signal.
  • Структуры: struct sigaction — обработчик, маска, флаги. struct siginfo_t — дополнительная информация о сигнале.
  • Для процессовых сигналов kill вызывает send_signal, который добавляет запись в pending нужной thread-группы.
  • В Go: runtime регистрирует собственный обработчик для многих сигналов, а пользовательский — через signal.Notify. Внутренне используются sigaction и канал для доставки.

Типичные ошибки кандидатов

  1. Совершение сложных действий в обработчике сигнала
    Обработчик сигнала выполняется асинхронно, прерывая основной поток; ограниченно безопасными можно считать лишь асинхронно-безопасные функции (в C — список из POSIX). В Go эту проблему частично решает буферизованный канал, но всё равно не следует делать длительные операции в обработчике. Исправление: вынести логику из обработчика через канал.

  2. Пытаться ловить SIGKILL и SIGSTOP
    Это невозможно на уровне пользователя. Ошибка — писать signal.Notify(ch, syscall.SIGKILL) без понимания.

  3. Игнорировать SIGTERM при выключении контейнера
    Docker при stop посылает SIGTERM, если приложение не ловит — завершается принудительно через 10 секунд, что может нарушить целостность данных. Правильно: добавить graceful shutdown с перехватом.

Возможные дополнительные вопросы интервьюера

  • Как Go обрабатывает SIGSEGV и превращает в panic? — Внутренний обработчик рантайма (sigtrampgo) при получении SIGSEGV анализирует адрес и контекст; если это следствие nil-разыменования, создаёт панику с сообщением и стектрейсом, обеспечивая безопасное завершение горутины.

  • Что такое SIGURG и почему Go его генерирует? — SIGURG используется рантаймом для прерывания долгих системных вызовов (например, при сборке мусора или остановке мира). Ранее на Linux для этой цели использовали SIGUSR1, но заменили на SIGURG для совместимости.

  • Какая разница между SIGSTOP и SIGTSTP? — SIGSTOP не может быть перехвачен или игнорирован, всегда приостанавливает процесс. SIGTSTP (Ctrl+Z) может быть перехвачен и обработан.


Что такое "зомби процесс" и как с ним бороться?

Краткий ответ (30–60 секунд)

Зомби (zombie) — завершившийся дочерний процесс, запись о котором всё ещё присутствует в таблице процессов ядра, поскольку родитель ещё не вызвал wait() для получения кода завершения. Зомби не потребляет ресурсы (память уже освобождена), но занимает PID. Борьба: родитель должен вызвать один из вариантов wait(): waitpid(), waitid(), или обработать сигнал SIGCHLD. Альтернативно, зомби наследует init (PID 1), который вызывает wait.

Развёрнутый ответ

Причина появления зомби:

  • Дочерний процесс завершается через exit(). Ядро освобождает ресурсы (память, файлы), но сохраняет task_struct в состоянии EXIT_ZOMBIE с кодом завершения, сигналом и статистикой.
  • Родитель оповещается сигналом SIGCHLD. Если родитель не вызывает wait и не обрабатывает сигнал, зомби остаётся до тех пор, пока родитель жив.

Методы избегания:

  1. Явный вызов waitpid() в родителе.
  2. Установка обработчика SIGCHLD, который вызывает waitpid(-1, &status, WNOHANG) в цикле, чтобы пожинать нескольких завершившихся потомков за раз.
  3. Отказ от ожидания: установить signal(SIGCHLD, SIG_IGN) (в Linux это заставляет ядро автоматически пожинать потомков).
  4. Двойной fork: создать внука, который выполнит основную работу, а потомок (родитель внука) немедленно завершится, тогда внук станет сиротой и унаследуется init, который вызовет wait.

В Go при использовании os/exec для однократного запуска программы, следует вызывать cmd.Wait() или cmd.Run() (которая включает Wait). Если процесс запущен без ожидания (например, через `cmd.Start()), необходимо провести Wait позже во избежание зомби. В веб-серверах, порождающих много процессов, надо аккуратно управлять ожиданием.

Под капотом (internals)

  • В do_exit ядро вызывает exit_notify, которая меняет состояние на EXIT_ZOMBIE и отправляет SIGCHLD родителю.
  • wait() реализуется в do_wait, которая выбирает одного зомби, читает статус и освобождает task_struct.
  • При игнорировании SIGCHLD в ядре (Linux специфичное) do_notify_parent будет автоматически пожинать потомка, не оставляя зомби.

Типичные ошибки кандидатов

  1. Думать, что зомби занимают память или CPU
    Зомби не имеет выделенной памяти или кода; это только запись в таблице процессов. Но если накопится много, может закончиться пространство PID, что опасно. Кандидат должен пояснить это.

  2. Не вызывать Wait после Start в Go
    Код вида cmd.Start(); ... // no cmd.Wait() оставит зомби до завершения родительского процесса. Исправить: отложенный cmd.Wait() или горутина, ожидающая завершения.

  3. Забыть об автоматическом пожинании при игнорировании SIGCHLD
    Многие не знают, что в Linux можно просто игнорировать SIGCHLD, и зомби не будут накапливаться. Это предпочтительный метод в однопоточных программах, но не всегда безопасно в многопоточном окружении (сигнал может быть доставлен любому потоку).

Возможные дополнительные вопросы интервьюера

  • Как увидеть зомби-процессы в системе? — ps aux | grep Z покажет процессы со статусом Z (zombie). Команда top также отображает количество зомби в заголовке.

  • Может ли init стать зомби? — Нет, потому что init не имеет родителя, и ядро не создаёт зомби-запись для сирот, которых init принимает, init всегда вызывает wait.

  • Как docker обрабатывает зомби в контейнере? — По умолчанию Docker не вызывает wait от имени приложения; если приложение PID 1 не обрабатывает SIGCHLD, зомби накапливаются в контейнере. Можно использовать --init флаг, чтобы Docker вставил tini-подобный init.


Что такое "сирота" процесс?

Краткий ответ (30–60 секунд)

Сирота (orphan) — дочерний процесс, чей родитель завершился раньше него. В UNIX такие процессы автоматически «усыновляются» процессом init (PID 1), который становится им новым родителем. Init затем вызовет wait при их завершении, предотвращая появление зомби. Это нормальная и предусмотренная часть управления процессами.

Развёрнутый ответ

Механизм:

  • Когда родитель умирает, ядро в exit_notify просматривает всех потомков и переназначает их родителем init (или ближайший живой предок в том же PID namespace). Дочерний процесс продолжает работу как ни в чём не бывало, за исключением того, что getppid() вернёт 1.
  • Сиротами часто становятся при использовании шаблона двойного форка для создания демона: промежуточный потомок запускает нужный процесс и завершается, оставляя внука сиротой с родителем init. Это гарантирует, что процесс не будет связан с сессией и терминалом.

В контейнерах PID 1 — это приложение. Если приложение не реализует перехват SIGCHLD и вызов wait, сироты, перешедшие к нему, по завершению станут зомби. Поэтому говорят, что приложение в контейнере должно вести себя как init.

В Go, если запускаются дочерние процессы через os/exec, родитель обычно жив, и сироты не возникают. Но в сценариях, где сам Go-процесс является PID 1, необходимо обрабатывать потомков.

Под капотом (internals)

  • Функция find_new_reaper() (в kernel/exit.c) находит подходящего «жнеца» для осиротевших потомков: это init в текущем PID namespace или ближайший предок.
  • Усыновлённый процесс продолжает выполняться без изменений; никакого сигнала не отправляется.
  • При завершении сироты init получает SIGCHLD и автоматически вызывает wait, очищая зомби.

Типичные ошибки кандидатов

  1. Путать сироту с зомби
    Сирота — живой процесс без родителя, зомби — мёртвый процесс, ожидающий пожинания. Ошибка: сказать, что сирота — мёртвый процесс.

  2. Думать, что усыновление init — ошибка
    Это штатное поведение, и многие демоны полагаются на него. Кандидат не должен считать это проблемой.

  3. В контейнерах игнорировать последствия появления сирот
    Если PID 1 не обрабатывает завершение потомков, сироты, завершившись, превратятся в зомби. Приходится решать это добавлением лёгкого init-процесса.

Возможные дополнительные вопросы интервьюера

  • Может ли сирота получить сигнал SIGHUP при смерти родителя? — Нет, ядро автоматически переназначает родителя; SIGHUP отправляется только членам foreground группы при отключении терминала, но не при смерти родителя.

  • Как проверить, что процесс является сиротой? — getppid() возвращает 1. В некоторых случаях может быть другой PID, если это не init, а subreaper.

  • Что такое subreaper в Linux? — Процесс может установить prctl(PR_SET_CHILD_SUBREAPER), и тогда он будет принимать осиротевших потомков вместо init, создавая локального «жнеца». Полезно в контейнерах.


Что такое "контекст переключения" (context switch) и почему это дорого?

Краткий ответ (30–60 секунд)

Переключение контекста — это операция сохранения состояния текущего потока (регистры, счётчик команд, указатель стека) и восстановление состояния другого потока. Оно дорого из-за прямых накладных расходов на сохранение/восстановление, а также косвенных: сброса TLB (при смене процесса), потери содержимого кэшей CPU, загрязнения предсказателя переходов, что приводит к промахам и падению производительности.

Развёрнутый ответ

Типы переключений:

  • Между потоками одного процесса: меняются только регистры и стек; TLB не сбрасывается (при условии ASID/PCID), кэши остаются валидными, дешевле.
  • Между процессами: дополнительно меняется таблица страниц (CR3), что инвалидирует TLB, если не используются PCID. Также выше вероятность, что кэшированные данные нового процесса вытеснят старые.

Стоимость включает:

  1. Прямые затраты: микросекунды на сохранение/восстановление аппаратного контекста (в ядре switch_to).
  2. ТLB miss: после смены адресного пространства первые обращения к памяти вызывают промахи TLB и требуют обхода таблиц (десятки циклов).
  3. Загрязнение кэшей: данные и инструкции нового процесса вытесняют кэш-линии предыдущего, вызывая промахи после обратного переключения (кэш-трединг).
  4. Планировщик: само решение, кого запускать, требует времени (обход дерева CFS, вычисление vruntime).

Go минимизирует системные переключения, используя горутины с планированием в пользовательском пространстве. Смена горутины стоит порядка десятков наносекунд (сохранение регистров GCC), без перехода в ядро. Однако если горутина выполнила блокирующий системный вызов, поток может быть переключён.

Под капотом (internals)

  • context_switch() в ядре Linux (kernel/sched/core.c) вызывает switch_mm() (смена адресного пространства) и switch_to() (ассемблерная магия для смены task_struct и регистров).
  • switch_mm может быть пустым, если следующий поток того же процесса, иначе загружает новый CR3 и, если не PCID, сбрасывает TLB.
  • Издержки TLB: после сброса каждый промах вызывает page walk (может занять до 5 уровней таблиц * 4-5 циклов на уровень ≈ много циклов). Это существенно.

Типичные ошибки кандидатов

  1. Недооценка стоимости context switch для высокопроизводительных систем
    Часто говорят «context switch — это быстро, потому что делает ядро». Но при большом количестве переключений (десятки тысяч в секунду) суммарные потери существенны. Кандидат должен приводить конкретные цифры: накладные расходы ~1-5 мкс на переключение.

  2. Путать переключение горутин с контекстным переключением ОС
    В Go переключение горутин дёшево, но если горутина блокируется на системном вызове, поток ОС тоже переключается. Ошибка: сказать, что горутины совсем не вызывают контекстных переключений.

  3. Считать, что многопоточность всегда ускоряет программу
    Из-за контекстных переключений чрезмерное число потоков может привести к треш-эффекту (thrashing), когда большую часть времени система переключается, а не выполняет работу. Нужно упоминать тюнинг GOMAXPROCS.

Возможные дополнительные вопросы интервьюера

  • Как измерить количество переключений контекста в Linux? — pidstat -w 1 показывает cswch/s (добровольные) и nvcswch/s (принудительные). Также perf stat -e context-switches.

  • Что такое voluntary и involuntary context switches? — Voluntary — когда поток сам уступает CPU (sleep, wait). Involuntary — когда ядро прерывает задачу по исчерпанию кванта или из-за прихода более приоритетной задачи.

  • Как Go runtime уменьшает число системных переключений? — Модель M:N с парковкой горутин и netpoller’ом удерживает потоки в ожидании в пользовательском режиме, а не в ядре, снижая число дорогих переключений.


Как работает планировщик задач в Linux (CFS, EEVDF)?

Краткий ответ (30–60 секунд)

CFS (Completely Fair Scheduler) — планировщик, внедрённый в ядро 2.6.23, нацеленный на справедливое распределение процессорного времени между задачами. Каждая задача накапливает виртуальное время выполнения (vruntime); планировщик выбирает задачу с наименьшим vruntime, храня её в красно-чёрном дереве. На смену CFS в ядре 6.6 приходит EEVDF (Earliest Eligible Virtual Deadline First), добавляющий понятие eligibility и deadline для более честного распределения и уменьшения задержек.

Развёрнутый ответ

CFS:

  • Не использует кванты в классическом смысле. Цель — обеспечить справедливую долю времени, пропорциональную весу задачи (nice значение).
  • Вес определяет скорость накопления vruntime: задача с низким приоритетом (nice >0) накапливает vruntime быстрее, тем самым получая меньше времени.
  • Задачи хранятся в cfs_rq (runqueue) в виде красно-чёрного дерева, упорядоченного по vruntime. Выбор следующей задачи — O(1) поиск самого левого узла.
  • Период планирования (sched period) гарантирует, что каждая runnable задача получит CPU хотя бы раз за период.

EEVDF (в ядре 6.6):

  • Улучшение для рабочих нагрузок с чувствительностью к латентности.
  • Вводит концепцию eligible time: задача может быть выбрана только если её запрос (request) уже eligible (наступило его время).
  • Использует виртуальный deadline, чтобы задача не могла голодать и обеспечивать предсказуемую задержку.
  • Также основан на красно-чёрном дереве, но структура более сложная; улучшает поведение для многопоточных приложений.

В Go взаимодействие с планировщиком Linux опосредовано: потоки Go (M) используют блокирующие вызовы, и ядро планирует их как обычные задачи. Понимание CFS помогает объяснить влияние nice на производительность и то, почему увеличение GOMAXPROCS в некоторых случаях может привести к ухудшению из-за конкуренции за CPU.

Под капотом (internals)

  • CFS: struct sched_entity (se) содержит vruntime, load (вес). cfs_rq содержит красно-чёрное дерево tasks_timeline. Функция pick_next_task_fair извлекает самый левый узел.
  • Виртуальное время: vruntime += delta_exec * NICE_0_LOAD / se.weight. При отрицательном nice вес высок, vruntime растёт медленно.
  • EEVDF структура: sched_entity расширена полями deadline, eligible_time, vlag. Планировщик проверяет eligibility и выбирает задачу с наименьшим deadline.
  • Переключение: оба планировщика используют периодический тик и ISR таймера, который проверяет, не истёк ли квант и не нужно ли выполнить перепланировку.

Типичные ошибки кандидатов

  1. Считать, что CFS — это round-robin
    Хотя CFS делает задачи runnable по очереди, он динамически переупорядочивает их на основе vruntime, что даёт иллюзию выделенного CPU каждому, без жёстких квантов.

  2. Игнорировать новое EEVDF
    С выходом ядер 6.6+ знание только CFS неполно. Кандидат должен понимать, что изменилось и зачем (уменьшение задержек, более строгая защита от голодания).

  3. Неправильно связывать приоритет nice с абсолютным приоритетом
    Nice — это вес, а не фиксированный приоритет; задача с nice 0 может получать больше времени, чем задача с nice -10, при определённых условиях. Важно уточнять разницу между nice и RT-приоритетами.

Возможные дополнительные вопросы интервьюера

  • Как CFS обрабатывает интерактивные задачи (лагерь latency)? — CFS использует sysctl sched_latency и минимальную гранулярность, чтобы дать интерактивным задачам больше шансов на быстрое выполнение. Но точный механизм эвристики: sleep+высокое vruntime дают приоритет.

  • Что даёт EEVDF по сравнению с CFS? — Более строгая гарантия задержки: задачи не могут быть отложены на длительное время из-за того, что другие задачи «опережают» по vruntime. Deadline enforcement улучшает отзывчивость.

  • Можно ли в Go управлять приоритетом потоков ОС? — Можно через runtime.LockOSThread и затем syscall.Setpriority, но это усложняет переносимость и обычно не рекомендуется.


Что такое nice и priority процесса?

Краткий ответ (30–60 секунд)

nice в Linux — это пользовательский приоритет процесса в диапазоне от -20 (высший приоритет) до +19 (низший), влияющий на долю выделяемого CPU-времени. Чем меньше nice, тем больший вес получает процесс в планировщике CFS/EEVDF. Приоритет ядра (внутренний) — это более широкая концепция, включающая RT-приоритеты (1-99) и обычный приоритет (100-139), отображаемый из nice.

Развёрнутый ответ

Взаимосвязь:

  • Пользователь задаёт значение nice для процесса (nice -n 10 command), оно хранится в task_struct->static_prio. Внутренний приоритет prio вычисляется с учётом динамических бонусов и реального времени, но для обычных задач prio = static_prio (по сути, nice + 120). В CFS используется вес, обратный приоритету: маппинг nice -> weight (таблица из 40 элементов).
  • Эффект: процесс с nice 0 имеет вес 1024, с nice 1 — вес ~820, что означает, что он получает примерно 80% CPU относительно задачи с nice 0.
  • Real-time приоритеты (1-99) фиксированы и всегда вытесняют обычные задачи (SCHED_OTHER); nice на них не влияет.

В Go можно изменить nice текущего процесса через syscall.Setpriority(syscall.PRIO_PROCESS, pid, value). Однако изменение приоритета редко требует явного изменения в приложениях; чаще полагаются на настройки контейнерной среды.

Под капотом (internals)

  • Массив sched_prio_to_weight[40] в kernel/sched/core.c преобразует nice в весовой коэффициент для CFS.
  • task_struct->prio фактический динамический приоритет, static_prio — базовый. Для обычных задач prio равен static_prio, если только не применяется приоритетное наследование реального времени.
  • Вес определяет скорость увеличения vruntime: более высокий вес (низкий nice) увеличивает vruntime медленнее, тем самым давая больше CPU времени.

Типичные ошибки кандидатов

  1. Путать nice и RT priority
    RT приоритеты (1–99) имеют жёсткое преимущество над обычными задачами, nice тут неприменим. Кандидат должен чётко различать политики планирования.

  2. Ожидать, что nice -20 гарантирует всё процессорное время
    Даже с nice -20 задача конкурирует с другими задачами и прерываниями; она не является real-time и может страдать от содержания.

  3. Игнорировать влияние nice на пропускную способность ввода-вывода
    В CFS nice влияет только на CPU, но не на I/O-приоритет (для этого ionice). Некоторые связывают ускорение с nice в неоправданных местах.

Возможные дополнительные вопросы интервьюера

  • Как nice влияет на интерактивные задачи в CFS? — Благодаря большей sleep latency интерактивные задачи получают меньшее vruntime при пробуждении, что даёт им небольшой «бонус», но общий вес всё равно важен.

  • Можно ли дать процессу real-time приоритет без root? — Обычно требует CAP_SYS_NICE или настройки ulimit -r.

  • Как проверить текущий nice процесса? — ps -o pid,ni,comm или cat /proc/PID/stat поле 19.


Как работает strace и как его использовать?

Краткий ответ (30–60 секунд)

strace — утилита, перехватывающая системные вызовы и сигналы, выполняемые целевым процессом или порождёнными им потомками. Она использует интерфейс ptrace для остановок процесса на каждом syscall и записи аргументов и возвращаемых значений. Используется для отладки, мониторинга и анализа производительности: strace -p PID, strace -c command (подсчёт статистики), strace -e trace=file -f program.

Развёрнутый ответ

Режимы работы:

  • Присоединение к существующему процессу: strace -p <PID>. Останавливает процесс, вводит ptrace.
  • Запуск новой программы: strace ./app. Позволяет отслеживать инициализацию.
  • Трассировка потомков: -f форкает потомков.
  • Фильтрация: -e trace=network отслеживает только сетевые syscall, -e trace=read,write.
  • Статистика: -c выводит таблицу счётчиков, времени выполнения syscall.

При использовании на Go-программах strace показывает, как runtime выполняет сетевой ввод-вывод, управление горутинами, выделение памяти. Однако следует помнить, что перехват замедляет выполнение и может исказить поведение, поэтому в продакшене — только осторожно.

Под капотом (internals)

  • ptrace (PROCESS) позволяет трассирующему процессу управлять трассируемым: чтение регистров, памяти, установка точек останова. Для каждого syscall strace устанавливает флаг PTRACE_SYSCALL, который останавливает трассируемого на входе и выходе из системного вызова.
  • Ядро в syscall_trace_enter и syscall_trace_leave проверяет флаг ptrace и приостанавливает процесс, отправляя SIGTRAP трассировщику.
  • strace в цикле ждёт waitpid, считывает регистры и аргументы, печатает их, затем возобновляет процесс.

Типичные ошибки кандидатов

  1. Бездумное использование strace в production
    Замедление программы может быть значительным (вплоть до порядка), что может нарушить тайминги и вызвать каскадные сбои. Правильно: использовать только для временной диагностики.

  2. Игнорирование -f для отслеживания многопоточных приложений
    В Go множество потоков и горутин; без -f можно пропустить важные syscall дочерних процессов или горутин, превращённых в потоки.

  3. Попытка интерпретировать strace вывод без понимания специфики Go runtime
    Например, множество вызовов futex и epoll_wait могут казаться подозрительными, но это нормальная работа планировщика.

Возможные дополнительные вопросы интервьюера

  • Как узнать, какой именно системный вызов вызывает задержку? — Использовать -c для агрегированной статистики времени, либо -T для вывода длительности каждого вызова. Затем проанализировать вызовы с большими временами.

  • Какое воздействие оказывает strace на многопоточную программу, особенно на Go? — Ловушки ptrace приостанавливают все потоки при входе/выходе, что может менять порядок выполнения и выявлять гонки, которые иначе скрыты.

  • Как strace помогает отладить ошибку «too many open files»? — Запустить с -e trace=open,openat,close и смотреть, какие файлы открываются и не закрываются.


Как работает lsof?

Краткий ответ (30–60 секунд)

lsof (list open files) отображает все открытые файловые дескрипторы, сокеты, каналы и другие файлы для указанного процесса или всей системы. Он работает, сканируя файловую систему /proc: каталоги fd для каждого процесса, /proc/net/ для сетевых сокетов, а также использует netlink для получения информации о сокетах. lsof -p PID — основной инструмент для диагностики утечек дескрипторов.

Развёрнутый ответ

lsof объединяет информацию из различных источников:

  • /proc/PID/fd/ — символические ссылки на открытые файлы.
  • /proc/PID/limits — лимиты на количество файлов.
  • /proc/net/tcp, /proc/net/udp — списки TCP/UDP сокетов с номерами индексных дескрипторов.
  • netlink интерфейс для получения более детальной информации о сетевых сокетах.

Примеры использования:

  • lsof -i :8080 — процессы, слушающие или подключённые к порту 8080.
  • lsof /path/to/file — процессы, открывшие этот файл.
  • lsof -p PID — всё, что открыто процессом.

В Go утечка файловых дескрипторов может возникать из-за незакрытых os.File, net.Conn или os/exec.Cmd с StdoutPipe. lsof позволяет быстро выявить рост числа открытых сокетов или файлов. Внутри контейнера команда также работает (обычно с ограниченным /proc).

Под капотом (internals)

  • lsof читает /proc/<PID>/fd/, где каждый элемент — симлинка на файл или строку типа socket:[ino].
  • Для сокетов он ищет inode в /proc/net/tcp и подобных, чтобы получить IP-адреса и порты.
  • Для получения дополнительной информации может использоваться netlink-сообщение INET_DIAG для запроса состояния сокетов.

Типичные ошибки кандидатов

  1. Забывать, что сокеты — это тоже файловые дескрипторы
    lsof показывает TCP-соединения и слушающие сокеты, важно это понимать при поиске утечек.

  2. Не знать, что /proc должен быть смонтирован
    Внутри некоторых контейнеров /proc может быть намеренно ограничен либо отсутствовать, тогда lsof не даст полной картины.

  3. Использовать lsof для мониторинга в реальном времени с высоким разрешением
    Это неэффективно, так как читает многие файлы; лучше использовать ss для сокетов или прямо читать /proc.

Возможные дополнительные вопросы интервьюера

  • Как посмотреть все открытые сокеты в системе без lsof? — Команда ss -t -u -a показывает TCP/UDP сокеты; также cat /proc/net/tcp.

  • Как в Go-программе проверить количество открытых дескрипторов? — Можно вызвать os.ReadDir("/proc/self/fd") и посчитать; это кроссплатформенный подход, но на Linux работает.

  • Что такое lsof +L1? — Показывает открытые файлы, которые были удалены, но всё ещё удерживаются (утечка). Полезно для обнаружения потерь дискового пространства.


Как работает tcpdump?

Краткий ответ (30–60 секунд)

tcpdump — это сниффер пакетов, захватывающий и анализирующий сетевой трафик в реальном времени. Он использует библиотеку libpcap для открытия AF_PACKET сокета, через который ядро передаёт копии пакетов сетевого интерфейса. Применяя BPF-фильтры, tcpdump отсеивает ненужные пакеты прямо в ядре, минимизируя копирование в userspace.

Развёрнутый ответ

Архитектура захвата:

  1. tcpdump создаёт RAW сокет (AF_PACKET) или использует libpcap, который может открыть такой сокет.
  2. Устанавливает фильтр BPF (Berkeley Packet Filter) с помощью setsockopt(SO_ATTACH_FILTER). Фильтр компилируется в байткод BPF и загружается в ядро, где выполняется над каждым пакетом.
  3. Для каждого проходящего пакета драйвер/сетевой стек копирует его в память, выделенную сокетом. Фильтр BPF отбрасывает ненужные пакеты на ранней стадии, снижая накладные расходы.
  4. tcpdump читает пакеты из сокета и декодирует протоколы (Ethernet, IP, TCP/UDP) для вывода.

Примеры: tcpdump -i eth0 port 80 — захват HTTP-трафика; tcpdump -i any tcp and host 10.0.0.1 — любой интерфейс, TCP-пакеты хоста.

В Go для анализа трафика можно использовать библиотеку github.com/google/gopacket, которая также использует AF_PACKET или pcap. Но прямой tcpdump остаётся незаменимым инструментом отладки.

Под капотом (internals)

  • AF_PACKET сокеты: тип SOCK_RAW с указанием протокола ETH_P_ALL захватывает все пакеты. Ядро помещает копии в очередь сокета.
  • BPF: классический BPF (cBPF) — простой виртуальный стек. Ядро компилирует tcpdump выражения в cBPF байткод. На современных ядрах может использоваться eBPF для более сложной фильтрации.
  • Обработка пакетов: в __netif_receive_skb_core после всех netfilter хуков и маршрутизации, перед передачей в стек протоколов, пакет доставляется всем зарегистрированным AF_PACKET сокетам через dev_queue_xmit_nit.

Типичные ошибки кандидатов

  1. Захват трафика на загруженном интерфейсе без фильтра
    Без BPF-фильтра tcpdump скопирует все пакеты, вызывая высокие накладные расходы CPU и может сам стать источником потерь пакетов. Всегда использовать фильтр.

  2. Неверная интерпретация «dropped by kernel»
    Сообщение означает, что ядро не успевало передавать пакеты в буфер userspace и отбрасывало их. Надо увеличить буфер сокета (-B размер) или улучшить фильтрацию.

  3. Попытка использовать tcpdump для захвата на виртуальных интерфейсах (veth) без понимания направления
    В контейнерной среде пакеты могут быть видны с разных точек; захват на veth хоста даёт трафик до NAT.

Возможные дополнительные вопросы интервьюера

  • Чем отличается захват на физическом интерфейсе от на any? — any псевдоинтерфейс перехватывает пакеты на стадии входа/выхода из ядра, до того как они могут быть изменены netfilter. Не поддерживает режим promiscuous.

  • Как читать pcap-файлы в Go? — Библиотека gopacket/pcap открывает файлы и позволяет анализировать пакеты программно.

  • Что такое tcpdump -w file и -r file? — -w сохраняет сырые пакеты для последующего анализа, -r читает сохранённый файл.


Как работает iptables / nftables?

Краткий ответ (30–60 секунд)

iptables и nftables — системы управления правилами сетевого фильтра ядра Linux (netfilter). iptables — устаревший, но всё ещё широко используемый инструмент, оперирующий цепочками (INPUT, OUTPUT, FORWARD) и таблицами (filter, nat). nftables — современная замена, объединяющая функциональность в едином фреймворке с более гибким синтаксисом и лучшей производительностью. Оба позволяют выполнять фильтрацию, NAT, манглинг пакетов.

Развёрнутый ответ

iptables:

  • Таблицы: filter (принятие/отклонение), nat (трансляция адресов), mangle (изменение заголовков), raw (обход connection tracking).
  • В каждой таблице встроенные цепочки: PREROUTING, INPUT, OUTPUT, FORWARD, POSTROUTING.
  • Правила организуются в списки, добавление через iptables -A <chain> .... Каждое правило может проверять множество условий и заканчиваться действием (ACCEPT, DROP, DNAT, SNAT, MASQUERADE).
  • Для работы используется модуль ядра ip_tables, который вставляет хуки в netfilter.

nftables:

  • Использует единую инфраструктуру nf_tables ядра. Конфигурация описывается таблицами с цепочками, но правила компилируются в байткод, что ускоряет обработку.
  • Синтаксис: nft add table inet filter, nft add chain inet filter input { type filter hook input priority 0; }.
  • Поддерживает множества, словари, атомарные обновления; лишён проблем с большими наборами правил (iptables сканирует список последовательно).

В Go прямое управление правилами обычно выполняется через вызов внешних команд или через библиотеки, работающие с netlink (например, vishvananda/netlink). Контейнерные оркестраторы (Docker, Kubernetes) генерируют правила iptables для проброса портов, балансировки.

Под капотом (internals)

  • Netfilter определены пять хуков: NF_INET_PRE_ROUTING, NF_INET_LOCAL_IN, NF_INET_FORWARD, NF_INET_LOCAL_OUT, NF_INET_POST_ROUTING. Модули (iptable_filter, iptable_nat) регистрируют обработчики на этих хуках.
  • При прохождении пакета через хук, ядро вызывает зарегистрированные функции, которые проходят по цепочке правил. Для iptables это линейный обход, O(n).
  • nftables: правила компилируются в state machine, и доступ к match-алгоритмам оптимизирован, что может давать O(1) во многих случаях.

Типичные ошибки кандидатов

  1. Путать iptables и netfilter
    netfilter — это инфраструктура ядра, а iptables — пользовательский инструмент для задания правил. Часто их отождествляют.

  2. Забывать про conntrack при анализе проблем
    Многие правила полагаются на connection tracking. Если таблица conntrack переполнена или пакеты не попадают в неё из-за raw-таблицы, поведение не соответствует ожиданиям.

  3. Использовать iptables в высокопроизводительных сценариях с тысячами правил
    Последовательный обход может стать узким горлом. nftables или даже eBPF-фильтрация предпочтительнее.

Возможные дополнительные вопросы интервьюера

  • Как Docker использует iptables для проброса портов? — Docker создаёт цепочку DOCKER в таблице nat и filter, добавляет правила DNAT для перенаправления трафика с порта хоста на порт контейнера, и соответствующие правила FORWARD.

  • Что такое MASQUERADE и зачем оно нужно? — Специальная цель NAT, которая динамически заменяет исходный адрес на IP исходящего интерфейса; используется, когда внешний IP может меняться (например, dialup).

  • Как можно программно читать и изменять правила netfilter в Go? — Через netlink-сообщения с использованием github.com/vishvananda/netlink для некоторых операций или через exec к nft.


Как работает TCP рукопожатие и TIME_WAIT состояние?

Краткий ответ (30–60 секунд)

TCP-соединение устанавливается трёхсторонним рукопожатием: клиент посылает SYN, сервер отвечает SYN-ACK, клиент подтверждает ACK. Для закрытия соединения используется четырёхстороннее завершение, после которого активная сторона, отправившая последний ACK, переходит в состояние TIME_WAIT на 2MSL (Maximum Segment Lifetime), чтобы гарантировать доставку последнего ACK и предотвратить перепутывание с новыми соединениями.

Развёрнутый ответ

Установление (SYN → SYN-ACK → ACK):

  1. Клиент: connect() отправляет SYN, переходит в SYN_SENT.
  2. Сервер: слушающий сокет в LISTEN, получает SYN, переходит в SYN_RCVD, отправляет SYN-ACK.
  3. Клиент: получает SYN-ACK, переходит в ESTABLISHED, отправляет ACK (может содержать данные).
  4. Сервер: получает ACK, переходит в ESTABLISHED, соединение готово.

Завершение (FIN → ACK, FIN → ACK):
Активная сторона (допустим, клиент) вызывает close(), отправляет FIN, переходит в FIN_WAIT_1. Сервер ACK'ует FIN и переходит в CLOSE_WAIT, потом посылает свой FIN, переходит в LAST_ACK. Клиент получает FIN, отвечает ACK и переходит в TIME_WAIT. Сервер получает ACK и закрывается.

TIME_WAIT длится 2*MSL (обычно 60–120 секунд). Зачем:

  • Убедиться, что удалённая сторона получила последний ACK; если она не получила, она повторно пошлёт FIN, и TIME_WAIT сможет ответить повторным ACK.
  • Предотвратить смешение старых и новых соединений: «заблудившиеся» сегменты старого соединения успевают исчезнуть из сети.

Проблемы с TIME_WAIT: большое количество портов в этом состоянии (на машине, совершающей много исходящих соединений) может исчерпать пул клиентских портов. Решения: SO_REUSEADDR на сервере, использование keep-alive для снижения числа новых соединений, увеличение диапазона локальных портов.

В Go сервер net.Listen по умолчанию устанавливает SO_REUSEADDR (на Linux), что позволяет быстро переиспользовать порт после перезапуска сервера.

Под капотом (internals)

  • Состояния описаны в enum tcp_state ( TCP_ESTABLISHED, TCP_FIN_WAIT1, ..., TCP_TIME_WAIT).
  • Переходы реализуются в tcp_rcv_state_process и tcp_set_state.
  • Таймер TIME_WAIT управляется через inet_twsk_schedule с таймером на 2*MSL. Когда время истекает, сокет освобождается.

Типичные ошибки кандидатов

  1. Путать активного и пассивного закрывающего
    В TIME_WAIT попадает только активная сторона (инициировавшая первый FIN). Пассивная после последнего ACK переходит в CLOSED немедленно. Ошибка — думать, что обе стороны остаются в TIME_WAIT.

  2. Считать TIME_WAIT багом или ненужным состоянием
    Это важная часть TCP; попытки искусственно обнулить TIME_WAIT (например, агрессивным таймаутом) могут привести к нарушению целостности данных.

  3. Использовать SO_LINGER для устранения TIME_WAIT непродуманно
    Установка SO_LINGER с таймаутом 0 приводит к отправке RST вместо FIN, что предотвращает TIME_WAIT, но может вызвать потерю данных. Нужно понимать компромисс.

Возможные дополнительные вопросы интервьюера

  • Можно ли переиспользовать порт в TIME_WAIT для нового слушающего сокета? — Да, с SO_REUSEADDR сервер может привязаться к тому же порту. Для клиентских соединений это не помогает, т.к. там уникальность (src IP, src port, dst IP, dst port) сохраняется.

  • Как уменьшить количество TIME_WAIT сокетов на стороне клиента? — Увеличить диапазон локальных портов (net.ipv4.ip_local_port_range), включить раннее повторное использование (tcp_tw_reuse), но осторожно.

  • Что произойдёт, если сервер пошлёт FIN, а клиент не ответит ACK? — Сервер будет повторно посылать FIN, находясь в FIN_WAIT_1, затем перейдёт в FIN_WAIT_2, и если совсем нет ответа, через таймаут (tcp_fin_timeout) закроется.


Что такое backlog в TCP?

Краткий ответ (30–60 секунд)

backlog — параметр, указывающий максимальное количество ожидающих соединений, которые ядро помещает в очередь слушающего сокета: очередь SYN_RCVD (не до конца установленных) и очередь принятых, ожидающих вызова accept(). Когда очередь заполнена, новые SYNs либо отбрасываются, либо обрабатываются с помощью SYN cookies. В Go значение backlog передаётся через net.Listen (Listen("tcp", addr) читает /proc/sys/net/core/somaxconn для ограничения).

Развёрнутый ответ

На самом деле Linux имеет две очереди для слушающего сокета:

  1. SYN queue (syn backlog): содержит соединения, получившие SYN (состояние SYN_RCVD), но ещё не завершившие рукопожатие. Размер ограничен net.ipv4.tcp_max_syn_backlog для каждого сокета и системными параметрами.
  2. Accept queue (socket backlog): содержит полностью установленные соединения (ESTABLISHED), ожидающие accept(). Её размер — минимум из переданного backlog (второй аргумент listen()) и somaxconn (по умолчанию 4096 на современных ядрах).

Когда accept queue переполнена:

  • По умолчанию ядро отбрасывает пакет SYN (или поведение зависит от tcp_abort_on_overflow). Если SYN cookies включены (net.ipv4.tcp_syncookies=1), ядро может корректно завершить рукопожатие, но соединение будет потеряно, если очередь не освободится.

В Go net.Listen вызывает listen(fd, backlog), где backlog берётся из syscall.SOMAXCONN или настраивается при создании ListenConfig. Для высоконагруженных серверов надо увеличивать somaxconn и следить за метриками переполнения (netstat -s | grep overflowed).

Под капотом (internals)

  • Функция tcp_v4_syn_recv_sock после завершения рукопожатия пытается поместить новый сокет в accept queue через inet_csk_reqsk_queue_add. Если очередь полная, срабатывает tcp_conn_request -> отбрасывание SYN или SYN cookies.
  • Структура слушающего сокета: struct inet_connection_sock содержит icsk_accept_queue с полем max (лимит).
  • При переполнении счётчик ListenOverflows в /proc/net/netstat инкрементируется.

Типичные ошибки кандидатов

  1. Передавать маленький backlog и думать, что им ограничивается количество одновременных соединений
    backlog ограничивает только очередь ожидающих accept, а не общее число установленных соединений. Кандидат может утверждать, что сервер с backlog=128 примет только 128 клиентов, что неверно. Исправление: понять разницу.

  2. Игнорировать настройку somaxconn
    В Linux значение backlog ограничивается сверху значением somaxconn (часто 4096). Если кандидат устанавливает backlog=65535, система ограничит до somaxconn. Надо упомянуть необходимость настройки sysctl.

  3. Не знать о SYN cookies и их влиянии
    При переполнении очереди SYN cookies позволяют не хранить состояние на сервере до завершения рукопожатия, но при этом отключаются некоторые возможности TCP (опции). Важно знать этот компромисс.

Возможные дополнительные вопросы интервьюера

  • Как проверить, что accept queue переполнена? — ss -lnt показывает Send-Q и Recv-Q. Для слушающего сокета Recv-Q — текущее число ожидающих accept, Send-Q — максимальный размер очереди accept. Если Recv-Q близок к Send-Q — переполнение.

  • Что произойдёт, если на сервере включены SYN cookies и большая нагрузка? — Соединения будут устанавливаться, но клиент может не заметить переполнения, если его ACK корректно обработан. Но сервер теряет опции (SACK, window scale), что может снизить производительность.

  • Как на лету изменить backlog слушающего сокета? — Напрямую нельзя, только пересоздать сокет. Можно динамически регулировать параметры ядра.


Что такое SO_KEEPALIVE?

Краткий ответ (30–60 секунд)

SO_KEEPALIVE — опция сокета TCP, при включении которой ядро начинает периодически отправлять keep-alive пробы на противоположную сторону для обнаружения мёртвых соединений. Если пробы не получают ответа, соединение разрывается. В Go настройка через net.Dialer.KeepAlive или syscall.SetsockoptInt. Не заменяет прикладной heart-beat, но полезна для освобождения ресурсов от зависших соединений.

Развёрнутый ответ

По умолчанию TCP-соединения могут висеть бесконечно долго без передачи данных, даже если удалённая сторона недоступна. SO_KEEPALIVE решает это:

  • После периода бездействия (обычно tcp_keepalive_time = 7200 секунд ядро начинает отправлять пробы каждые tcp_keepalive_intvl (75 с) до tcp_keepalive_probes (9) раз. При отсутствии ответа соединение разрывается.
  • Параметры глобальны, но могут быть переопределены для сокета через TCP_KEEPIDLE и друзей.

В Go при создании net.Dialer{KeepAlive: 10*time.Second} автоматически устанавливает SO_KEEPALIVE и задаёт idle-интервал, после которого посылаются пробы. Это особенно важно в сценариях долгоживущих соединений (gRPC stream, WebSocket), чтобы своевременно обнаружить отвал сети.

Под капотом (internals)

  • tcp_sock хранит таймеры keepalive_timer. Когда истекает idle, запускается таймер пробы; посылается пакет без данных с ACK, на который ожидается ответ.
  • Если ответ не приходит, таймер перезапускается; при исчерпании попыток сокет переходит в состояние закрытия.
  • Для каждого сокета можно установить TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT через setsockopt.

Типичные ошибки кандидатов

  1. Полагать, что keepalive быстро определяет обрыв
    Стандартный idle 2 часа не подходит для интерактивных приложений. Надо настраивать KeepAlive в Go на разумные интервалы (минуты). Ошибка — не менять дефолты.

  2. Путать TCP keepalive с HTTP keep-alive
    HTTP keep-alive — это переиспользование TCP-соединения для нескольких запросов, а TCP keepalive — про проверку живости на уровне транспорта.

  3. Заменять прикладной health-check только на keepalive
    Keepalive проверяет лишь сетевую доступность транспортного уровня, но не функциональность приложения. Необходимо дополнение прикладными ping/pong.

Возможные дополнительные вопросы интервьюера

  • Как настроить TCP keepalive в Go для серверного сокета, принятого через Accept? — Использовать tcpConn.SetKeepAlive(true) и tcpConn.SetKeepAlivePeriod(duration). Внутренне вызывает setsockopt с TCP_KEEPIDLE.

  • Влияет ли SO_KEEPALIVE на трафик? — Объём проб незначителен (один пакет без данных), но при очень большом количестве idle-соединений может создавать нагрузку.

  • Можно ли отключить SO_KEEPALIVE для сокета? — Да, параметр отключается через SetKeepAlive(false); обычно это нежелательно для долгоживущих соединений.


Что такое SO_REUSEPORT?

Краткий ответ (30–60 секунд)

SO_REUSEPORT — опция сокета, позволяющая нескольким слушающим сокетам (возможно, в разных процессах) привязываться к одному и тому же порту. Ядро распределяет входящие соединения между этими сокетами на основе хеширования (обычно 5‑типлета), что обеспечивает балансировку нагрузки без необходимости координации в userspace. Это улучшает параллелизм и производительность серверов.

Развёрнутый ответ

До появления SO_REUSEPORT (Linux 3.9) традиционный подход требовал одного слушающего сокета, а рабочие процессы наследовали его через fork или использовали общую очередь. Это создавало проблемы с блокировкой и масштабированием.

SO_REUSEPORT решает это:

  • Несколько сокетов, все с этой опцией, могут быть привязаны к одному адресу:порту при условии, что все имеют один и тот же эффективный UID.
  • Ядро ведёт хеш-таблицу слотов для распределения. При входящем соединении хешируется (src IP, src port, dst IP, dst port) (или только src port для UDP), выбирается конкретный слушающий сокет. Это даёт отличное распределение при большом количестве IP/портов.
  • В Go можно использовать net.ListenConfig с функцией Control для установки опции, или reuseport библиотеку. Это позволяет запустить несколько горутин, каждая со своим listener’ом на одном порту, что устраняет single-listener bottleneck.

Под капотом (internals)

  • Структура inet_hashinfo содержит массивы listening_hash указателей на слушающие сокеты. При добавлении с SO_REUSEPORT ядро использует reuseport_select_sock для выбора сокета.
  • Реализация reuseport_select_sock: вычисляет хеш, выбирает слот (методом consistent hashing), возвращает сокет.
  • Для epoll каждое событие accept будет поступать только на тот сокет, которому назначено это соединение, что естественно распараллеливает обработку.

Типичные ошибки кандидатов

  1. Путаница с SO_REUSEADDR
    SO_REUSEADDR позволяет переиспользовать порт в TIME_WAIT и подобных состояниях, но не разрешает нескольким слушающим сокетам одновременно принимать соединения на одном порту (исключая случаи с разными IP). SO_REUSEPORT именно это и делает.

  2. Не учитывать, что SO_REUSEPORT требует полной идентичности адреса
    Все сокеты должны быть привязаны идентично (один и тот же IP, порт), иначе поведение не определено. Ошибка — думать, что можно смешивать wildcard и конкретный IP.

  3. Считать, что балансировка идеальна при малом количестве клиентов
    Хеширование при малом наборе клиентов может дать неравномерное распределение, особенно если клиенты с одного IP. Это важно упомянуть.

Возможные дополнительные вопросы интервьюера

Как настроить SO_REUSEPORT на слушающем сокете в Go?

Go
lc := net.ListenConfig{
    Control: func(network, address string, c syscall.RawConn) error {
        return c.Control(func(fd uintptr) {
            syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
        })
    },
}
ln, _ := lc.Listen(context.Background(), "tcp", ":8080")
  • Что будет, если процессы с разными UID попытаются использовать один порт с SO_REUSEPORT? — Ядро откажет второму вызову, если effective UIDs не совпадают или первый процесс не разрешил reuse.

  • Какое преимущество даёт SO_REUSEPORT в связке с epoll? — Каждый сокет может иметь собственный epoll, обрабатываемый отдельным потоком/горутиной, что снижает contention на общем epoll-дескрипторе.


Что такое SO_RCVBUF / SO_SNDBUF?

Краткий ответ (30–60 секунд)

SO_RCVBUF и SO_SNDBUF — параметры сокета, задающие размеры буферов ядра для приёма и отправки данных соответственно. Они определяют, сколько данных может быть накоплено в ядре до того, как приложение сможет их прочитать (recv) или операционная система отправит их в сеть (send). Автонастройка Linux динамически регулирует эти буферы, но ручная установка может потребоваться для оптимизации пропускной способности (например, для высокоскоростных сетей).

Развёрнутый ответ

Буфер приёма (sk_receive_queue):

  • Когда приходят данные по сети, они помещаются в этот буфер, пока приложение не вызвало read.
  • Если буфер заполнен, TCP сокращает окно приёма (window), замедляя отправителя.
  • Слишком маленький буфер ограничивает пропускную способность (bandwidth-delay product = BDP).
  • Слишком большой может привести к потреблению большого объёма памяти ядра.

Буфер отправки (sk_write_queue):

  • Данные от приложения через write/send попадают в этот буфер, откуда ядро отправляет их в сеть.
  • Если буфер полон, системный вызов блокируется (или возвращает EAGAIN для неблокирующего сокета).

Автоматическое масштабирование (autotuning) в Linux: ядро динамически увеличивает буферы до предела, заданного tcp_rmem и tcp_wmem (Min, Default, Max). В Go net.Dialer по умолчанию не изменяет эти значения, полагаясь на автонастройку. Для специфических требований (видео стриминг, высокоскоростные передачи) может потребоваться увеличение.

Под капотом (internals)

  • Размеры хранятся в полях sk_rcvbuf и sk_sndbuf структуры sock. По умолчанию они берутся из sysctl_tcp_rmem[1] и sysctl_tcp_wmem[1].
  • При выделении буфера ядро может выделить вдвое больше запрошенного размера (учёт служебных структур struct sk_buff), поэтому фактически используемая память больше.
  • Установка SO_RCVBUF пользователем отключает автонастройку для этого сокета (за исключением случаев, когда значение устанавливается < sysctl_min — тогда ядро может игнорировать). Чтобы вернуть автонастройку, надо установить -1.

Типичные ошибки кандидатов

  1. Устанавливать слишком большие буферы без необходимости
    Это может привести к исчерпанию памяти ядра при большом количестве соединений. Нужно рассчитывать под требования BDP.

  2. Не знать, что значение, переданное в setsockopt, удваивается ядром
    Например, setsockopt(fd, SOL_SOCKET, SO_RCVBUF, 4096) реально даст ~8192 байт. Соответственно, при расчётах надо учитывать.

  3. Игнорировать автонастройку при ручной установке
    Если установить значение больше нуля, автонастройка отключается. Кандидат может этого не знать и лишиться преимуществ автоматической регулировки.

Возможные дополнительные вопросы интервьюера

  • Как получить текущий размер буфера сокета? — syscall.GetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF) вернёт удвоенный лимит (или ioctl).

  • Какое влияние оказывают большие буферы на задержку? — Буферизация увеличивает задержку, так как данные задерживаются в очереди. Для интерактивных приложений часто настраивают маленькие буферы с приоритетом низкой латентности.

  • Как узнать, что буфер приёма переполняется? — В статистике netstat Udp: для UDP или счётчики RcvbufErrors (можно посмотреть в /proc/net/snmp). Для TCP видимые симптомы — падение пропускной способности.


Как работает нагрузка (load average) в Linux?

Краткий ответ (30–60 секунд)

Load average — это экспоненциально сглаженное среднее количество задач, находящихся в состоянии runnable (R) и uninterruptible sleep (D), за периоды 1, 5 и 15 минут. Это показатель не только загрузки CPU, но и I/O, и других блокировок. Значение, близкое к количеству ядер, говорит о полной загрузке; превышение говорит о перегрузке.

Развёрнутый ответ

Ключевое: loadavg включает процессы в состоянии бесперебойного сна (D) — обычно ожидающие завершения дискового ввода-вывода. Поэтому высокий load average может быть вызван не интенсивной вычислительной нагрузкой, а медленными дисками.

  • Вычисление: ядро каждые 5 секунд (LOAD_FREQ) вычисляет количество задач в состояниях TASK_RUNNING и TASK_UNINTERRUPTIBLE, затем применяется экспоненциальное скользящее среднее к массиву avenrun для периодов 1,5,15 минут.
  • Просмотр: uptime, cat /proc/loadavg. В первом поле — load за 1 мин.

В контексте Go высокая нагрузка может возникнуть из-за многих горутин, совершающих блокирующие системные вызовы (особенно ввод-вывод), что переводит потоки в состояние D или R, увеличивая load. Но горутины, заблокированные на каналах в пользовательском пространстве, не входят в счёт, так как поток может быть неактивен или парковаться.

Под капотом (internals)

  • Функция calc_global_load() в kernel/sched/loadavg.c обновляет глобальный load average на основе количества nr_uninterruptible (статистика из nr_iowait?) и nr_running.
  • Массив avenrun использует целочисленную арифметику с фиксированной точкой.
  • nr_running — счётчик, используемый планировщиком, отражающий задачи, готовые к запуску. nr_uninterruptible — задачи в D.

Типичные ошибки кандидатов

  1. Считать load average только показателем загрузки CPU
    Игнорирование uninterruptible состояния — частая ошибка. Необходимо объяснить, что D-процессы тоже увеличивают load.

  2. Паниковать при load < количества ядер, но высокой латентности
    Система может быть перегружена I/O, что увеличит только load из-за D-задач, либо нагрузка может быть неравномерной. Надо анализировать дополнительные метрики.

  3. Сравнивать load average разных систем напрямую без учёта числа ядер
    Значение 4.0 на 4-ядерной системе — 100% утилизация, на 32-ядерной — лишь 12.5%. Кандидат должен всегда уточнять нормализацию.

Возможные дополнительные вопросы интервьюера

  • Какая разница между load average и CPU utilization? — Утилизация CPU показывает процент времени, когда процессор был занят, но не учитывает очереди задач в runnable и блокировки I/O. Load average — интегральный показатель насыщенности системы.

  • Как в Go посмотреть load average? — Можно прочитать /proc/loadavg через os.ReadFile.

  • Что значит, если load average высок, а CPU idle высок? — Вероятно, много процессов в D (ожидают диск), либо много короткоживущих задач, что не показывает мгновенная утилизация.


Что такое "kernel panic"?

Краткий ответ (30–60 секунд)

Kernel panic — это критическая ошибка ядра, при которой оно не может продолжать безопасную работу и принудительно останавливает систему. Паника вызывается фатальными сбоями (например, двойное нарушение страницы, повреждение структур, нехватка памяти для ядра) и выводит диагностическое сообщение на консоль, после чего система либо зависает, либо перезагружается (в зависимости от настройки). Это эквивалент BSoD в Windows.

Развёрнутый ответ

Причины kernel panic:

  • Необработанное исключение в коде ядра (ошибки драйверов, баги).
  • Повреждение структур данных ядра из-за сбоев памяти (hardware error).
  • Неудачная попытка загрузки init (kernel panic «No working init found»).
  • Исчерпание памяти на ранних стадиях загрузки.
  • Обнаружение противоречивого состояния (BUG_ON макрос).

После паники ядро вызывает функцию panic(), которая:

  • Отключает все остальные ядра (на SMP).
  • Выводит стек вызовов, регистры, возможно дамп состояния.
  • Если настроен panic_timeout, ждёт указанное время и перезагружает систему. Если нет, зависает.

Для сбора отладочной информации используется kdump (резервируется память для загрузочного ядра, которое сохраняет дамп памяти) или pstore.

В Go (пользовательские программы) приложение не может напрямую вызвать kernel panic (за исключением случаев загрузки eBPF программ, которые могут содержать ошибки). Но важно понимать последствия для хоста, на котором работает приложение.

Под капотом (internals)

  • Функция panic() определена в kernel/panic.c. Она отключает локальные прерывания, вызывает smp_send_stop(), затем выводит сообщения и переходит в бесконечный цикл или вызывает machine_restart.
  • При панике на ранней стадии до инициализации консоли сообщения могут быть потеряны, если не используется early console.
  • panic_on_oops — параметр ядра, заставляющий паниковать даже при менее серьёзных сбоях (например, oops).

Типичные ошибки кандидатов

  1. Считать kernel panic аналогичным панике в Go
    Несмотря на общее название, Go panic — это всего лишь исключительная ситуация в пользовательском процессе, не влияющая на ядро.

  2. Игнорировать конфигурацию panic_timeout для автоматической перезагрузки
    В серверных окружениях часто устанавливают panic_timeout=10, чтобы система автоматически перезагрузилась после паники, минимизируя время простоя.

  3. Не различать kernel oops и panic
    Oops — это серьёзная ошибка, но ядро может продолжить работу с ограниченной функциональностью. Паника — фатальный исход. Не надо путать.

Возможные дополнительные вопросы интервьюера

  • Как настроить систему для сбора crash dump после kernel panic? — Использовать kdump: резеревировать небольшое ядро (crashkernel=...), при панике загружается crash-ядро, которое сохраняет дамп в /var/crash.

  • Может ли приложение пользовательского пространства вызвать kernel panic? — Напрямую нет, но eBPF программы с ошибками верификатора или модули ядра могут. Также возможно через эксплуатацию уязвимости ядра.

  • Что такое sysrq+alt+prtsc+c? — Комбинация, вызывающая panic для отладки (если разрешено).


Как работает copy-on-write (COW)?

Краткий ответ (30–60 секунд)

Copy-on-Write — техника оптимизации, при которой страницы памяти не копируются немедленно при форке, а разделяются между родительским и дочерним процессами и помечаются как доступные только для чтения. Как только какой-либо из процессов пытается записать в такую страницу, MMU генерирует page fault, и ядро выделяет отдельную физическую страницу, копирует туда данные и обновляет отображения, после чего запись разрешается. Это сводит к минимуму использование памяти и накладные расходы при создании процессов.

Развёрнутый ответ

COW фундаментально важно для fork: без него копирование гигабайт памяти занимало бы секунды и расходовало бы вдвое больше RAM. Механизм:

  1. При fork ядро копирует VMA, но все частные страницы помечаются как «только чтение» в PTE обоих процессов, и счётчик ссылок страницы увеличивается.
  2. При попытке записи в такую страницу возникает page fault.
  3. Обработчик do_wp_page определяет, что это COW-страница. Если на страницу ссылается более одного процесса, он аллоцирует новую страницу, копирует данные, обновляет PTE для вызывающего процесса (делает доступной на запись), уменьшает счётчик ссылок старой страницы. Если остался только один процесс, просто делает страницу доступной для записи.
  4. После этого выполнение возобновляется.

В Go COW напрямую не используется, так как нет fork. Но для понимания работы os/exec и управления памятью в ОС это важно. Копирование слайсов в Go — это поверхностная операция, а не COW памяти, но принцип лежит в основе оптимизации clone.

Под капотом (internals)

  • dup_mmap устанавливает флаг VM_WRITE в VMA, но при проходе по PTE сбрасывает бит PTE_WRITE, устанавливая доступ только для чтения.
  • do_wp_page в mm/memory.c: проверяет page_count(page). Если >1, wp_page_copy выделяет новую страницу (alloc_page_vma), копирует copy_user_highpage, и обновляет PTE.
  • Счётчик ссылок страницы (_refcount) отслеживает, сколько отображений используют страницу. COW помогает при разделении библиотек и при posix_spawn.

Типичные ошибки кандидатов

  1. Считать, что COW копирует только при записи любым процессом
    Уточнение: при записи родителем — копия для родителя, дочерний продолжает использовать исходную; при записи потомком — копия для потомка. Страница может быть приватной для того, кто первый записал.

  2. Игнорировать, что COW применим только к частным страницам
    Разделяемые страницы (MAP_SHARED) не используют COW; изменения видны всем процессам.

  3. Не понимать ограничения COW с точки зрения производительности
    В многопоточных программах из-за модификации общего счётчика ссылок страниц возможны блокировки (lock on struct page), хотя и митигированные.

Возможные дополнительные вопросы интервьюера

  • Почему при форке не копируются стеки всех потоков, кроме одного? — Потому что fork копирует только вызывающий поток; другие потоки не дублируются. Это может привести к проблемам. COW работает только для адресного пространства, но другие потоки «исчезают».

  • Какая связь COW и overcommit? — COW позволяет выделять больше виртуальной памяти, чем физической, так как страницы могут быть зарезервированы без немедленного выделения. Overcommit полагается на COW и спрос.

  • Может ли COW страница стать shared dirty? — Нет, приватные COW страницы после записи становятся private, не shared.


Что такое "huge pages" и зачем они нужны?

Краткий ответ (30–60 секунд)

Huge pages — это страницы памяти увеличенного размера (2 МБ или 1 ГБ) вместо стандартных 4 КБ. Их главное назначение — уменьшение количества записей TLB (Translation Lookaside Buffer), необходимых для покрытия больших областей памяти, что снижает промахи TLB и повышает производительность приложений, работающих с огромными объёмами данных (базы данных, кэши, вычисления). Linux поддерживает два механизма: явные (hugetlbfs) и прозрачные (Transparent Huge Pages).

Развёрнутый ответ

Проблема 4K страниц: Для адресации 1 ГБ памяти требуется 262 144 записи PTE. TLB имеет ограниченный размер (обычно ~1024 записей). При частом доступе к большому региону промахи TLB ведут к дорогим page walk'ам.

Решение huge pages:

  • Явные (hugetlbfs): администратор резервирует часть памяти под huge pages (например, 1024 страницы по 2 МБ). Приложение запрашивает их через mmap с флагом MAP_HUGETLB или через файловую систему hugetlbfs. Не поддаются свопингу, всегда в памяти.
  • Transparent Huge Pages (THP): ядро автоматически пытается выделять большие страницы, когда это возможно, и сворачивает их при необходимости. Доступны для обычных анонимных маппингов и page cache. Могут быть настроены на always, madvise, never.

Зачем: Приложения с большим рабочим набором (in-memory базы данных, кэши, JVM, Go) могут получить значительное снижение TLB-промахов. Например, Redis рекомендует THP выключать из-за латентности, но для других нагрузок THP может улучшить производительность.

Go runtime не использует huge pages по умолчанию, но может выиграть от THP для кучи, если они включены. Однако при копировании при сборке мусора большие страницы могут давать повышенную нагрузку, поэтому в некоторых случаях их отключают. В Go 1.21+ появились эксперименты с поддержкой huge pages.

Под капотом (internals)

  • Страницы 2M/1G закладываются в аппаратную поддержку: PMD (Page Middle Directory) для 2M, PUD для 1G. При huge page TLB одна запись охватывает целую большую страницу.
  • THP реализован через khugepaged — демон ядра, сканирующий память и продвигающий 4K страницы до 2M, когда они образуют непрерывный диапазон. Это добавляет накладные расходы.
  • hugetlbfs: при mmap резервируются физические страницы из выделенного пула, TLB записи фиксированы.

Типичные ошибки кандидатов

  1. Включать THP на системах с микросервисами без тестирования
    THP может вызывать «jitter» из-за дефрагментации и уплотнения страниц. Для приложений, чувствительных к задержкам (Redis, некоторые Go-серверы), рекомендуют выключать.

  2. Думать, что huge pages бесплатны в смысле памяти
    Из-за фиксированного размера внутренняя фрагментация растёт: даже 1 байт данных может занять 2 МБ, если используется huge page. Важно анализировать.

  3. Не знать, как настроить THP в Linux
    Путь: /sys/kernel/mm/transparent_hugepage/enabled и defrag. Ошибка — не уметь изменять политику для конкретного приложения через madvise.

Возможные дополнительные вопросы интервьюера

  • Как проверить, использует ли процесс huge pages? — grep -i huge /proc/meminfo, cat /proc/pid/smaps | grep -i huge, perf stat -e dtlb_load_misses.miss_causes_a_walk.

  • Почему некоторые приложения отключают THP? — Из-за повышенной задержки, создаваемой khugepaged при компакции памяти, а также из-за увеличения времени копирования при операциях (fork, GC).

  • Можно ли выделить huge pages через mmap в Go? — Можно через syscall.Mmap с флагом syscall.MAP_HUGETLB, но это потребует учёта выравнивания и специфики размера страниц.


Как работает seccomp?

Краткий ответ (30–60 секунд)

seccomp (secure computing mode) — механизм ядра Linux, позволяющий процессу ограничить набор доступных системных вызовов. Существует два режима: strict (разрешены только read, write, exit, sigreturn) и filter (BPF-фильтр, гибко разрешающий/запрещающий syscall и даже проверяющий аргументы). seccomp широко используется в контейнерах (Docker, Kubernetes) для повышения безопасности, предотвращая использование опасных системных вызовов внутри контейнера.

Развёрнутый ответ

Strict mode:

  • Процесс вызывает prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT). После этого любые попытки выполнить другие syscall приводят к SIGKILL.
  • Используется редко из-за крайней ограниченности.

Filter mode (seccomp-bpf):

  • Процесс может загрузить в ядро программу BPF (classic BPF), которая будет проверять каждый syscall. Если фильтр разрешает вызов — он выполняется, иначе действие (SECCOMP_RET_KILL, TRAP, ERRNO).
  • Фильтры могут проверять номер системного вызова и значения аргументов (константы, но не указатели).
  • Можно вложить фильтры: дочерние процессы могут добавлять более строгие фильтры.
  • В Docker профили seccomp по умолчанию блокируют опасные вызовы, такие как reboot, kexec_load, unshare некоторых типов, и т.п.

Go позволяет установить seccomp через библиотеки (libseccomp CGO, или чистый Go seccomp пакет). Но в большинстве случаев разработчикам не нужно трогать это напрямую: Docker/Kubernetes настраивают seccomp профиль.

Под капотом (internals)

  • struct seccomp_filter хранит BPF программу и счётчик ссылок. Ядро выполняет фильтр при входе в системный вызов (в __secure_computing).
  • Установка: prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog). После этого процесс больше не может увеличить свои привилегии (избавиться от фильтра), что реализует свойство no-new-privileges.
  • Фильтр может быть применён через SECCOMP_RET_ALLOW для разрешения, SECCOMP_RET_KILL_THREAD (убить поток), SECCOMP_RET_ERRNO (вернуть ошибку) и SECCOMP_RET_TRACE (уведомить трассировщик).

Типичные ошибки кандидатов

  1. Думать, что seccomp может фильтровать всё
    Он не может разыменовывать указатели для глубокой проверки аргументов, не может полагаться на изменяемое состояние вне ядра. Проверки ограничены статическими значениями.

  2. Блокировка exit/exit_group и других жизненно важных syscall
    Некорректный профиль приведёт к немедленной смерти приложения. Кандидат должен понимать необходимость тщательного тестирования.

  3. Путать seccomp с capabilities
    Capabilities ограничивают привилегии, seccomp ограничивает доступные системные вызовы. Это разные, дополняющие механизмы.

Возможные дополнительные вопросы интервьюера

  • Как в Docker-образе задать собственный seccomp профиль? — Через docker run --security-opt seccomp=profile.json или seccomp в Kubernetes через securityContext.

  • Может ли процесс, однажды установив seccomp фильтр, ослабить его? — Нет, принцип no-new-privileges (если SECCOMP_FILTER_FLAG_NO_NEW_PRIVS установлен, или он не может быть отменён). Фильтры можно только дополнять, ужесточая.

  • Как проверить, какие системные вызовы использует Go-приложение для построения профиля? — strace -c ./app покажет список используемых syscall. Затем можно создать белый список.


Что такое "файловая система" и как работает VFS (Virtual File System)?

Краткий ответ (30–60 секунд)

Файловая система (ФС) определяет способ организации, хранения и доступа к данным на носителе (ext4, XFS, Btrfs). VFS (Virtual File System) — абстрактный уровень в ядре Linux, предоставляющий единый программный интерфейс для работы с разными типами файловых систем. VFS оперирует объектами: суперблок, inode, dentry, file, позволяя прозрачно монтировать и использовать различные ФС без изменения кода приложений.

Развёрнутый ответ

Ключевые объекты VFS:

  • superblock (суперблок): описание смонтированной ФС, хранит общую информацию (размер блока, корневую inode, указатели на функции-операции).
  • inode: универсальное представление файла/директории (метаданные). Любая ФС предоставляет inode_operations.
  • dentry (directory entry): кэш записей каталога, связывающий имя с inode. Ускоряет поиск файлов по имени, кэшируя результаты.
  • file: представление открытого файла с текущей позицией, указателем на file_operations.

VFS позволяет файловым системам регистрироваться через register_filesystem(). При монтировании (mount) ядро считывает суперблок и связывает с точкой монтирования в дереве пространства имён процесса.

Пример в Go: os.Open("/mnt/data/file") вызывает системный вызов open, который через VFS попадает в path_openat, производит walk по dentry, разрешает имя, и в конечном счёте вызывает open-реализацию конкретной ФС (например, ext4). Разработчику не нужно знать тип ФС.

Под капотом (internals)

  • struct super_operations содержит указатели на функции: alloc_inode, destroy_inode, write_inode, sync_fs, put_super.
  • struct inode_operations: create, lookup, link, unlink, mkdir и т.д.
  • struct file_operations: read, write, llseek, mmap, open, release.
  • Маршрутизация: при open происходит do_filp_open → path_openat → link_path_walk (использует dentry cache) → do_last → вызов vfs_open → f->f_op->open.

Типичные ошибки кандидатов

  1. Путаница между VFS и конкретными ФС
    VFS — это уровень абстракции ядра, а не файловая система типа ext4. Ошибка: говорить, что VFS — это ext4.

  2. Игнорирование важности dentry cache
    Dentry cache значительно ускоряет операции с путями. Кандидат, не упоминающий dentry, упускает критический компонент.

  3. Неверное представление о том, что после монтирования все операции проходят через VFS
    Даже для сетевых ФС (NFS) VFS обеспечивает единый интерфейс, но нужно знать, что часть операций может блокироваться (например, сетевой read).

Возможные дополнительные вопросы интервьюера

  • Что такое FUSE и как она взаимодействует с VFS? — FUSE (Filesystem in Userspace) позволяет реализовать ФС в пользовательском пространстве. Ядро через VFS перенаправляет запросы демону FUSE через /dev/fuse.

  • Как Linux обрабатывает монтирование USB-накопителя именно через VFS? — Драйвер USB передаёт блочное устройство, mount выбирает ФС (автоопределение), считывает суперблок, создаёт VFS superblock, связывает с точкой монтирования в дереве пространства имён.

  • Можно ли в Go напрямую взаимодействовать с VFS? — Нет, только через системные вызовы (os.Open, os.Stat), которые вызывают VFS. Однако можно написать FUSE-ФС на Go с помощью библиотеки bazil.org/fuse.


Что такое "inotify" и как его использовать для отслеживания изменений?

Краткий ответ (30–60 секунд)

inotify — подсистема ядра Linux, позволяющая приложениям отслеживать изменения в файловой системе: создание, удаление, изменение файлов и директорий. Используется системные вызовы inotify_init / inotify_add_watch и read() для получения событий. Это основной механизм для реализации live-reload, отслеживания изменений конфигураций в Go-приложениях (через библиотеки, например fsnotify).

Развёрнутый ответ

API:

  • inotify_init1(flags) создаёт экземпляр inotify, возвращает файловый дескриптор.
  • inotify_add_watch(fd, path, mask) регистрирует отслеживание для path с маской событий (IN_CREATE, IN_MODIFY, IN_DELETE, IN_MOVED_FROM, IN_MOVED_TO, IN_ATTRIB и т.д.). Возвращает watch descriptor.
  • read(fd, buf, size) блокируется до наступления событий и возвращает структуры inotify_event (содержат wd, маску, имя (если изменено)).

Особенности:

  • Не рекурсивный: для отслеживания всей иерархии нужно добавлять watch на каждую поддиректорию.
  • Ограничение на количество watch (/proc/sys/fs/inotify/max_user_watches, по умолчанию 8192). При больших проектах может потребоваться увеличить.
  • События могут накапливаться, при переполнении очереди посылается событие IN_Q_OVERFLOW.

В Go популярная библиотека fsnotify оборачивает inotify (на Linux) и kqueue/FSEvents на других ОС. Пример использования:

Go
watcher, _ := fsnotify.NewWatcher()
defer watcher.Close()
watcher.Add("/path/to/watch")
for {
    select {
    case event := <-watcher.Events:
        log.Println(event)
    case err := <-watcher.Errors:
        log.Println(err)
    }
}

Под капотом (internals)

  • Каждый открытый дескриптор inotify представлен структурой inotify_device, содержащей очередь событий и список watch’ей.
  • inotify_add_watch вызывает inotify_find_inode, который регистрирует watch на inode через fsnotify (общая подсистема ядра для уведомлений). При изменении (например, vfs_unlink) ядро генерирует событие и помещает его в очередь.
  • read из дескриптора извлекает события из очереди. При переполнении ставится флаг IN_Q_OVERFLOW.

Типичные ошибки кандидатов

  1. Не обрабатывать переполнение очереди
    При большом потоке событий может произойти переполнение. Нужно проверять и при необходимости перезапускать наблюдение.

  2. Полагать, что inotify отслеживает рекурсивно
    Нет, он отслеживает только указанную директорию. Для рекурсивного мониторинга надо проходить дерево директорий и добавлять watch на каждую.

  3. Забывать про ограничение на количество watches
    В больших проектах (например, мониторинг всего репозитория) можно исчерпать лимит, что приведёт к ошибке. Необходимо увеличить max_user_watches или использовать альтернативы (fanotify).

Возможные дополнительные вопросы интервьюера

  • В чём отличие inotify от fanotify? — fanotify позволяет мониторить всю файловую систему или точку монтирования глобально, использует меньше ресурсов, может выдавать доступ к файлу. inotify — более простой и лёгкий.

  • Как в Go сделать рекурсивное отслеживание с fsnotify? — filepath.Walk для добавления watch на каждую папку, и при событии Create (директория) добавлять новый watch.

  • Можно ли мониторить изменения в /proc или /sys с inotify? — Многие виртуальные ФС не поддерживают inotify (например, procfs, sysfs). В таких случаях нужно использовать polling.


Как работает sysfs и procfs?

Краткий ответ (30–60 секунд)

sysfs и procfs — две виртуальные файловые системы в Linux, предоставляющие интерфейс к структурам ядра. procfs (монтируется в /proc) содержит информацию о процессах и системе (PID, память, прерывания) и некоторые изменяемые параметры. sysfs (/sys) представляет иерархию устройств, драйверов и ядерных объектов (блочные устройства, шины, классы), позволяя получать информацию и управлять параметрами оборудования и модулей ядра.

Развёрнутый ответ

procfs:

  • Процессы: /proc/PID/ — каталог с информацией о процессе (status, fd, maps, smaps), PID.
  • Системная информация: /proc/cpuinfo, /proc/meminfo, /proc/loadavg.
  • Настройки ядра: /proc/sys/ (sysctl) — изменение параметров во время выполнения. Например, /proc/sys/net/ipv4/ip_forward.

sysfs:

  • Организует информацию о устройствах и драйверах в иерархическом виде: /sys/block (блочные устройства), /sys/class (классы устройств: net, tty), /sys/bus (PCI, USB), /sys/devices (физические устройства).
  • Позволяет управлять параметрами устройств: например, яркость экрана, мощность WiFi, настройки DMA.
  • Каждый объект ядра (kobject) представлен директорией, а атрибуты — файлами.

Go читает /proc/cpuinfo для определения количества процессоров в runtime.NumCPU(). Также многие библиотеки для получения информации о памяти используют /proc/self/status. Прямая запись в /proc/sys/... может менять параметры системы.

Под капотом (internals)

  • procfs реализован через seq_file для удобной генерации многострочного вывода. Например, чтение /proc/pid/status вызывает последовательно генерацию строк, читая поля из task_struct.
  • sysfs базируется на kobject/kset — объектной модели ядра. Каждый kobject соответствует директории, а атрибуты (struct attribute) — файлам. При чтении/записи вызываются show/store функции, зарегистрированные драйвером или подсистемой.
  • Файловые системы proc и sys являются виртуальными: их содержимое не хранится на диске, а генерируется на лету.

Типичные ошибки кандидатов

  1. Считать, что /proc и /sys всегда доступны
    В контейнерах часть /proc может быть скрыта или заменена, /sys часто монтируется только для чтения. Код должен учитывать это.

  2. Парсить текстовые файлы в procfs без учёта изменений формата
    Формат /proc/meminfo относительно стабилен, но некоторые файлы могут меняться между версиями ядра. Лучше использовать библиотеки (github.com/prometheus/procfs).

  3. Путать /proc/sys и /sys
    /proc/sys — интерфейс sysctl, а /sys — sysfs для устройств. Ошибка — искать параметры сетевой карты в /proc/sys/net вместо /sys/class/net/eth0.

Возможные дополнительные вопросы интервьюера

  • Как Go получает количество CPU в контейнере? — runtime.NumCPU() анализирует /proc/cpuinfo и информацию из cgroup (cpu quota). Если cgroup ограничивает CPU, возвращается скорректированное значение.

  • Можно ли создавать свои файлы в procfs из модуля ядра? — Да, через proc_create или proc_mkdir. Для sysfs — sysfs_create_group. Это используется драйверами.

  • Как узнать текущие настройки TCP из командной строки? — sysctl net.ipv4.tcp_rmem или cat /proc/sys/net/ipv4/tcp_rmem.


Что такое "unix domain sockets" и их преимущества перед TCP?

Краткий ответ (30–60 секунд)

Unix domain sockets (UDS) — механизм межпроцессного взаимодействия на одном хосте, использующий файловую систему для адресации. По сравнению с TCP-loopback он обеспечивает более низкую задержку и более высокую пропускную способность, поскольку данные передаются через ядро без накладных расходов сетевого стека (TCP/IP заголовки, контрольные суммы, маршрутизация). UDS также поддерживают передачу файловых дескрипторов и учётные данные (SCM_CREDENTIALS).

Развёрнутый ответ

Типы UDS:

  • SOCK_STREAM — надёжный потоковый сокет, аналог TCP.
  • SOCK_DGRAM — датаграммы, с гарантией доставки и сохранением порядка (в отличие от UDP).
  • SOCK_SEQPACKET — комбинация потоков и границ сообщений.

Адресация: привязываются к пути в файловой системе (/var/run/app.sock), также возможны абстрактные имена (не имеют файла на диске, начинаются с нуля).

Преимущества над TCP:

  1. Производительность: нет TCP-хендшейка, буферов управления потоком, алгоритмов перегрузки; копирование данных происходит эффективнее.
  2. Меньше накладных расходов: не нужно вычислять контрольные суммы IP/TCP, не нужна маршрутизация.
  3. Безопасность: можно ограничивать доступ через права файла; передача учётных данных процесса (PID, UID).
  4. Передача FD: через SCM_RIGHTS можно отправить файловый дескриптор другому процессу (полезно для балансировки).

В Go использование UDS так же просто, как TCP: net.Listen("unix", "/tmp/app.sock") и net.Dial("unix", "/tmp/app.sock"). Часто применяется для gRPC-серверов, взаимодействия Docker-демона с клиентом, systemd-notify.

Под капотом (internals)

  • struct unix_sock оборачивает обычный сокет (sock). Связывание с файлом: ядро создаёт сокетный файл через VFS.
  • При connect или sendto данные передаются напрямую в очередь приёма целевого сокета, минуя TCP-стек.
  • Передача дескриптора: ядро копирует struct file* из таблицы одного процесса в таблицу другого, используя механизм SCM.
  • Отсутствие контроля потока: если очередь приёма заполнена, отправитель блокируется (для потоковых) или отбрасывает (для датаграмм, если нет места).

Типичные ошибки кандидатов

  1. Забывать удалять файл сокета после остановки сервера
    При следующем запуске bind вернёт ошибку address already in use, потому что файл остался. Решение: удалять перед Listen или использовать абстрактные имена.

  2. Считать, что UDS работает между хостами
    Unix domain sockets ограничены локальной машиной. Для межмашинного взаимодействия нужен TCP.

  3. Не использовать bufio для повышения производительности
    Хотя UDS быстрее, при мелких сообщениях всё равно выгодно буферизировать чтение/запись. Однако некоторые разработчики ошибочно полагают, что UDS магически всё ускоряет без буферизации.

Возможные дополнительные вопросы интервьюера

  • Как передать файловый дескриптор через UDS в Go? — Использовать syscall.Sendmsg с вспомогательными данными syscall.UnixRights(fd), и на принимающей стороне syscall.Recvmsg с разбором syscall.ParseSocketControlMessage.

  • Можно ли использовать UDS с net.Conn в Go? — Да, типы unix.Conn реализуют net.Conn, так что можно использовать с теми же паттернами.

  • Как обеспечить аутентификацию по UDS? — Получить учётные данные отправителя через syscall.SO_PEERCRED и проверить UID/PID. Это быстрее и проще, чем TCP-шифрование.


Как работает "memory mapping" (mmap) и зачем он нужен?

Краткий ответ (30–60 секунд)

mmap отображает участок файла или анонимной памяти в виртуальное адресное пространство процесса, позволяя обращаться к данным через обычные операции с памятью (чтение/запись), а не через системные вызовы read/write. Это повышает производительность при работе с большими файлами (избегая копирования данных в пользовательский буфер), упрощает разделение памяти между процессами и используется для реализации эффективного выделения памяти (кучи).

Развёрнутый ответ

Виды отображений:

  • File-backed: страницы связаны с файлом. Изменения (при MAP_SHARED) сбрасываются на диск через page cache. Подходит для работы с большими файлами (БД, логи).
  • Anonymous: не связаны с файлом, используются для выделения памяти (аналог malloc), инициализируются нулями. В Go используется для кучи и стеков горутин.
  • Guarded/Protected: комбинация флагов резервирует адреса без выделения памяти.

Зачем:

  1. Производительность ввода-вывода: вместо read → копирование в буфер → обработка, mmap даёт прямой доступ к данным, экономит одно копирование. При случайном доступе ядро подгружает страницы по мере необходимости (demand paging), что может быть эффективнее явных pread.
  2. Межпроцессное взаимодействие: MAP_SHARED позволяет нескольким процессам разделять одну физическую память, изменения видны всем.
  3. Низкоуровневое управление памятью: можно резервировать большие виртуальные области без немедленного выделения.

В Go syscall.Mmap напрямую используется в runtime для выделения heap. Пользовательские программы могут использовать mmap для файлов (например, через golang.org/x/exp/mmap или вручную) для высокопроизводительной работы с файлами.

Под капотом (internals)

  • Системный вызов mmap создаёт VMA с флагами VM_MAYREAD/VM_MAYWRITE/VM_SHARED. При обращении к странице генерируется page fault, и ядро связывает физическую страницу из page cache (или выделяет анонимную).
  • Для file-backed: filemap_fault находит или загружает страницу. Для MAP_SHARED PTE устанавливается с возможностью записи, и запись маркирует страницу грязной, а затем writeback записывает на диск.
  • munmap удаляет VMA и освобождает физические страницы (если они не разделяются).
  • madvise позволяет давать ядру подсказки о планируемом использовании (MADV_SEQUENTIAL, MADV_WILLNEED, MADV_DONTNEED), что используется Go GC для возврата памяти ОС.

Типичные ошибки кандидатов

  1. Забывать про msync для гарантии записи на диск
    Хотя ядро рано или поздно запишет грязные страницы, для гарантии сохранности нужно вызывать msync. Ошибка — считать, что изменения через mmap сразу видны на диске.

  2. Игнорировать возможность SIGBUS при доступе за пределы файла
    Если файл укорачивается, обращение к отображённой, но не существующей области вызывает SIGBUS. Необходимо обрабатывать или избегать.

  3. Использовать mmap для последовательного чтения вместо read без сравнения
    Для последовательного доступа read с большим буфером может быть быстрее из-за предвыборки и избежания page fault overhead. Нет серебряной пули.

Возможные дополнительные вопросы интервьюера

  • Как mmap помогает сборщику мусора Go? — GC при необходимости возвращает неиспользуемые страницы ОС через madvise(MADV_DONTNEED), что возможно именно потому, что куча выделена через анонимный mmap.

  • В чём разница между MAP_PRIVATE и MAP_SHARED? — MAP_PRIVATE делает изменения приватными (COW), не затрагивая файл, MAP_SHARED — изменения видны другим процессам и могут быть записаны на диск.

  • Как безопасно работать с mmap файлом в Go без утечек? — Использовать defer syscall.Munmap(b) после отображения и не удерживать ссылки после закрытия.


Что такое "asynchronous IO" (AIO) и "io_uring"?

Краткий ответ (30–60 секунд)

Asynchronous IO (AIO) — интерфейс для выполнения операций ввода-вывода без блокировки потока; операции отправляются, и об их завершении приложение уведомляется позже. AIO в Linux долгое время был ограничен (работал только с O_DIRECT и имел проблемы). io_uring — новый, высокопроизводительный интерфейс асинхронного ввода-вывода, появившийся в ядре 5.1, использующий разделяемые кольцевые буферы (SQ and CQ) для отправки запросов и получения результатов, минимизируя количество системных вызовов.

Развёрнутый ответ

AIO (Posix AIO / Linux native AIO):

  • Традиционный aio_read/aio_write (glibc) реализован в userspace через потоки, не идеален.
  • Native Linux AIO (libaio, io_submit, io_getevents) работает только с O_DIRECT (без кэширования), что ограничивает применение. Не подходит для буферизованного I/O.

io_uring:

  • Современная альтернатива, решающая проблемы AIO. Основные компоненты:
    • Submission Queue (SQ): кольцевой буфер, куда приложение помещает записи запросов (SQE).
    • Completion Queue (CQ): кольцевой буфер, куда ядро помещает результаты (CQE).
  • Шаги:
    1. io_uring_setup — создаёт кольца и возвращает fd.
    2. Приложение пишет SQE в SQ (в разделяемой памяти), затем может вызвать io_uring_enter (или ядро само опрашивает SQ в режиме polling).
    3. Ядро обрабатывает запросы, помещает CQE в CQ.
    4. Приложение читает CQE и обрабатывает результаты.
  • Преимущества: поддержка любых операций (read/write, send/recv, fsync, openat, accept и т.д.), низкие накладные расходы благодаря уменьшению копирований и системных вызовов, возможность связывания запросов в цепочки.

В Go стандартный netpoller использует epoll, а не io_uring. Однако существуют библиотеки (например, github.com/iceber/iouring-go), и в будущем возможна интеграция. Использование io_uring может дать прирост в высоконагруженных хранилищах.

Под капотом (internals)

  • io_uring использует mmap для отображения кольцевых буферов в userspace. sq_ring и cq_ring имеют индексы head/tail.
  • Ядро может работать в режиме polling (IORING_SETUP_SQPOLL), где kernel thread опрашивает SQ без необходимости системного вызова.
  • Поддержка fixed buffers и registered files уменьшает время подготовки.
  • Реализация: fs/io_uring.c — большой и сложный код, с поддержкой асинхронной обработки даже для операций, которые традиционно блокируются (например, буферизованное чтение).

Типичные ошибки кандидатов

  1. Думать, что стандартный Go netpoller использует io_uring
    На данный момент это не так. Необходимо знать, что Go использует epoll/kqueue.

  2. Ожидать, что AIO работает без O_DIRECT
    Linux AIO без O_DIRECT часто блокируется или реализуется через потоки. Это распространённое заблуждение.

  3. Игнорировать сложность io_uring API
    Несмотря на простоту идеи, корректное использование требует управления памятью, синхронизацией, особенно в многопоточном окружении. В Go эксплуатация через CGo может быть сложной.

Возможные дополнительные вопросы интервьюера

  • В каких сценариях io_uring даёт наибольший выигрыш? — При большом количестве параллельных операций ввода-вывода (серверы БД, файловые серверы), где снижение числа syscall и копирования даёт значительное улучшение.

  • Как io_uring соотносится с SPDK или DPDK? — SPDK/DPDK идут ещё дальше, обходя ядро (userspace drivers). io_uring остаётся в рамках ядра, но значительно эффективнее традиционных API.

  • Можно ли использовать io_uring для сетевых сокетов? — Да, начиная с ядра 5.19/6.0 добавлена поддержка сетевых операций (recv/send, accept, connect). Это конкурирует с epoll.


Краткий ответ (30–60 секунд)

Netlink — семейство сокетов (AF_NETLINK), предназначенное для обмена сообщениями между ядром и пользовательскими процессами или между процессами. Используется для настройки сетевых интерфейсов, маршрутизации, межпроцессного взаимодействия (команды ip, iptables, мониторинг событий). Netlink поддерживает мультикаст-группы, что позволяет получать уведомления о событиях (например, добавление/удаление интерфейса) без поллинга.

Развёрнутый ответ

Протоколы netlink включают:

  • NETLINK_ROUTE — управление маршрутизацией, адресами, соседней таблицей, link-уровнем.
  • NETLINK_KOBJECT_UEVENT — уведомления от ядра (hotplug устройств).
  • NETLINK_AUDIT — аудит событий.
  • NETLINK_SOCK_DIAG — диагностика сокетов (используется ss).
  • NETLINK_NETFILTER — управление iptables/nftables.

Формат сообщения: заголовок nlmsghdr (длина, тип, флаги, последовательность), за которым могут следовать атрибуты (TLV). Это бинарный, надёжный протокол с гарантией доставки внутри ядра.

Особенности:

  • Мультикаст: пользователь может подписаться на группу и получать события от ядра без периодического опроса.
  • Асинхронность: можно отправлять запросы и получать ответы.
  • Используется в Docker, systemd-networkd, iproute2.

В Go для работы с netlink популярны библиотеки vishvananda/netlink (для управления сетью), mdlayher/netlink (низкоуровневый) и ti-mo/netfilter (для netfilter). Прямая работа с syscall требует ручной сборки/разбора сообщений.

Под капотом (internals)

  • struct netlink_sock расширяет обычный сокет. Ядро обрабатывает сообщения, передавая их в соответствующие подсистемы в зависимости от типа (NETLINK_ROUTE -> rtnetlink).
  • netlink_rcv_skb вызывается для входящих сообщений, маршрутизирует их зарегистрированным обработчикам.
  • Мультикаст: ядро ведёт список слушающих сокетов для каждой группы и рассылает сообщения.

Типичные ошибки кандидатов

  1. Пытаться читать netlink как поток текста
    Сообщения бинарные, требуют правильной десериализации. Ошибка — использовать fmt.Sscanf или обычное чтение.

  2. Игнорировать обработку NLMSG_DONE и подтверждений
    При отправке запроса (например, получение списка адресов) ответ может быть многочастным, завершаемым NLMSG_DONE. Без обработки можно потерять часть данных или зависнуть.

  3. Не использовать библиотеки и изобретать велосипед
    Сложность протокола оправдывает использование хорошо протестированных библиотек. Но для глубокого понимания нужно знать структуру.

Возможные дополнительные вопросы интервьюера

  • Как получить уведомления о событиях добавления/удаления сетевого интерфейса в Go? — Подписаться на multicast группу RTMGRP_LINK через netlink.Listen (библиотека) или сырой syscall.Socket(AF_NETLINK, ...). Слушать сообщения.

  • Как ip addr show использует netlink? — Команда ip отправляет RTM_GETADDR запрос через NETLINK_ROUTE, ядро отвечает списком конфигураций адресов.

  • В чём преимущество netlink перед ioctl для настройки сети? — Netlink асинхронный, поддерживает многочастные сообщения, multicast-уведомления, не требует блокировок на уровне kernel/userspace, в отличие от ioctl, который был синхронным и ограниченным.


Что такое os.Root в Go 1.24?

Краткий ответ (30–60 секунд)

os.Root — это тип в стандартной библиотеке Go 1.24+, представляющий строго изолированный корень файловой системы (filesystem scoping / sandboxing). Любые операции ввода-вывода через методы *os.Root (Open, Create, Remove, Stat) аппаратно и системно ограничены заданным поддеревом и не могут выйти за его пределы ни через относительные пути (../../), ни через абсолютные пути, ни через символические ссылки (symlink race / directory traversal).

Развёрнутый ответ

Исторически для защиты от атак path traversal (../../../../etc/passwd) разработчики использовали filepath.Clean и строковые проверки префиксов. Однако лексическая очистка путей принципиально не решает проблему Time-of-Check to Time-of-Use (TOCTOU) и обхода через симлинки: если внутри разрешённого каталога злоумышленник сумел создать симлинк на /etc/shadow, стандартный os.Open перейдёт по нему.

В Go 1.24 представлен системный примитив os.OpenRoot(dir):

Go
root, err := os.OpenRoot("/var/app/uploads")
if err != nil {
    log.Fatalf("не удалось открыть корень: %v", err)
}
defer root.Close()

// Безопасное открытие: файл гарантированно открывается внутри /var/app/uploads
f, err := root.Open("avatars/user123.png")
if err != nil {
    // обработка ошибки
}
defer f.Close()

// Попытка Directory Traversal вернёт системную ошибку
fBad, err := root.Open("../../../../etc/passwd")
// err != nil (доступ за пределы корня аппаратно заблокирован)

Системная реализация под капотом в Linux:

  • openat(2) с дескриптором корневой директории и флагом O_PATH.
  • openat2(2) с флагом RESOLVE_BENEATH (ядро Linux на уровне VFS проверяет, чтобы резолвинг пути ни при каких условиях не покинул пределы каталога, ассоциированного с дескриптором корня, блокируя как .., так и выход через symlink).
  • На других ОС и старых ядрах рантайм эмулирует это поведение последовательным открытием промежуточных каталогов с системной проверкой дескрипторов.

Под капотом (internals)

  • os.Root инкапсулирует файловый дескриптор sysfd корня.
  • Метод root.FS() возвращает реализацию интерфейса io/fs.FS, позволяя передавать изолированный корень в стандартные библиотеки: http.FileServerFS(root.FS()) или template.ParseFS(root.FS(), ...).
  • Атомарность: предотвращает гонки TOCTOU, поскольку ядро проверяет ограничения безопасности непосредственно в момент открытия дескриптора.

Типичные ошибки кандидатов

  1. Считать, что filepath.Clean защищает от path traversal:
    filepath.Clean производит чисто строковую нормализацию и ничего не знает о симлинках на реальной файловой системе.
  2. Пытаться писать собственные валидаторы путей через регулярные выражения:
    Симлинки, альтернативные кодировки и гонки создания файлов обойдут любые строковые эвристики. Для изоляции в Go 1.24+ необходимо использовать os.Root.

Возможные дополнительные вопросы интервьюера

  • Чем os.Root отличается от системного вызова chroot? — chroot меняет корневую директорию для всего процесса, требует прав суперпользователя (root / CAP_SYS_CHROOT) и нарушает доступ к общим библиотекам /lib и /dev. os.Root работает в userspace без привилегий суперпользователя на уровне конкретных операций ввода-вывода.
  • Как os.Root интегрируется с HTTP-сервером в Go? — Через вызов root.FS(): http.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(root.FS()))). Это исключает любые уязвимости раздачи статики.