Дайджесты новостей
Кинематографичный ночной натюрморт рабочего места программиста с терминалом отладки процессов Go и таймером WaitDelay.

Почему os/exec в Go зависает после завершения процесса: разбираемся с WaitDelay

Каждый бэкенд-разработчик на Go рано или поздно сталкивается с задачей запуска внешних утилит операционной системы: перекодировать видео через ffmpeg, выполнить системный bash-скрипт, сгенерировать архив или вызвать консольную утилиту миграции. Стандартная библиотека предлагает для этого лаконичный пакет os/exec. Ты создаёшь команду через exec.Command(), вызываешь cmd.Run() или cmd.CombinedOutput() и спокойно ждёшь результата.

Но в производственной среде периодически разыгрывается мистический сценарий:

  • Внешняя утилита отработала всю логику и исчезла из системной таблицы процессов. В выводе консольной команды ps или htop процесса либо нет вовсе, либо он висит в статусе завершённого с нулевым кодом возврата (Exit code 0).
  • Операционная система Linux рапортует, что программа успешно завершила работу.
  • При этом вызывающая горутина в Go продолжает намертво висеть на строчке cmd.Run(), блокируя пул потоков, удерживая память и порождая коварную утечку горутин (goroutine leak).

Ранее мы разбирали глубокие системные интерфейсы ввода-вывода и нативного рантайма в материалах о GoNIX: UNIX-подобная операционная система с пространством пользователя на Go и проблеме major page faults в Go: почему системный вызов mmap может замедлить программу. Давай заглянем внутрь пакета os/exec и разберёмся, где здесь собака зарыта.

Две фазы завершения: почему смерти процесса в ОС недостаточно

Чтобы понять причину зависания, нужно исследовать исходный код метода cmd.Wait(). Завершение работы с внешней командой в рантайме Go разделено на две изолированные фазы:

  1. Фаза процесса (process.Wait()): рантайм вызывает системный вызов ядра Linux wait4 или waitid. Этот вызов блокируется до тех пор, пока процесс операционной системы не завершит выполнение и не вернёт статус выхода.
  2. Фаза каналов ввода-вывода (I/O copy goroutines): одновременно с запуском процесса Go поднимает фоновые горутины, которые непрерывно вычитывают байты из каналов стандартного вывода (stdout и stderr) и перекачивают их в буферы или переданные структуры io.Writer.

Ключевой архитектурный нюанс заключается в том, что метод cmd.Wait() принципиально не отдаёт управление вызывающему коду, пока фоновые горутины ввода-вывода не завершат свою работу. А горутина копирования байтов завершается ровно в одном случае: когда системный вызов чтения read() из файлового дескриптора пайпа возвращает признак конца файла — io.EOF.

Если процесс умер, но дескриптор пайпа остаётся открытым в ядре, вызов read() никогда не вернёт io.EOF, и программа на Go останется заблокированной навсегда.

Механика наследования: как потомки удерживают системные пайпы

Откуда в операционной системе берётся открытый файловый дескриптор, если запущенный бинарник уже завершился? Виновата механика системных вызовов fork() и execve() в Unix/Linux.

  1. Создание канала: перед стартом команды Go создаёт однонаправленный канал через системный вызов pipe2() с флагом закрытия при выполнении O_CLOEXEC. Один конец канала остаётся у родительского процесса Go для чтения, а второй передаётся дочернему процессу как дескриптор стандартного вывода (stdout, дескриптор 1).
  2. Порождение потомков: если запущенный скрипт или программа запускает внутри себя дочерний процесс — например, фоновую команду через амперсанд (nohup command &), демон мониторинга или дочерний воркер, — системный вызов fork() полностью дублирует таблицу файловых дескрипторов родителя в дочерний процесс.
  3. Удержание дескриптора записи: если автор внешнего скрипта забыл явно перенаправить потоки вывода фоновой задачи в /dev/null, процесс-внук автоматически наследует открытый файловый дескриптор stdout.
  4. Блокировка EOF: основной процесс благополучно завершает работу и выходит. Но ядро Linux устроено строго: канал (pipe) не закрывается до тех пор, пока на него ссылается хотя бы один открытый дескриптор записи в любом живом процессе операционной системы. Так как внук продолжает выполняться в фоне и держать дескриптор открытым, ядро держит канал активным. Рантайм Go терпеливо ждёт сигнала io.EOF, который никогда не наступит.

Таймер спасения: как устроен Cmd.WaitDelay под капотом

Для устранения этой фундаментальной проблемы, начиная с релиза Go 1.20, инженеры добавили в структуру exec.Cmd специальное поле:

type Cmd struct {
    // ...
    // WaitDelay задает максимальное время ожидания завершения операций ввода-вывода
    // после того, как сам процесс уже завершился.
    WaitDelay time.Duration
}

Когда поле WaitDelay заполнено, под капотом рантайма запускается таймер time.AfterFunc:

  1. Метод cmd.Wait() дожидается смерти основного процесса в ОС через wait4.
  2. Как только процесс завершился, рантайм взводит таймер на интервал WaitDelay.
  3. Если горутины I/O успели дочитать остатки данных и закрыть пайп до истечения таймера, таймер аннулируется, и вызов завершается успешно.
  4. Если срок WaitDelay истёк, а горутины всё ещё заблокированы на системном чтении read():
    • Рантайм Go принудительно вызывает системный close() для внутренних дескрипторов чтения.
    • Заблокированный системный вызов read() немедленно возвращает ошибку закрытого файла (file already closed).
    • Фоновые горутины перекачки данных разблокируются и корректно выходят.
    • Метод cmd.Wait() возвращает специальную сигнальную ошибку exec.ErrWaitDelay.
var ErrWaitDelay = errors.New("exec: WaitDelay expired before I/O complete")

Разработчик может перехватить эту ошибку через errors.Is(err, exec.ErrWaitDelay). Если код выхода команды был 0, приложение может забрать прочитанный вывод и продолжить выполнение, зная, что основной процесс завершился успешно, а пайп был заблокирован неуправляемым фоновым потомком.

Границы применимости: зомби-процессы и пользовательские io.Writer

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

  1. Неуправляемый io.Writer: таймер WaitDelay принудительно закрывает только те внутренние пайпы, которыми управляет сам пакет os/exec (созданные через cmd.StdoutPipe() или стандартные буферы). Если ты напрямую передал в cmd.Stdout кастомную структуру, чей метод Write() заблокировался на внешнем сетевом сокете, мьютексе или канале Go, рантайм не сможет прервать этот вызов.
  2. Осиротевшие фоновые процессы: принудительное закрытие пайпа разблокирует программу на Go, но сам процесс-потомок продолжает бесконечно работать в операционной системе в отвязанном виде, расходуя процессор и память. Чтобы навести порядок, необходимо управлять всей группой процессов.

Безопасный рецепт запуска с изоляцией группы процессов

Вот эталонный пример функции запуска внешних команд на Go, сочетающий контекстный таймаут, настройку WaitDelay, изоляцию группы процессов через Setpgid и гарантированное уничтожение всех потомков по сигналу SIGKILL:

package main

import (
    "context"
    "errors"
    "fmt"
    "os/exec"
    "syscall"
    "time"
)

func executeProcessSafely(ctx context.Context, binary string, args ...string) ([]byte, error) {
    // 1. Ограничиваем общее время жизни операции контекстом
    cmdCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
    defer cancel()

    cmd := exec.CommandContext(cmdCtx, binary, args...)

    // 2. Задаем лимит ожидания сброса пайпов после смерти процесса
    cmd.WaitDelay = 2 * time.Second

    // 3. Выделяем команду и ее будущих потомков в отдельную Process Group
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Setpgid: true,
    }

    // 4. При отмене контекста отправляем SIGKILL всей группе процессов целиком
    cmd.Cancel = func() error {
        if cmd.Process != nil && cmd.Process.Pid > 0 {
            // Отрицательный PID означает отправку сигнала всей группе процессов
            return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
        }
        return nil
    }

    // 5. Запуск команды и сборка вывода
    output, err := cmd.CombinedOutput()
    if err != nil {
        // Проверяем, был ли сброс вызван зависшим пайпом потомка при успешном бинарнике
        if errors.Is(err, exec.ErrWaitDelay) {
            fmt.Println("Предупреждение: команда отработала, но пайпы удерживались фоновым процессом")
            return output, nil
        }

        if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
            return nil, fmt.Errorf("превышен общий таймаут выполнения команды: %w", err)
        }

        return output, fmt.Errorf("ошибка исполнения команды: %w", err)
    }

    return output, nil
}

Такая связка гарантирует, что твоё Go-приложение никогда не повиснет из-за чужих незакрытых дескрипторов, а контейнеры останутся чистыми от блуждающих зомби-процессов.