All Posts

Architecture of the Go Zombie Demon

Published on 2025-02-25 by Timur Syrma

Разбирал одну занятную постановку в разговоре с архитектором(человек, с именем в комьюнити), специализирующимся на системном уровне. Тематика — поведение 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() и освобождает ресурсы.