Система работает вроде бы нормально: процессор загружен на треть, памяти хватает, диск не пилится. Но звук заикается, курсор дёргается рывками, а сетевая карта периодически теряет пакеты. Диспетчер задач равнодушно показывает нули, и стандартный процесс-центричный взгляд здесь бессилен. Виновник сидит глубже: какой-то драйвер закидывает процессор прерываниями или подолгу удерживает отложенный вызов процедуры, не давая нормально работать планировщику. Разберём, как такие проблемы измеряются, как отличить шторм прерываний от тяжёлого DPC и какими инструментами добраться до конкретной функции в конкретном модуле.
Как ядро делит работу на прерывания и отложенные вызовы
Когда устройство хочет привлечь внимание процессора, оно поднимает линию прерывания, и контроллер (исторически PIC, позже APIC и его расширение I/O APIC) доставляет запрос на одно из ядер. Ядро повышает уровень IRQL до значения, соответствующего вектору прерывания, и вызывает обработчик прерывания - ISR, который драйвер зарегистрировал через IoConnectInterruptEx. Контракт здесь жёсткий: ISR должен быть коротким, потому что на время его исполнения маскируются все прерывания равного и более низкого уровня. Никаких долгих циклов, никакого ожидания, никакого обращения к страничной памяти.
Правильный ISR делает минимум: считывает регистр статуса устройства, подтверждает прерывание, чтобы оно не сработало повторно, и запрашивает отложенный вызов процедуры - DPC. DPC исполняется на уровне DISPATCH_LEVEL. Это выше PASSIVE_LEVEL, на котором живёт обычный код, но ниже уровней устройств, поэтому прерывания снова принимаются, зато планировщик потоков не работает и удерживать этот уровень долго тоже нельзя. Классический каркас выглядит так:
BOOLEAN MyIsr(PKINTERRUPT IntObj, PVOID ctx) {
PDEVICE_CONTEXT c = ctx;
ULONG st = READ_REGISTER_ULONG(&c->Regs->Status);
if (!(st & INT_PENDING)) return FALSE; // не наше прерывание
WRITE_REGISTER_ULONG(&c->Regs->Status, st); // гасим источник
IoRequestDpc(c->Device, c->Irp, ctx);
return TRUE;
}
VOID MyDpc(PKDPC Dpc, PDEVICE_OBJECT dev, PIRP irp, PVOID ctx) {
PDEVICE_CONTEXT c = ctx;
// тяжёлая часть: обход дескрипторных колец, пересылка данных
}
Эта двухэтажная конструкция - фундамент всей остальной истории. Шторм рождается на верхнем этаже, когда прерывания валятся слишком часто. Проблема с латентностью - на нижнем, когда DPC работает слишком долго. Оба состояния снаружи выглядят одинаково: система подтормаживает, а "виновника" в диспетчере задач нет.
Что такое шторм прерываний и почему он съедает процессор без процессов
Штормом называют ситуацию, когда частота срабатываний прерываний на одном или нескольких ядрах взлетает до десятков тысяч в секунду и каждое срабатывание тащит за собой вход в ISR, переключение контекста, сброс конвейера и промахи кэша. Сам по себе ISR короткий, допустим 2-3 микросекунды, но при высокой частоте срабатываний эти микросекунды складываются в заметную долю ядра. Считается нагрузка элементарно, частота умножается на длительность одного захода:
50 000 прерываний/с * 2,5 мкс = 125 000 мкс/с = 125 мс на каждую секунду = 12,5% ядра
50 000 прерываний/с * 10 мкс = 500 000 мкс/с = 500 мс на каждую секунду = 50% ядра
Плюс сюда ложатся накладные расходы на доставку вектора, сохранение контекста и возврат, которые в коротком ISR легко добавляют ещё десятки процентов сверху. Ядро в статистике потоков эту нагрузку не показывает: она не принадлежит ни одному процессу, поэтому диспетчер задач и рисует честные нули.
Типичные причины штормов хорошо изучены на практике. Старые line-based прерывания делят одну линию между несколькими устройствами, и ISR каждого участника цепочки вызывается при каждом срабатывании; если у одного устройства "залипает" линия, страдают все. Дефектная прошивка сетевого адаптера или контроллера хранения генерирует прерывание и снова поднимает его до того, как драйвер успел подтвердить приём. Экономия энергии тоже умеет штормить: устройство, которое будят и усыпляют чаще разумного, может генерировать прерывание на каждый переход. Отдельная глава - таймеры высокого разрешения: программа, установившая периодический таймер на 0,5 миллисекунды через timeBeginPeriod или ExSetTimer, оставляет после себя тысячи срабатываний в секунду на каждое ядро.
Важная деталь устройства современных систем: прерывания с сообщениями, MSI и MSI-X, избавляют от разделения линий и позволяют раскидать очереди сетевого адаптера по разным ядрам, то есть механизм создан для борьбы со штормами. Но если драйвер запросил один вектор MSI-X там, где устройство поддерживает шестнадцать, или настроил рассылку так, что все очереди упираются в нулевое ядро, преимущество превращается в узкое горлышко.
Трассировка ETW как единственный надёжный способ измерить прерывания и отложенные вызовы
Основной источник правды для такой диагностики - трассировка событий ядра, ETW. Ядро содержит провайдеры событий, которые фиксируют каждый вход в ISR и каждый DPC с метками времени и принадлежностью к модулю. Сбор запускают инструментами набора Windows Performance Toolkit: записывает данные Windows Performance Recorder, разбирает дамп Windows Performance Analyzer. Команда сбора в простейшем виде:
wpr -start CPU -start DiskIO
rem ... воспроизводим проблему 30-60 секунд ...
wpr -stop trace.etl
Для точечной настройки вместо профилей можно напрямую указать флаги ядерного логгера через xperf:
xperf -on PROC_THREAD+LOADER+DPC+INTERRUPT+CSWITCH
xperf -d trace.etl
Флаг INTERRUPT включает события ядра для каждого прерывания, DPC - для каждого отложенного вызова, CSWITCH - для переключений контекста. Флаг -stackwalk добавляет к событию обход стека, и без него в анализаторе будет видно только имя модуля, но не функцию внутри него. Отдельно придётся позаботиться о символах:
.sympath srv*C:\Symbols
.reload /f
В самом анализаторе путь к символам задаётся через меню Trace, пункт Configure Symbol Paths. Если его не указать, вместо имён процедур на графиках окажутся голые адреса и весь разбор упрётся в тупик. Полученный файл .etl открывают в WPA, и дальше работа уже с графиками. Из полезного рядом всегда держат утилиту LatencyMon: она в реальном времени считает максимальную длительность ISR и DPC по каждому модулю и сразу подсвечивает драйвер, превышающий разумные пороги, что удобно для быстрой проверки "здорова ли система вообще". Для навязчивых проблем со временем отклика музыканты и инженеры реального времени давно держат такую проверку обязательным пунктом приёмки железа, и не случайно: одна машина с "заводским" набором драйверов может показывать пикового DPC в 30 микросекунд, а её клон с другой версией сетевого драйвера - 3 миллисекунды, в сто раз хуже.
Как читать графики WPA и найти конкретный драйвер
В анализаторе нас интересуют два представления из раздела Computation: графики DPC/ISR Usage by Module и распределение длительностей. Логика разбора простая, но её стоит проговаривать по шагам, потому что новички тонут в обилии таблиц:
- Открыть график ISR по модулям, сгруппировать по полю Module и найти строку с максимальной долей времени. Если верхушку занимает один модуль с десятками тысяч вызовов в секунду - это кандидат на шторм;
- Переключиться на Duration и посмотреть не только сумму, но и максимум. Модуль с редкими, но длинными заходами - другая болезнь, её лечат иначе;
- На графике DPC по модулям отсортировать по максимальной длительности одного вызова. Для чувствительных задач порог комфортной работы лежит в районе 100 микросекунд, всё заметно выше уже видно на глаз в виде заиканий;
- Развернуть дерево стека вызовов внутри найденного модуля и убедиться, какая именно функция драйвера съедает время. Без символов будет один адрес, с символами - имя процедуры, по которому уже можно искать в коде или в базе знаний вендора.
Отдельно смотрят распределение по процессорам. Если столбик одного ядра торчит вверх, а остальные пустые, диагноз - плохая аффинность прерываний. Её лечат настройкой привязки в реестре устройства или утилитой от производителя контроллера, а в части случаев простым обновлением драйвера, где появилась поддержка большего числа очередей.
Тут хорошо работает мысленный образ регистратуры. Прерывание - это посетитель, который врывается без очереди, и пока врач с ним разбирается, остальные ждут за дверью. Если посетители валят толпой, приёмная стоит, хотя каждый визит занимает полминуты. A DPC - это когда один пациент расселся в кабинете и рассказывает историю болезни уже сорок минут. В обоих случаях очередь жалуется одинаково, а меры разные.
Пороги, сторожевой таймер DPC и синие экраны как подсказка
У долгих отложенных вызовов есть встроенный надзиратель. Механизм DPC watchdog следит за временем, которое один DPC проводит на ядре, и за суммарным временем всех DPC вокруг одного таймера. При превышении лимитов система не ждёт дальше, а останавливается по проверке 0x133 с именем DPC_WATCHDOG_VIOLATION. Параметры стоп-кода различают две ситуации, и читать их нужно по таблице, а не по догадке:
Arg1 = 0 один DPC или ISR превысил свой лимит времени
Arg2 = набранное время DPC в тиках
Arg3 = выделенный лимит времени в тиках
Arg4 = указатель на nt!DPC_WATCHDOG_GLOBAL_TRIAGE_BLOCK
Arg1 = 1 система суммарно слишком долго находилась на DISPATCH_LEVEL и выше
Arg2 = период надзирателя
Arg3 = указатель на nt!DPC_WATCHDOG_GLOBAL_TRIAGE_BLOCK
Arg4 = зарезервирован
Для серверов, где стабильность важнее красоты, это повод не отключать надзирателя, а разбирать дамп памяти. Типовой сеанс выглядит так:
0: kd> !analyze -v
DPC_WATCHDOG_VIOLATION (133)
Arguments:
Arg1: 0000000000000000, A single DPC or ISR exceeded its time allotment.
Arg2: 0000000000000501, The DPC time count (in ticks).
Arg3: 0000000000000500, The DPC time allotment (in ticks).
Arg4: 0000000000000000
IMAGE_NAME: BthA2DP.sys
0: kd> k
0: kd> !pcr
KPCR for Processor 0 at fffff8035f5a4000:
Prcb: fffff8035f5a4180
Irql: 0000000000000002
0: kd> dt nt!_KPRCB fffff8035f5a4180 Dpc*
0: kd> !dpcs
0: kd> !dpcwatchdog
Здесь набранные 501 тик против выделенных 500 и есть формальный повод остановки, а настоящую причину показывает IMAGE_NAME и стек под командой k. Расширение !pcr печатает область управления процессором и текущий IRQL, !dpcs показывает очереди отложенных вызовов по процессорам, !dpcwatchdog выводит длительности в секундах с привязкой к системному тику. Если дамп приходит регулярно и в стеке стабильно виден один и тот же драйвер, ситуация перестаёт быть загадкой: дальше вопрос к коду модуля или к его версии. Кстати, в Driver Verifier существует специальная проверка DDI compliance и отдельный флаг отслеживания времени DPC, и прогон подозрительного драйвера под верификатором на стенде - хороший способ поймать проблему до продуктива, пусть и ценой заметного замедления тестовой машины.
Практический ориентир по числам, который опытные инженеры держат в голове: ISR в норме живёт в единицах микросекунд, DPC стараются укладывать в десятки. Разработчики ядра формулируют норму жёстче, отложенный вызов не должен идти дольше 100 микросекунд, а обработчик прерывания дольше 25 микросекунд, при этом системные таймауты надзирателя выставлены заметно выше этой нормы и срабатывают уже на совсем запущенных случаях. Всё, что стабильно дольше нескольких сотен микросекунд на DISPATCH_LEVEL, уже отражается на отзывчивости системы, на потоках реального времени и на аудиотракте, где бюджет буфера измеряется единицами миллисекунд.
Типовые приёмы снижения нагрузки от прерываний
Когда виновник найден, дальше начинается самая интересная часть - собственно исправление. Для сетевых адаптеров первым делом включают и настраивают RSS, масштабирование приёма по нескольким очередям, чтобы пакеты и их DPC раскладывались по ядрам, и проверяют, что число очередей драйвера соответствует числу задействованных процессоров. Делается это штатными командлетами:
Get-NetAdapterRss
Set-NetAdapterRss -Name "Ethernet0" -Enabled $True -NumberOfReceiveQueues 8
Get-NetAdapterRss -Name "Ethernet0" | Format-List Name,Enabled,NumberOfReceiveQueues,MaxProcessors
Get-NetAdapterAdvancedProperty -Name "Ethernet0" |
Where-Object DisplayName -match "Interrupt|Receive Buffers|RSS"
Set-NetAdapterAdvancedProperty -Name "Ethernet0" -DisplayName "Interrupt Moderation" -DisplayValue "Enabled"
Многие контроллеры поддерживают модерацию прерываний: вместо срабатывания на каждый кадр устройство копит несколько кадров и тревожит процессор пачкой. Рост задержки при этом измеряется микросекундами, а падение нагрузки - в разы, поэтому для штормящего интерфейса модерация первое лекарство. Оценить выигрыш можно той же арифметикой: если пакеты приходят пачками по восемь и частота прерываний падает с 50 тысяч до 6 тысяч в секунду, доля ядра под ISR уменьшается с 12,5 до 1,5 процента, то есть почти на порядок.
Проверить, что настройки действительно применились и очереди разошлись по ядрам, помогает короткий трейс с раскладкой по процессорам:
xperf -on PROC_THREAD+LOADER+INTERRUPT+DPC+CSWITCH -stackwalk DPC
rem прогон целевой нагрузки 30 секунд
xperf -d after-fix.etl
wpa after-fix.etl
В коде драйвера основные приёмы тоже давно устоялись. Уменьшают время ISR до подтверждения прерывания и перепостановки работы в DPC. Внутри DPC ограничивают объём работы за один заход: обработали, скажем, до тридцати двух дескрипторов из кольца и вышли, остальное заберёт следующий вызов - такая самодисциплина не даёт одному устройству подавить ядро. Таймерные проблемы решают отказом от коротких периодов в пользу коалесцинга и точных таймеров там, где они действительно нужны. А флажок "запретить системе выключать это устройство для экономии энергии" в свойствах адаптера, который энтузиасты считают универсальным лекарством, как раз и есть борьба с одним из конкретных источников штормов на ровном месте: устройство перестаёт просыпаться и засыпать, а значит, перестаёт поднимать прерывание на каждый переход состояния.
Как превратить разбор в регулярную практику, а не в подвиг
Самая частая ошибка команд - заниматься латентностью только когда "прилетело". Штормы и долгие DPC удобнее ловить на халяве: разово снять короткую трассировку на эталонной конфигурации, сохранить её как базу и сравнивать с ней каждое обновление драйверов и прошивок. Тогда вопрос "что изменилось" перестаёт быть философским: разница между двумя файлами .etl показывает и новый участник цепочки прерываний, и поползшие вверх перцентили длительности DPC.
Второе правило звучит скучно, но экономит недели: чинить нужно по одному фронту за раз и после каждого изменения снимать контрольную трассировку той же длительности. Включили модерацию прерываний - замерили. Обновили прошивку - замерили. Сменили привязку векторов - снова замерили. Числа на графике либо пошли вниз, либо изменение откатывается без обсуждений. В таком режиме загадочные "тормоза на ровном месте" превращаются в обычную инженерную задачу с измеримым результатом, а драйвер, который когда-то душил систему, остаётся в памяти коллектива как хорошая история для курилки.