Процессы-завершённый висячий - это завершённые программы, которые формально уже мертвы, но всё ещё занимают место в таблице процессов операционной системы. Они не исполняют ни одной инструкции, не потребляют процессорное время и не трогают память, однако ядро вынуждено хранить их записи, потому что кто-то ещё не забрал результат их работы. Ситуация выглядит странно только на первый взгляд: за ней стоит строгая и очень старая семантика, придуманная в раннем Unix и до сих пор определяющая, как родитель узнаёт о судьбе своих потомков. В Windows механика другая, но эффект похожий: объект процесса продолжает существовать, пока жив хотя бы один дескриптор на него, и из-за этого система тоже знает своих следов. Эта статья разбирает оба мира глазами системного программиста: что именно остаётся от процесса после смерти, кто обязан подмести останки, чем завершённый висячий отличается от сироты и от зависшего окна, и почему перезагрузка остаётся самым честным лекарством, когда счёт следов идёт на тысячи.

Unix-семантика завершения и рождение завершённый висячий

В Unix процесс умирает в два акта. Первый акт - сам вызов exit или получение фатального сигнала: ядро освобождает адресное пространство, закрывает файлы, отпускает память, отменяет таймеры. Второй акт - передача итогового статуса родителю через системный вызов wait или один из его родственников waitpid, waitid, wait3. Между этими актами процесс находится в состоянии Z, zombie: от него осталась лишь строчка в таблице процессов, где записаны идентификатор PID, код возврата, статистика потреблённых ресурсов и причина гибели. Это скудный набор, но без него родитель никогда не узнал бы, чем закончилась работа потомка, - а вся философия Unix построена на том, что код возврата ноль означает успех, а любое другое значение требует реакции.

Ядро намеренно не уничтожает запись само. Представление о том, что потомок может тихо исчезнуть, сломало бы контракт fork-и-wait, на котором держатся оболочки, фоновые службы и любая программа, запускающая внешние утилиты. Пока родитель не позвал wait, ядро обязано хранить статус, даже если родитель занят, спит или написан небрежно. Классический сценарий массового появления завершённый висячий - сервер, который порождает дочерний процесс на каждое соединение, но забывает обрабатывать сигнал SIGCHLD и собирать статусы. Родитель живёт месяцами, соединений тысячи в час, и таблица процессов постепенно заполняется мёртвыми записями.

У этой архитектуры есть аварийный клапан. Если родитель умирает раньше потомков, осиротевшие процессы усыновляет процесс с PID 1 - традиционно init, в современных дистрибутивах чаще systemd. Усыновление означает простую вещь: когда приёмный потомок завершится, единица вызовет wait и заберёт статус, потому что init и systemd написаны так, чтобы делать это неукоснительно. Поэтому завершённый висячий, родитель которых скончался, исчезает почти мгновенно. Настоящая проблема - живой родитель, который упорно не собирает урожай: тут система бессильна, ведь право на код возврата принадлежит именно ему.

Чем завершённый висячий отличается от сироты и от зависшего приложения

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

Разница принципиальна для диагностики. Такой процесс нельзя завершить сигналом - убивать уже некого, kill -9 по нему ничего не изменит, потому что исполняемого кода нет. Единственный способ избавиться от завершённый висячий - заставить родителя позвать wait или завершить самого родителя, тогда усыновление и повторный wait сделают работу. Зависший процесс, напротив, прекрасно убивается: он жив, значит, сигнал SIGKILL или TerminateProcess его добьёт. Сироту вообще трогать не надо, если она делает полезную работу; в контейнерах, где единицей оказывается неповоротливый entrypoint-скрипт, сироты и неубранные завершённый висячий становятся бытовой бедой, из-за чего появились крошечные init-обвязки вроде tini и dumb-init, чья единственная задача - усыновлять и косить мёртвых.

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

Почему завершённый висячий не едят CPU, но едят таблицу процессов

Ресурс, который удерживает завершённый висячий, крошечный, но исчерпаемый. Ядро хранит на каждый процесс структуру с учётной информацией, и хотя в состоянии Z из неё выброшено почти всё - память, файловые дескрипторы, обработчики сигналов - сама структура и номер PID остаются занятыми. Пространство идентификаторов конечно: на типичной Linux-системе pid_max равен четырём с лишним миллионам, но эта цифра обманчиво велика. Встроенная техника, старые ядра и контейнеры с жёсткими лимитами сужают его до десятков тысяч. Сервер с багом в обработчике SIGCHLD, порождающий потомка на каждый асинхронный запрос, выедает таблицу за дни. Когда свободные записи кончаются, fork начинает возвращать EAGAIN, и система перестаёт запускать вообще что-либо - включая аварийную оболочку администратора, пришедшего разбираться.

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

Windows-аналог, объект процесса живёт, пока открыт дескриптор

В Windows нет состояния zombie и семантики wait, но есть зеркальная механика на счётчиках ссылок. Процесс здесь - объект ядра EPROCESS, и диспетчер объектов хранит его, пока счётчик ссылок больше нуля. Ссылку держит само ядро, пока идёт исполнение, а после завершения последнего потока объект остаётся живым, если хотя бы один дескриптор на процесс открыт где-то в системе. Отсюда бытовая картина: Process Explorer показывает процесс затенённым, как завершённый, а строка не исчезает - значит, кто-то забыл CloseHandle. Жертвой чаще всего оказывается запускающая программа, которая открыла дескриптор через OpenProcess или получила его из CreateProcess и поленилась закрыть после WaitForSingleObject.

Вырожденные дескрипторы - скопления таких строк - это и есть хендл-лик в чистом виде. Мониторинговые агенты, антивирусы, инструменты телеметрии и сценарии автозапуска любят опрашивать процессы через WMI или Toolhelp, открывая дескрипторы пачками. Если в коде утечка, счётчик Handles у процесса-виновника растёт без возврата, а таблица процессов зарастает полумёртвыми объектами. Тяжёлый случай - драйвер, захвативший ссылку на EPROCESS через ObReferenceObjectByPointer и не отпустивший её: такой объект не исчезнет, пока машина не перезагрузится, и никакой пользовательский инструмент дескриптор не закроет, потому что ссылка не представлена дескриптором вообще.

Увидеть всё это можно родными средствами. В Process Explorer колонка Handles показывает рост у виновника, а затенённые строки завершённых процессов намекают, где копятся сироты-сироты. RAMMap на вкладке процессов подтверждает, что память давно освобождена и удерживается только объект. Утилита handle.exe с ключом -a перечисляет все открытые дескрипторы всех процессов, и поиск по имени завершённого образа быстро указывает на забывчивого. В отладчике ядра команда !handle раскладывает таблицу дескрипторов конкретного процесса, а !process 0 0 показывает EPROCESS-объекты с их счётчиками ссылок.

Как увидеть завершённый висячий и утечки дескрипторов на практике

Диагностика начинается с простейших команд и занимает минуты:

  1. Выполнить ps aux и отфильтровать по Z в колонке STAT, либо сразу ps -eo pid,ppid,stat,comm с поиском состояния Z - заодно виден идентификатор родителя PPID, который и есть виновник.
  2. Проверить счётчик: комбинация ps -eo stat с подсчётом строк Z даёт текущий размер толпы зависших; сотни уже повод будить владельца сервиса.
  3. Посмотреть на родителя через ps -p и идентификатор из PPID: чаще всего это воркер веб-сервера или воркер очереди задач с неаккуратным порождением подпроцессов.
  4. На Windows открыть Process Explorer, добавить колонку Handles, отсортировать по ней и найти процесс, у которого число растёт без остановки; завершённые строки затенены и хорошо заметны.
  5. Утилитой handle.exe -a выгрузить полный список дескрипторов и поискать тип Process с именем давно умершего образа - владелец дескриптора указан рядом.
  6. При подозрении на драйвер грузить дамп или живую систему в WinDbg и смотреть !process 0 0: объекты с нулевым числом потоков и непустым списком ссылок - искомые следи.

Симптомы в жизни обычно такие: сервис работает неделями, память стабильна, CPU низкий, но число Handles в диспетчере задач ползёт вверх, а на Linux-соседе ps показывает растущую толпу Z. Перезапуск службы обнуляет счётчики и подтверждает диагноз: утечка живёт в коде, а не в железе.

Почему перезагрузка лечит и как перезапускать зависший сервис

Перезагрузка уничтожает таблицу процессов целиком. Ни записей Z, ни забытых дескрипторов, ни захваченных EPROCESS - ядро стартует с чистого листа. Именно поэтому вечный совет перезагрузить машину технически честен: он возвращает все невозвратно удержанные ресурсы разом. Но лечит он симптом, а не причину; если код порождает завершённый висячий или капает дескрипторами, через неделю картина повторится. Дисциплинированный подход - использовать перезапуск как инструмент диагностики: если после перезапуска службы счётчики обнулились и снова растут с той же скоростью, утечка доказана и остаётся найти место в коде.

Практика перезапуска зависшего сервиса проста, но требует различать, с кем имеем дело. Живой зависший процесс на Linux убивается системным сигналом: сначала вежливый SIGTERM, через таймаут безжалостный SIGKILL, затем systemctl restart поднимет чистый экземпляр. Висячая запись этим не берётся - приходится обращаться к родителю: перезапуск родительского сервиса заставляет init или systemd усыновить записи и вызвать wait по ним единым махом. На Windows службу перезапускают через services.msc или Restart-Service, а если процесс не умирает по команде остановки, принудительный taskkill по идентификатору добивает; завершённые строки с открытыми дескрипторами исчезнут только с закрытием дескрипторов или с перезапуском их владельца. Полезно перед перезапуском зафиксировать цифры: число Handles, число процессов, потребление пула. Эти значения снимают споры о том, глюк это был или систематическая утечка.

Хорошая инженерная привычка - закрывать вопрос в коде: на Unix вешать полноценный обработчик SIGCHLD с циклом waitpid и флагом WNOHANG, на Windows парой строк к WaitForSingleObject добавлять CloseHandle. Тогда следам просто негде поселиться, а таблица процессов остаётся таблицей активных, как и задумано.

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