Когда сервер начинает тормозить от нехватки памяти, пользовательские счётчики обычно показывают уже следствие: своп вырос, кэши сжались, отклик поплыл. Куда ценнее видеть момент, когда менеджер памяти ядра только начинает чувствовать голод и рассылает сигналы об этом всем подписавшимся драйверам. Windows давно умеет оповещать kernel-mode компоненты о нехватке и об освобождении памяти, и грамотно построенный драйвер-наблюдатель способен написать целую историю давления на память задолго до того, как о ней догадается мониторинг верхнего уровня.
Как менеджер памяти сигнализирует о голоде
Внутри ядра работает поток балансировки, который просыпается примерно раз в секунду (чаще под нагрузкой) и оценивает запасы: размеры списков свободных, обнулённых, модифицированных и простаивающих страниц. Когда количество доступных страниц опускается ниже порогов, относящихся к текущему объёму физической памяти, система переводит себя в состояние высокого давления: ужимает рабочие наборы процессов, системный кэш и список ожидания, форсирует запись модифицированных страниц. Наружу, в сторону драйверов, это состояние транслируется через события высокого и низкого уровня памяти, известные как \\KernelObjects\\HighMemoryCondition и \\KernelObjects\\LowMemoryCondition, а также через их более гранулярных родственников, появившихся в новых версиях системы: события по отдельным типам страниц и по пороговым ступеням.
Драйвер подписывается на такое событие стандартным механизмом ожидания объекта ядра: создаётся поток, который открывает событие по имени через IoCreateNotificationEvent либо просто ожидает глобальный объект, и при его установке выполняет реакцию. На практике чаще используют обёртку в виде рабочего элемента, чтобы не удерживать поток вечно:
NTSTATUS StartMemoryWatch(PDEVICE_OBJECT dev) {
UNICODE_STRING name;
RtlInitUnicodeString(&name, L"\\KernelObjects\\LowMemoryCondition");
g_Event = IoCreateNotificationEvent(&name, &g_Handle);
if (!g_Event) return STATUS_INSUFFICIENT_RESOURCES;
KeClearEvent(g_Event);
return PsCreateSystemThread(&g_Thread, THREAD_ALL_ACCESS,
NULL, NULL, NULL, WatchThread, dev);
}
VOID WatchThread(PVOID ctx) {
PVOID objs[2] = { g_Event, g_StopEvent };
for (;;) {
NTSTATUS st = KeWaitForMultipleObjects(2, objs, WaitAny,
Executive, KernelMode, FALSE, NULL, NULL);
if (st == STATUS_WAIT_1) break;
RecordPressureEvent(); // снять срез состояния памяти
KeClearEvent(g_Event);
}
PsTerminateSystemThread(STATUS_SUCCESS);
}
Заметим сразу: событие срабатывает не один раз, а на каждую волну голода. Поэтому любая разумная реализация дополняет сработку собственной логикой дребезга, иначе журнал будет состоять из тысяч одинаковых записей, снятых за секунду.
Отдельный поток на всю жизнь драйвера - не единственный путь, но ожидание на объекте ядра так и остаётся основным документированным способом узнать о голоде из кода драйвера. Вторую половину картины даёт собственный опрос: драйвер периодически снимает показания доступных страниц и сравнивает их с порогом, который сам же и вычислил. Глобальная переменная MmAvailablePages экспортируется ядром и объявлена в заголовках WDK, поэтому читается напрямую:
#define PAGE_SIZE_BYTES 4096
typedef struct _PRESSURE_POLL {
ULONG_PTR ThresholdPages; // порог из реестра, в страницах
ULONG PeriodMs; // период опроса, миллисекунды
ULONG LastStage; // ступень давления в прошлый раз
PKTIMER Timer;
PKDPC Dpc;
} PRESSURE_POLL, *PPRESSURE_POLL;
VOID PressureTimerRoutine(PKDPC Dpc, PVOID Ctx, PVOID Arg1, PVOID Arg2)
{
PPRESSURE_POLL poll = (PPRESSURE_POLL)Ctx;
ULONG_PTR freePages = MmAvailablePages;
ULONG stage = 0;
// две ступени: половина порога и сам порог
if (freePages < poll->ThresholdPages / 2) {
stage = 2;
}
else if (freePages < poll->ThresholdPages) {
stage = 1;
}
if (stage != poll->LastStage) {
poll->LastStage = stage;
if (stage != 0) {
LogPressureStage(stage, freePages); // только смена ступени
}
}
}
VOID StartPolling(PPRESSURE_POLL poll)
{
LARGE_INTEGER due;
// единицы по 100 наносекунд, знак минус означает относительный срок
due.QuadPart = -10000LL * (LONG64)poll->PeriodMs;
KeInitializeTimer(poll->Timer);
KeInitializeDpc(poll->Dpc, PressureTimerRoutine, poll);
KeSetTimerEx(poll->Timer, due, (LONG)poll->PeriodMs, poll->Dpc);
}
VOID StopPolling(PPRESSURE_POLL poll)
{
KeCancelTimer(poll->Timer);
KeFlushQueuedDpcs();
}
Обработка ведётся на DISPATCH_LEVEL, поэтому внутри неё нельзя трогать страничную память и нельзя блокироваться: фиксация ступени обязана укладываться в несколько инструкций, а всё тяжёлое уходит в рабочий элемент или в канал трассировки. Проверить, что наблюдатель вообще срабатывает, удобно со стороны пользовательского режима тем же самым механизмом:
HANDLE low = CreateMemoryResourceNotification(LowMemoryCondition);
HANDLE high = CreateMemoryResourceNotification(HighMemoryCondition);
// стенд намеренно съедает память и ждёт перехода
WaitForSingleObject(low, INFINITE);
printf("низкий уровень памяти зафиксирован\n");
WaitForSingleObject(high, INFINITE);
Такой стенд воспроизводит голод по требованию и позволяет откалибровать ThresholdPages за один прогон вместо недели ожидания на продуктиве.
Что именно снимать в момент срабатывания
Само по себе событие говорит только "стало плохо". Картину делает срез величин, которые драйвер фиксирует в тот же момент. Минимальный полезный набор фиксации выглядит так:
- Значения структуры системной информации, прежде всего число свободных страниц, объём коммита и его лимит;
- Размеры обоих пулов, подкачиваемого и неподкачиваемого, с округлением до страниц;
- Текущий список топ-процессов по занятой памяти с идентификаторами;
- Величина модифицированного списка страниц, показывающая отложенную запись на диск. Часть этого доступна прямым чтением глобальных счётчиков ядра (MmAvailablePages и соседи по духу), часть - через ZwQuerySystemInformation с классами SystemPerformanceInformation и SystemProcessInformation.
Рассуждение о том, кто виноват, строится тогда на контрасте двух срезов: если голод совпал с ростом неподкачиваемого пула, искать нужно драйвер, который выделяет и не отдаёт; теги пула в этот момент покажут подозреваемого почти без анализа. Если же пулы спокойны, а распух системный кэш и рабочие наборы - история про файловый ввод-вывод и приложения, и фиксация списка топ-процессов по байтам рабочего набора даёт ответ в лоб. Такая классификация "бедность по причине драйвера против бедности по причине приложений" - половина ценности наблюдателя: дальше разбираются уже предметно, RAMMap или командами !poolused и !memusage в отладчике, но направление задаёт момент срабатывания.
Отдельно стоит сказать про коммит. Исчерпание лимита коммита (физическая память плюс файл подкачки) и нехватка физических страниц - разные болезни с похожими симптомами. Первое видно по счётчикам CommittedBytes на подходе к CommitLimit, второе - по длине очередей списков страниц. Событие LowMemoryCondition касается второго; первое наблюдатель ловит собственным опросом, сравнивая значения между срезами.
Сравнение с наблюдением сверху и его преимущества
У читателя резонно возникает вопрос: зачем драйверу заниматься этим, когда есть счётчики производительности и журналы? Ответ простой: разрешение по времени и привязка к механизму. Внешний сбор через счётчики шагает интервалами в секунды и не знает о внутреннем пороге срабатывания: он фиксирует только усреднённую картину. Ядерный обратный вызов привязан к тому самому переходу состояния, по которому менеджер памяти реально начинает ужимать систему. Для диагностики коротких пиковых нагрузок, "проглатываемых" усреднением, это разница между "не видим проблемы" и "вот момент, вот цифры, вот виновник".
Плюс к этому наблюдение из ядра достаёт величины, которые снаружи собираются неудобно или дорого: точные размеры пулов и внутренние списки страниц, а при желании и расширенные сигналы о нехватке неподкачиваемого пула или о приближении к лимиту коммита, которые в новых версиях ядра имеют собственные события в том же семействе объектов. Драйвер-монитор становится, по сути, встроенной сейсмостанцией памяти, пишущей в журнал только значимые толчки.
Ограничения и типичные ошибки реализации
Работа внутри обратного вызова соблюдает обычную дисциплину ядра, и игнорирование её превращает наблюдателя в источник новых проблем. В потоке, ожидающем событие, нельзя трогать страничную память и нельзя блокироваться надолго: срез делается быстро, тяжёлая запись выносится в отложенную работу. Чтение статистики по процессам через ZwQuerySystemInformation в момент паники системы по памяти - удовольствие небесплатное, поэтому опытные реализации выделяют буфер для неё заранее, при старте драйвера, а в срабатывании только обновляют содержимое.
Есть и организационная ловушка, известная каждому, кто писал драйвера-мониторы. События семейства состояний памяти зависят от порогов, которые система выбирает исходя из объёма ОЗУ; на машине с терабайтом памяти порог в абсолютных страницах гигантский, и наблюдатель может годами молчать на пустых местах или, наоборот, срабатывать на каждое изменение погоды. Точное значение порога документировано плохо, зато его легко измерить на своей машине: наблюдатель в момент сработки читает MmAvailablePages и пишет в журнал вместе с объёмом физической памяти, а после нескольких волн становится видно, на каком уровне доступных страниц система считает себя голодной. Пересчёт тривиальный, страница равна 4096 байтам:
Available Pages в момент срабатывания: 412871
412871 * 4096 / 1024 / 1024 = 1612 МБ свободных при 32768 МБ всего = 4,9%
Такая калибровка превращает абстрактное событие в конкретный процент, с которым уже можно строить алерты и сравнивать серверы между собой. Отсюда практический вывод: пороговые события используют не в одиночку, а в паре с периодическим опросом тех же счётчиков. Событие отвечает на вопрос "когда стало хуже обычного", опрос отслеживает динамику между срабатываниями. Вместе они дают ту самую историю, по которой можно восстановить, кто начал качать память, задолго до падения отклика.
События помимо классической пары и трассировка ядра
Классические HighMemoryCondition и LowMemoryCondition - лишь самая известная часть механизма. В актуальных версиях системы семейство расширено более тонкими сигналами: отдельные условия для нехватки неподкачиваемого пула, для приближения к лимиту выделенной виртуальной памяти (событие высокого коммита), а также многоступенчатые шкалы давления, позволяющие отличить "стало чуть теснее" от "менеджер уже душит всё подряд". Драйвер, который хочет реагировать по-разному на разные стадии голода, подписывается сразу на несколько таких событий и ставит в журнал не только факт срабатывания, но и его ступень; это превращает журнал из набора одинаковых тревог в лестницу состояний, по которой видно, как быстро система скатывалась к обрыву.
Параллельно с прямым ожиданием объектов стоит помнить про путь трассировки: события давления на память и переключения состояний списков страниц поднимаются и провайдерами ядра ETW, поэтому часть наблюдений можно снимать вообще без собственного драйвера. Длительную сессию запускают заранее и оставляют на ночь:
logman create trace MemoryPressure ^
-p "Microsoft-Windows-Kernel-Memory" 0xffffffffffffffff ^
-o C:\PerfLogs\mempressure.etl -ets
rem ... оставляем сбор на нужный период ...
logman stop MemoryPressure -ets
logman query -ets
Дальше трассу разбирают в Windows Performance Analyzer и фильтруют по моментам кратного падения счётчика доступных страниц. На стендах, где нельзя ставить сторонний код, это единственный законный способ. Связка из внешней трассы для продуктивного сервера плюс собственного драйвера на стенде проверена временем: наблюдатель учится на стенде, а диагноз по продуктиву ставится по совпадающим признакам трассировки.
Для отладчика ориентиры тоже общеизвестны: команда !vm выводит сводку менеджера памяти, включая число доступных страниц, размеры пулов, лимит коммита и заметную строку о том, сколько раз уже срабатывало событие нехватки:
0: kd> !vm
*** Virtual Memory Usage ***
Physical Memory: 8388608 ( 32768.00 Mb)
Page File: \??\C:\pagefile.sys
Current: 16777216 ( 65536.00 Mb)
Committed: 4194304 ( 16384.00 Mb)
Kernel Pool: 262144 ( 1024.00 Mb)
NonPaged Pool: 131072 ( 512.00 Mb)
Paged Pool: 393216 ( 1536.00 Mb)
Available Pages: 412871 ( 1612.78 Mb)
Low Memory Events: 17
High Memory Events: 22
0: kd> !poolused 2
0: kd> !memusage
Счётчик Low Memory Events накапливается с загрузки системы, и его ненулевое значение на сервере, который вроде бы ни на что не жаловался, служит честным доказательством: голод был, система пережила его сама, но запись в журнале менеджера памяти осталась. Рядом всегда полезно смотреть на Available Pages и переводить их в мегабайты умножением на 4096, а на разницу между High Memory Events и Low Memory Events: она показывает, сколько раз система качнулась от голода обратно к достатку, то есть насколько колебания были частыми.
Что уместно делать драйверу при сигнале нехватки и где проходит граница разумной реакции
Помимо наблюдения, тот же механизм используют для сокращения собственного аппетита. Драйверы с кэшами в памяти, фильтры файловой системы, сетевые стеки с большими буферами - все они обязаны уметь ужиматься по команде. Правильная реакция на сигнал нехватки - немедленно освободить необязательное: очистить кэши, уменьшить пулы предвыборки, отменить накопленные отложенные операции. Известный положительный ориентир здесь - драйвер кэша файловой системы, который на событие давления отдаёт назад десятки мегабайт страниц; драйвер стороннего производителя, игнорирующий тот же сигнал, на фоне него выглядит соседом, который занял общий коридор шкафами.
При этом существует и обратная опасность, о которой трезвые инженеры помнят: чрезмерная чувствительность. Если на каждую короткую волну драйвер сбрасывает весь свой кэш, а через секунду строит его заново, система получает двойной удар - сначала голод, потом шторм повторных выделений. Поэтому реакцию делают ступенчатой, пропорциональной ступени сигнала, и обязательно с гистерезисом: ужались на треть, подождали несколько циклов, посмотрели, не повторился ли сигнал. Ровно та же самодисциплина, какую разбирали для регистрации событий, применима и к реакции на них: наблюдатель и потребитель должны быть экономными гостями на чужой кухне, особенно в момент, когда хозяйка уже считает остатки крупы. Гистерезис проще всего выразить парой счётчиков и минимальным интервалом между реакциями:
typedef struct _PRESSURE_GUARD {
LONG64 LastReactionTicks; // время последней урезки кэша
ULONG LastStage; // ступень давления в прошлый раз
ULONG MinIntervalMs; // порог из реестра, по умолчанию 5000
} PRESSURE_GUARD, *PPRESSURE_GUARD;
BOOLEAN ShouldShrinkCache(PPRESSURE_GUARD g, ULONG stage)
{
LONG64 now = KeQueryInterruptTimePrecise(NULL);
// ступень ниже прежней - давление спало, ничего не делаем
if (stage < g->LastStage) {
g->LastStage = stage;
return FALSE;
}
// та же ступень раньше минимального интервала - подавляем дребезг
if (stage == g->LastStage &&
(now - g->LastReactionTicks) < g->MinIntervalMs * 10000) {
return FALSE;
}
g->LastStage = stage;
g->LastReactionTicks = now;
return TRUE;
}
Умножение на 10000 переводит миллисекунды в единицы по 100 наносекунд, в которых живёт KeQueryInterruptTimePrecise. Порог MinIntervalMs выносят в реестр драйвера вместе с остальными настройками чувствительности, и эксплуатирующая команда подкручивает его без пересборки:
HKLM\SYSTEM\CurrentControlSet\Services\MemWatch\Parameters
MinIntervalMs REG_DWORD 5000
StageThresholds REG_SZ "70,85,95"
LogToEtw REG_DWORD 1
Как внедряют наблюдатель в эксплуатацию
В продуктивных контурах такой драйвер обычно пишет не в текстовый файл, а в канал трассировки ETW с собственным провайдером: это дёшево по накладным расходам, события ровно ложатся в общий поток с остальной телеметрией системы и потом смотрятся привычными инструментами вместе с трассой ядра. Порог и период опроса выносят в параметры реестра драйвера, чтобы эксплуатация могла подкрутить чувствительность без пересборки.
Установка дисциплины здесь важнее кода. Разбор любого случая нехватки начинается с журнала наблюдателя: нашли первую волну давления, взяли срез, увидели, что именно росло, после чего классический инструментарий показывает детали. Команды, которые выстроили эту связку, замечают любопытный побочный эффект: пропадает жанр "необъяснимых ночных тормозов", потому что каждому эпизоду теперь соответствует запись с временем, уровнем доступных страниц и списком подозреваемых. А там, где раньше спорили, виноват ли антивирус или база, журнал молча кладёт на стол две строчки со срезом пулов и рабочих наборов - и спор заканчивается фактом. Полезно держать в голове и различие платформ: код наблюдателя, собранный под старую модель событий, на новой системе продолжит работать, но часть более тонких сигналов он просто не увидит, поэтому при переезде инструментария на актуальные версии системы подписку пересматривают и документируют заново.