Глубины ввода-вывода Windows (часть 2) — синхронный и асинхронный ввод-вывод: что на самом деле означает OVERLAPPED

· Обновлено: · · Windows, Win32, I/O, Асинхронный ввод-вывод, OVERLAPPED, Ядро, .NET, C#

В прошлый раз (часть 1) мы увидели, что запрос ввода-вывода в Windows превращается в пакет под названием IRP, который проходит через стек устройств, и что выдача запроса и его завершение в ядре разделены. На этот раз мы разберём асинхронный ввод-вывод (overlapped I/O), с помощью которого приложение пользуется этим разделением.

Вы указали FILE_FLAG_OVERLAPPED, а вызов всё равно заставляет ждать. Вы использовали OVERLAPPED повторно, и данные испортились. Вы освободили буфер сразу после отмены, и процесс упал. Ключ к пониманию всех трёх случаев — одно разделение обязанностей: режим принадлежит дескриптору, состояние — каждой операции, а уборка делается только после подтверждения завершения.

В статье мы последовательно пройдём весь путь: открыть файл, выдать ввод-вывод, получить результат, выполнить уборку. Разобравшись с механизмом Win32, мы проверим, куда подключаются FileStream, ReadAsync и CancellationToken из .NET.

Это вторая часть серии «Глубины ввода-вывода Windows». Общая структура изложена в начале части 1.

1. Сначала вывод: три различия, которые легко спутать

Асинхронный ввод-вывод становится непонятным, если судить о нём только по названиям API. Сначала отделите то, что вы настраиваете, от момента, в который определяется завершение.

Легко спутать Как их различить
Режим дескриптора и состояние операции Синхронный или асинхронный режим определяется в момент CreateFile. OVERLAPPED хранит состояние одной операции, выданной на этот дескриптор
Результат выдачи и приём завершения ERROR_IO_PENDING — не сбой, а принятие. TRUE означает синхронное завершение, но по умолчанию приходит и уведомление. Не обрабатывайте результат в обоих местах
Запрос отмены и момент, когда допустима уборка CancelIoEx — это запрос отмены. Структуру и буфер освобождают только после подтверждения завершения этой операции

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

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

Если цель уже ясна, начинайте с приведённого ниже указателя.

Что нужно узнать или что не получается Где читать в первую очередь
Чем синхронный ввод-вывод отличается от асинхронного Глава 2: как устроено ожидание, раздел 3.1: режим дескриптора
Данные портятся при использовании OVERLAPPED, падение после выхода из функции Раздел 3.2: состояние и время жизни для каждой операции
ReadFile возвращает FALSE, двойная обработка при синхронном завершении Раздел 3.3: три ветви результата выдачи
Нужно выбрать способ приёма завершения, либо обратный вызов не приходит Глава 4: сравнение способов уведомления, раздел 4.3: как ждать APC
Ввод-вывод сделали асинхронным, а вызов всё равно ждёт Глава 5: условия синхронного завершения и отзывчивость
Отмена не действует, падение после отмены Глава 6: уборка только после подтверждения завершения
Используете ReadAsync, а число потоков всё равно растёт Глава 7: сочетание дескриптора и API в .NET

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 36, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Синхронный ввод-вывод: поток, ожидающий завершения, спит, не расходуя CPU

2.1. Ожиданием завершения занимается диспетчер ввода-вывода

Дескриптор, открытый без FILE_FLAG_OVERLAPPED, находится в синхронном режиме. ReadFile не возвращается, пока ввод-вывод не завершится.1

Когда драйвер приостанавливает запрос (переводит его в pending) в ожидании ответа оборудования, диспетчер ввода-вывода дожидается завершения и только затем возвращает управление приложению. Поток приложения всё это время ждёт внутри ядра.

Драйвер (стек)Диспетчер ввода-выводаПоток приложенияДрайвер (стек)Диспетчер ввода-выводаПоток приложенияПоток переходит в состояние ожидания внутри ядраи спит, не расходуя CPUReadFile(синхронный дескриптор)Выдать IRPSTATUS_PENDING (ожидание ответа)Завершение (IoCompleteRequest)Вернуть результат и разбудить потокReadFile возвращает TRUE или FALSE

Рис. 1: Синхронный ввод-вывод для запроса, который был приостановлен. ReadFile возвращается только после ожидания завершения

Впрочем, синхронный ввод-вывод не всегда усыпляет поток. Запрос, который можно выполнить на месте, например при попадании в кэш, возвращает результат вообще без ожидания (путь «немедленного завершения» на рис. 5 в части 1). Гарантируется лишь то, что вызов не вернётся до завершения.

2.2. Не расходовать CPU и иметь возможность делать другую работу — разные вещи

Поток в состоянии ожидания исключается из набора потоков, готовых к выполнению, поэтому CPU он не расходует. Почему ожидание события лучше собственного опроса, объясняется и в статье «Почему в Windows стоит предпочитать ожидание события вместо Sleep(1)».

С другой стороны, ожидающий поток не может делать другую работу. Если это поток UI, экран замирает; если сервер выделяет поток на каждое соединение, несколько сотен соединений означают несколько сотен потоков. Слабое место синхронного ввода-вывода — не загрузка CPU, а то, что поток недоступен до завершения.

В синхронном режиме ядро ведёт также указатель файла (текущую позицию). Поэтому последовательные вызовы ReadFile читают «с того места, где остановился предыдущий». Позиция принадлежит файловому объекту за дескриптором, поэтому дескрипторы, продублированные через DuplicateHandle, делят одну позицию (часть 1, раздел 3.3).

Есть также CancelSynchronousIo, который запрашивает отмену синхронного ввода-вывода, выполняющегося в другом потоке. Когда использовать его, а когда API для асинхронного ввода-вывода, разбирается в главе 6.5

3. Подготовка и выдача асинхронного ввода-вывода: разделяем режим, состояние и возвращаемое значение

3.1. Асинхронный режим определяется при открытии файла

Передача FILE_FLAG_OVERLAPPED в CreateFile переводит файловый объект за дескриптором в асинхронный режим. Режим не переключается вызов за вызовом. Один и тот же файл можно открыть дважды — дескриптор для синхронной работы и дескриптор для асинхронной, и тогда файловых объектов тоже будет два.1

В асинхронном режиме система не ведёт указатель файла. Поскольку несколько операций могут выполняться одновременно, для файла на диске позицию чтения и записи задают каждый раз через OVERLAPPED.Offset / OffsetHigh. На устройствах, у которых нет позиции поиска, таких как последовательные порты и именованные каналы, эта позиция не используется, и её оставляют нулевой. Даже когда позиция не задаётся, выделенная под операцию структура OVERLAPPED всё равно нужна.6

И наоборот: передача OVERLAPPED синхронному дескриптору не делает его асинхронным. Чтение идёт с позиции из Offset, но блокировка до завершения никуда не уходит. Важно не то, передали ли вы структуру, а то, в каком режиме был открыт дескриптор.6

3.2. Одна структура OVERLAPPED и один буфер на одну операцию

OVERLAPPED — это структура, которая идентифицирует выданную операцию и несёт её позицию, состояние и результат. Если рассматривать её как «накладную на одну операцию», разделение обязанностей с дескриптором становится понятным.3

Член Роль
Offset / OffsetHigh Позиция в файле, которую читает или пишет эта операция (задаётся при выдаче; на устройствах без позиции не используется)
hEvent Событие, сигнализируемое при завершении (необязательно; рекомендуется событие с ручным сбросом)
Internal Состояние операции. До завершения содержит эквивалент STATUS_PENDING (для системного использования)
InternalHigh Число переданных байт при завершении (для системного использования)
Структура OVERLAPPED = накладная на одну операциюOffset: откуда читатьhEvent: как узнать о завершенииInternal/InternalHigh:состояние и результат (записывает система)Дескриптор (файловый объект) = режимСинхронный режимядро ведёт текущую позициюReadFile не возвращается до завершенияАсинхронный режим (FILE_FLAG_OVERLAPPED)текущая позиция не ведётсявыдача и завершение разделеныОпределяется один раз, в момент CreateFileГотовится заново на каждый вызов ReadFile/WriteFile

Рис. 2: Дескриптор хранит режим, а OVERLAPPED — позицию и состояние каждой операции

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

Кроме того, до завершения операции структура и буфер данных должны оставаться действительными: их нельзя изменять, переиспользовать или освобождать. Ядро всё ещё работает с этой областью памяти. Если выдать операцию со структурой OVERLAPPED, лежащей в локальной переменной, и выйти из функции, пока операция не завершена, ядру достанется область стека с истёкшим временем жизни.32

Когда структура используется повторно после подтверждения завершения, её инициализируют заново, чтобы состояние предыдущей операции не сохранилось. Если выбран способ с событием, для hEvent используют событие с ручным сбросом. Как это связано со способом ожидания, объясняется в разделе 4.2.3

3.3. Делим возвращаемое значение ReadFile на три случая и решаем, где что обрабатывать

Результат выдачи ReadFile на асинхронном дескрипторе определяется сочетанием возвращаемого значения и GetLastError(). Важно не считать любой FALSE сбоем.6

Возвращаемое значение ReadFile GetLastError() Значение Что делает вызывающий
TRUE (не проверяется) Завершилось на месте (синхронное завершение) По умолчанию уведомление о завершении тоже приходит отдельно. Обработку результата оставляем стороне уведомления
FALSE ERROR_IO_PENDING (997) Принято; выполняется Ничего не делать. Ждать уведомления о завершении, не трогая OVERLAPPED и буфер
FALSE Любое другое Сама выдача не удалась Уведомление о завершении не придёт. Обработать ошибку на месте и выполнить уборку OVERLAPPED и буфера
ReadFile(асинхронный дескриптор, с OVERLAPPED)Каково возвращаемое значение?TRUEзавершилось на месте (синхронное завершение)по умолчанию уведомление о завершении тоже приходитFALSE + ERROR_IO_PENDINGпринято. О завершении сообщат позжеFALSE + другая ошибкасама выдача не удаласьЖдать уведомления о завершении(четыре способа из главы 4)

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

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

// C++ / Win32
// hFile : дескриптор, открытый с FILE_FLAG_OVERLAPPED
// ov    : структура OVERLAPPED, выделенная только под эту операцию (Offset и hEvent уже заданы)
// buf/len: буфер только для этой операции. Не освобождать до прихода уведомления о завершении
DWORD IssueRead(HANDLE hFile, OVERLAPPED* ov, BYTE* buf, DWORD len)
{
    // При асинхронной выдаче в lpNumberOfBytesRead передаётся NULL,
    // а число переданных байт принимают через GetOverlappedResult после завершения
    if (ReadFile(hFile, buf, len, nullptr, ov))
    {
        // (1) Синхронное завершение. По умолчанию уведомление тоже придёт, поэтому результат здесь не обрабатываем
        return ERROR_SUCCESS;
    }

    DWORD err = GetLastError();
    if (err == ERROR_IO_PENDING)
    {
        // (2) Принято. Ждём уведомления о завершении, не трогая ov и buf
        return ERROR_IO_PENDING;
    }

    // (3) Неудача самой выдачи. Уведомление не придёт, поэтому уборку здесь выполняет вызывающий
    return err;
}

ERROR_IO_PENDING — это результат «принято, но ещё не завершено». Его нельзя убирать как обычную ошибку. С другой стороны, синхронное завершение с возвратом TRUE — тоже нормальный путь, поэтому его обязательно обрабатывают. Почему происходит синхронное завершение, объясняется в главе 5.

Результат обрабатывают ровно один раз. По умолчанию даже для синхронно завершившейся операции, если дескриптор связан с IOCP, в очередь кладётся пакет завершения, а при способе с событием событие сигнализируется. Если обработать результат и сразу после TRUE, и при получении уведомления, одна и та же операция будет обработана дважды, и структура может быть освобождена дважды. Безопасная базовая схема — свести оба пути, TRUE и ERROR_IO_PENDING, к обработке результата на стороне уведомления.1

В отличие от этого, на пути, где не удалась сама выдача, обработку ошибки и уборку выполняет тот, кто выдал операцию. Если отправить её в ожидание, когда уведомление не придёт, ожидание станет вечным.

Есть и оптимизация, пропускающая уведомление IOCP при синхронном завершении, но это другая схема, отличная от поведения по умолчанию. Область применения FILE_SKIP_COMPLETION_PORT_ON_SUCCESS разбирается отдельно в разделе 5.3.7

4. Выбор способа уведомления о завершении: решают число операций и поток, который их обрабатывает

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

Способ Поток, в котором выполняется обработка завершения Сколько операций можно держать в работе одновременно Для чего подходит
(1) Сигнал дескриптора Любой поток, который ждал Фактически одна. Если операций несколько, нельзя понять, какая завершилась Практически нигде (4.1)
(2) Событие + GetOverlappedResult Любой поток, который ждал По одному событию на операцию. При ожидании через WaitForMultipleObjects предел — 64 До нескольких одновременных операций. Обмен с устройствами (4.2)
(3) APC (ReadFileEx) Поток, выдавший операцию, и только пока он находится в alertable wait Ограничений по количеству нет, но вся обработка завершения идёт последовательно в этом одном потоке Логика обмена, которую хотят замкнуть на один поток (4.3)
(4) Порт завершения ввода-вывода Пул рабочих потоков, привязанных к порту Многие операции можно обслужить немногими потоками Серверы, пулы потоков (4.4)
Ввод-вывод завершился в ядре(IoCompleteRequest → APC фиксирует результат)(1) Дескриптор файла переходит в сигнальное состояниеприём: WaitForSingleObject(дескриптор)(2) hEvent в OVERLAPPED переходит в сигнальное состояниеприём: WaitForSingleObject + GetOverlappedResult(3) Завершающая процедура попадает в очередь APC потока-отправителяприём: выполняется во время alertable wait, например SleepEx(4) Пакет завершения попадает в порт завершения ввода-выводаприём: GetQueuedCompletionStatus (часть 3)

Рис. 4: Четыре пути уведомления о завершении. Способ приёма зависит от того, как была выдана операция

4.1. Сигнал дескриптора: операции не различить

Если выдать операцию, не указав hEvent, то при завершении сигнальное состояние получит сам дескриптор файла. Однако если на одном дескрипторе выполняется несколько операций, понять, какая из них завершилась, невозможно.1

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

4.2. События и GetOverlappedResult: базовая схема для нескольких одновременных операций

В OVERLAPPED.hEvent каждой операции задают событие с ручным сбросом и выдают операцию. После ожидания через WaitForSingleObject успех или сбой и число переданных байт получают через GetOverlappedResult. Чтобы ждать несколько событий сразу, используют WaitForMultipleObjects, но одновременно ждать можно не более 64.18

Если задать TRUE в bWait у GetOverlappedResult, можно дождаться завершения и затем забрать результат. Если при этом использовать событие с автоматическим сбросом, то после того как другое ожидание потребит сигнал, GetOverlappedResult может продолжить ждать. Событие с ручным сбросом применяют именно для того, чтобы избежать этой проблемы с ожиданием сигнала.83

Для надёжной работы с несколькими одновременными операциями это наглядный и понятный способ. Он применяется и в логике последовательного порта «читать во время записи». Практический пример см. в статье «Подводные камни приложений последовательной связи».

4.3. APC: ждать в alertable wait до завершения, оставаясь в том же потоке

ReadFileEx / WriteFileEx — это способ, в котором задаётся завершающая процедура (обратный вызов). Когда ввод-вывод завершается, процедура попадает в очередь APC потока, выдавшего операцию. Она выполняется, когда этот поток входит в alertable wait через SleepEx, WaitForSingleObjectEx и подобные вызовы.91011

Поскольку обработка завершения идёт последовательно в одном и том же потоке, логика, замкнутая на один поток, может обойтись без блокировок. С другой стороны, если выдавший поток не войдёт в alertable wait, завершающая процедура не выполнится. Совместное использование с циклом сообщений UI требует MsgWaitForMultipleObjectsEx, так что проектировать ожидание становится сложнее. В универсальных случаях чаще выбирают события или IOCP.

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

// C++ / Win32. hFile — дескриптор, открытый с FILE_FLAG_OVERLAPPED,
// ov и buf должны оставаться живыми до завершения (раздел 3.2)

// Плохой пример: завершающая процедура не будет вызвана никогда
ReadFileEx(hFile, buf, len, ov, OnReadCompleted);
Sleep(1000);            // это не alertable-ожидание. APC не доставляется

// Хороший пример: ждать в alertable-режиме, пока этот ввод-вывод не закончится
//
// Завершающая процедура выставляет этот флаг (храните его, например, в структуре, которая содержит ov)
volatile bool completed = false;

// Обязательно проверяем, удалась ли выдача. Если вернулся 0, завершающая процедура не поставлена в очередь
if (!ReadFileEx(hFile, buf, len, ov, OnReadCompleted))
{
    const DWORD err = GetLastError();   // берём сразу. Последующие API её перезапишут
    ReportError(err);                   // устройство извлечено, недопустимый дескриптор и т. п.
    return;                             // ВНИМАНИЕ: в цикл ожидания ниже входить нельзя
}

while (!completed)
{
    DWORD r = SleepEx(1000, TRUE);   // TRUE во втором аргументе означает alertable
    if (r == WAIT_IO_COMPLETION)
    {
        // Выполнилась какая-то APC. Но это не обязательно ваш ввод-вывод,
        // поэтому судим по completed и, если это не он, ждём снова
        continue;
    }
    // Вернулись по тайм-ауту. Ввод-вывод всё ещё в работе, поэтому
    // если вы отказываетесь от ожидания, отменяйте через CancelIoEx и ждите доставки завершения
    CancelIoEx(hFile, ov);
}

Если выдача не удалась, в ожидание не входят. Когда ReadFileEx вернула 0 из-за извлечения устройства, недопустимого дескриптора или по другой причине, завершающая процедура не поставлена в очередь. Немедленно получите GetLastError(), обработайте ошибку и выйдите. Упустите это — и completed не будет выставлен, а SleepEx и CancelIoEx будут бесконечно повторяться для несуществующего ввода-вывода.9

Тайм-аут ожидания не означает конец ввода-вывода. Когда SleepEx завершается по тайм-ауту, alertable wait прерывается, но выданный ввод-вывод может остаться в работе. Не выходите из области видимости, давая ov или buf истечь. Либо продолжайте ждать завершения, либо, если вы отказываетесь от ожидания, запросите отмену и ждите доставки этого завершения. Правило времени жизни из раздела 3.2 действует и после тайм-аута.

По одному WAIT_IO_COMPLETION не делайте вывода, что ваш ввод-вывод закончился. Это возвращаемое значение означает, что выполнилась одна или несколько APC. Если в тот же поток поставлены APC от другого ввода-вывода или от QueueUserAPC, возврат произойдёт и из-за них. Судите по флагу, который выставляет ваша завершающая процедура, и, если он ещё не выставлен, ждите снова.10

Когда «APC не приходит», проверяйте не только успешность выдачи, но и функцию ожидания. Не Sleep, а SleepEx(..., TRUE); не WaitForSingleObject, а WaitForSingleObjectEx(..., TRUE). Замыкающие Ex и TRUE в alertable-аргументе — вот две точки проверки.10

4.4. IOCP: много операций ввода-вывода на небольшом числе рабочих потоков

В случае порта завершения ввода-вывода (IOCP) дескриптор связывают с портом. Пакеты завершения попадают в очередь порта, а рабочие потоки извлекают их через GetQueuedCompletionStatus. Это механизм обработки множества одновременных операций небольшим числом потоков.12

Это же и путь, на котором держится асинхронный ввод-вывод в .NET. Как очередь уведомлений о завершении сочетается с управлением числом одновременно выполняющихся потоков, подробно разбирается в следующей части, третьей.

5. Исключение — синхронное завершение: «асинхронный» и «без ожидания» не одно и то же

5.1. Типичные условия завершения внутри вызова

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

ничего из перечисленногоВыдать ReadFile/WriteFile на асинхронном дескриптореСработало ли условие синхронного завершения?Запрос, который можно удовлетворить сразу(данные уже в кэше и т. п.)Файл со сжатием NTFS(сжатые файлы не обрабатываются асинхронно)Файл с шифрованием NTFS (EFS)Запись, увеличивающая длину файлаСразу возвращается TRUE= выполнение дошло до конца внутри вызоваВозвращается ERROR_IO_PENDING= действительно выполняется асинхронно

Рис. 5: Основные условия, при которых выданный асинхронно ввод-вывод завершается синхронно. Не путайте это со временем до возврата вызова

В документе Microsoft по устранению неполадок перечислены следующие причины.2

Условие Почему обработка синхронная и на что обратить внимание в коде
Запрос, который можно удовлетворить сразу, попадание в кэш Если данные есть в памяти, драйвер может завершить операцию на месте. Быстрое завершение — это нормально, но код, который всегда рассчитывает на ERROR_IO_PENDING, ломается
Чтение с активным кэшем, когда нужной страницы нет Кэш Windows реализован через отображение файла в память. Асинхронного механизма обработки ошибок страниц нет, поэтому запрос может обрабатываться синхронно
Файл со сжатием NTFS или шифрованием EFS Драйвер файловой системы преобразует доступ в синхронный
Запись, увеличивающая файл Запись, меняющая длину, становится синхронной

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

5.2. Ветвление результата выдачи и отзывчивость UI проектируются отдельно

Сначала нужно обработать все три ветви из раздела 3.3. TRUE тоже рассматривается как нормальный результат, а по умолчанию обработка результата сводится к стороне уведомления.

Однако корректно написанные ветви ещё не гарантируют отзывчивости. Поскольку сказать «раз ввод-вывод асинхронный, UI не замрёт» нельзя, нужна схема, в которой сама выдача ввода-вывода выносится с потока, который не должен останавливаться, на выделенный поток или в пул потоков. Связанная практика описана в статье «Практическое руководство: как максимально приблизиться к soft real-time на обычной Windows».

5.3. Пропуск уведомления при синхронном завершении — оптимизация только для IOCP

При высокочастотном вводе-выводе можно оптимизировать работу, пропуская уведомление при синхронном завершении. Включение FILE_SKIP_COMPLETION_PORT_ON_SUCCESS через SetFileCompletionNotificationModes переводит систему в режим, когда для операции, успешно завершившейся сразу, пакет завершения не кладётся в IOCP. Это настройка для случая, когда переходят к схеме обработки результата на месте, а не на стороне уведомления.7

Пропускается только пакет в IOCP: сигнализация OVERLAPPED.hEvent не подавляется. Не применяйте ту же оптимизацию к способу с событием. Смешивание пути уведомления по умолчанию и оптимизированного пути ведёт к двойной обработке из раздела 3.3 или к ожиданию уведомления, которое не придёт. Сочетание с IOCP разбирается в третьей части.

6. Отмена и завершение работы: запросить, подтвердить завершение, закрыть

6.1. Выбираем API под то, что отменяем

API отмены выбирают по операции и по потоку, который её выдал.4135

API Что отменяет и как это задаётся
CancelIoEx Запрашивает отмену незавершённого ввода-вывода на заданном дескрипторе независимо от того, какой поток его выдал. Если во втором аргументе OVERLAPPED, целью становится эта операция; если NULL — все операции дескриптора
CancelIo Отменяет только операции, выданные самим вызывающим потоком
CancelSynchronousIo Отменяет синхронный ввод-вывод, выполняющийся в указанном другом потоке

CancelIoEx появился в Vista. В случае асинхронного ввода-вывода сегодня нет причин сознательно использовать старый CancelIo с его ограничением по потоку-отправителю, поэтому за основу берут CancelIoEx.

6.2. Успех CancelIoEx не означает, что ввод-вывод закончился

CancelIoEx — это API, который запрашивает отмену незавершённых IRP, а не ждёт завершения операции. Успех означает лишь, что отмена запрошена. Операция, которая уже была на грани завершения, может завершиться нормально, потому что отмена не успела.14

ДрайверДиспетчер ввода-выводаПриложениеДрайверДиспетчер ввода-выводаПриложениеЗапросить отмену соответствующегонезавершённого IRP (пометить его)Если отменить ещё можно — прерываемесли операция почти завершена, она может завершиться нормальноОсвобождать OVERLAPPED и буфертолько после получения этого уведомленияCancelIoEx(дескриптор, OVERLAPPED)Вызов процедуры отменыIoCompleteRequest(STATUS_CANCELLED)Приходит уведомление о завершенииGetOverlappedResult сообщает ERROR_OPERATION_ABORTED

Рис. 6: Отменённая операция тоже сообщается как завершение. Уборка выполняется после этого подтверждения

Операция, которая действительно была отменена, возвращается в уведомлении о завершении как ERROR_OPERATION_ABORTED. И когда операция завершилась нормально, и когда она была отменена, структуру и буфер не освобождают до получения уведомления. Освободите их раньше — и ядро потеряет область, с которой ещё работает, что ведёт к повреждению памяти. Если после отмены возникает нарушение доступа, в первую очередь проверяйте именно это время жизни.414

6.3. Перед закрытием дескриптора соберите выданные операции

Базовая последовательность завершения работы: запросить отмену → дождаться завершения → закрыть дескриптор.

Как мы видели в части 1, закрытие последнего дескриптора запускает обработку cleanup, при которой отменяются незавершённые IRP. Однако если закрыть дескриптор, оставив выданные операции, управление уведомлениями о завершении и временем жизни буферов обычно разваливается. Не перекладывайте уборку на закрытие, а сначала разберитесь с незавершёнными операциями.

То же относится и к случаю, когда работу хотят прервать по тайм-ауту. ОС не принимает за приложение решение о критериях отказа, поэтому отмену после тайм-аута и порядок приёма завершения проектируют вместе. При способе с APC, как в разделе 4.3, alertable wait продолжают до доставки завершения.

7. Соответствие с .NET: смотрите не только на ReadAsync, но и на место открытия файла

7.1. Согласовываем режим дескриптора и вызываемый API

useAsync у FileStream, он же FileOptions.Asynchronous, соответствует FILE_FLAG_OVERLAPPED в Win32. Как и в таблице соответствий из части 1, в .NET тоже важен режим, выбранный при открытии файла.1516

данетawait fs.ReadAsync(...)Дескриптор в асинхронном режиме(FileOptions.Asynchronous)?Настоящий асинхронный ввод-выводвыдаётся эквивалент OVERLAPPED,а завершение через IOCP приходит в пул потоков (часть 3)Мнимая асинхронностьпоток из пула потоковберёт на себя синхронный Read и ждёт

Рис. 7: Даже у одного и того же ReadAsync путь обработки на стороне ОС зависит от режима дескриптора

Сочетание дескриптора и API Что происходит внутри
Асинхронный режим + ReadAsync / WriteAsync Сочетание, использующее асинхронный ввод-вывод ОС
Синхронный режим + ReadAsync / WriteAsync Поток из пула потоков берёт на себя синхронное чтение или запись: «мнимая асинхронность»
Асинхронный режим + синхронные Read / Write Появляются накладные расходы на ожидание завершения внутри

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

7.2. Сравним три способа создания FileStream

В примерах (A) и (B) ниже ReadAsync вызывается совершенно одинаково. Отличается только useAsync при открытии файла. (C) — пример для .NET 6 и новее, где дескриптор и позиция заданы явно.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

string path = @"C:\temp\data.bin";
byte[] buffer = new byte[4096];

// (A) Мнимая асинхронность. Если опустить useAsync или задать false, дескриптор откроется в синхронном режиме
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: false))
{
    // Вызывающий поток не блокируется, но за кулисами один поток из пула берёт на себя синхронный Read и ждёт
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (B) Настоящая асинхронность. useAsync: true напрямую соответствует FILE_FLAG_OVERLAPPED
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
                               bufferSize: 4096, useAsync: true))
{
    // Завершение через IOCP приходит в пул потоков (часть 3)
    await fs.ReadAsync(buffer, 0, buffer.Length);
}

// (C) .NET 6 и новее. Прямолинейная запись с явным указанием режима и смещения
using (SafeFileHandle handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                               options: FileOptions.Asynchronous))
{
    int read = await RandomAccess.ReadAsync(handle, buffer, fileOffset: 0);
}

При анализе существующего кода ищите не только места вызова ReadAsync / WriteAsync, но и места создания FileStream. File.OpenRead и короткие перегрузки вида new FileStream(path, FileMode.Open) открывают файл в синхронном режиме. Когда FileStream создаётся из SafeFileHandle, аргумент isAsync тоже приводят в соответствие с фактическим режимом дескриптора.

7.3. В RandomAccess дескриптор и смещение задаются явно

В .NET 6 внутренняя реализация FileStream была переписана целиком, и появились File.OpenHandle и RandomAccess. Это API, в которых работают напрямую с SafeFileHandle и передают позицию чтения-записи при каждом вызове.16

Форма из примера (C), где режим и fileOffset заданы явно, соответствует разделению обязанностей, описанному в этой статье: асинхронный дескриптор и OVERLAPPED.Offset для каждой операции.

7.4. Даже с CancellationToken отмена остаётся запросом

На дескрипторе в асинхронном режиме отмена файлового ввода-вывода через CancellationToken внутри приводит к CancelIoEx. Когда ReadAsync, которому передан токен, завершается исключением OperationCanceledException, за этим работает механизм из главы 6. Не гарантируется и немедленное прерывание — здесь всё так же.

У «мнимой асинхронности» в синхронном режиме нет операции перекрытия, которую можно отменить, поэтому этот путь недоступен. В современных средах выполнения .NET есть и механизм, пытающийся отменить синхронно выполняющийся вызов через CancelSynchronousIo, но поведение зависит от версии среды выполнения и вида операции, и надёжное прерывание не гарантируется. Если проектировать с расчётом на отмену, правильный путь — согласовать режим дескриптора и использовать асинхронный ввод-вывод ОС.

Практику уровня выше async/await, например ConfigureAwait и связь с потоком UI, см. в статьях «Практическая таблица решений по C# async/await: Task.Run и ConfigureAwait» и «async в WPF/WinForms и поток UI на одном листе». Эта статья объясняет, как под всем этим ОС продвигает чтение и запись.

8. Итог: от выдачи до уборки — одним непрерывным путём

При аудите асинхронного ввода-вывода код просматривают в таком порядке.

  1. На месте открытия проверьте синхронный или асинхронный режим. Для файла на диске позиция задаётся при каждой асинхронной операции.
  2. На месте выдачи проверьте, что есть OVERLAPPED и буфер, выделенные под операцию, и что обработаны все три ветви из раздела 3.3.
  3. На месте приёма завершения проверьте, что способ ожидания соответствует используемому способу — событие, APC, IOCP, — и что один и тот же результат не обрабатывается дважды.
  4. На месте завершения работы проверьте, что ничего не освобождается только на основании тайм-аута или запроса отмены.

Синхронный и асинхронный ввод-вывод — не раздельная канализация. Разница в том, возвращаться ли после ожидания завершения или использовать путь, который возвращается раньше. Но поскольку синхронное завершение случается и в асинхронном режиме, проектирование ветвления результата выдачи и проектирование отзывчивости ведут раздельно.12

Режим принадлежит дескриптору, состояние — каждой операции, а уборка делается после подтверждения завершения. Это разделение обязанностей одинаково и при прямой работе с OVERLAPPED в Win32, и при использовании FileOptions.Asynchronous в .NET. Отменённая операция тоже остаётся под вашим управлением, пока не получено её завершение.3415

Продолжение — часть 3: «Порт завершения ввода-вывода (IOCP) и пул потоков .NET — подвал async/await». В ней разбирается, почему IOCP из раздела 4.4 объединяет очередь уведомлений о завершении с управлением числом выполняющихся потоков, и в каком потоке выполняется продолжение после await.

Связанные статьи

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

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

Источники

  1. Microsoft Learn, Synchronous and asynchronous I/O. О том, что синхронный ввод-вывод блокирует вызывающую функцию до завершения, а асинхронный — сразу возвращается из функции, выдавшей запрос, и поток может продолжить другую работу; о том, что для асинхронного ввода-вывода нужен дескриптор, открытый с FILE_FLAG_OVERLAPPED; о способах уведомления о завершении — сигнале дескриптора файла, сигнале события, указанного в структуре OVERLAPPED, завершающей процедуре (APC), выполняемой во время alertable wait, и портах завершения ввода-вывода; и о том, что сигнал дескриптора файла не позволяет различить, какая операция завершилась, когда одновременно выполняется несколько операций.  2 3 4 5 6 7 8

  2. Microsoft Learn, Asynchronous disk I/O appears as synchronous on Windows. О причинах, по которым ввод-вывод, написанный как асинхронный, всё равно завершается синхронно: файл со сжатием NTFS (драйвер файловой системы не обращается к сжатым файлам асинхронно, поэтому все операции становятся синхронными), файл с шифрованием NTFS, запись, увеличивающая длину файла, и запрос, который можно удовлетворить немедленно (например, данные уже в кэше в памяти), — тогда драйвер завершает операцию на месте и возвращает TRUE; о том, что кэш Windows реализован через отображение файла в память и при отсутствии страницы асинхронного механизма обработки ошибок страниц нет; а также о том, что для трёх операций ввода-вывода нужны три структуры OVERLAPPED, что повторное использование ведёт к непредсказуемым результатам или повреждению данных и что нельзя читать или писать в соответствующий буфер данных до завершения операции.  2 3 4 5 6

  3. Microsoft Learn, OVERLAPPED structure. О том, что структура OVERLAPPED хранит сведения для асинхронного ввода-вывода; о том, что Offset/OffsetHigh хранят позицию в файле, hEvent — событие, сигнализируемое при завершении, а Internal/InternalHigh — код состояния операции и число переданных байт; о том, что во время выполнения операции структуру нельзя изменять и она должна оставаться действительной; и о мерах предосторожности при использовании события.  2 3 4 5 6

  4. Microsoft Learn, CancelIoEx function. О том, что CancelIoEx помечает незавершённый ввод-вывод на заданном дескрипторе для отмены независимо от того, какой поток его выдал; о том, что указание lpOverlapped нацелено только на эту операцию, а NULL — на весь незавершённый ввод-вывод; о том, что отменённая операция завершается с ERROR_OPERATION_ABORTED; и о том, что отмена всех операций не гарантируется и вызывающий должен дождаться окончания обработки завершения.  2 3 4

  5. Microsoft Learn, CancelSynchronousIo function. О том, что CancelSynchronousIo помечает для отмены синхронную операцию ввода-вывода, выполняемую указанным потоком, и о том, что отменённая операция возвращается как сбой с ERROR_OPERATION_ABORTED.  2

  6. Microsoft Learn, ReadFile function. О том, что для дескриптора, открытого с FILE_FLAG_OVERLAPPED, обязателен lpOverlapped, а позиция начала чтения задаётся через Offset/OffsetHigh в структуре OVERLAPPED; о том, что при асинхронной обработке возвращаются FALSE и ERROR_IO_PENDING; о том, что система не ведёт указатель файла для асинхронного дескриптора; и о том, что передача OVERLAPPED дескриптору, открытому без FILE_FLAG_OVERLAPPED, приводит к чтению с указанного смещения, но ReadFile по-прежнему не возвращается до окончания чтения.  2 3

  7. Microsoft Learn, SetFileCompletionNotificationModes function. О том, что FILE_SKIP_COMPLETION_PORT_ON_SUCCESS позволяет не помещать пакет завершения в порт завершения ввода-вывода, когда операция успешно завершилась сразу, и о том, что FILE_SKIP_SET_EVENT_ON_HANDLE позволяет пропустить установку события дескриптора файла.  2

  8. Microsoft Learn, GetOverlappedResult function. О том, что GetOverlappedResult извлекает результат асинхронной операции — успех или сбой и число переданных байт; о том, что передача TRUE в bWait заставляет ждать завершения операции; и о том, что если hEvent в OVERLAPPED — событие с автоматическим сбросом и другое ожидание потребило сигнал, вызов с bWait=TRUE может не обнаружить завершение и зависнуть, поэтому следует использовать событие с ручным сбросом.  2

  9. Microsoft Learn, ReadFileEx function. О том, что ReadFileEx принимает завершающую процедуру (FileIOCompletionRoutine), вызываемую по завершении чтения; о том, что завершающая процедура выполняется, когда вызывающий поток находится в состоянии alertable wait; и о том, что требуется дескриптор, открытый с FILE_FLAG_OVERLAPPED.  2

  10. Microsoft Learn, Alertable I/O. О том, что при alertable I/O запись для завершающей процедуры помещается в очередь APC потока; о том, что APC выполняется, когда поток входит в alertable-состояние через SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx и подобные вызовы; и о том, что APC всегда выполняется в контексте потока, который его выдал.  2 3

  11. Microsoft Learn, Asynchronous Procedure Calls. О том, что APC — это функция, выполняемая асинхронно в контексте конкретного потока; о том, что у каждого потока своя очередь APC; и о том, что APC пользовательского режима выполняется только тогда, когда поток находится в alertable-состоянии. 

  12. Microsoft Learn, I/O Completion Ports. О том, что порт завершения ввода-вывода даёт эффективную модель потоков для обработки множества асинхронных запросов ввода-вывода на многопроцессорной системе; о том, что связывание дескриптора файла с портом помещает пакеты завершения в очередь, а рабочие потоки извлекают их через GetQueuedCompletionStatus; и о том, что порт управляет числом одновременно выполняющихся потоков. 

  13. Microsoft Learn, CancelIo function. О том, что CancelIo может отменить только операции ввода-вывода, выданные самим вызывающим потоком, и о том, что для отмены операций, выданных другими потоками, используется CancelIoEx. 

  14. Microsoft Learn, Canceling pending I/O operations. О механизме отмены незавершённого ввода-вывода, о том, что даже после запроса отмены операция иногда уже идёт к завершению, о том, что перед освобождением ресурсов нужно подтвердить завершение отменённой операции, и о разделении: для синхронных операций используется CancelSynchronousIo, а для асинхронных — CancelIo/CancelIoEx.  2

  15. Microsoft Learn, Asynchronous file I/O (.NET). О подходе .NET к асинхронному файловому вводу-выводу, о том, что в FileStream указывают useAsync (FileOptions.Asynchronous) в конструкторе, чтобы включить асинхронный ввод-вывод на уровне ОС, и о различии между использованием синхронных и асинхронных методов.  2 3

  16. Microsoft .NET Blog, File IO improvements in .NET 6. О том, что в .NET 6 внутренняя реализация FileStream была переписана целиком; о том, что стратегия зависит от того, открыт ли дескриптор в асинхронном режиме; о том, что File.OpenHandle даёт SafeFileHandle напрямую, а RandomAccess позволяет читать и писать с явным смещением (потокобезопасно); и о том, что асинхронные вызовы на дескрипторе, не находящемся в асинхронном режиме, переносятся в пул потоков.  2 3

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

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

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

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

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

Что меняется, если указать FILE_FLAG_OVERLAPPED?
Файловый объект за дескриптором открывается в "асинхронном режиме". Это свойство уровня дескриптора, которое определяется в момент вызова CreateFile, и переключать синхронный и асинхронный режим для каждого отдельного вызова нельзя. Для дескриптора в асинхронном режиме в ReadFile/WriteFile всегда передаётся структура OVERLAPPED. Система не ведёт для такого дескриптора указатель файла (текущую позицию), поэтому на устройствах, у которых есть позиция, — например, для файла на диске — позицию чтения и записи каждый раз задают через Offset в OVERLAPPED (на устройствах без позиции, таких как последовательный порт, Offset не используется). Выданная операция может вернуть управление, не дождавшись завершения; в этом случае ReadFile возвращает FALSE, а GetLastError сообщает ERROR_IO_PENDING. Завершение принимают через уведомление — событие, APC или порт завершения ввода-вывода.
Я выдал асинхронный ввод-вывод — почему он сразу возвращается завершённым?
Потому что асинхронный режим означает "можно не ждать завершения", а не "ждать не придётся никогда". В документации Microsoft перечислены типичные причины, по которым операция, выданная асинхронно, всё равно завершается синхронно: запрос, который можно удовлетворить немедленно (например, данные уже лежат в кэше), файл со сжатием NTFS, файл с шифрованием NTFS (EFS) и запись, увеличивающая длину файла. В этих случаях ReadFile/WriteFile возвращает TRUE, и результат уже окончателен. Поэтому код, использующий асинхронный ввод-вывод, обязан предусматривать и случай возврата ERROR_IO_PENDING, и случай завершения на месте, а отзывчивость тоже не гарантируется абсолютно. Заметим, что по умолчанию уведомление о завершении (сигнал события или пакет в порт завершения ввода-вывода) приходит отдельно даже для операции, завершившейся синхронно, поэтому надёжнее свести обработку результата только к стороне уведомления.
Можно ли использовать одну структуру OVERLAPPED повторно?
Делить её между несколькими одновременно выполняющимися операциями нельзя. Структура OVERLAPPED представляет состояние одной выданной операции, и в документации Microsoft прямо сказано, что для трёх операций ввода-вывода нужны три структуры OVERLAPPED, а повторное использование ведёт к непредсказуемым результатам и повреждению данных. До завершения операции и структура, и буфер чтения-записи должны оставаться действительными, и трогать их содержимое нельзя. Если структура используется повторно после завершения, её каждый раз инициализируют заново, чтобы остатки предыдущего использования не влияли на результат. Для hEvent, в котором хранится событие, безопасный выбор — событие с ручным сбросом.
Как отменить операцию ввода-вывода, которая уже выполняется?
CancelIoEx позволяет запросить отмену незавершённого ввода-вывода для заданного дескриптора независимо от того, какой поток его выдал. Если передать во втором аргументе OVERLAPPED, целью станет одна конкретная операция, а NULL — все операции этого дескриптора. Старый CancelIo может отменить только операции, выданные самим вызывающим потоком. Важно то, что отмена — это запрос, а не немедленная гарантия. Операция, которая уже была на грани завершения, может завершиться нормально, а отменённая операция сообщается как завершившаяся с ERROR_OPERATION_ABORTED. В обоих случаях освобождать структуру OVERLAPPED и буфер нельзя до получения уведомления о завершении. Для потока, застрявшего в синхронном вводе-выводе в другом потоке, есть отдельный API — CancelSynchronousIo.
Что будет, если в FileStream из .NET не указать FileOptions.Asynchronous (useAsync)?
Дескриптор открывается в синхронном режиме, поэтому вызов ReadAsync/WriteAsync не даёт настоящего асинхронного ввода-вывода: синхронное чтение или запись берёт на себя поток из пула потоков — это "мнимая асинхронность". Вызывающий поток не блокируется, но за кулисами ждёт другой поток, а это ведёт к исчерпанию пула потоков и падению масштабируемости. Наоборот, если открыть в асинхронном режиме и затем вызвать синхронные Read/Write, внутри возникнут накладные расходы на ожидание завершения. Принцип — согласовывать режим дескриптора и вызываемый API; начиная с .NET 6 File.OpenHandle вместе с RandomAccess позволяет писать это прямо, с явным указанием и режима, и смещения.

Об авторе

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

Го Комура

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

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

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

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