Завершение работы Windows глазами приложения — как правильно пережить уведомления о выходе, перезапуски и потерю питания

· · Windows, Завершение работы, Разработка под Windows, Службы Windows, ПК устройств, Целостность данных, Длительная работа, UPS

«После ночного перезапуска Windows Update измерительное приложение на ПК устройства упало посреди записи, и к утру файл измерений был повреждён.» «Кто-то вышел из системы на общем ПК и пожаловался, что несохранённые правки пропали.» — Для долго работающих Windows-приложений эти две консультации классика.

Общее у обоих площадок — трактовать завершение работы как «аномальное событие, которого не должно быть». На деле же от автоматических перезапусков Windows Update, выхода пользователя и завершения по ИБП до незаявленной потери питания события, которые обрезают выполнение снаружи приложения, придут рано или поздно. Предотвратить их приход нельзя. Что можно предотвратить — «потерять данные, когда они приходят».

К счастью, у Windows есть механизм, который уведомляет приложение перед завершением — и для GUI-приложений, и для консольных, и для служб. Рассчитанная на ИТ-сотрудников малого и среднего бизнеса и разработчиков Windows-приложений (особенно ПК устройств и долго работающих приложений), эта статья собирает, как принимать эти уведомления, как проектировать очистку, которая «укладывается в несколько секунд», автоматическое восстановление после перезапуска и как готовиться к потере питания без уведомления — всё по первичным источникам Microsoft Learn на август 2026 года.

1. Сначала вывод

  • Проектируйте завершение как «нормальное событие, которое придёт рано или поздно». Время, которым можно пользоваться после уведомления, в принципе всего около 5 секунд, поэтому проект, который лихорадочно сохраняет всё на месте, развалится. Предпосылка — частое автосохранение, чтобы «дельта, которую нужно сохранить при завершении», оставалась маленькой.1
  • На клиентских ОС начиная с Windows 8, когда быстрый запуск включён (по умолчанию на большинстве ПК, которые поддерживают гибернацию), «Завершить работу» — hybrid shutdown, и ядро только гибернирует. Полностью сбрасывается только «Перезагрузить». Вот настоящая причина «выключил — не помогло, перезапустил — помогло».2
  • GUI-приложение должно сразу вернуть TRUE на WM_QUERYENDSESSION и делать очистку в WM_ENDSESSION. В принципе нельзя возвращать FALSE (отказывать).1
  • Только когда действительно есть операция, которую нельзя прервать, показывайте причину через ShutdownBlockReasonCreate. Даже тогда пользователь и ОС могут принудить продолжение, поэтому проект, который исходит из «мы можем заблокировать», не держится.34
  • Консольное приложение принимает уведомление через SetConsoleCtrlHandler. Льготный срок ещё короче — по умолчанию 5 секунд на закрытие консоли. Есть и ловушка: в процессе, который загрузил gdi32.dll или user32.dll, часть этих событий не приходит.56
  • Очистка, которая полагается на AppDomain.ProcessExit .NET, начиная с .NET 10 не выполняется на путях, где процесс «завершают снаружи». При обычном выходе вроде возврата из Main она по-прежнему идёт как раньше, но поскольку среда больше не даёт обработку по умолчанию для сигналов завершения вроде закрытия консоли и shutdown, очистку на этих путях нужно перенести на уведомление, которое соответствует модели приложения.7
  • Служба Windows может принять SERVICE_ACCEPT_PRESHUTDOWN раньше и с настраиваемым льготным сроком, чем SERVICE_ACCEPT_SHUTDOWN (около 20 секунд льготы). Тайм-аут PRESHUTDOWN по умолчанию, однако, укоротили до 10 секунд начиная с Windows 10 Creators Update, поэтому в любом случае нужен проект, который не слишком опирается на льготный срок.89
  • Автоматическое восстановление после перезапуска достигается сочетанием RegisterApplicationRestart с ARSO (автоматический вход). Пути восстановления предусмотрены для сбоя, «не отвечает» и перезапуска по обновлению.1011
  • Потеря питания не несёт никакого уведомления. Стандартный шаблон — полностью записать во временный файл, сбросить и обменять через ReplaceFile, но поскольку ReplaceFile тоже не гарантирует атомарность через потерю питания, путь восстановления «резерв (.bak) плюс проверка при загрузке» входит в комплект. Последующую изоляцию можно сделать из журнала событий (1074/41/6008).121314

В одном предложении вывод статьи: «всегда держите состояние, из которого можно закрыть лавку за несколько секунд, когда придёт уведомление, и пишите так, чтобы не ломалось даже при потере питания без уведомления».

2. Что происходит при завершении — четыре способа закончить

2.1. Выход, завершение, перезапуск и потеря питания

С точки зрения приложения важны две оси: «как заканчивается сеанс пользователя» и «что происходит с ядром».

Операция Сеанс пользователя Ядро и драйверы Уведомление приложению
Выход Заканчивается Продолжает работать WM_QUERYENDSESSION (ENDSESSION_LOGOFF) → WM_ENDSESSION
Завершение (быстрый запуск включён) Заканчивается Гибернирует (сохранено в hiberfil.sys) WM_QUERYENDSESSION → WM_ENDSESSION, (PRE)SHUTDOWN службам
Перезапуск Заканчивается Заканчивается полностью; следующая загрузка полная То же
Потеря питания Исчезает сразу Исчезает сразу Нет

Выход и завершение с точки зрения приложения почти одно и то же событие. Если в lParam WM_QUERYENDSESSION установлен бит ENDSESSION_LOGOFF — это выход; если 0 — завершение или перезапуск (их не различить).1 Иными словами, самоуспокоенность «это всего лишь выход, всё будет хорошо» не держится, и правильный проект — чтобы вызывался один и тот же код очистки.

Четыре способа закончить и уведомление приложениюВыход, завершение и перезапуск доставляют уведомление WM_QUERYENDSESSION к WM_ENDSESSION, и очистка укладывается в несколько секунд. Только потеря питания не имеет уведомления вовсе, поэтому готовьтесь проектом записи главы 8 и ИБПВыходQUERY → ENDSESSIONЗавершениеПерезапускПотеря питанияНет уведомления: запись + ИБПОчистка за секунды

Рис. 1: Выход, завершение и перезапуск доставляют уведомление WM_QUERYENDSESSION к WM_ENDSESSION, и очистка укладывается в несколько секунд. Только потеря питания не имеет уведомления вовсе, поэтому готовьтесь проектом записи главы 8 и ИБП.

2.2. Настоящая причина «выключил — не помогло» — hybrid shutdown

Строка, которую легко пропустить, — вторая в таблице. На клиентских ОС начиная с Windows 8 быстрый запуск (hybrid shutdown) включён по умолчанию на ПК, которые поддерживают гибернацию, и поведение «Завершить работу» изменилось. Выход сеанса пользователя по-прежнему происходит как обычно, но сеанс ядра не закрывается; он сохраняется вместе с драйверами устройств в файл гибернации (hiberfil.sys) и восстанавливается как есть при следующей загрузке. Это ускоряет запуск, но состояние ядра и драйверов переживает даже отключение питания.2 Это, однако, условное поведение. В среде, где сама гибернация отключена (powercfg /hibernate off), где политика или параметры электропитания выключили быстрый запуск, и на Windows Server завершение — обычное полное. Каким путём идёт данный ПК, видно по флажку «Включить быстрый запуск» в параметрах электропитания или по тому, перечисляет ли powercfg /a (доступные состояния сна) «Fast Startup».

Что происходит с ядром при операции Завершить работуОперация Завершить работу расходится на полное завершение или гибернацию ядра в зависимости от того, включён ли быстрый запуск, а Перезагрузить всегда делает полную загрузкуБыстрый запуск включёнГибернация выкл. / ServerЗавершить работуПерезагрузитьСеанс заканчивается + ядро гибернируетПолное завершениеДальше: восстановить ядроДальше: полная загрузка

Рис. 2: Операция Завершить работу расходится на полное завершение или гибернацию ядра в зависимости от того, включён ли быстрый запуск, а Перезагрузить всегда делает полную загрузку.

«Перезагрузить», напротив, всегда выполняет полный цикл загрузки. После обновления драйвера, например, нужно совершенно новое состояние.2 Отсюда несколько явлений, которые слышны в поле, встают на место.

  • «Выключил и снова включил, а неполадка устройства не ушла» — ядро и драйверы только восстановили из гибернации; их не сбросили
  • «Помогло после перезапуска» — потому что полная загрузка их инициализировала
  • Процедуры инцидентов на ПК устройств должны говорить «Перезагрузить», а не «выключить и включить»

Если нужно явно сделать полное завершение из командной строки — shutdown /s (у Shutdown.exe по умолчанию полное завершение); если нужно гибридное поведение по умолчанию — shutdown /s /hybrid.2 Отключать быстрый запуск не рекомендуется. Сторона приложения должна исходить из «при завершении ядро может только гибернировать» — например, не оценивать «накопленное время работы» по времени загрузки ОС — и проектировать так, чтобы не ломаться в любом случае (включён ли быстрый запуск, зависит от среды).

3. Как должно вести себя GUI-приложение — WM_QUERYENDSESSION и WM_ENDSESSION

3.1. Как два сообщения делят работу

Приложение с окном и очередью сообщений уведомляют о конце сеанса в два этапа.1

  1. WM_QUERYENDSESSION — запрос: «можно закончить?» Приложение должно сразу вернуть TRUE; ответ DefWindowProc по умолчанию тоже TRUE. Очистку здесь не начинайте.
  2. WM_ENDSESSION (wParam=TRUE) — подтверждённое уведомление: «сеанс действительно заканчивается». Очистка идёт здесь.

Возврат FALSE на WM_QUERYENDSESSION может прервать завершение, но документация явно говорит «следует вернуть TRUE и уважить намерение пользователя», и приложение, вернувшее FALSE, всё равно показывают в полноэкранном UI как «приложение, которое препятствует завершению». Консольные приложения и приложения без видимого окна завершение прервать не могут вовсе, и если не ответят за 5 секунд, их завершают автоматически.14

Поток двухэтапного уведомления о конце сеансаВозврат TRUE на запрос WM_QUERYENDSESSION подтверждает через WM_ENDSESSION и запускает очистку. Отказ FALSE показывает приложение как препятствующее завершению, а около 5 секунд без ответа могут принудить продолжениеTRUE(правило)FALSE(отказ)Нет ответа ~5 сПринудить продолжениеОтменитьWM_QUERYENDSESSIONWM_ENDSESSION(подтверждено)Показано как блокирующее завершениеСчитается зависшимОчистка здесьВыход процессаЗавершение прервано

Рис. 3: Возврат TRUE на запрос WM_QUERYENDSESSION подтверждает через WM_ENDSESSION и запускает очистку. Отказ FALSE показывает приложение как препятствующее завершению, а около 5 секунд без ответа могут принудить продолжение.

3.2. Что будет, если не ответить — стена 5 секунд

И на WM_QUERYENDSESSION, и на WM_ENDSESSION ответ можно задержать примерно на 5 секунд. Дальше система показывает экран «Это приложение препятствует завершению работы», и пользователь может выбрать принудительное продолжение (= принудительно завершить приложение).4 Принудительно завершённому процессу ещё одного шанса дописать сохранение не дают.

Поэтому проектные пункты такие два.

  • Держите очистку в объёме, который укладывается в 5 секунд. Сама Microsoft рекомендует часто сохранять данные в обычной работе, чтобы меньше пришлось сохранять при завершении, и сохранять несохранённые данные во временное место, чтобы восстановить при следующем запуске.1
  • Не выводите диалог подтверждения во время завершения. Пока сидите в ожидании «Сохранить?», 5 секунд уходят. Молча падайте на безопасную сторону (автосохранение).

3.3. Реализация в WinForms и WPF

В десктопном .NET-приложении эти сообщения переводятся в события платформы. В WinForms поднимается FormClosing, и CloseReason говорит, причина ли — завершение.

// WinForms: FormClosing is also raised on shutdown / sign-out
private void MainForm_FormClosing(object sender, FormClosingEventArgs e)
{
    if (e.CloseReason == CloseReason.WindowsShutDown)
    {
        // Do only an idempotent snapshot save. Do not show a dialog.
        // Do not set e.Cancel = true (refuse) either.
        SaveWorkingStateToTempFile();
        return;
    }

    // For ordinary closes such as the user clicking the × button, you may confirm here
}

В WPF соответствует событие Application.SessionEnding (атрибут XAML SessionEnding или переопределение OnSessionEnding).

Как события WinForms/WPF сопоставляются с сообщениямиФаза запроса WM_QUERYENDSESSION сопоставляется с FormClosing в WinForms и SessionEnding в WPF, и там делают не больше идемпотентного снимка. Для подтверждённого WM_ENDSESSION соответствующего события нет, поэтому принимайте его в WndProc или хуке и делайте очистку, которая может идти только после подтвержденияWM_QUERYENDSESSIONWinForms: FormClosingWPF: SessionEndingТолько идемпотентный снимокWM_ENDSESSIONНет события: хук WndProcОчистка после подтверждения

Рис. 4: Фаза запроса WM_QUERYENDSESSION сопоставляется с FormClosing в WinForms и SessionEnding в WPF, и там делают не больше идемпотентного снимка. Для подтверждённого WM_ENDSESSION соответствующего события нет, поэтому принимайте его в WndProc или хуке и делайте очистку, которая может идти только после подтверждения.

// WPF: App.xaml.cs
protected override void OnSessionEnding(SessionEndingCancelEventArgs e)
{
    base.OnSessionEnding(e);

    // You can distinguish ReasonSessionEnding.Logoff / Shutdown,
    // but the baseline is to run the same snapshot save in either case
    SaveWorkingStateToTempFile();

    // Do not set e.Cancel = true unless you have an exceptional reason
}

Здесь одна оговорка. И FormClosing (CloseReason.WindowsShutDown), и SessionEnding WPF соответствуют фазе запроса (WM_QUERYENDSESSION). Если другое приложение откажет, завершение прервут, а ваше приложение продолжит работать. Поэтому в этих событиях можно делать идемпотентное сохранение снимка, которое не вредит, если завершение прервано, и даёт тот же результат, сколько бы раз ни выполнилось. Если нужна «очистка, которую нужно делать только когда действительно заканчиваем» (отключение, возврат ресурсов и так далее), хукайте подтверждённый WM_ENDSESSION (wParam=TRUE) напрямую в WndProc и делайте её там.

На любом пути сверните тело в общую функцию «сохранения снимка» и пишите данные восстановления для обычного выхода, завершения и (если возможно) сбоя в одном формате, чтобы логика восстановления при следующем запуске была одним путём. Проектирование так, чтобы оставлять информацию даже при сбое, разобрано в «Проектирование Windows-приложений, сохраняющих логи и дампы при сбое».

4. Если действительно нужно блокировать — ShutdownBlockReasonCreate

Операции, которые физически ломаются, если их обрезать на середине, вроде записи CD или прошивки, — исключение. Правильная практика здесь — зарегистрировать строку причины через ShutdownBlockReasonCreate, когда непрерываемая операция начинается, и сразу вызвать ShutdownBlockReasonDestroy, когда она заканчивается. Когда запрошено завершение, эта причина показывается на экране «Это приложение препятствует завершению работы», и пользователь решает, продолжить или отменить.3

Поток защиты через ShutdownBlockReasonCreateЗарегистрируйте причину, когда начинается непрерываемая операция; если запрос завершения приходит, пока она защищена, причина показывается на весь экран и WM_QUERYENDSESSION отвергается FALSE. Пользователь может отменить или принудить продолжение, и причина снимается, когда операция заканчиваетсяОтменитьПринудить продолжениеНачать непрерываемую работуShutdownBlockReasonCreateВыполнить на рабочем потокеКонец: DestroyЗавершение во время этогоПоказать причину + FALSEВыход процесса

Рис. 5: Зарегистрируйте причину, когда начинается непрерываемая операция; если запрос завершения приходит, пока она защищена, причина показывается на весь экран и WM_QUERYENDSESSION отвергается FALSE. Пользователь может отменить или принудить продолжение, и причина снимается, когда операция заканчивается.

[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern bool ShutdownBlockReasonCreate(IntPtr hWnd, string pwszReason);

[DllImport("user32.dll", SetLastError = true)]
static extern bool ShutdownBlockReasonDestroy(IntPtr hWnd);

// Call from the thread that created the main window (it fails from other threads)
_criticalOperationInProgress = true;
ShutdownBlockReasonCreate(this.Handle, "Writing measurement data to a file");
try
{
    // Run the uninterruptible operation on a worker thread. If you run it
    // synchronously on the UI thread the message pump stops, and the process
    // is force-continued as "Not Responding" before the WM_QUERYENDSESSION
    // refusal code below can run
    await Task.Run(() => WriteMeasurementData());
}
finally
{
    ShutdownBlockReasonDestroy(this.Handle);
    _criticalOperationInProgress = false;
}

// Also, refuse WM_QUERYENDSESSION with FALSE only while protected
protected override void WndProc(ref Message m)
{
    const int WM_QUERYENDSESSION = 0x0011;
    if (m.Msg == WM_QUERYENDSESSION && _criticalOperationInProgress)
    {
        m.Result = IntPtr.Zero;   // Refuse. The registered reason string is shown in the full-screen UI
        return;
    }
    base.WndProc(ref m);
}

Легкое недопонимание здесь — разделение ролей. Всё, что делает ShutdownBlockReasonCreate, — регистрирует строку причины; само по себе оно завершение не останавливает. Что реально удерживает завершение — ваша обработка, которая возвращает FALSE на WM_QUERYENDSESSION, пока установлен флаг защиты, как выше. Используйте оба как комплект и снимайте оба сразу, когда операция закончена. Также саму защищённую операцию выполняйте на рабочем потоке и держите UI-поток способным обрабатывать сообщения — механизм отказа работает только когда сообщение дошло (и даже тогда пользователь и ОС могут принудить продолжение, поэтому проект записи, который не ломается «если не остановилось» — глава 8 — всё равно нужен).

Есть три эксплуатационные оговорки.

  • Держите строку причины короткой и конкретной. Пользователь торопится и прочитает только несколько секунд. Сама документация даёт «Burning a CD» как уместный пример.3
  • Не оставляйте её зарегистрированной на всю жизнь приложения. «Только пока идёт непрерываемая операция» — то, из чего исходит API.
  • Не проектируйте из допущения, что можете заблокировать. Пользователь может выбрать принудительное продолжение, а принудительное завершение (ENDSESSION_CRITICAL) не будет ждать вовсе. Документация явна: «Applications should not depend on being able to block shutdown».4

5. Как должны вести себя консольные приложения и фоновые процессы

5.1. SetConsoleCtrlHandler и короткий льготный срок

Консольное приложение не может принимать оконные сообщения, поэтому управляющие сигналы приходят в функцию-обработчик, зарегистрированную через SetConsoleCtrlHandler. Льготный срок по умолчанию на сигнал такой.5

Сигнал Когда возникает Льготный срок по умолчанию
CTRL_C_EVENT / CTRL_BREAK_EVENT Ctrl+C / Ctrl+Break Без тайм-аута
CTRL_CLOSE_EVENT Закрытие консоли, «Завершить задачу» Диспетчера задач (принудительное убийство процесса со вкладки «Подробности» — немедленный выход без уведомления и вне этой таблицы) Около 5 секунд
CTRL_SHUTDOWN_EVENT Завершение системы (процессы служб) Около 20 секунд

Есть два пункта, за которыми следить. Первый: по сути только процесс, работающий как служба, может принять CTRL_LOGOFF_EVENT и CTRL_SHUTDOWN_EVENT. Приложение в интерактивном сеансе завершают при выходе, поэтому проект, который ждёт этих сигналов, не держится.5 Второй: процесс, который загрузил gdi32.dll или user32.dll, трактуют как Windows-приложение, даже если вы думаете о нём как о консольном, и обработчики LOGOFF/SHUTDOWN не вызываются. Официальный обходной путь — создать скрытое окно и принимать WM_QUERYENDSESSION/WM_ENDSESSION.6

Льготный срок на консольный сигналУ Ctrl+C и Ctrl+Break нет явного тайм-аута; у закрытия консоли около 5 секунд, у сигнала завершения службе — около 20 секунд; превышение принудительно завершает процессБез тайм-аутаОколо 5 сОколо 20 сCTRL_C / BREAKОчистка HandlerRoutineCTRL_CLOSECTRL_SHUTDOWNПринудительное убийство после льготы

Рис. 6: У Ctrl+C и Ctrl+Break нет явного тайм-аута; у закрытия консоли около 5 секунд, у сигнала завершения службе — около 20 секунд; превышение принудительно завершает процесс.

// Console app: clean up on Ctrl+C and console close
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetConsoleCtrlHandler(HandlerRoutine handler, bool add);

delegate bool HandlerRoutine(int ctrlType);   // 2 = CTRL_CLOSE_EVENT

static readonly HandlerRoutine s_handler = OnCtrlEvent;  // Keep a reference so GC does not collect it

static bool OnCtrlEvent(int ctrlType)
{
    // Do only cleanup that finishes within 5 seconds
    FlushAndCloseDataFile();
    return false;   // Proceed to the default handler; the process exits
}

static void Main()
{
    SetConsoleCtrlHandler(s_handler, add: true);
    // ...
}

5.2. Ловушка .NET — не полагайтесь на ProcessExit

В .NET давно есть запасной шаблон «просто почистить в AppDomain.ProcessExit», но начиная с .NET 10 среда больше не предоставляет обработчики сигналов завершения по умолчанию, и ни ProcessExit, ни AssemblyLoadContext.Unloading не срабатывают на CTRL_CLOSE_EVENT/CTRL_SHUTDOWN_EVENT. Обработчик ОС по умолчанию просто сразу завершает процесс.7

Как изменился ProcessExit в .NET 10До .NET 9 обработчик сигналов среды по умолчанию принимал сигнал завершения, поднимал ProcessExit, затем выходил. Начиная с .NET 10 среда не даёт обработчик по умолчанию, обработка ОС по умолчанию сразу завершает процесс, и обработчик регистрируете самиCTRL_CLOSE / SHUTDOWNДо .NET 9: ProcessExitС .NET 10: немедленный выходЗарегистрировать обработчик сами

Рис. 7: До .NET 9 обработчик сигналов среды по умолчанию принимал сигнал завершения, поднимал ProcessExit, затем выходил. Начиная с .NET 10 среда не даёт обработчик по умолчанию, обработка ОС по умолчанию сразу завершает процесс, и обработчик регистрируете сами.

Вместо этого переходите на канонический путь для каждой модели приложения.

  • GUI-приложение: FormClosing / SessionEnding из предыдущей главы
  • Generic Host (включая Worker Service): IHostApplicationLifetime и BackgroundService.StopAsync. Сделайте льготный срок остановки явным через HostOptions.ShutdownTimeout
  • Голое консольное приложение: SetConsoleCtrlHandler (или подпишитесь на эквиваленты SIGINT/SIGTERM через PosixSignalRegistration)
Где каждая модель приложения принимает уведомление о выходеGUI-приложение использует FormClosing и SessionEnding плюс хук WM_ENDSESSION для подтверждённой работы; Generic Host — IHostApplicationLifetime и StopAsync; голое консольное — SetConsoleCtrlHandler или PosixSignalRegistration. Полагаться на ProcessExit на путях внешнего сигнала не срабатываетGUIНе GUIHostКонсольКакая модель приложения?FormClosing / SessionEndingHost или консоль?Хук ENDSESSIONLifetime + StopAsyncSetConsoleCtrlHandlerЗадать ShutdownTimeoutНе полагаться на ProcessExit

Рис. 8: GUI-приложение использует FormClosing и SessionEnding плюс хук WM_ENDSESSION для подтверждённой работы; Generic Host — IHostApplicationLifetime и StopAsync; голое консольное — SetConsoleCtrlHandler или PosixSignalRegistration. Полагаться на ProcessExit на путях внешнего сигнала не срабатывает.

Льготный срок различается по пути — около 5 секунд для GUI и закрытия консоли, льготный срок SCM главы 6 для службы (около 20 секунд или настроенное значение для PRESHUTDOWN) и нет явного тайм-аута для Ctrl+C. На каждом пути, однако, льготный срок ограничен и на него нельзя рассчитывать, поэтому ось проекта в том, что нормальный случай — «уже сохранено на каждой контрольной точке обработки», а не «пахать в событии выхода».

6. Как должна вести себя служба Windows — SHUTDOWN и PRESHUTDOWN

6.1. Два вида уведомления о завершении

На службу не влияет выход, но её останавливают при завершении и перезапуске. Уведомление приходит как код управления от Service Control Manager (SCM), и чтобы его принять, нужно объявить флаг приёма.8

Объявление Какое уведомление приходит Момент и льготный срок
SERVICE_ACCEPT_SHUTDOWN SERVICE_CONTROL_SHUTDOWN Уведомляют во время обработки завершения. По умолчанию около 20 секунд, потолок WaitToKillServiceTimeout
SERVICE_ACCEPT_PRESHUTDOWN SERVICE_CONTROL_PRESHUTDOWN Уведомляют до SHUTDOWN. SCM ждёт, пока служба остановится или истечёт тайм-аут
Порядок уведомлений о завершении службеКогда начинается завершение, службы, объявившие PRESHUTDOWN, уведомляют первыми с настроенным льготным сроком, затем отправляют уведомление SHUTDOWN с умолчанием около 20 секунд, и процесс завершают, когда льготный срок истекаетЗавершение начинаетсяPRESHUTDOWN(если объявлено)SHUTDOWN(около 20 с)Льгота истекает → выход

Рис. 9: Когда начинается завершение, службы, объявившие PRESHUTDOWN, уведомляют первыми с настроенным льготным сроком, затем отправляют уведомление SHUTDOWN с умолчанием около 20 секунд, и процесс завершают, когда льготный срок истекает.

Тайм-аут PRESHUTDOWN настраивается через ChangeServiceConfig2 (SERVICE_CONFIG_PRESHUTDOWN_INFO); по умолчанию 10 секунд начиная с Windows 10 Creators Update (сборка 15063) и 3 минуты до того.9 Если вы всё ещё исходите из старого знания «PRESHUTDOWN даёт 3 минуты», на текущей ОС у вас только 1/18 ожидаемого льготного срока. Кроме того, PRESHUTDOWN удерживает завершение всей системы на этот интервал, поэтому и документация говорит, что его «should be used only in special circumstances».8

Практика на стороне обработчика тоже важна. Обработчик управления должен вернуться за 30 секунд; долгую работу остановки оставляйте другому потоку, сообщайте SERVICE_STOP_PENDING и сразу возвращайтесь.8

// Win32 service: accept PRESHUTDOWN and leave stop work to a worker
g_status.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_PRESHUTDOWN;

DWORD WINAPI ServiceHandlerEx(DWORD control, DWORD, LPVOID, LPVOID)
{
    switch (control)
    {
    case SERVICE_CONTROL_PRESHUTDOWN:
    case SERVICE_CONTROL_STOP:
        ReportStatus(SERVICE_STOP_PENDING, /*waitHintMs*/ 3000);
        SetEvent(g_stopEvent);   // Tell the worker to stop and return immediately
        return NO_ERROR;
    }
    return ERROR_CALL_NOT_IMPLEMENTED;
}

// Worker side: if cleanup runs longer than waitHint, keep reporting
// SERVICE_STOP_PENDING periodically while incrementing dwCheckPoint.
// The SCM judges "still alive and making progress" from waitHint and
// checkpoint advance. If reporting stops it can be treated as hung and
// shutdown can proceed. Always report SERVICE_STOPPED when finished

6.2. Проект, который не опирается на льготный срок

Продлевать потолок льготного срока WaitToKillServiceTimeout, переписывая его со стороны службы, явно не рекомендуется. Документация просит обратное — служба должна закончить очистку как можно быстрее, чтобы машина на ИБП успела завершиться до разряда батареи. Руководство — часто сохранять в обычной работе, чтобы несохранённых данных было минимум, не тратить время на освобождение памяти при завершении и не слишком долго ждать ответа, уведомляя сетевого собеседника. Кроме того, SCM при завершении по умолчанию не учитывает зависимости, поэтому обработка остановки должна работать «даже если служба, от которой вы зависите, уже упала».8

Проектирование обработки остановки, которая не опирается на льготный срокЕсли сохраняете на каждой контрольной точке обработки так, что несохранённых данных всегда минимум, очистка при уведомлении об остановке укладывается в несколько секунд. Проект, который сохраняет всё на выходе, не уложится в льготный срок, и принудительное завершение теряет данныеСохранять на каждой точкеСтоп → сохранить щепотку → готовоСохранять всё на выходеСтоп → сохранение не укладываетсяПринудительное убийство → потеря данных

Рис. 10: Если сохраняете на каждой контрольной точке обработки так, что несохранённых данных всегда минимум, очистка при уведомлении об остановке укладывается в несколько секунд. Проект, который сохраняет всё на выходе, не уложится в льготный срок, и принудительное завершение теряет данные.

В .NET Worker Service (UseWindowsService) SERVICE_CONTROL_STOP и SHUTDOWN переводятся в остановку узла, и вызывается BackgroundService.StopAsync. Запасная реализация на момент написания принимает семейство STOP/SHUTDOWN; если нужен ещё PRESHUTDOWN, понадобится расширенный обработчик. В любом случае сделайте HostOptions.ShutdownTimeout явным и уложите StopAsync в несколько секунд. О построении службы в целом см. «Как создавать и эксплуатировать службы Windows».

7. Автоматически восстановиться после перезапуска

На ПК устройства или необслуживаемом ПК охват проекта — не только «пережить завершение», но и «вернуться самому после перезапуска».

7.1. RegisterApplicationRestart и обратный вызов восстановления

Если вы вызвали RegisterApplicationRestart, приложение регистрируется как кандидат на перезапуск при сбое (необработанное исключение), «не отвечает», перезапуске приложения по обновлению и перезапуске ОС по обновлению. Можно зарегистрировать аргументы командной строки для перезапуска, поэтому если включить «какой файл был открыт» и «какая точка восстановления», можно продолжить с того места после перезапуска.10

Спецификации, которые нужно принять, такие.10

  • Регистрацию нужно закончить до того, как проблема случится (обработка WM_QUERYENDSESSION — последний шанс в сценарии обновления)
  • Чтобы не зациклить перезапуск, процесс, который работает меньше 60 секунд, не перезапускают
  • Процесс с повышенными правами не кандидат на автоматический перезапуск (процесс нельзя воссоздать без согласия на повышение). Автоматическое восстановление приложения, которому нужно повышение, проектируют, держа UI на обычных правах и изолируя привилегированную работу в службе, или явным путём запуска вроде задачи Планировщика «Выполнять с наивысшими правами»
  • Перезапуск после сбоя или зависания идёт через согласие пользователя; перезапуск после обновления — автоматический
  • Чтобы восстановиться через перезапуск ОС, сторона, которая запрашивает перезапуск (установщик и подобное), должна вызвать API завершения с флагами EWX_RESTARTAPPS / SHUTDOWN_RESTARTAPPS

Если также зарегистрировать RegisterApplicationRecoveryCallback, WER (Windows Error Reporting) вызывает обратный вызов при сбое и даёт льготный срок сохранить данные в процессе. Если сохранение занимает время, однако, нужно продолжать вызывать ApplicationRecoveryInProgress в пределах интервала ping, заданного при регистрации, иначе работу восстановления обрежут на середине. Когда сохранение закончено, сообщите о завершении через ApplicationRecoveryFinished. «Заменить файл, который используется, и перезапустить» при обновлении приложения — территория Restart Manager, подробно разобранная в «Как заменить exe или DLL, которые используются».

7.2. ARSO — автоматический вход после перезапуска по обновлению

После перезапуска Windows Update, если никто не входит, приложения пользовательского сеанса не возвращаются. Эту щель заполняет ARSO (Winlogon Automatic Restart Sign-On). Когда Windows Update начинает перезапуск, он надёжно сохраняет учётные данные последнего интерактивного пользователя, настраивает Autologon и после перезапуска автоматически входит этим пользователем, затем блокирует экран.11 Есть и команда вроде shutdown /g, которая запрашивает перезапуск плюс возобновление зарегистрированных приложений. Некоторые среды отключают это организационной политикой (DisableAutomaticRestartSignOn и подобное), поэтому когда проектируете необслуживаемое восстановление, проверяйте эту настройку комплектом. А если для фоновой работы, которая всегда нужна, вы полагаетесь на автозапуск в пользовательском сеансе, правильный ход — сделать её службой Windows с самого начала.

Путь, которым приложение автоматически восстанавливается после перезапускаЕсли зарегистрировать через RegisterApplicationRestart до того, как случится проблема, приложение перезапускают после согласия пользователя при сбое или «не отвечает» и после автоматического входа ARSO и блокировки экрана при перезапуске по обновлению. Процессы меньше 60 секунд работы и процессы с повышением вне охватаСогласиеRegisterApplicationRestartСбой или зависаниеПерезапуск по обновлениюПерезапуск приложенияВход ARSO + блокировкаНет: меньше 60 с / с повышением

Рис. 11: Если зарегистрировать через RegisterApplicationRestart до того, как случится проблема, приложение перезапускают после согласия пользователя при сбое или «не отвечает» и после автоматического входа ARSO и блокировки экрана при перезапуске по обновлению. Процессы меньше 60 секунд работы и процессы с повышением вне охвата.

8. Пережить потерю питания без уведомления — проект записи и ИБП

8.1. Запись, которая «не ломается, когда бы ни обрезали» — временный файл + ReplaceFile

Сработавший автомат, отказавший блок питания или выдернутая вилка не несут ни WM_ENDSESSION, ни PRESHUTDOWN. Пока вы «перезаписываете исходный файл на месте» для настроек или результатов измерений, потеря питания посреди записи может оставить сломанный файл, где смешаны старое и новое.

Стандартный шаблон — полностью записать во временный файл на том же томе и затем обменять. ReplaceFile упаковывает последовательность «сохранить в новый файл → отложить исходный → переименовать → удалить» в один API и также переносит атрибуты исходного файла вроде времени создания, ACL и альтернативных потоков (три файла должны быть на одном томе).12 File.Replace в .NET вызывает это как есть.

Поток сохранения и восстановления через временный файл и ReplaceFileПри сохранении полностью запишите во временный файл, сбросьте и обменяйте через ReplaceFile, оставив старое содержимое в .bak. При следующем запуске проверьте основной файл и откатитесь на .bak, если он сломанПри следующем запускеПри сохраненииЦелСломанПотеря питания на любом шагеПроверить основнойИспользовать как естьОткатиться на .bakСбросить на дискЗаписать полный временныйReplaceFile → .bak

Рис. 12: При сохранении полностью запишите во временный файл, сбросьте и обменяйте через ReplaceFile, оставив старое содержимое в .bak. При следующем запуске проверьте основной файл и откатитесь на .bak, если он сломан.

// The standard pattern for settings and data: write completely to a temporary file, then swap, and keep the old contents
public static void SaveAtomically(string path, string content)
{
    string dir = Path.GetDirectoryName(path)!;
    string tmp = Path.Combine(dir, Path.GetRandomFileName());  // Create it on the same volume

    try
    {
        using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write))
        using (var writer = new StreamWriter(fs))
        {
            writer.Write(content);
            writer.Flush();
            fs.Flush(flushToDisk: true);   // FlushFileBuffers equivalent. Write the OS buffer
                                           // out to disk (device-side cache limits are in 8.2)
        }

        if (File.Exists(path))
            File.Replace(tmp, path, path + ".bak");  // Calls ReplaceFile. Keep the old contents as .bak
        else
            File.Move(tmp, path);
    }
    catch
    {
        // If we fail mid-way, do not leave the temporary file. Repeated failures
        // of a periodic save would fill the volume with complete copies
        try { File.Delete(tmp); } catch { /* Prefer the original exception if delete fails */ }
        throw;
    }
}

С этим обычная работа всегда оставляет возможность прочитать либо «полный старый файл», либо «полный новый файл». ReplaceFile, однако, — многошаговая операция пространства имён, и атомарность через потерю питания спецификацией не гарантируется. Поэтому пример выше держит резерв (.bak) — сторона чтения проверяет основной файл при запуске и откатывается на резерв, если он сломан, комплектом. Для журналов только на дописывание или CSV это использовать нельзя, поэтому те используют формат, в который поломка встроена, вроде «одна строка = одна запись, и отбрасывать сломанную последнюю строку при чтении».

8.2. Успех WriteFile — не прибытие на диск

Другая предпосылка: даже если WriteFile вернул успех, данные могут всё ещё быть только в кэше ОС. Windows кладёт чтения и записи файлов в системный буфер и отражает их на диск периодически ленивой записью. Чтобы данные наверняка дошли до диска, либо явно сбрасывайте через FlushFileBuffers, либо укажите FILE_FLAG_WRITE_THROUGH при CreateFile, чтобы каждая запись шла через кэш. Метаданные файловой системы всегда кэшируются, поэтому подтверждение метаданных тоже требует сброса или write-through.13

Вызывать FlushFileBuffers каждый раз неэффективно, и документация тоже побуждает рассмотреть FILE_FLAG_NO_BUFFERING+WRITE_THROUGH вместо частых вызовов.13 На практике «сбрасывать только на контрольной точке транзакции или непосредственно перед закрытием файла» — реалистичный компромисс. Механика этого слоя — cache manager, ленивая запись и факт «я сбросил, а до диска всё ещё могло не дойти» из-за аппаратного кэша — подробно разобраны в «Cache Manager: когда ваш WriteFile на самом деле достигает диска?».

8.3. ИБП и мониторинг батареи — превратить потерю питания в завершение

Настоящая контрмера против потери питания на ПК устройства — ИБП. Роль ИБП думайте не как «остановить отключение», а как превратить «потерю питания без уведомления» в «запланированное завершение с уведомлением». Проект — двухэтапная схема.

  1. Проектирование льготного срока: время удержания батареи ИБП > сумма «обнаружить переход на батарею → очистка приложения и служб → полное завершение ОС». Если обработка остановки служб медленная, это уравнение больше не держится (раздел 6.2)
  2. Обнаружение: переход с сети на батарею и падение остатка уведомляют событием PBT_APMPOWERSTATUSCHANGE. Приложение с окном принимает его как WM_POWERBROADCAST; служба без окна объявляет SERVICE_ACCEPT_POWEREVENT и принимает как SERVICE_CONTROL_POWEREVENT в HandlerEx (WM_POWERBROADCAST в обработчик управления службой не приходит). По получении вызовите GetSystemPowerStatus, проверьте ACLineStatus (на сети ли) и BatteryLifePercent и ведите к прерыванию измерения, сохранению и запросу завершения15
Поток превращения потери питания в запланированное завершение через ИБПКогда отключение переводит ИБП на батарею, уведомляют PBT_APMPOWERSTATUSCHANGE, проверяют состояние питания, и сохранение плюс запрос завершения превращают потерю питания без уведомления в запланированное завершение с уведомлениемОтключениеИБП на батареюPBT_APMPOWERSTATUSCHANGEGetSystemPowerStatusПрервать и сохранитьЗапросить завершениеОбычный поток уведомлений(3–6)

Рис. 13: Когда отключение переводит ИБП на батарею, уведомляют PBT_APMPOWERSTATUSCHANGE, проверяют состояние питания, и сохранение плюс запрос завершения превращают потерю питания без уведомления в запланированное завершение с уведомлением.

Типичный USB-ИБП выглядит для Windows как батарея, поэтому его можно обнаружить этим стандартным API. Если у ПО управления вендора есть функция «завершить ОС при N% остатка», также проверьте, что порог совпадает со временем очистки вашего приложения. Возобновление из сна или гибернации и проблемы долгой работы — отдельная ось, разобранная в «Спящий режим, гибернация, Modern Standby и долго работающие приложения».

9. Как проверять — безопасно пробовать завершение

Обработка завершения склонна становиться «написали, но ни разу не пробовали в условиях, эквивалентных продакшену». Держите процедуру, чтобы проверять это безопасно.

  • Пробуйте на тестовой машине или ВМ: не пробуйте сначала на продакшен-ПК устройства. В тестовой среде с контрольной точкой Hyper-V (снимок) повторяйте завершение, перезапуск и принудительную потерю питания (выключение ВМ). «Выключение» ВМ, однако, воспроизводит только «гостевая ОС останавливается без предупреждения»; оно не воспроизводит исчезновение энергозависимой кэш-памяти физического диска или поломку, зависящую от контроллера. Если поставляете как ПК устройства, финальная проверка — реальный тест отключения питания на железе, эквивалентном продакшену
  • Быстрая проверка выходом: путь WM_QUERYENDSESSION → WM_ENDSESSION идёт и при выходе (единственная разница — в lParam установлен бит ENDSESSION_LOGOFF), поэтому поведение кода очистки удобно подтвердить на машине разработки1
  • Пробуйте полное завершение и гибридное отдельно: пробуйте shutdown /s /t 0 (полное), shutdown /s /hybrid /t 0 (поведение по умолчанию) и shutdown /r /t 0 (перезапуск) по отдельности2
  • Измерьте, сколько занимает очистка: пишите метку времени в лог в начале и конце функции очистки и измеряйте, укладывается ли она в 5 секунд (или настроенный льготный срок службы)
Операции для проверки и что каждая может подтвердитьВыход — удобная проверка пути уведомления; полное, гибридное и перезапуск командой подтверждают продакшен-путь уведомления и льготный срок; выключение ВМ проверяет устойчивость к внезапной остановке; физический тест отключения питания — финальная проверка включая физическое хранилищеВыходПуть QUERY → ENDSESSIONshutdown /s /hybrid /rПродакшен-путь + льготаВыключение ВМВнезапная остановка гостяФизическое отключениеХранилище включено(финал)

Рис. 14: Выход — удобная проверка пути уведомления; полное, гибридное и перезапуск командой подтверждают продакшен-путь уведомления и льготный срок; выключение ВМ проверяет устойчивость к внезапной остановке; физический тест отключения питания — финальная проверка включая физическое хранилище.

Для последующей изоляции полезен журнал событий (Система). При обычном завершении или перезапуске записывается событие с кодом 1074 (какой процесс начал завершение, для кого и по какой причине). При внезапной потере питания или сбое 1074 нет, и при следующей загрузке записываются событие с кодом 41 (Kernel-Power) и 6008 (The previous system shutdown was unexpected).14 «Что случилось ночью» начинается отсюда.

# Check the recent history of shutdown-related events
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Id = 1074, 6008, 41 } -MaxEvents 20 |
    Select-Object TimeCreated, Id, ProviderName, Message |
    Format-List

Если 1074 показывает «перезапуск Windows Update», а данные приложения были сломаны, проблема — код очистки. 6008/41 показывают только «неожиданное завершение»; их записывают и для синего экрана (сбоя), и для принудительного сброса, не только для потери питания. Если BugcheckCode у 41 ненулевой — это сбой; если 0 и дампа памяти тоже нет, вероятна потеря питания — изолируйте причину по окружающим сведениям, и когда узнаете, что это была потеря питания, проект записи главы 8 и ИБП — следующий шаг.

10. Итоги

  • Завершение — «нормальное событие, которое придёт рано или поздно». Льготный срок после уведомления в принципе всего около 5 секунд, поэтому предпосылка — частое автосохранение, чтобы «то, что делаете на выходе» было минимизировано.
  • На клиентских ОС начиная с Windows 8, если быстрый запуск включён, «Завершить работу» — hybrid shutdown, и ядро только гибернирует. Полный сброс только у «Перезагрузить» — пишите «Перезагрузить» в процедуру инцидента.
  • GUI-приложение сразу возвращает TRUE на WM_QUERYENDSESSION и делает подтверждённую очистку в WM_ENDSESSION. FormClosing и SessionEnding WinForms/WPF соответствуют фазе запроса, поэтому там — не больше идемпотентного сохранения снимка. Не выводите диалог во время завершения.
  • Операцию, которую действительно нельзя прервать, защищают, показывая причину через ShutdownBlockReasonCreate. Однако нигде нет гарантии, что удастся заблокировать.
  • Консольное приложение принимает уведомление через SetConsoleCtrlHandler; служба — через SERVICE_ACCEPT_PRESHUTDOWN/SHUTDOWN. Льготный срок PRESHUTDOWN по умолчанию на текущей ОС — 10 секунд. В .NET перестаньте полагаться на ProcessExit и перейдите на канонический путь модели приложения.
  • Восстановление после перезапуска можно сделать необслуживаемым через RegisterApplicationRestart (+ обратный вызов восстановления) и ARSO.
  • Потеря питания не несёт уведомления. Готовьтесь обменом временный файл + ReplaceFile (комплектом с резервом + проверкой при загрузке), сбросом на контрольных точках и ИБП, который «превращает потерю питания в запланированное завершение».
Общая картина обработки завершенияНа окончание с уведомлением отвечайте очисткой, которая может закрыть лавку за несколько секунд, и ведите к автоматическому восстановлению после перезапуска; на потерю питания без уведомления готовьтесь записью, которая не ломается, когда бы ни обрезали, и ИБП, и проверяйте включая на физическом железе. Эти два столпа — вывод статьиС уведомлениемБез уведомленияКак заканчиваетсяОчистка за секунды(3–6)Безопасная запись + ИБП(8)Автовосстановление после перезапуска(7)Проверить на железе(9)

Рис. 15: На окончание с уведомлением отвечайте очисткой, которая может закрыть лавку за несколько секунд, и ведите к автоматическому восстановлению после перезапуска; на потерю питания без уведомления готовьтесь записью, которая не ломается, когда бы ни обрезали, и ИБП, и проверяйте включая на физическом железе. Эти два столпа — вывод статьи.

  • Проверяйте безопасно на ВМ и выходом и изолируйте постфактум по кодам событий 1074/41/6008.

В следующий раз, когда добавляете функцию в приложение, один раз спросите себя: если посреди этой работы придёт WM_ENDSESSION или выдернут питание, что останется при следующем запуске? Записать этот ответ в проект — кратчайший путь никогда не провести утро стоя перед ПК устройства, держась за голову.

Похожие статьи

Смежные области консультирования

KomuraSoft LLC занимается проектированием и реализацией контрмер завершения и потери питания для ПК устройств и долго работающих приложений, расследованием первопричин повреждения данных и инцидентов «к утру уже стояло», которые начинаются с перезапуска Windows Update или выхода, и ревью проекта обработки остановки служб Windows и автоматического восстановления. Можно начать со стадии «каждый раз, когда выключаем, что-то вроде ломается, и я не знаю, с чего начать».

Справочные ссылки

  1. Microsoft Learn, WM_QUERYENDSESSION message. О том, что WM_QUERYENDSESSION отправляется в конце сеанса и приложение должно вернуть TRUE и уважить намерение пользователя (у DefWindowProc по умолчанию тоже TRUE); о том, что очистку следует отложить до WM_ENDSESSION; о том, что через 5 секунд система показывает UI для приложений, препятствующих завершению, и пользователь может принудительно завершить; о смысле битов ENDSESSION_LOGOFF/CLOSEAPP/CRITICAL в lParam; о том, что завершение и перезапуск неразличимы; и о том, что данные следует часто сохранять, чтобы меньше пришлось сохранять на выходе.  2 3 4 5 6 7

  2. Microsoft Learn, Fast startup causes hibernation or shutdown to fail in Windows 10 or Windows 8.1. О том, что при быстром запуске сеанс ядра не закрывается и трактуется как гибернация, а состояние ядра и драйверов устройств сохраняется в hiberfil.sys; о том, что «Перезагрузить» всегда делает полную загрузку, потому что нужно совершенно новое состояние Windows; о том, что быстрый запуск включён по умолчанию и отключать его не рекомендуется; и о том, что у Shutdown.exe по умолчанию полное завершение, а параметр /hybrid даёт гибридное поведение.  2 3 4 5

  3. Microsoft Learn, ShutdownBlockReasonCreate function (winuser.h). О том, что её вызывают в начале непрерываемой операции, чтобы зарегистрировать строку причины, и вызывают ShutdownBlockReasonDestroy по окончании; о том, что вызывать можно только из потока, создавшего окно; и о том, что пользователь прочитает причину только несколько секунд, поэтому строка должна быть короткой и ясной.  2 3

  4. Microsoft Learn, Shutdown Changes for Windows Vista. О том, что ответ на WM_QUERYENDSESSION/WM_ENDSESSION можно задержать на 5 секунд каждый, после чего пользователь выбирает продолжить или отменить; о том, что консольное приложение или приложение без видимого окна не может прервать завершение и автоматически завершается через 5 секунд без ответа или при ответе FALSE; о том, что если нужна блокировка, причину следует зарегистрировать через ShutdownBlockReasonCreate; и о том, что приложение не должно зависеть от возможности заблокировать завершение.  2 3 4

  5. Microsoft Learn, HandlerRoutine callback function. События CTRL_C/BREAK/CLOSE/LOGOFF/SHUTDOWN, принимаемые обработчиком, зарегистрированным через SetConsoleCtrlHandler; о том, что тайм-аут по умолчанию для CTRL_CLOSE_EVENT — около 5000 миллисекунд, а для CTRL_SHUTDOWN_EVENT у процесса службы — около 20000 миллисекунд; о том, что CTRL_LOGOFF/SHUTDOWN_EVENT по сути принимают только службы, потому что интерактивное приложение завершают при выходе; и о том, что обработчик идёт на отдельном потоке.  2 3

  6. Microsoft Learn, SetConsoleCtrlHandler function. О том, что процесс, загрузивший gdi32.dll или user32.dll, трактуют как Windows-приложение и обработчики CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT не вызываются; о том, что обходной путь — создать скрытое окно и обрабатывать WM_QUERYENDSESSION/WM_ENDSESSION; и о том, что консольные функции могут работать некорректно во время обработки сигнала.  2

  7. Microsoft Learn, .NET runtime no longer provides default termination signal handlers. О том, что начиная с .NET 10 среда больше не предоставляет обработчик по умолчанию для Windows CTRL_SHUTDOWN_EVENT/CTRL_CLOSE_EVENT (эквиваленты Unix SIGTERM/SIGHUP); о том, что обработка ОС по умолчанию сразу завершает приложение и AppDomain.ProcessExit и AssemblyLoadContext.Unloading больше не срабатывают; и о том, что обработку сигналов, уместную для модели приложения, следует регистрировать в библиотеке более высокого уровня или в коде приложения.  2

  8. Microsoft Learn, Service Control Handler Function. О том, что служба, объявившая SERVICE_ACCEPT_PRESHUTDOWN, сначала принимает SERVICE_CONTROL_PRESHUTDOWN, затем служба SERVICE_ACCEPT_SHUTDOWN принимает SERVICE_CONTROL_SHUTDOWN; о том, что льготный срок по умолчанию при завершении — около 20 секунд, а потолок при перезапуске ОС — WaitToKillServiceTimeout; о том, что это значение не следует продлевать; о том, что обработчик управления должен вернуться за 30 секунд, сообщить STOP_PENDING и wait hint и оставить долгую работу другому потоку; о том, что очистку следует закончить как можно быстрее с учётом работы от ИБП; и о том, что SCM при завершении по умолчанию не учитывает зависимости.  2 3 4 5

  9. Microsoft Learn, SERVICE_PRESHUTDOWN_INFO structure (winsvc.h). О том, что после уведомления PRESHUTDOWN SCM ждёт, пока служба остановится или истечёт тайм-аут; о том, что тайм-аут по умолчанию — 10 секунд начиная с Windows 10 Creators Update (сборка 15063) и 3 минуты до того; о том, что он настраивается через ChangeServiceConfig2; и о том, что состояние можно продолжать обновлять во время SERVICE_STOP_PENDING.  2

  10. Microsoft Learn, RegisterApplicationRestart function (winbase.h). О том, что перезапуск можно зарегистрировать для сбоя, «не отвечает», обновления и перезапуска компьютера, сопровождающего обновление; о том, что можно указать аргументы командной строки для перезапуска; о том, что регистрацию нужно сделать до того, как случится проблема, и обработка WM_QUERYENDSESSION — последний шанс в сценарии обновления; о том, что процесс меньше 60 секунд работы не перезапускают; о том, что перезапуск после сбоя или зависания идёт через согласие пользователя; и о том, что пересечение перезапуска ОС требует завершения с EWX_RESTARTAPPS/SHUTDOWN_RESTARTAPPS.  2 3

  11. Microsoft Learn, Winlogon automatic restart sign-on (ARSO). О том, что когда Windows Update начинает автоматический перезапуск, он сохраняет учётные данные последнего интерактивного пользователя и настраивает Autologon; о том, что после перезапуска автоматически входит пользователь и блокирует сеанс; о том, что сохранённые учётные данные удаляются после успешного входа; и о том, что это можно настроить групповой политикой (DisableAutomaticRestartSignOn и подобное).  2

  12. Microsoft Learn, ReplaceFileW function (winbase.h). О том, что ReplaceFile упаковывает в одну функцию несколько шагов, эквивалентных «сохранить в новый файл, временно переименовать исходный, переименовать новый, удалить исходный»; о том, что она сохраняет атрибуты исходного файла вроде времени создания, DACL, шифрования, сжатия и именованных потоков; и о том, что резерв, заменяемый файл и файл замены должны быть на одном томе.  2

  13. Microsoft Learn, File Caching. О том, что записи по умолчанию идут в системный кэш и отражаются на диск ленивой записью; о том, что FILE_FLAG_WRITE_THROUGH пишет сразу на диск; о том, что FlushFileBuffers может явно сбросить; и о том, что метаданные файловой системы всегда кэшируются, поэтому подтверждение метаданных требует сброса или write-through.  2 3

  14. Microsoft Learn, Troubleshoot unexpected reboots using system event logs. О том, что обычный перезапуск записывает событие с кодом 1074 (какой процесс начал завершение, для кого и по какой причине); о том, что неожиданный перезапуск записывает событие с кодом 41 (Kernel-Power) и 6008 (the previous shutdown was unexpected); и о том, что эти коды позволяют изолировать вид перезапуска.  2

  15. Microsoft Learn, PBT_APMPOWERSTATUSCHANGE event. О том, что это событие уведомляют через WM_POWERBROADCAST при переходе между батареей и сетью или падении остатка; и о том, что по получении следует вызвать GetSystemPowerStatus и проверить поля SYSTEM_POWER_STATUS вроде ACLineStatus, BatteryFlag и BatteryLifePercent. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Приложения, которые ломаются после выхода из сна — как работают события питания Windows и как строить бизнес-приложения, которые это переживают

Открыли ноутбук — и соединения бизнес-приложения мертвы: причина в проекте, который не учитывал сон. Статья разбирает поток уведомлений W...

Что на самом деле значит «Не отвечает» — как Windows решает, что приложение зависло, и как проектировать приложения, которые не зависают

«Не отвечает» в Windows — механизм, в котором ОС судит, что окно не извлекало сообщение 5 секунд, и подменяет его окном-призраком. Статья...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Проблема, которая не ушла после «Завершить работу», ушла после «Перезагрузить». Почему?
На клиентских ОС начиная с Windows 8, когда быстрый запуск включён (по умолчанию на большинстве ПК, которые поддерживают гибернацию), «Завершить работу» использует механизм под названием hybrid shutdown. Пользователь выходит из системы, но состояние ядра и драйверов сохраняется в файл гибернации и восстанавливается как есть при следующей загрузке. Иными словами, ядро ОС не сброшено. «Перезагрузить», напротив, всегда выполняет полную загрузку, поэтому неполадки драйверов и служб сбрасываются. Пишите «Перезагрузить» в процедуру изоляции, а не «выключить и снова включить». Если нужна полная остановка из командной строки, можно использовать shutdown /s.
Можно ли остановить завершение работы, пока приложение не закончит сохранение?
Можно попросить подождать временно, но надёжно остановить нельзя. Если зарегистрировать строку причины через ShutdownBlockReasonCreate только пока идёт непрерываемая операция, эта причина появляется на экране «Это приложение препятствует завершению работы», и пользователь решает, продолжить или отменить. Пользователь всё равно может выбрать принудительное продолжение, а принудительное завершение или перезапуск по обновлению могут не ждать вовсе. Правильный подход поэтому не «блокировать», а частое автосохранение, чтобы меньше данных было под риском, плюс очистка, спроектированная так, чтобы уложиться в несколько секунд от уведомления о выходе.
Остановка моей службы Windows занимает много времени. Можно ли продлить льготный срок завершения?
В конфигурации по умолчанию, которая принимает SERVICE_CONTROL_SHUTDOWN, льготный срок — примерно 20 секунд и зависит от значения реестра WaitToKillServiceTimeout. Переписывать это значение со стороны приложения, чтобы продлить его, не рекомендуется. Если нужен более длинный льготный срок, можно объявить SERVICE_ACCEPT_PRESHUTDOWN и принимать SERVICE_CONTROL_PRESHUTDOWN; вас уведомляют раньше остальных, а тайм-аут настраивается через ChangeServiceConfig2 (по умолчанию 10 секунд начиная с Windows 10 Creators Update и 3 минуты до того). PRESHUTDOWN, однако, удерживает всё завершение на этот интервал, поэтому ограничивайте его случаями, которые действительно нужны, и в основе проектируйте саму работу остановки так, чтобы она укладывалась в несколько секунд.
Безопасно ли делать очистку при завершении в AppDomain.ProcessExit .NET?
Рекомендую на это не полагаться. Исторически среда выполнения регистрировала обработчик сигналов по умолчанию, и ProcessExit срабатывал на CTRL_CLOSE_EVENT и CTRL_SHUTDOWN_EVENT, но начиная с .NET 10 среда больше не предоставляет обработчики сигналов завершения по умолчанию, и ProcessExit в этих случаях больше не срабатывает. Реализуйте очистку на пути уведомления, который соответствует модели приложения: GUI-приложения используют FormClosing или SessionEnding (это уведомления фазы запроса, поэтому ограничивайте их идемпотентными сохранениями; очистка, которая может идти только после того, как сеанс подтверждён, принадлежит хуку WM_ENDSESSION); Generic Host / Worker Service используют IHostApplicationLifetime и StopAsync; консольные приложения — SetConsoleCtrlHandler или PosixSignalRegistration.
Как не дать файлам повредиться от внезапной потери питания?
Потеря питания не несёт никакого уведомления, поэтому единственный вариант — писать так, чтобы не ломалось, когда бы ни обрезали питание. Базовая схема — не перезаписывать исходный файл на месте: полностью записать во временный файл на том же томе, сбросить и обменять через ReplaceFile (File.Replace в .NET). В обычной работе это оставляет возможность прочитать либо полный старый файл, либо полный новый, но атомарность ReplaceFile через потерю питания спецификацией не гарантируется, поэтому держите резервную копию (третий аргумент) и реализуйте восстановление при загрузке, которое проверяет основной файл и откатывается на резерв, если он сломан. Кроме того, успех WriteFile не значит, что данные дошли до диска, поэтому на важных контрольных точках подтверждайте запись через FlushFileBuffers или FILE_FLAG_WRITE_THROUGH. На ПК устройств стандартная схема — сочетать это с ИБП, обнаруживать переход на батарею и вести к безопасному завершению.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог