23. Linux и ОС
«UNIX прост. Просто нужно быть гением, чтобы понять его простоту».
— Деннис Ритчи
- Что такое процесс?
- Что такое поток?
- Что такое системный вызов?
- Что такое
epoll? - Как работает сеть в Linux?
- Что такое
select,poll,epollотличия? - Что такое inode?
- Что такое файловый дескриптор?
- Что такое page cache?
- Что такое buffer cache?
- Что такое виртуальная память?
- Что такое MMU (Memory Management Unit)?
- Что такое page fault?
- Что такое TLB (Translation Lookaside Buffer)?
- Что такое NUMA?
- Что такое сgroups (control groups)?
- Что такое namespaces?
- Как работает Docker с точки зрения Linux?
- Что такое OOM Killer?
- Как посмотреть использование памяти процессом (
/proc/[pid]/smaps)? - Как работает
forkиexec? - Что такое
initпроцесс (PID 1) и его роль? - Что такое
daemonи как его создать? - Как работают сигналы (
SIGTERM,SIGKILL,SIGSEGV)? - Что такое "зомби процесс" и как с ним бороться?
- Что такое "сирота" процесс?
- Что такое "контекст переключения" (context switch) и почему это дорого?
- Как работает планировщик задач в Linux (CFS, EEVDF)?
- Что такое
niceиpriorityпроцесса? - Как работает
straceи как его использовать? - Как работает
lsof? - Как работает
tcpdump? - Как работает
iptables/nftables? - Как работает
TCPрукопожатие иTIME_WAITсостояние? - Что такое
backlogв TCP? - Что такое
SO_KEEPALIVE? - Что такое
SO_REUSEPORT? - Что такое
SO_RCVBUF/SO_SNDBUF? - Как работает
нагрузка(load average) в Linux? - Что такое "kernel panic"?
- Как работает
copy-on-write(COW)? - Что такое "huge pages" и зачем они нужны?
- Как работает
seccomp? - Что такое "файловая система" и как работает VFS (Virtual File System)?
- Что такое "inotify" и как его использовать для отслеживания изменений?
- Как работает
sysfsиprocfs? - Что такое "unix domain sockets" и их преимущества перед TCP?
- Как работает "memory mapping" (
mmap) и зачем он нужен? - Что такое "asynchronous IO" (AIO) и "io_uring"?
- Как работает "netlink" сокеты?
- Что такое os.Root в Go 1.24?
Что такое процесс?
Краткий ответ (30–60 секунд)
Процесс — это экземпляр исполняемой программы, обладающий собственным адресным пространством, одним или несколькими потоками, файловыми дескрипторами и контекстом выполнения. Ядро Linux представляет каждый процесс структурой task_struct, которая хранит всё его состояние. Процессы изолированы друг от друга средствами виртуальной памяти и могут взаимодействовать только через явные механизмы IPC.
Развёрнутый ответ
Процесс является основной единицей управления ресурсами в Linux. Он включает:
- Идентификатор (PID), родительский PID, идентификаторы сессии и группы процессов.
- Адресное пространство, описываемое
mm_struct: сегменты кода, данных, кучи, стека, отображённых файлов, библиотек. - Таблицу файловых дескрипторов (
files_struct), указывающую на открытые файлы, сокеты, каналы. - Контекст безопасности (credentials, capabilities, пространства имён).
- Статистику использования ресурсов (время CPU, page faults, сигналы).
Жизненный цикл:
- Создание:
fork()+exec()(илиclone()с флагами). Приforkдочерний процесс наследует копию родительскогоtask_struct, но разделяет страницы памяти по механизму Copy-on-Write. - Выполнение: планировщик ядра выделяет кванты времени в соответствии с политикой (CFS/EEVDF) и значением
nice. - Завершение:
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, которые определяют очерёдность выполнения.
Типичные ошибки кандидатов
-
Путаница процесса и программы
Процесс — динамический объект (исполняющийся экземпляр), программа — статический файл на диске. Ошибка в том, что при вопросе «что такое процесс» отвечают «это программа в исполнении», но не раскрывают свойства, ресурсы, структуры. Исправить: дать чёткое различие, упомянутьtask_structи жизненный цикл. -
Утверждение, что
forkкопирует всю память процесса
На самом деле используется Copy-on-Write, иначе создание процесса было бы катастрофически медленным. Кандидат должен показать понимание COW. В контексте Go важно, чтоos/execтоже опирается наfork, а горутины могут привести к проблемам, если форкать многопоточный процесс без немедленногоexec. -
Игнорирование различий между потоками и процессами
Часто кандидаты говорят, что процессы изолированы, а потоки разделяют всё, но не уточняют, что на уровне 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 не требуется, если новый поток того же процесса.
Типичные ошибки кандидатов
-
Считать, что потоки всегда быстрее процессов
Хотя в Linux потоки создаются быстрее и переключаются с меньшими затратами (нет переключения адресного пространства), выигрыш не абсолютен. При большой конкуренции блокировки могут нивелировать преимущества. Исправить: обсудить конкретные механизмы планировщика и TLB. -
Незнание о
tgidи thread-группах
Многие говорят "поток — тоже процесс", но не указывают, как ОС группирует потоки. При опросеgetpid()из потока возвращаетсяtgid, а истинный PID потока —gettid(). Важно понимать, как это работает. -
Игнорирование проблем с сигналами в многопоточных программах
Сигналы направляются всей 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). Пользовательская программа не может напрямую писать в порты ввода-вывода или менять таблицы страниц; всё это выполняется ядром.
Механизм:
- Приложение помещает номер syscall в регистр (x86-64:
rax) и аргументы вrdi,rsi,rdx,r10,r8,r9. - Вызывает инструкцию
syscall(быстрый путь) или старыйint 0x80. - Процессор переключается в Ring 0, сохраняет контекст, выполняет handler из MSR-регистров.
- Ядро по номеру находит запись в таблице
sys_call_table, вызывает функцию-обработчик. - Результат помещается в
rax, для ошибок устанавливается флаг переноса, а отрицательное значение -errno возвращается вrax. - Возврат через
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.
Типичные ошибки кандидатов
-
Путаница между библиотечными функциями и системными вызовами
Часто называютprintfсистемным вызовом, хотя это библиотечная функция C, которая в итоге может вызватьwrite. Правильно провести границу:fread/fwrite— буферизованные библиотечные функции,read/write— обёртки над syscall. -
Мнение, что системные вызовы всегда медленные и этого следует избегать любой ценой
Накладные расходы включают смену кольца, но современные процессоры имеют оптимизации (инструкцияsyscall~ несколько сотен циклов). Вопрос в их амортизации: к примеру, буферизация в netpoller снижает частоту syscall. Но в Go системный вызовreadс неблокирующим сокетом быстр, и его активно используют. -
Незнание, как 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.
Типичные ошибки кандидатов
-
Использование level-triggered по умолчанию без понимания edge-triggered
В высоконагруженных серверах ET снижает количество системных вызововepoll_wait, но требует аккуратного цикла чтения доEAGAIN. Ошибка — написать цикл, который не читает все данные, и потерять уведомление. Исправить: чётко контролировать цикл неблокируемого чтения. -
Добавление одного fd несколько раз
epollпозволяет добавить fd лишь один раз. Повторная попытка сEPOLL_CTL_ADDвозвращаетEEXIST. Кандидаты часто забывают об этом. Нужно использоватьEPOLL_CTL_MODдля изменения маски. -
Потокобезопасность
Хотя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 — это не монолитный блок, а слоёный стек:
- Аппаратный уровень: сетевая карта, драйвер (например,
igb,ixgbe). При поступлении пакета DMA'и помещают его в кольцевые буферы (RX ring), генерируется прерывание. - NAPI (New API): для снижения нагрузки прерываний драйвер отключает прерывания и использует механизм опроса (
softirq NET_RX), планировщик softirq обрабатывает несколько пакетов за раз. - Netfilter: после входа пакет проходит хуки
NF_INET_PRE_ROUTING, затем маршрутизация,NF_INET_LOCAL_IN(если пакет адресован нам), и на выходеNF_INET_LOCAL_OUT,NF_INET_POST_ROUTING.iptables/nftablesрегистрируют правила именно на этих хуках. - Транспортный уровень: протокол TCP или UDP обрабатывает сегменты. TCP собирает поток, управляет окнами, повторными передачами, состояниями. Пакет помещается в очередь приёма сокета (
sk_receive_queue). - Сокет: структура
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, ядро автомасштабирует в зависимости от нагрузки.
Типичные ошибки кандидатов
-
Путаница с уровнями: "драйвер кладёт пакет прямо в сокет"
На самом деле между ними — NAPI, netfilter, протоколы TCP/IP. Описание должно отражать цепочку. -
Заблуждение, что
epollявляется частью сети как таковой
epoll— это универсальный механизм опроса, а сеть использует его через API файлового дескриптора. Правильно: сокет как файловый дескриптор регистрируется вepoll, который затем оповещает о готовности. -
Непонимание, почему
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.
Типичные ошибки кандидатов
-
Сказать, что
selectхорош для небольшого числа дескрипторов
Хотя это формально верно, на практике даже при небольших fdepollпредпочтительнее, так как системный вызовepoll_waitтребует лишь ожидания, не повторяя регистрацию. При написании нового кода используютepoll, если не требуется портабельность. -
Не знать, что
pollтакже копирует массив в ядро при каждом вызове
Это важная деталь: при большом количестве fd копирование структуры ощутимо. Эффективность ниже, чем у epoll. -
Утверждение, что 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 и суперблоку.
Типичные ошибки кандидатов
-
Путать inode с файлом или его именем
Файл — это абстракция, имя — запись в директории, inode — метаданные. Ошибка: сказать, что inode содержит имя файла. Исправление: чётко разделять: имя хранит dentry, inode — метаданные и блоки. -
Не знать, что исчерпание inode может привести к
No space left on device
Сообщение одинаковое, но причина иная: закончились not блоки, а inode’ы. Командаdf -iпоказывает использование inode. Кандидат должен это знать. -
Неверное представление о жёстких ссылках и счётчике
Часто думают, что при удалении файла, на который есть жёсткая ссылка, удаляются данные. Данные удаляются только при падении 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.
Типичные ошибки кандидатов
-
Утечка дескрипторов: забывают закрывать файлы/сокеты
В Go это приводит к накоплению открытых fd и в конечном счёте к ошибке "too many open files". Правильное использованиеdefer f.Close()сразу после открытия предотвращает проблему. -
Непонимание разницы между дескриптором и
FILE*
В CFILE*— это буферизованная библиотечная обёртка над дескриптором. В Go аналогов нет, но*os.Fileработает с дескриптором напрямую без буферизации пользовательского пространства. Отсюда путаница. -
После
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периодически обходит и записывает грязные страницы.
Типичные ошибки кандидатов
-
Путаница между
freeи доступной памятью
Командаfree -mпоказывает память, занятую page cache, как использованную (в графеused= MemTotal - MemFree - Buffers - Cached). Кандидаты часто думают, что память почти заполнена, игнорируя, что кэш можно быстро освободить при необходимости. Нужно различатьavailableиfree. -
Пренебрежение
fsyncпосле записи важных данных
Утверждение, чтоwriteсразу записывает на диск, неверно. Данные остаются в page cache до сброса. В Go потеря данных может произойти при внезапном отключении, если не вызваноf.Sync(). Исправить: явно синхронизировать или использоватьO_SYNC. -
Считать, что
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).
Типичные ошибки кандидатов
-
Считать, что buffer cache и page cache разделены физически
Многие ошибочно думают, что памятьbuffersиcache— это два разных пула, и очистка кэша черезecho 3 > /proc/sys/vm/drop_cachesсбросит и то и другое. На самом деле они взаимосвязаны, ноdrop_caches=1освобождает page cache,=2— dentries/inodes,=3— и то и другое. -
Игнорировать buffer cache при анализе потребления памяти приложениями БД
Базы данных часто обходят page cache черезO_DIRECT, но метаданные ФС всё равно используют буферы. Небольшое увеличение буферов нормально. -
Заблуждение, что 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’ами.
Типичные ошибки кандидатов
-
Путать виртуальную память с swap’ом
Многие говорят «виртуальная память — это swap». Swap — лишь один из механизмов, а виртуальная память охватывает всё адресное пространство и аппаратную поддержку. Ответ должен начинаться с изоляции и трансляции адресов. -
Игнорировать стоимость выделения физической памяти
Выделение памяти черезmmapрезервирует только виртуальные адреса, фактическая память выделяется при первом касании страницы (major/minor fault). В Go аллокация большого слайса может быть быстрой, но первая запись может вызвать множество page faults, задерживая программу. -
Считать, что
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-страницу) и либо выделяет/копирует страницу, либо убивает процесс.
Типичные ошибки кандидатов
-
Путать MMU и контроллер памяти
MMU отвечает за трансляцию адресов, а контроллер памяти — за физический доступ к DRAM (тайминги, чипы). Хотя в современных SoC они могут быть интегрированы в один блок, функционально это разные вещи. -
Заявлять, что MMU используется только для виртуальной памяти и свапа
MMU обеспечивает также изоляцию и защиту памяти между процессами; даже при отключённом swap его роль критична. -
Не знать о 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или возврат из исключения, повторяя вызвавшую инструкцию.
Типичные ошибки кандидатов
-
Путать page fault со сбоем приложения
Не каждый page fault — катастрофа. Minor fault — нормальная часть ленивого выделения. Ошибка думать, что любое исключение страницы — признак деградации. -
Игнорировать разницу между major/minor при анализе производительности
Программа, генерирующая много major faults (например, работа с большим swap’ом или интенсивный mmap файла), резко замедляется из-за I/O. Кандидат должен уметь различать их черезgetrusage(RUSAGE_SELF, &ru)(поляru_minflt,ru_majflt) или/proc/pid/stat. -
Считать, что 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) и влияет на масштабируемость.
Типичные ошибки кандидатов
-
Считать TLB неважным для производительности
Промахи TLB могут добавлять до 50% к времени выполнения кода, интенсивно обращающегося к памяти. Игнорирование этого в высоконагруженных системах критично. -
Не учитывать эффект TLB при выборе размера страниц
При работе с огромными структурами данных разумно использовать huge pages, чтобы одна TLB-запись покрывала 2 МБ вместо 512 4-КБ страниц. Голословные заявления, что стандартной страницы достаточно, — ошибка. -
Путать 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 учитывает топологию: пытается сохранять задачи на том же узле, где ранее выполнялись, и по возможности на том же ядре.
Типичные ошибки кандидатов
-
Считать, что при наличии NUMA всё работает оптимально по умолчанию
Ядро применяет эвристики, но они не всегда оптимальны для конкретного приложения. Без анализа может возникнуть «NUMA miss» — 80% доступов к удалённой памяти, ухудшая производительность на 20-40%. -
Игнорировать NUMA при проектировании микросервисов на Golang
На многоядерных серверах часто запускают один процесс с GOMAXPROCS=все ядра, но блокировки межгорутинного общения и общие структуры могут вынудить обращаться к удалённой памяти. Иногда выгоднее запустить несколько экземпляров, привязанных к NUMA-узлам. -
Не использовать
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— виртуальную ФС с файлами для управления. Чтение/запись в файлы вызывает соответствующие обработчики ядра.
Типичные ошибки кандидатов
-
Недооценка влияния cgroup memory limit на Go
При ограничении памяти контейнера runtime Go видит только этот лимит (через cgroup v2 memory.max). Но GC настраивается на % от лимита, однако без явного указанияGOMEMLIMIT(с 1.19) он может пытаться использовать больше, что приведёт к OOMKilled. Кандидат должен предложить выставлениеGOMEMLIMITи правильный расчёт пределов. -
Считать, что
GOMAXPROCSвсегда надо ставить по количеству ядер
В контейнере с ограничением CPU cgroup часто дают долю ядра, и без явной установкиGOMAXPROCS(или её автоматического определения) Go может запустить слишком много потоков, вызывая лишние переключения. Правильно: доверять автомагическому определению runtime или явно задавать на основеruntime.NumCPU()лимитов cgroup. -
Игнорирование 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.
Типичные ошибки кандидатов
-
Путать namespaces с cgroups
Хотя оба используются для контейнеров, namespaces отвечают за видимость и изоляцию, а cgroups — за ограничение ресурсов. Ошибка: сказать, что namespaces ограничивают память. Исправить: чётко разграничить функции. -
Не знать о user namespace и его роли в безопасности
Благодаря user namespace контейнеры могут запускаться от root внутри, не имея root-прав на хосте. Незнание этого — пробел, так как это критично для защиты. -
Игнорировать возможность утечки информации между 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). Его работа:
- Создаёт корневую файловую систему (
rootfs), объединяя слои образа через OverlayFS (или другой union FS) в единую точку монтирования. - Вызывает
cloneс флагами для создания новых namespace:CLONE_NEWNS,CLONE_NEWUTS,CLONE_NEWIPC,CLONE_NEWPID,CLONE_NEWNET,CLONE_NEWUSER(опционально). Получает изолированный процесс. - В новом процессе настраивает
pivot_rootилиchrootдля переключения корня в подготовленную rootfs. - Настраивает cgroup через запись в
/sys/fs/cgroup/...для установки лимитов памяти, CPU, весов. - Применяет профили безопасности: seccomp-фильтры ограничивают доступные системные вызовы, AppArmor/SELinux усиливают изоляцию.
- Запускает указанную команду (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, другой — в bridgedocker0. Пакеты проходят через iptables (правила в цепочке DOCKER).
Типичные ошибки кандидатов
-
Считать контейнер лёгкой виртуальной машиной
Контейнер — это просто процесс (или группа процессов) с изолированным view, он разделяет ядро хоста. Отсутствие гипервизора делает старт быстрее, но открывает поверхность атаки на ядро. -
Не настраивать лимиты памяти и CPU
Без лимитов контейнер может занять всю память хоста, вызывая глобальный OOM. В Go безGOMEMLIMITGC ориентируется на всю память хоста, что в контейнере может привести к превышению cgroup лимита и убийству процесса. -
Игнорирование 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.
Типичные ошибки кандидатов
-
Полагать, что OOM Killer выбирает жертву только по размеру RSS
Это основной фактор, но также учитываются трафик в page cache, OOM-счётчик и настройки adj. Утверждение, что самый большой процесс всегда убивается, неверно; надо упоминать badness-эвристику. -
Игнорирование возможности настройки
oom_score_adj
Кандидат, не предложивший защитить критичные процессы (например, Go-приложение с заданнымoom_score_adj) для предотвращения случайного завершения, не показывает системного мышления. -
Не связывать 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 выводится блок:
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_fileAPI, обходя 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).
Типичные ошибки кандидатов
-
Путать VIRT (виртуальная память) и RES (резидентная)
Кандидаты часто смотрят на top/htop и обращают внимание на VIRT, которая включает зарезервированные, но не выделенные области. Ошибка: думать, что процесс потребляет 5 ГБ при VIRT=5G, а RES=100M. Правильно: оценивать RES и особенно PSS. -
Не учитывать PSS при анализе многопоточных приложений
Использование RSS завышает потребление при разделяемых библиотеках. PSS даёт более реалистичную картину. Кандидат, не упоминающий PSS, не полностью оценивает память. -
Игнорирование
SharedvsPrivateв контексте утечек
Рост 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, который выделяет новую физическую страницу и копирует данные.
Типичные ошибки кандидатов
-
Вызов
forkв многопоточном приложении без последующегоexec
Так как копируется только один поток, заблокированные мьютексы или состояние других потоков могут остаться в несогласованном состоянии в потомке. Это ведёт к deadlocks. Go не предоставляетforkнапрямую именно поэтому. -
Предположение, что
forkкопирует всё адресное пространство немедленно
Если бы каждая страница копировалась при форке, создание процесса было бы медленным и расходовало память. COW — критическая оптимизация, которую нужно знать. -
Забывать о
FD_CLOEXEC
Послеforkи передexecмогут быть утечки дескрипторов в новый образ. УстановкаCLOEXECгарантирует, что дескриптор не перейдёт к новой программе. В Goos/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:
- Запуск системы: инициализация сети, демонов, монтирование файловых систем согласно конфигурации.
- Управление потомками: когда родитель умирает раньше потомка, ядро переназначает такого потомка к init. Init вызывает
waitpid()в цикле или обрабатываетSIGCHLD, чтобы не оставлять зомби. - Сигнальная диспетчеризация: по умолчанию сигналы, отправляемые на PID 1 (например, SIGTERM), игнорируются, если явно не перехвачены. Это требует специальной обработки для graceful shutdown.
- Защита от случайного завершения: 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) игнорирует сигналы, для которых нет обработчика, и предотвращает завершение.
Типичные ошибки кандидатов
-
Не обрабатывать
SIGCHLDв приложении-PID 1
Написанное на Go приложение, запущенное как PID 1 в контейнере, должно перехватыватьSIGCHLD(или регулярно вызыватьWait), иначе зомби-процессы накапливаются. Часто забывают об этом и удивляются зомби. -
Пытаться послать
SIGTERMPID 1 и ожидать немедленного завершения
По умолчанию сигналы игнорируются, если не установлен обработчик. Поэтому приdocker stopсигнал отправляется PID 1, и если приложение Go его не ловит, контейнер не остановится корректно (по истечении таймаута docker принудительно убьёт). -
Считать, что 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):
fork(), родитель завершается. Потомок продолжает.- В потомке
setsid()— создаёт новую сессию и группу процессов, отрываясь от управляющего терминала. - Второй
fork(), родитель (лидер сессии) завершается, внук продолжает. Это гарантирует, что процесс не сможет случайно получить доступ к терминалу (не является лидером сессии). chdir("/")— освобождает точку монтирования исходного рабочего каталога.umask(0)— сброс маски прав на значения по умолчанию.- Закрытие/перенаправление файловых дескрипторов 0,1,2, чтобы демон не удерживал терминал.
В Go написать демона можно, но в современных deployment обычно предпочитают использовать systemd-сервисы или запускать в контейнере, где PID 1 управляется оркестратором. Если требуется демонизация, используют os.StartProcess с флагами syscall.SysProcAttr{Setsid: true} и т.д., либо обёртки, но это может привести к сложностям с горутинами.
Под капотом (internals)
setsidсоздаёт новую сессию, делая вызвавший процесс лидером новой сессии и новой группы процессов, при этом он лишается управляющего терминала.- Двойной fork гарантирует, что процесс-лидер сессии завершается, а внук уже не может стать лидером и не сможет случайно открыть терминал.
- Управляющий терминал: при открытии
/dev/ttyпроцессом-лидером сессии терминал становится управляющим; завершение лидера сессии отправляетSIGHUPна все процессы в foreground группе. Отсюда необходимость избежать лидерства.
Типичные ошибки кандидатов
-
Пропуск второго
fork
Без него процесс остаётся лидером сессии и может случайно акквирировать терминал, что нарушает требование демона. Правильно: всегда второй fork. -
Не закрывать дескрипторы (0,1,2)
Оставшиеся открытые дескрипторы могут удерживать pipe или терминал, мешая корректному завершению родителя. Исправить: открыть/dev/nullи дуплицировать на 0,1,2. -
Пытаться выполнить демонизацию в 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.
Развёрнутый ответ
Жизненный цикл сигнала:
- Отправитель: ядро (из-за аппаратного исключения, например, page fault), другой процесс (
kill,raise), терминал (Ctrl+C -> SIGINT). - Доставка: ядро проверяет, не заблокирован ли сигнал (маска
sigmask), если да — остаётся в ожидании (pending). Иначе ядро обновляетtask_struct->pendingи будит поток, который может обработать сигнал. - Обработка: если установлен обработчик (
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и канал для доставки.
Типичные ошибки кандидатов
-
Совершение сложных действий в обработчике сигнала
Обработчик сигнала выполняется асинхронно, прерывая основной поток; ограниченно безопасными можно считать лишь асинхронно-безопасные функции (в C — список из POSIX). В Go эту проблему частично решает буферизованный канал, но всё равно не следует делать длительные операции в обработчике. Исправление: вынести логику из обработчика через канал. -
Пытаться ловить
SIGKILLиSIGSTOP
Это невозможно на уровне пользователя. Ошибка — писатьsignal.Notify(ch, syscall.SIGKILL)без понимания. -
Игнорировать
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и не обрабатывает сигнал, зомби остаётся до тех пор, пока родитель жив.
Методы избегания:
- Явный вызов
waitpid()в родителе. - Установка обработчика
SIGCHLD, который вызываетwaitpid(-1, &status, WNOHANG)в цикле, чтобы пожинать нескольких завершившихся потомков за раз. - Отказ от ожидания: установить
signal(SIGCHLD, SIG_IGN)(в Linux это заставляет ядро автоматически пожинать потомков). - Двойной 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будет автоматически пожинать потомка, не оставляя зомби.
Типичные ошибки кандидатов
-
Думать, что зомби занимают память или CPU
Зомби не имеет выделенной памяти или кода; это только запись в таблице процессов. Но если накопится много, может закончиться пространство PID, что опасно. Кандидат должен пояснить это. -
Не вызывать
WaitпослеStartв Go
Код видаcmd.Start(); ... // no cmd.Wait()оставит зомби до завершения родительского процесса. Исправить: отложенныйcmd.Wait()или горутина, ожидающая завершения. -
Забыть об автоматическом пожинании при игнорировании 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, очищая зомби.
Типичные ошибки кандидатов
-
Путать сироту с зомби
Сирота — живой процесс без родителя, зомби — мёртвый процесс, ожидающий пожинания. Ошибка: сказать, что сирота — мёртвый процесс. -
Думать, что усыновление init — ошибка
Это штатное поведение, и многие демоны полагаются на него. Кандидат не должен считать это проблемой. -
В контейнерах игнорировать последствия появления сирот
Если 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. Также выше вероятность, что кэшированные данные нового процесса вытеснят старые.
Стоимость включает:
- Прямые затраты: микросекунды на сохранение/восстановление аппаратного контекста (в ядре
switch_to). - ТLB miss: после смены адресного пространства первые обращения к памяти вызывают промахи TLB и требуют обхода таблиц (десятки циклов).
- Загрязнение кэшей: данные и инструкции нового процесса вытесняют кэш-линии предыдущего, вызывая промахи после обратного переключения (кэш-трединг).
- Планировщик: само решение, кого запускать, требует времени (обход дерева 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 циклов на уровень ≈ много циклов). Это существенно.
Типичные ошибки кандидатов
-
Недооценка стоимости context switch для высокопроизводительных систем
Часто говорят «context switch — это быстро, потому что делает ядро». Но при большом количестве переключений (десятки тысяч в секунду) суммарные потери существенны. Кандидат должен приводить конкретные цифры: накладные расходы ~1-5 мкс на переключение. -
Путать переключение горутин с контекстным переключением ОС
В Go переключение горутин дёшево, но если горутина блокируется на системном вызове, поток ОС тоже переключается. Ошибка: сказать, что горутины совсем не вызывают контекстных переключений. -
Считать, что многопоточность всегда ускоряет программу
Из-за контекстных переключений чрезмерное число потоков может привести к треш-эффекту (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 таймера, который проверяет, не истёк ли квант и не нужно ли выполнить перепланировку.
Типичные ошибки кандидатов
-
Считать, что CFS — это round-robin
Хотя CFS делает задачи runnable по очереди, он динамически переупорядочивает их на основе vruntime, что даёт иллюзию выделенного CPU каждому, без жёстких квантов. -
Игнорировать новое EEVDF
С выходом ядер 6.6+ знание только CFS неполно. Кандидат должен понимать, что изменилось и зачем (уменьшение задержек, более строгая защита от голодания). -
Неправильно связывать приоритет 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 времени.
Типичные ошибки кандидатов
-
Путать nice и RT priority
RT приоритеты (1–99) имеют жёсткое преимущество над обычными задачами, nice тут неприменим. Кандидат должен чётко различать политики планирования. -
Ожидать, что nice -20 гарантирует всё процессорное время
Даже с nice -20 задача конкурирует с другими задачами и прерываниями; она не является real-time и может страдать от содержания. -
Игнорировать влияние 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) позволяет трассирующему процессу управлять трассируемым: чтение регистров, памяти, установка точек останова. Для каждого syscallstraceустанавливает флагPTRACE_SYSCALL, который останавливает трассируемого на входе и выходе из системного вызова.- Ядро в
syscall_trace_enterиsyscall_trace_leaveпроверяет флаг ptrace и приостанавливает процесс, отправляяSIGTRAPтрассировщику. straceв цикле ждётwaitpid, считывает регистры и аргументы, печатает их, затем возобновляет процесс.
Типичные ошибки кандидатов
-
Бездумное использование strace в production
Замедление программы может быть значительным (вплоть до порядка), что может нарушить тайминги и вызвать каскадные сбои. Правильно: использовать только для временной диагностики. -
Игнорирование
-fдля отслеживания многопоточных приложений
В Go множество потоков и горутин; без-fможно пропустить важные syscall дочерних процессов или горутин, превращённых в потоки. -
Попытка интерпретировать 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для запроса состояния сокетов.
Типичные ошибки кандидатов
-
Забывать, что сокеты — это тоже файловые дескрипторы
lsofпоказывает TCP-соединения и слушающие сокеты, важно это понимать при поиске утечек. -
Не знать, что
/procдолжен быть смонтирован
Внутри некоторых контейнеров/procможет быть намеренно ограничен либо отсутствовать, тогдаlsofне даст полной картины. -
Использовать
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.
Развёрнутый ответ
Архитектура захвата:
tcpdumpсоздаёт RAW сокет (AF_PACKET) или используетlibpcap, который может открыть такой сокет.- Устанавливает фильтр BPF (Berkeley Packet Filter) с помощью
setsockopt(SO_ATTACH_FILTER). Фильтр компилируется в байткод BPF и загружается в ядро, где выполняется над каждым пакетом. - Для каждого проходящего пакета драйвер/сетевой стек копирует его в память, выделенную сокетом. Фильтр BPF отбрасывает ненужные пакеты на ранней стадии, снижая накладные расходы.
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.
Типичные ошибки кандидатов
-
Захват трафика на загруженном интерфейсе без фильтра
Без BPF-фильтраtcpdumpскопирует все пакеты, вызывая высокие накладные расходы CPU и может сам стать источником потерь пакетов. Всегда использовать фильтр. -
Неверная интерпретация «dropped by kernel»
Сообщение означает, что ядро не успевало передавать пакеты в буфер userspace и отбрасывало их. Надо увеличить буфер сокета (-B размер) или улучшить фильтрацию. -
Попытка использовать
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) во многих случаях.
Типичные ошибки кандидатов
-
Путать
iptablesи netfilter
netfilter — это инфраструктура ядра, аiptables— пользовательский инструмент для задания правил. Часто их отождествляют. -
Забывать про conntrack при анализе проблем
Многие правила полагаются на connection tracking. Если таблица conntrack переполнена или пакеты не попадают в неё из-за raw-таблицы, поведение не соответствует ожиданиям. -
Использовать
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):
- Клиент:
connect()отправляет SYN, переходит вSYN_SENT. - Сервер: слушающий сокет в
LISTEN, получает SYN, переходит вSYN_RCVD, отправляет SYN-ACK. - Клиент: получает SYN-ACK, переходит в
ESTABLISHED, отправляет ACK (может содержать данные). - Сервер: получает 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. Когда время истекает, сокет освобождается.
Типичные ошибки кандидатов
-
Путать активного и пассивного закрывающего
ВTIME_WAITпопадает только активная сторона (инициировавшая первый FIN). Пассивная после последнего ACK переходит вCLOSEDнемедленно. Ошибка — думать, что обе стороны остаются в TIME_WAIT. -
Считать TIME_WAIT багом или ненужным состоянием
Это важная часть TCP; попытки искусственно обнулить TIME_WAIT (например, агрессивным таймаутом) могут привести к нарушению целостности данных. -
Использовать 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 имеет две очереди для слушающего сокета:
- SYN queue (syn backlog): содержит соединения, получившие SYN (состояние SYN_RCVD), но ещё не завершившие рукопожатие. Размер ограничен
net.ipv4.tcp_max_syn_backlogдля каждого сокета и системными параметрами. - 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инкрементируется.
Типичные ошибки кандидатов
-
Передавать маленький backlog и думать, что им ограничивается количество одновременных соединений
backlogограничивает только очередь ожидающих accept, а не общее число установленных соединений. Кандидат может утверждать, что сервер с backlog=128 примет только 128 клиентов, что неверно. Исправление: понять разницу. -
Игнорировать настройку
somaxconn
В Linux значениеbacklogограничивается сверху значениемsomaxconn(часто 4096). Если кандидат устанавливаетbacklog=65535, система ограничит доsomaxconn. Надо упомянуть необходимость настройкиsysctl. -
Не знать о 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.
Типичные ошибки кандидатов
-
Полагать, что keepalive быстро определяет обрыв
Стандартный idle 2 часа не подходит для интерактивных приложений. Надо настраиватьKeepAliveв Go на разумные интервалы (минуты). Ошибка — не менять дефолты. -
Путать TCP keepalive с HTTP keep-alive
HTTP keep-alive — это переиспользование TCP-соединения для нескольких запросов, а TCP keepalive — про проверку живости на уровне транспорта. -
Заменять прикладной 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будет поступать только на тот сокет, которому назначено это соединение, что естественно распараллеливает обработку.
Типичные ошибки кандидатов
-
Путаница с
SO_REUSEADDR
SO_REUSEADDRпозволяет переиспользовать порт в TIME_WAIT и подобных состояниях, но не разрешает нескольким слушающим сокетам одновременно принимать соединения на одном порту (исключая случаи с разными IP).SO_REUSEPORTименно это и делает. -
Не учитывать, что
SO_REUSEPORTтребует полной идентичности адреса
Все сокеты должны быть привязаны идентично (один и тот же IP, порт), иначе поведение не определено. Ошибка — думать, что можно смешивать wildcard и конкретный IP. -
Считать, что балансировка идеальна при малом количестве клиентов
Хеширование при малом наборе клиентов может дать неравномерное распределение, особенно если клиенты с одного IP. Это важно упомянуть.
Возможные дополнительные вопросы интервьюера
Как настроить SO_REUSEPORT на слушающем сокете в 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.
Типичные ошибки кандидатов
-
Устанавливать слишком большие буферы без необходимости
Это может привести к исчерпанию памяти ядра при большом количестве соединений. Нужно рассчитывать под требования BDP. -
Не знать, что значение, переданное в
setsockopt, удваивается ядром
Например,setsockopt(fd, SOL_SOCKET, SO_RCVBUF, 4096)реально даст ~8192 байт. Соответственно, при расчётах надо учитывать. -
Игнорировать автонастройку при ручной установке
Если установить значение больше нуля, автонастройка отключается. Кандидат может этого не знать и лишиться преимуществ автоматической регулировки.
Возможные дополнительные вопросы интервьюера
-
Как получить текущий размер буфера сокета? —
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.
Типичные ошибки кандидатов
-
Считать load average только показателем загрузки CPU
Игнорирование uninterruptible состояния — частая ошибка. Необходимо объяснить, что D-процессы тоже увеличивают load. -
Паниковать при load < количества ядер, но высокой латентности
Система может быть перегружена I/O, что увеличит только load из-за D-задач, либо нагрузка может быть неравномерной. Надо анализировать дополнительные метрики. -
Сравнивать 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).
Типичные ошибки кандидатов
-
Считать kernel panic аналогичным панике в Go
Несмотря на общее название, Go panic — это всего лишь исключительная ситуация в пользовательском процессе, не влияющая на ядро. -
Игнорировать конфигурацию
panic_timeoutдля автоматической перезагрузки
В серверных окружениях часто устанавливаютpanic_timeout=10, чтобы система автоматически перезагрузилась после паники, минимизируя время простоя. -
Не различать 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. Механизм:
- При
forkядро копирует VMA, но все частные страницы помечаются как «только чтение» в PTE обоих процессов, и счётчик ссылок страницы увеличивается. - При попытке записи в такую страницу возникает page fault.
- Обработчик
do_wp_pageопределяет, что это COW-страница. Если на страницу ссылается более одного процесса, он аллоцирует новую страницу, копирует данные, обновляет PTE для вызывающего процесса (делает доступной на запись), уменьшает счётчик ссылок старой страницы. Если остался только один процесс, просто делает страницу доступной для записи. - После этого выполнение возобновляется.
В 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.
Типичные ошибки кандидатов
-
Считать, что COW копирует только при записи любым процессом
Уточнение: при записи родителем — копия для родителя, дочерний продолжает использовать исходную; при записи потомком — копия для потомка. Страница может быть приватной для того, кто первый записал. -
Игнорировать, что COW применим только к частным страницам
Разделяемые страницы (MAP_SHARED) не используют COW; изменения видны всем процессам. -
Не понимать ограничения 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 записи фиксированы.
Типичные ошибки кандидатов
-
Включать THP на системах с микросервисами без тестирования
THP может вызывать «jitter» из-за дефрагментации и уплотнения страниц. Для приложений, чувствительных к задержкам (Redis, некоторые Go-серверы), рекомендуют выключать. -
Думать, что huge pages бесплатны в смысле памяти
Из-за фиксированного размера внутренняя фрагментация растёт: даже 1 байт данных может занять 2 МБ, если используется huge page. Важно анализировать. -
Не знать, как настроить 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(уведомить трассировщик).
Типичные ошибки кандидатов
-
Думать, что seccomp может фильтровать всё
Он не может разыменовывать указатели для глубокой проверки аргументов, не может полагаться на изменяемое состояние вне ядра. Проверки ограничены статическими значениями. -
Блокировка
exit/exit_groupи других жизненно важных syscall
Некорректный профиль приведёт к немедленной смерти приложения. Кандидат должен понимать необходимость тщательного тестирования. -
Путать 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.
Типичные ошибки кандидатов
-
Путаница между VFS и конкретными ФС
VFS — это уровень абстракции ядра, а не файловая система типа ext4. Ошибка: говорить, что VFS — это ext4. -
Игнорирование важности dentry cache
Dentry cache значительно ускоряет операции с путями. Кандидат, не упоминающий dentry, упускает критический компонент. -
Неверное представление о том, что после монтирования все операции проходят через 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 на других ОС. Пример использования:
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.
Типичные ошибки кандидатов
-
Не обрабатывать переполнение очереди
При большом потоке событий может произойти переполнение. Нужно проверять и при необходимости перезапускать наблюдение. -
Полагать, что
inotifyотслеживает рекурсивно
Нет, он отслеживает только указанную директорию. Для рекурсивного мониторинга надо проходить дерево директорий и добавлять watch на каждую. -
Забывать про ограничение на количество 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 являются виртуальными: их содержимое не хранится на диске, а генерируется на лету.
Типичные ошибки кандидатов
-
Считать, что
/procи/sysвсегда доступны
В контейнерах часть/procможет быть скрыта или заменена,/sysчасто монтируется только для чтения. Код должен учитывать это. -
Парсить текстовые файлы в procfs без учёта изменений формата
Формат/proc/meminfoотносительно стабилен, но некоторые файлы могут меняться между версиями ядра. Лучше использовать библиотеки (github.com/prometheus/procfs). -
Путать
/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:
- Производительность: нет TCP-хендшейка, буферов управления потоком, алгоритмов перегрузки; копирование данных происходит эффективнее.
- Меньше накладных расходов: не нужно вычислять контрольные суммы IP/TCP, не нужна маршрутизация.
- Безопасность: можно ограничивать доступ через права файла; передача учётных данных процесса (PID, UID).
- Передача 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. - Отсутствие контроля потока: если очередь приёма заполнена, отправитель блокируется (для потоковых) или отбрасывает (для датаграмм, если нет места).
Типичные ошибки кандидатов
-
Забывать удалять файл сокета после остановки сервера
При следующем запускеbindвернёт ошибкуaddress already in use, потому что файл остался. Решение: удалять передListenили использовать абстрактные имена. -
Считать, что UDS работает между хостами
Unix domain sockets ограничены локальной машиной. Для межмашинного взаимодействия нужен TCP. -
Не использовать
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: комбинация флагов резервирует адреса без выделения памяти.
Зачем:
- Производительность ввода-вывода: вместо
read→ копирование в буфер → обработка,mmapдаёт прямой доступ к данным, экономит одно копирование. При случайном доступе ядро подгружает страницы по мере необходимости (demand paging), что может быть эффективнее явныхpread. - Межпроцессное взаимодействие:
MAP_SHAREDпозволяет нескольким процессам разделять одну физическую память, изменения видны всем. - Низкоуровневое управление памятью: можно резервировать большие виртуальные области без немедленного выделения.
В 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_SHAREDPTE устанавливается с возможностью записи, и запись маркирует страницу грязной, а затем writeback записывает на диск. munmapудаляет VMA и освобождает физические страницы (если они не разделяются).madviseпозволяет давать ядру подсказки о планируемом использовании (MADV_SEQUENTIAL,MADV_WILLNEED,MADV_DONTNEED), что используется Go GC для возврата памяти ОС.
Типичные ошибки кандидатов
-
Забывать про
msyncдля гарантии записи на диск
Хотя ядро рано или поздно запишет грязные страницы, для гарантии сохранности нужно вызыватьmsync. Ошибка — считать, что изменения черезmmapсразу видны на диске. -
Игнорировать возможность SIGBUS при доступе за пределы файла
Если файл укорачивается, обращение к отображённой, но не существующей области вызывает SIGBUS. Необходимо обрабатывать или избегать. -
Использовать
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).
- Шаги:
io_uring_setup— создаёт кольца и возвращает fd.- Приложение пишет SQE в SQ (в разделяемой памяти), затем может вызвать
io_uring_enter(или ядро само опрашивает SQ в режиме polling). - Ядро обрабатывает запросы, помещает CQE в CQ.
- Приложение читает 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— большой и сложный код, с поддержкой асинхронной обработки даже для операций, которые традиционно блокируются (например, буферизованное чтение).
Типичные ошибки кандидатов
-
Думать, что стандартный Go netpoller использует io_uring
На данный момент это не так. Необходимо знать, что Go использует epoll/kqueue. -
Ожидать, что AIO работает без O_DIRECT
Linux AIO без O_DIRECT часто блокируется или реализуется через потоки. Это распространённое заблуждение. -
Игнорировать сложность 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.
Как работает "netlink" сокеты?
Краткий ответ (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вызывается для входящих сообщений, маршрутизирует их зарегистрированным обработчикам.- Мультикаст: ядро ведёт список слушающих сокетов для каждой группы и рассылает сообщения.
Типичные ошибки кандидатов
-
Пытаться читать netlink как поток текста
Сообщения бинарные, требуют правильной десериализации. Ошибка — использоватьfmt.Sscanfили обычное чтение. -
Игнорировать обработку
NLMSG_DONEи подтверждений
При отправке запроса (например, получение списка адресов) ответ может быть многочастным, завершаемымNLMSG_DONE. Без обработки можно потерять часть данных или зависнуть. -
Не использовать библиотеки и изобретать велосипед
Сложность протокола оправдывает использование хорошо протестированных библиотек. Но для глубокого понимания нужно знать структуру.
Возможные дополнительные вопросы интервьюера
-
Как получить уведомления о событиях добавления/удаления сетевого интерфейса в 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):
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, поскольку ядро проверяет ограничения безопасности непосредственно в момент открытия дескриптора.
Типичные ошибки кандидатов
- Считать, что
filepath.Cleanзащищает от path traversal:
filepath.Cleanпроизводит чисто строковую нормализацию и ничего не знает о симлинках на реальной файловой системе. - Пытаться писать собственные валидаторы путей через регулярные выражения:
Симлинки, альтернативные кодировки и гонки создания файлов обойдут любые строковые эвристики. Для изоляции в 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()))). Это исключает любые уязвимости раздачи статики.