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

Как устроена таблица дескрипторов и почему она становится свидетелем блокировки

Каждый процесс Windows владеет собственной таблицей дескрипторов, адрес которой лежит в поле ObjectTable структуры EPROCESS. Таблица трёхуровневая и растёт по мере открытия новых объектов: файлов, ключей реестра, мьютексов, событий, секций, потоков и других процессов. Каждая запись содержит указатель на заголовок объекта в пуле ядра, маску доступа и счётчик ссылок. Когда процесс открывает дескриптор другого процесса через OpenProcess, в его таблице появляется запись, указывающая на EPROCESS чужого процесса, и вот здесь начинается самое интересное для анализа.

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

Практическая ценность такого графа огромна, потому что пользовательский отладчик каждого процесса показывает лишь половину картины. Один процесс видит, что его поток заснул в WaitForSingleObject, но не знает, кто должен взвести событие. Другой процесс честно держит мьютекс и ждёт освобождения LPC-порта, о котором первый процесс даже не подозревает. Полную картину даёт только ядерный отладчик, потому что обе таблицы дескрипторов и обе цепочки ожидания живут в его юрисдикции.

Сбор улик в WinDbg через !process и !handle

Первое движение при анализе зависания - обзор процессов и их потоков:

kd> !process 0 0
kd> !process 0 7
kd> !process ffffc180`0b1f4080 7
PROCESS ffffc180`0b1f4080  SessionId: 1  Cid: 1c48  Peb: 7ff6e2c1a000  ParentCid: 0a10
    DirBase: 3a1f4000  ObjectTable: ffffc180`05a1b000  HandleCount: 4711
    THREAD ffffc180`0a2b1080  Cid 1c48.1d02  Teb: 0000000000000000  Win32Thread: 0
        WAIT: (WrUserRequest) KernelMode Non-Alertable
            ffffc180`0b2a3990  NotificationEvent
        IRP List:
            ffffc180`05c33a10: (0006,0194) Flags: 00000000  Mdl: 00000000
        Not impersonating
        GetUlongPtrFromPool: ffffc180`0a2b1080
        ImpersonationInfo: 00000000
        Owning Process: ffffc180`0b1f4080
        Wait Start TickCount: 1284771

Поле ObjectTable в этом выводе и есть адрес таблицы дескрипторов, а строка WAIT показывает, на каком объекте спит поток и по какой причине. Дальше берётся вторая половина картины - содержимое таблицы:

kd> !handle 0 f ffffc180`0b1f4080
kd> !handle 0 f ffffc180`0b1f4080 Mutant
kd> !handle 0 f ffffc180`0b1f4080 Event
kd> !handle 0 f ffffc180`0b1f4080 Section

Полный вывод без фильтра занимает десятки тысяч строк, поэтому тип объекта задают последним аргументом: Mutant, Event, Section или Process. Найденный адрес объекта сопоставляется с адресом ожидания из стека потока другого процесса, и совпадение означает прямую связку.

Для объектов синхронизации удобна команда !object, а структуру мьютекса читают напрямую:

kd> !object ffffc180`0c112240
Object: ffffc180`0c112240  Type: (ffffc180`04a11b40) Mutant
    ObjectHeader: ffffc180`0c112210 (new version)
    HandleCount: 3  PointerCount: 4

kd> dt nt!_KMUTANT ffffc180`0c112240
   +0x000 Header           : _DISPATCHER_HEADER
   +0x018 MutantListEntry  : _LIST_ENTRY [ 0xffffc180`0a2b10b8 - 0xffffc180`0a2b10b8 ]
   +0x028 OwnerThread      : 0xffffc180`0a2b1080 _KTHREAD
   +0x030 Abandoned        : 0 ''

Поле OwnerThread и есть указатель на поток-хозяина, а список MutantListEntry перечисляет все мьютексы, которые держит этот поток: его читают командой !thread с флагом вывода списка владения. Если владелец мьютекса спит на событии, которое должен взвести процесс, ожидающий этот же мьютекс, цикл замкнулся, и расследование может объявить deadlock доказанным.

Чтобы читать такие таблицы уверенно, инженер разбирается в устройстве самой записи дескриптора. Запись в таблице дескрипторов на x64 занимает шестнадцать байт и состоит из двух слов:

kd> dt nt!_HANDLE_TABLE_ENTRY ffffc180`05a1c000
   +0x000 VolatileLowValue : 0xffffc180`0c112210 Void
   +0x000 InfoTable        : 0xffffc180`05a2b000 _HANDLE_TABLE_ENTRY_INFO
   +0x008 UnparsedObject   : 0x001f0fff
   +0x00c GrantedAccessBits : 0y000000000001111100001111111111 (0x1f0fff)
   +0x00f Attributes       : 0y00

Первое слово - указатель на заголовок объекта со служебными битами в младших разрядах, второе содержит маску доступа GrantedAccess, флаги наследования и бит сопровождения. Когда инженер через !handle видит в колонке Access значение 0x001F0FFF у дескриптора процесса, это PROCESS_ALL_ACCESS, и такая широкая маска сама по себе намёк на слабую гигиену: процесс запросил максимум прав там, где хватило бы SYNCHRONIZE.

Сама таблица описывается структурой _HANDLE_TABLE, в которой инженеру важны поля TableCode с указателем на страницы записей, HandleCount с текущим числом записей и UniqueProcessId владельца:

kd> dt nt!_HANDLE_TABLE ffffc180`05a1b000
   +0x000 TableCode        : 0xffffc180`05a1c000
   +0x008 UniqueProcessId  : 0x00000000`00001c48 Void
   +0x018 HandleCount      : 0n4711
   +0x01c FirstFreeHandle  : 0x1270
   +0x020 LastFreeHandleEntry : 0xffffc180`05a22f80 _HANDLE_TABLE_ENTRY

Размеры таблицы легко прикинуть в уме, они следуют из размера записи и страницы:

16 байт на запись            -> 4096 / 16 = 256 записей в одной странице
один указатель уровня 1      -> страница записей = 256 дескрипторов
таблица уровня 1             -> 256 * 256 = 65536 дескрипторов
таблица уровня 2             -> 256 * 65536 = 16777216 дескрипторов
практический лимит на процесс -> около 16384 дескрипторов

Последняя строка важнее предыдущих: трёхуровневая схема даёт теоретически огромное пространство, но упирается процесс в практический лимит порядка шестнадцати тысяч записей, и именно он превращает утечку дескрипторов в отказ приложения. Аномально большой HandleCount у процесса-посредника часто указывает на утечку, а утечка, в свою очередь, держит объекты живыми дольше, чем задумано, и провоцирует ожидания, которых при здоровой логике не было бы.

Ещё одна расшифровка касается атрибутов наследования. Дескриптор с флагом OBJ_INHERIT передаётся дочерним процессам, и именно так рождается сценарий, где внук держит мьютекс, о существовании которого дед не подозревал. Команда !handle отражает флаг в колонке Attributes, а само дерево родства строят по полю наследования в структуре процесса, потому что список !process 0 1 показывает потоки, но не родителей:

kd> dt nt!_EPROCESS ffffc180`0b1f4080 UniqueProcessId InheritedFromUniqueProcessId ObjectTable HandleCount
   +0x440 UniqueProcessId            : 0x00000000`00001c48 Void
   +0x448 InheritedFromUniqueProcessId : 0x00000000`00000a10 Void
   +0x578 ObjectTable                : 0xffffc180`05a1b000 Void
   +0x58c HandleCount                : 0n4711

Пройдя цепочку InheritedFromUniqueProcessId вверх, инженер восстанавливает, кто кого породил, и находит того самого внука, которому достался наследованный дескриптор.

Пример расследования зависания службы и клиента шаг за шагом

Ситуация из практики: на терминальном сервере раз в сутки зависает связка из службы мониторинга и клиентского приложения, обе задачи помогают только через перезапуск. Инженер подключается ядерным отладчиком в момент зависания и последовательно снимает показания. Первое: !process 0 7 показывает, что в службе три потока спят в KeWaitForSingleObject на объекте Event с адресом ffffc1800b2a3990. Второе: !object по этому адресу говорит тип Event, NotificationEvent, взведётся вручную. Третье: поиск этого же адреса среди ожиданий других процессов показывает, что его никто в системе не собирается сигналить, потому что единственный держатель дескриптора этого события - клиентское приложение, а его главный поток в этот момент спит в WaitForSingleObject уже на другом объекте.

Четвёртый шаг самый вкусный: !handle 0 f по EPROCESS клиента показывает дескриптор типа Mutant с адресом ffffc1800c112240, а !object по нему выдаёт в структуре мьютекса владельца - поток той самой службы, который спит на событии. Круг замкнулся: служба держит мьютекс и ждёт событие от клиента, клиент ждёт мьютекс и не может взвести событие. Пятым шагом инженер смотрит стек службы командой k на владельце мьютекса и видит, что мьютекс захвачен внутри обработчика запроса, а затем этот же обработчик ждёт подтверждения через событие. Ошибка дизайна лежит как на ладони: захват залоченного объекта совмещён с ожиданием внешнего ответа.

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

Проверка утечек дескрипторов как источника скрытых ожиданий

Не каждый deadlock выглядит как замок на двоих. Встречаются гирлянды: процесс A ждёт B, B ждёт C, C ждёт освобождения объекта, который придерживает забытый дескриптор в давно уснувшем процессе D. Такие истории почти всегда сопровождаются утечкой: таблица D раздута до сотен тысяч записей, и среди них теряются живые ссылки на объекты, которые давно никому не нужны по логике, но живы по книгам ядра. Инструментом здесь становится тот же !handle с фильтром по типу: если процесс держит двадцать тысяч Event, при том что создавал их для кратковременных операций, - утечка налицо.

Пойманную утечку добивают в пользовательском режиме через !htrace, который показывает стеки открытия дескрипторов, если сопровождение включено заранее:

gflags /i monitor.exe +htrace
0:000> !htrace -diff

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

Типовые сценарии межпроцессных узлов и их графы

На живых системах одни и те же схемы повторяются с завидным постоянством. Ниже перечислены конфигурации, которые инженер встречает чаще всего:

  1. Два процесса открыли по дескриптору друг на друга через OpenProcess и оба вызвали WaitForSingleObject на чужой процесс - каждый ждёт завершения соседа, а завершиться никто не может, потому что выход заблокирован ожиданием;
  2. Приложение захватило именованный мьютекс и обратилось к службе через RPC, а служба в обработчике пытается захватить тот же мьютекс - владелец ждёт ответа, служба ждёт мьютекса, и RPC-таймаут не всегда спасает, если вызов синхронный по своей природе;
  3. Родительский процесс придерживает handle секции памяти, которую дочерний процесс должен освободить перед тем, как сообщить о готовности, - дочерний ждёт подтверждения, родитель ждёт освобождения, и оба висят;
  4. Драйвер фильтра держит ERESOURCE на запись, а пользовательский поток с открытым дескриптором файла ждёт ответа фильтра, который сам подвис на том же ресурсе - граница режимов ядра и userland нисколько не мешает блокировке.

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

Чтение цепочек ожидания вручную и через !locks

Для ресурсов ядра типа ERESOURCE существует готовый инструмент - команда !locks:

kd> !locks
**** DUMP OF ALL RESOURCE OBJECTS ****
KD: Scanning for held locks.......
Resource @ ffffc180`07a12340    Exclusively owned
    Contention Count = 71
    Threads: ffffc180`0a2b1080-01<*>
1 total locks, 1 locks currently held.

Она выводит все занятые ресурсы с указанием владельцев и ждущих потоков, а метка Contention Count показывает, сколько раз за этот ресурс боролись. Межпроцессная природа блокировки при этом видна по полю Cid владельца и ждущих: разные пары процесс-поток означают, что ожидание пересекло процессную границу.

Когда deadlock завязан не на ERESOURCE, а на диспетчерских объектах, приходится идти ручным маршрутом. Инженер берёт адрес объекта из стека ожидающего потока и проходит цепочку командами:

kd> !object ffffc180`0b2a3990
kd> dt nt!_KMUTANT ffffc180`0c112240 OwnerThread
kd> !thread ffffc180`0a2b1080 1f
kd> k L20

Сначала выясняют тип объекта, потом для мьютекса читают владельца, а затем смотрят, чем занят поток-владелец. Если тот ждёт другой объект, процедура повторяется. Три-четыре итерации такого ручного обхода неизменно приводят либо к циклу, либо к потоку, который не ждёт ничего, а просто держит ресурс из-за логической ошибки - забытого ReleaseMutex в ветке ошибки или раннего return до разблокировки.

Удобный приём для больших дампов - обход всех процессов скриптом. Отладчик поддерживает конструкцию .foreach, и перебор мьютексов по всей системе пишется в одну строку:

kd> .foreach /pS 0 (proc {!process 0 0}) { .process /p /r ${proc}; !handle 0 f ${proc} Mutant }

Результат перенаправляют в файл журналом и уже там сводят пары объект-владелец в таблицу. Десять минут работы такого обхода заменяют час ручного переключения контекстов по тысяче потоков на сервере.

Профилактика на уровне дизайна межпроцессного взаимодействия

Лучший deadlock - тот, который невозможно построить. Практику извлекли из сотен расследований несколько простых правил проектирования. Первое - упорядочивание: всем объектам синхронизации в системе присваиваются ранги, и захват разрешён только в порядке возрастания ранга. Цикл в графе ожидания становится невозможным уже по построению. Второе - таймауты на всех межпроцессных ожиданиях: WaitForSingleObject с бесконечным ожиданием между процессами запрещается, заменой служит конечный таймаут с понятной реакцией на его истечение. Третье правило касается владения: никакой процесс не должен держать ресурс во время вызова в другой процесс, будь то RPC, отправка сообщения или синхронный вызов драйвера. Владение и ожидание чужих действий разнесены по времени - и половина историй про зависания исчезает из практики.

Четвёртое правило относится к видимости. Каждый именованный объект в системе документируется: кто создаёт, кто захватывает, в каком порядке. На схему из пяти блоков уходит полчаса работы, а экономия исчисляется неделями отладки.

Умение читать таблицы дескрипторов окупается команде на каждом инциденте. Таблица дескрипторов - это, по сути, бухгалтерская книга ядра, и бухгалтерия не умеет лгать. Пока процесс держит объект, запись существует, пока поток ждёт - запись об ожидании тоже. Инженер, который умеет свести одно с другим, перестаёт перезагружать зависшие серверы в надежде, что само рассосётся, и начинает получать ответы. Каждый разобранный deadlock улучшает архитектуру: в проект входят ранги захвата, таймауты и привычка не звонить наружу с замком в руке. Со временем взаимные блокировки из стихийного бедствия превращаются в рядовую задачу на пару часов, и весь инструментарий сводится к двум командам отладчика да одной аккуратной схеме владения на доске.