Разбирал одну занятную постановку в разговоре с архитектором(человек, с именем в комьюнити), специализирующимся на системном уровне. Тематика — поведение Linux-процессов, взаимодействие с ядром, системные вызовы.
Формулировка была подана с ироничной прямотой:
Можно ли на Go написать демона, который порождает армию зомби? Сколько для этого нужно убить детей? И сколько памяти займёт тысяча зомби?
Под обёрткой — абсолютно практичный смысл: как работает fork(), что происходит с процессами в Z-состоянии, когда и как init подбирает “трупы”, как Go взаимодействует с системным API, и где заканчивается контроль со стороны рантайма.
Разберем базовые моменты и основные аспекты реализации этого демона со всем SE контекстом вокруг него.
Что такое зомби-процессы и демоны?
Зомби-процессы
Начнем с основ. Зомби-процесс в Unix-системах — это процесс, который завершил свое выполнение, но запись о нем всё ещё хранится в таблице процессов ядра операционной системы. Это происходит, когда родительский процесс не вызывает системный вызов wait() для подтверждения завершения дочернего процесса.
Фактически, зомби — это "призрак" процесса, содержащий минимальную информацию: идентификатор процесса (PID), статус завершения и статистику использования ресурсов. Он не потребляет вычислительные ресурсы, но занимает место в таблице процессов ядра.
Демоны
Демон — это фоновый процесс, который работает независимо от терминала.
func daemonize() error {
if os.Getppid() != 1 {
cmd := exec.Command(os.Args[0], os.Args[1:]...)
cmd.Start()
os.Exit(0)
}
return nil
}
Разберем этот код:
- Проверка os.Getppid() != 1
определяет, является ли родительским процессом init (PID 1). Если нет, значит демон запущен из терминала, и нужно выполнить демонизацию.
- exec.Command(os.Args[0], os.Args[1:]...)
создает новый процесс, запускающий ту же программу с теми же аргументами.
- exec.Command
в Go — это не прямой системной вызов, а функция из пакета os/exec
, которая внутри использует системные вызовы fork
и execve
.
- После cmd.Start()
родительский процесс завершается, а дочерний продолжает работу.
В классической Unix-демонизации обычно используется двойной fork, но в моей реализации достаточно одного, так как Go уже правильно обрабатывает отсоединение от терминала. Фактически же если демон становится лидером сессии, он может случайно получить контрольный терминал (например, /dev/tty1
), что недопустимо для фонового процесса, поэтому нужен второй fork. Выглядит двойной fork вот так:
Первый fork:
- Родитель завершается.
- Дочерний процесс вызывает setsid()
и становится лидером новой сессии.
Второй fork:
- Лидер сессии завершается.
- Внучатый процесс работает в фоне и не может стать лидером сессии.
Как это реализовать:
// Первый fork
if pid := syscall.Fork(); pid > 0 {
os.Exit(0)
}
// Создать новую сессию
syscall.Setsid()
// Второй fork
if pid := syscall.Fork(); pid > 0 {
os.Exit(0)
}
Fork создает копию процесса, а setsid создает новую сессию, чтобы демон не зависел от терминала.
Технически:
- fork()
— системный вызов, создающий точную копию текущего процесса.
- setsid()
создает новую сессию и группу процессов.
В моей реализации setsid()
вызывается автоматически при демонизации через exec.Command
.
Почему процесс не может работать в фоне без отсоединения от терминала?
Если процесс привязан к терминалу, то при закрытии терминала процесс получит сигнал SIGHUP
(Hangup), который по умолчанию завершает процесс. Для работы в фоне независимо от терминала необходимо:
- Отсоединиться от управляющего терминала с помощью setsid()
, создавая новую сессию без управляющего терминала.
- Перенаправить стандартные потоки ввода/вывода, обычно в /dev/null
, так как терминал становится недоступен.
В Go при использовании exec.Command
эти действия выполняются автоматически при создании нового процесса.
PID-файл и права доступа
Затем создается PID-файл, который хранит идентификатор процесса демона. Это помогает управлять демоном, например, останавливать его по PID. Сохраняет PID демона в файл для управления (например, kill $(cat /tmp/zombie_daemon.pid)
).
os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0644)
Права доступа 0644
означают:
- 6 (110)
— владелец: чтение + запись
- 4 (100)
— группа: чтение
- 4 (100)
— остальные: чтение
Наличие PID-файла позволяет системным администраторам управлять демоном, например, посылать ему сигналы:
kill $(cat /tmp/zombie_daemon.pid)
Обработка сигналов
Для корректного завершения демона необходимо обрабатывать системные сигналы:
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigChan
cancel() // Отмена контекста для завершения всех горутин
}()
Здесь настраивается перехват сигналов SIGTERM
(завершение) и SIGINT
(прерывание, обычно Ctrl+C). При получении сигнала выполняется отмена контекста, что приводит к корректному завершению всех горутин.
Создание армии зомби
Проверка системных ограничений
Перед массовым созданием процессов важно проверить системные ограничения:
func checkSystemLimits() error {
var rLimit syscall.Rlimit
if err := syscall.Getrlimit(6, &rLimit); err != nil { // 6 is RLIMIT_NPROC on Linux
return fmt.Errorf("error getting RLIMIT_NPROC: %v", err)
}
if rLimit.Cur < uint64(defaultZombieCount) {
return fmt.Errorf("RLIMIT_NPROC too low (%d). Run: ulimit -u %d",
rLimit.Cur, defaultZombieCount*2)
}
return nil
}
RLIMIT_NPROC
определяет максимальное количество процессов, которые пользователь может создать. Если лимит меньше требуемого количества зомби, программа предлагает увеличить его через команду ulimit
.
Параллельное создание процессов с ограничением
Создание тысячи процессов одновременно может перегрузить систему, поэтому используется семафор для ограничения параллелизма:
func createZombies(ctx context.Context, count int) error {
sem := make(chan struct{}, maxParallelZombies)
defer close(sem)
var wg sync.WaitGroup
for i := 0; i < count; i++ {
select {
case <-ctx.Done():
return nil
case sem <- struct{}{}:
}
wg.Add(1)
go func() {
defer func() {
<-sem
wg.Done()
}()
cmd := exec.Command(truePath)
cmd.Start()
time.Sleep(10 * time.Millisecond)
}()
}
wg.Wait()
return nil
}
Разберем этот механизм:
- Семафор: sem := make(chan struct{}, maxParallelZombies)
создает канал с буфером на 50 элементов. Это ограничивает число одновременно выполняемых горутин.
- WaitGroup: Используется для ожидания завершения всех горутин. wg.Add(1)
увеличивает счетчик, wg.Done()
уменьшает его, а wg.Wait()
блокируется, пока счетчик не станет нулевым.
- Контекст: ctx.Done()
возвращает канал, который закрывается при отмене контекста, позволяя горутинам корректно завершиться.
- Выбор с помощью select
: Конструкция select
позволяет выбрать между отменой через контекст и отправкой в семафор.
Для каждого зомби запускается команда /usr/bin/true
(по факту код этой директивы просто exit 0
), которая сразу завершается. Ключевой момент: демон не вызывает cmd.Wait()
, из-за чего дочерние процессы остаются зомби.
Ответы на вопросы интервьюера
Сколько "детей" нужно убить?
Фактически, ни одного! Зомби-процессы уже "мертвы" — они завершили свое выполнение. Создание зомби происходит так:
1. Родитель (демон) создает процесс.
2. Дочерний процесс завершается сам (в нашем случае /usr/bin/true
просто делает exit 0
).
3. Родитель не подтверждает их завершение (не вызывает wait()
), поэтому ОС сохраняет их в таблице процессов как зомби.
Сколько памяти занимают зомби?
Зомби-процессы сохраняют только запись в таблице процессов ядра:
- Примерно 1-2 КБ на один зомби-процесс.
- Для 1000 зомби: около 1-2 МБ памяти ядра.
Зомби не используют ни процессорное время, ни оперативную память пользовательского пространства — только минимальное место в структурах данных ядра.
Почему это работает именно так
Механизм зомби — это не баг, а особенность Unix-систем. По правилам Unix, родительский процесс обязан вызвать wait()
для завершенных дочерних процессов, чтобы получить их статус завершения. Если родитель не выполняет эту обязанность, ядро сохраняет записи как зомби.
Цель этого механизма — дать родительскому процессу возможность узнать статус завершения дочернего процесса в любой момент. Если родитель "забыл" это сделать или намеренно игнорирует, зомби остаются в системе.
В реальных системах чрезмерное количество зомби может быть проблемой, поскольку они занимают место в таблице процессов ядра, которая имеет ограниченный размер! Однако полностью избавиться от зомби в нормальных условиях можно только завершив родительский процесс — тогда все его дочерние зомби-процессы "усыновляются" процессом init (PID 1), который автоматически вызывает wait()
и освобождает ресурсы.