Одна строка File.ReadAllText или один вызов ReadFile. Что происходит внутри Windows с момента, когда вы вызвали эту функцию, и до момента, когда она возвращает управление?
Зная этот путь, вы сможете в рамках одного механизма объяснить и IRP_MJ_READ с FASTIO_READ, появляющиеся в Process Monitor, и файл, который не освобождается, хотя вы вызвали CloseHandle, и поведение, которое меняется только на сетевом диске или на машине с установленным антивирусом.
Серия «Глубины ввода-вывода Windows» рассматривает устройство ядра, которое описывает такая книга, как Windows Internals (в японском переводе — Inside Windows), и объясняет не только то, как пользоваться API, но и почему он ведёт себя именно так.1 Эта первая статья закрепляет основу для всего остального — разрешение имён, состав действующих лиц и путь запроса и его завершения — с помощью диаграмм. Она написана не для того, чтобы вы научились писать драйвер, а для того, чтобы разработчик приложения понимал, куда на самом деле попадает вызванный им API.
Что нужно знать заранее: статью можно читать, если вы использовали FileStream в C# или вызывали CreateFile / ReadFile в C/C++. Опыт разработки драйверов не требуется, а код на стороне ядра приведён как концептуальная иллюстрация. Ориентир по времени: около 25 минут, чтобы прочитать всё подряд, разглядывая диаграммы, или 2–3 минуты, если нужен только вывод одной главы.
Структура серии
| Часть | Тема |
|---|---|
| Часть 1 (эта статья) | Общая картина системы ввода-вывода — каждое чтение и запись становятся IRP |
| Часть 2 | Синхронный и асинхронный ввод-вывод — что на самом деле означает OVERLAPPED |
| Часть 3 | Порты завершения ввода-вывода (IOCP) и пул потоков .NET — подвал под async/await |
| Часть 4 | Диспетчер кэша — когда ваш WriteFile действительно доходит до диска? |
| Часть 5 | Внутреннее устройство NTFS — файловая система через призму MFT |
| Часть 6 | Фильтр-драйверы и минифильтры — почему Procmon и антивирусное сканирование могут перехватывать ввод-вывод |
1. Сначала вывод
Прежде всего стоит усвоить три момента.
- Ввод-вывод в Windows определяет адресата по имени и передаёт запрос пакетом.
CreateFileоткрывает не обязательно файл на диске, иC:тоже является символической ссылкой на имя NT-устройства. Большинство запросов, отправляемых драйверу устройства, проходит по стеку устройств в виде IRP (I/O Request Packet). Но есть и короткий путь под названием Fast I/O, который IRP не создаёт (главы 2 и 4, раздел 5.2).2345 - Держите в голове отдельно «то, что обрабатывает запрос», «адресата» и «состояние одного открытия». Объект драйвера хранит таблицу функций обработки, объект устройства — адресата запроса, а объект файла — состояние одного конкретного открытия.
HANDLE, которым владеет приложение, — это ссылка на объект файла (глава 3).6789 - Выдача и завершение запроса, закрытие дескриптора и освобождение ссылки — разные этапы. Драйвер сочетает завершение IRP, передачу его вниз и удержание в состоянии pending. Синхронный ввод-вывод — это гарантия того, что вызов не вернётся до завершения, а не отдельный механизм ввода-вывода. Кроме того, cleanup, наступающий при закрытии последнего дескриптора, нужно отличать от close, наступающего при исчезновении ссылок (главы 4–6).310111213
Как читать статью в зависимости от цели
| Что вы хотите узнать | Где читать |
|---|---|
| Общая картина того, что происходит после вызова API | Глава 1 → глава 2 (разрешение имён) → глава 5 (один круг ReadFile) |
| Разница между объектами драйвера, устройства и файла и IRP | Таблица соответствий в главе 3 → глава 4 |
| Почему возникают утечки дескрипторов и «закрыл, а он всё ещё занят» | Раздел 3.3 → глава 6 |
| Как проверить это на своём компьютере | Глава 7. Наблюдайте пространство имён в WinObj, а ввод-вывод — в Process Monitor |
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 40, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Что на самом деле означает «всё выглядит как файл»
2.1. Куда попадает имя, переданное в CreateFile?
Точка входа при открытии файла или устройства — это имя. Локальный путь вроде C:\project\report.csv, путь UNC вроде \\server\share\data.csv и обозначение устройства вроде \\.\COM3 можно передать в один и тот же CreateFile.14
Согласованность поддерживает пространство имён, которым управляет диспетчер объектов ядра. Именованные устройства, события, секции разделяемой памяти и многое другое обрабатываются в древовидном пространстве имён. Инструмент WinObj из Sysinternals позволяет заглянуть в это пространство имён напрямую.15
flowchart TB
ROOT["\ (корень пространства имён)"]
DEV["\Device<br/>(объекты устройств, созданные драйверами)"]
GLB["\GLOBAL??<br/>(глобальные имена, видимые из Win32)"]
BNO["\BaseNamedObjects<br/>(именованные мьютексы и т. п.)"]
ROOT --> DEV
ROOT --> GLB
ROOT --> BNO
DEV --> D1["HarddiskVolume3"]
DEV --> D2["Serial0"]
DEV --> D3["Mup (сетевой редиректор)"]
GLB --> L1["C: → \Device\HarddiskVolume3"]
GLB --> L2["COM1 → \Device\Serial0"]
GLB --> L3["PhysicalDrive0 → \Device\Harddisk0\DR0"]
Рис. 1: Пространство имён диспетчера объектов (фрагмент). То, что находится под \GLOBAL??, — это символические ссылки, а сами объекты лежат под \Device
На рисунке 1 посмотрите отдельно на две стороны.
| Сторона имени | Пример | Роль |
|---|---|---|
| Имена, которые используют приложения Win32 | Буквы дисков, имена COM-портов | Точка входа со стороны приложения |
| Имена, которые использует ядро | Имена NT-устройств вроде \Device\HarddiskVolume3 |
Имена, указывающие на объект устройства |
Связывает их символическая ссылка. Имя, которое видит приложение, и имя реального объекта на стороне ядра не совпадают.
2.2. Буква диска — это символическая ссылка
Чтобы устройство было видно приложениям Win32, драйвер с помощью IoCreateSymbolicLink создаёт ссылку с имени MS-DOS-устройства вроде \DosDevices\COM1 на имя NT-устройства.4 C: работает так же: это ссылка на устройство тома, например \Device\HarddiskVolume3.
Разрешение имени для CreateFile("C:\project\report.csv") идёт так.
flowchart TB
A["Имя, переданное приложением<br/>C:\project\report.csv"]
B["Слой Win32 преобразует его в форму NT<br/>\??\C:\project\report.csv"]
C["Диспетчер объектов ищет имя в пространстве имён<br/>и обнаруживает, что \??\C: — символическая ссылка"]
D["Переход по ссылке и подстановка<br/>\Device\HarddiskVolume3\project\report.csv"]
E["\Device\HarddiskVolume3 приводит<br/>к объекту устройства тома"]
F["Разрешение оставшейся части \project\report.csv<br/>диспетчер ввода-вывода поручает, выдавая IRP_MJ_CREATE<br/>драйверу файловой системы (NTFS)"]
A --> B
B --> C
C --> D
D --> E
E --> F
Рис. 2: Разрешение имени в CreateFile. Первая половина — работа диспетчера объектов; после достижения устройства вторая половина — дело файловой системы
Отделите имя, ведущее к устройству, от имени внутри файловой системы
В первой половине рисунка 2 путь Win32 преобразуется в форму NT, и переход по ссылке приводит к объекту устройства тома. Разрешение оставшейся части \project\report.csv диспетчер ввода-вывода поручает драйверу файловой системы (NTFS), выдавая ему IRP_MJ_CREATE.
Когда вы знаете, где проходит эта граница, следующие обозначения читаются так же.
\\.\— это указание в пространство имён устройств Win32.\\.\PhysicalDrive0и\\.\COM10задают ссылку на имя устройства напрямую, не проходя через букву диска.514CONиNUL— зарезервированные имена MS-DOS-устройств. Даже записанные внутри пути они могут разрешиться в сторону устройства, поэтому их нельзя использовать как обычные имена файлов.5 Практические последствия разбираются в статье «MAX_PATH и подводные камни путей и имён файлов в Windows».- В случае пути UNC меняется устройство, в которое разрешается имя.
\\server\shareразрешается в устройство сетевого редиректора (\Device\Mup), и дальше запрос через сеть несёт клиент SMB. Почему поведение локальных путей и путей UNC различается, можно рассуждать исходя из этого различия в адресате разрешения (см. «Подводные камни сетевых дисков и путей UNC»).
\?? и \GLOBAL?? — не одно и то же
\?? — не просто другое имя для реально существующего каталога. Это вход в порядок поиска: сначала ищем в локальной карте DOS-устройств сеанса входа, а если там ничего нет — в \GLOBAL??. На рисунке 1 нарисована только глобальная сторона, \GLOBAL??.
Буквы дисков, созданные командой net use или subst, попадают в локальную сторону. Поэтому на одном и том же компьютере видимые диски различаются от сеанса входа к сеансу, и поэтому служба иногда не видит сетевые диски пользователя.
Подводя итог, файлы и устройства можно обрабатывать через один и тот же API потому, что имя приводит к объекту устройства, а последующие запросы можно передавать в общем формате. Дальше разберём объекты, которые представляют этого адресата и состояние открытия.
3. Действующих лиц трое: три объекта
Сначала сопоставим то, что видно в обычном коде Win32 / .NET, и соответствующие сущности на стороне ядра. Всякий раз, когда ниже возникнет путаница в терминах, возвращайтесь к этой таблице.
| Что видно со стороны Win32 / .NET | Соответствие на стороне ядра | Одной фразой |
|---|---|---|
HANDLE / SafeFileHandle |
Запись в таблице дескрипторов (ссылка на объект файла) | Номерок, указывающий на «одно открытие» (3.3) |
Состояние открытия, которое держит FileStream |
Объект файла | Вместилище режима совместного доступа, флагов и текущей позиции (3.3) |
Буква диска C: или \\.\COM3 |
Объект устройства (результат разрешения имени) | Адресат запроса (главы 2 и 3.2) |
| «Драйвер NTFS», «драйвер диска» | Объект драйвера | Таблица функций обработки по видам запросов (3.1) |
Один вызов ReadFile / stream.Read |
Обычно один IRP | Пакет запроса, доставляемый адресату (глава 4). Синхронное чтение и запись файла, который находится в кэше, обходится коротким путём под названием Fast I/O, не создавая IRP (5.2). Это строки, начинающиеся с FASTIO_, в Procmon |
Флаги FileOptions / CreateFile |
Атрибуты, записанные в объект файла | Определяют упреждающее чтение и асинхронное поведение (таблица соответствий в главе 7) |
Значение GetLastError / исключение .NET |
NTSTATUS (например, STATUS_PENDING) |
Состояние завершения. Оно транслируется в код ошибки Win32 по пути наверх |
Эта таблица нужна, чтобы понять соответствие. Она не означает, что от ReadFile до самого диска всегда идёт ровно один и тот же IRP. Путь, который не создаёт IRP, описан в разделе 5.2, а граница, на которой выдаётся отдельный нижний IRP, — в разделе 4.2.
3.1. Объект драйвера — таблица функций обработки
При загрузке драйвера диспетчер ввода-вывода создаёт объект драйвера (DRIVER_OBJECT), представляющий этот драйвер.6
Разработчику приложений важнее всего массив MajorFunction. Это таблица соответствия «вид запроса → функция обработки», а вид запроса называется кодом основной функции (major function code). Типичные примеры: IRP_MJ_CREATE (открыть), IRP_MJ_READ (читать), IRP_MJ_WRITE (писать), IRP_MJ_CLEANUP, IRP_MJ_CLOSE.16
Если выразить только отношения на C#, получится следующее. Это не пример реализации, а концептуальная иллюстрация для понимания структуры C внутри ядра.
// Концептуальная иллюстрация. В действительности это структура C внутри ядра
class DriverObject
{
// IRP_MJ_XXX — это индекс. Всего 28 видов
public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// Для ntfs.sys в MajorFunction[IRP_MJ_READ] лежит «обработчик чтения NTFS»
3.2. Объект устройства — адресат запроса
Драйвер создаёт объект устройства (DEVICE_OBJECT) для каждого устройства, которым он управляет. Это адресат запроса ввода-вывода.7
Однако он не обязательно соответствует физическому устройству один к одному. И логическая сущность вроде тома (HarddiskVolume3), и устройство, которое фильтр-драйвер создаёт, чтобы вмешаться в обработку, представлены этим объектом.
Объект устройства указывает на создавший его объект драйвера. Значит, отношение такое: как только определён адресат, определена и таблица функций, которые будут обрабатывать запрос.
3.3. Объект файла — состояние «одного открытия»
Каждый раз, когда CreateFile завершается успешно, ядро создаёт один объект файла. Он представляет не сам файл на диске, а состояние одного конкретного открытия файла или устройства.8
Откройте один и тот же файл дважды — получите два объекта файла. Текущий указатель файла у синхронного дескриптора, а также режим совместного доступа и флаги, заданные при открытии, принадлежат своему состоянию открытия. HANDLE, который получает приложение, — это ссылка на этот объект файла, доступная через таблицу дескрипторов процесса.9
flowchart LR
subgraph P["Процесс (пользовательский режим)"]
H1["HANDLE 0x1A4"]
H2["HANDLE 0x1B8"]
end
subgraph K["Пространство ядра"]
FO1["Объект файла 1<br/>report.csv открыт на чтение<br/>текущее смещение 4096"]
FO2["Объект файла 2<br/>report.csv открыт на дозапись<br/>текущее смещение 65536"]
DO["Объект устройства<br/>соответствует HarddiskVolume3"]
DR["Объект драйвера NTFS<br/>MajorFunction = таблица функций обработки"]
end
H1 --> FO1
H2 --> FO2
FO1 --> DO
FO2 --> DO
DO --> DR
Рис. 3: Связь трёх объектов. Дескриптор указывает на объект файла через таблицу дескрипторов, объект файла ведёт к устройству, а устройство — к драйверу
«Открыть один и тот же файл дважды» и «продублировать дескриптор» — разные вещи
| Операция | Объект файла | Указатель файла |
|---|---|---|
| Открыть один и тот же файл отдельно | Создаётся по одному на каждое открытие | Они независимы |
Продублировать дескриптор через DuplicateHandle |
Они ссылаются на один и тот же объект | Он общий |
Даже для дескрипторов, указывающих на один и тот же файл, то, какое состояние они разделяют, зависит от того, файл открыли заново или дескриптор продублировали.
Утечки дескрипторов и нарушения совместного доступа тоже рассматриваются исходя из состояния открытия
Когда дескриптор файла утекает, объект файла и ресурсы за ним остаются под ссылками. При расследовании считают записи в таблице дескрипторов. Информация, которую показывают Process Explorer и handle.exe, соответствует этому (см. «Process Explorer / Handle / VMMap на практике» и отчёт о расследовании «Расследование длительного сбоя промышленной камеры — утечка дескрипторов»).
Нарушение совместного доступа (sharing violation) определяется сопоставлением режима совместного доступа у существующего состояния открытия с запросом нового CreateFile. Практическая сторона исключающего управления разобрана в «Основы исключающего управления при файловом взаимодействии».
4. IRP — запрос ввода-вывода становится посылкой
4.1. Зачем упаковывать его в пакет?
Диспетчер ввода-вывода упаковывает запросы на открытие, чтение и запись в IRP (I/O Request Packet) и передаёт его драйверу. Большинство запросов к драйверу устройства имеет именно эту форму.3
Причина использования пакета — возможность разъединить выдачу запроса и его завершение. Устройства вроде диска работают не на той же скорости, что процессор. Если запрос можно удержать как независимый пакет, его можно завершить позже, в другой момент после выдачи.2
Внутри IRP тоже есть разделение на информацию о запросе в целом и указания каждому драйверу.17
| Область | Что она хранит |
|---|---|
| Заголовок | Информацию о запросе в целом |
| Стековые локации ввода-вывода | Вид запроса и параметры для каждого драйвера, через который проходит запрос |
Стековые локации ввода-вывода располагаются в соответствии с числом драйверов, через которые, как ожидается, пройдёт запрос. Каждый драйвер читает свою область и определяет, какая операция от него требуется.
4.2. Спуск по стеку устройств
Набор сложенных друг на друга объектов устройств называется стеком устройств.18 При чтении файла на локальном диске запрос идёт примерно по следующему маршруту.
flowchart TB
IOM["Диспетчер ввода-вывода собирает IRP<br/>(IRP_MJ_READ + стековые локации)"]
subgraph FSSTACK["Стек со стороны файловой системы"]
FLT["Фильтры файловой системы<br/>(антивирус, шифрование, Procmon и так далее)"]
NTFS["NTFS<br/>переводит смещение внутри файла в позицию на томе"]
end
subgraph STSTACK["Стек со стороны хранилища"]
VOL["Управление томами и разделами<br/>(volmgr и так далее)"]
DISK["Драйвер класса диска<br/>(disk.sys)"]
PORT["Порт и минипорт хранилища<br/>(storport и так далее)"]
end
HW[("Дисковое устройство")]
IOM --> FLT
FLT --> NTFS
NTFS --> VOL
VOL --> DISK
DISK --> PORT
PORT --> HW
Рис. 4: Путь, по которому идёт запрос на чтение. Сторона файловой системы и сторона хранилища — разные стеки устройств, и NTFS выдаёт новый нижний IRP, адресованный стороне хранилища, чтобы поручить работу ей
Фильтры — это точка расширения, которую предоставляет ОС
Антивирус может проверять файловый ввод-вывод потому, что использует механизм, официально предоставленный ОС для вмешательства в середину обработки запроса.19 Process Monitor записывает ввод-вывод с той же позиции.
В расследовании «файловый доступ медленный только в той среде» фильтры, стоящие на этом маршруте, — первые кандидаты для проверки. Подробно это разбирается в части 6.
Один IRP не проходит насквозь до самого диска
Приложение просит «8 КБ начиная со смещения 4096 в этом файле». NTFS отображает это на кластеры тома, а стек хранилища — на секторы диска. Верхний уровень может сделать запрос, не зная подробностей нижнего уровня.
Здесь важно то, что сторона файловой системы и сторона хранилища — разные стеки. Чтобы обработать IRP, адресованный файлу, NTFS создаёт и выдаёт новый нижний IRP, адресованный тому. Один и тот же IRP не передаётся напрямую от приложения до диска.
У фрагментированного файла одно чтение может даже разделиться на несколько нижних IRP. Гранулярность и время жизни запроса приходится рассматривать для каждого уровня отдельно.
4.3. У каждого драйвера три варианта
То, что драйвер делает с полученным IRP, в основном сводится к следующему.310
| Действие | Основное поведение | Пример |
|---|---|---|
| Завершить самому | Завершить вызовом IoCompleteRequest |
Удовлетворить запрос из локального кэша |
| Передать драйверу ниже | Отправить следующему устройству вызовом IoCallDriver |
Фильтр проверяет запрос и передаёт его дальше |
| Удержать в состоянии pending | Вернуть STATUS_PENDING и завершить позже |
Дождаться ответа оборудования |
flowchart TB
RECV["Драйвер получает IRP"]
Q{"Как обработать этот запрос"}
DONE["(1) Завершить самому<br/>вызвать IoCompleteRequest<br/>пример: сразу ответить данными из кэша"]
PASS["(2) Передать устройству ниже<br/>вызвать IoCallDriver<br/>пример: фильтр проверяет и пропускает дальше"]
PEND["(3) Удержать в состоянии pending<br/>вернуть STATUS_PENDING и поставить IRP в очередь<br/>пример: ожидание ответа оборудования"]
LATER["Позже вызвать IoCompleteRequest,<br/>по прерыванию или другому событию"]
UP["Обработка завершения идёт вверх по стеку в обратном порядке<br/>(вызывается процедура завершения каждого уровня)"]
RECV --> Q
Q --> DONE
Q --> PASS
Q --> PEND
PASS -->|"нижний уровень завершил запрос сразу"| UP
PASS -->|"нижний уровень удержал его в pending<br/>(pending доходит до вызывающего)"| LATER
PEND --> LATER
DONE --> UP
LATER --> UP
Рис. 5: Три варианта, которые есть у драйвера, получившего IRP. Они не исключают друг друга: самый обычный путь — «передать вниз и где-то ниже удержать в состоянии pending», и все они в итоге заканчиваются завершением через IoCompleteRequest
«Передача дальше» и «удержание в pending» пересекаются в одном запросе
Эти три варианта не являются взаимоисключающими. Типичный случай: верхний драйвер передаёт запрос вниз, и где-то ниже он переходит в pending. В этом случае STATUS_PENDING проходит через промежуточные драйверы обратно к вызывающему.
Когда нижний уровень позже завершает запрос, все уровни, зарегистрировавшие при передаче процедуру завершения, вызываются в обратном порядке. Читайте передачу запроса вниз и обработку завершения, возвращающую результат наверх, как две разные вещи.
Это сочетание — основа механизмов, которые разбираются в остальной части серии. Если кэш может завершить запрос сразу, становится быстрее (часть 4), а по пути можно накладывать фильтры (часть 6). Поскольку pending и завершение можно разделить, асинхронный ввод-вывод позволяет потоку заниматься другой работой, пока ожидается медленное устройство (части 2 и 3).
5. Проследим один круг ReadFile
Здесь мы проследим ReadFile в случае, когда данные не оказались в кэше и приходится идти на диск. На диаграмме сначала посмотрите на «прямой путь», который выдаёт запрос, а затем на «обратный путь» после чтения данных.
sequenceDiagram
participant App as Поток приложения
participant IOM as Диспетчер ввода-вывода
participant FS as Фильтры + NTFS
participant ST as Стек хранилища
participant HW as Дисковое устройство
App->>IOM: ReadFile → NtReadFile (системный вызов)
Note over IOM: Разрешить объект файла по дескриптору<br/>и собрать IRP (IRP_MJ_READ)
IOM->>FS: IoCallDriver (на вершину стека)
FS->>ST: Перевести позицию в координаты тома и выдать<br/>нижний IRP, адресованный стеку хранилища
ST->>HW: Выдать команду чтения
ST-->>FS: STATUS_PENDING (нижний IRP в состоянии pending)
FS-->>IOM: Исходный IRP тоже возвращается, оставаясь pending<br/>(прямой путь на этом заканчивается)
Note over App: Синхронный ввод-вывод ждёт здесь завершения<br/>Асинхронный ввод-вывод возвращает управление и позволяет заняться другим
HW-->>ST: Прерывание, «данные прочитаны»
Note over ST: Из процедуры обработки прерывания (ISR)<br/>обработка завершения продолжается в DPC
ST->>FS: Завершить нижний IRP (IoCompleteRequest)<br/>процедура завершения на стороне NTFS принимает его
Note over FS: Когда завершились<br/>все необходимые нижние IRP
FS->>IOM: Завершить исходный IRP (IRP_MJ_READ)
Note over IOM: Выполнить процедуры завершения каждого уровня в обратном порядке<br/>и зафиксировать результат через APC в потоке-инициаторе
IOM->>App: Состояние и число байтов окончательны (сигнализация события и так далее)
Рис. 6: Один круг ReadFile, который не попал в кэш. Завершение нижнего IRP и завершение исходного IRP — разные шаги, а «прямой» и «обратный» путь идут как отдельные события
5.1. Прямой и обратный путь — разные события
Разделитель на рисунке 6 — STATUS_PENDING. Выдав команду оборудованию, драйвер хранилища удерживает запрос в состоянии pending и возвращает управление. На этом «прямой путь» заканчивается, и позже, по прерыванию или другому событию, продолжается обработка завершения «обратного пути».10
| Этап | Что происходит |
|---|---|
| Выдача | По дескриптору находится адресат, отправляются исходный IRP и необходимые нижние IRP |
| Pending | Пока оборудование не закончило работу, запрос удерживается незавершённым |
| Завершение нижнего IRP | После окончания чтения завершается нижний IRP на стороне хранилища |
| Завершение исходного запроса | Когда завершились все необходимые нижние IRP, завершается и исходный IRP, а состояние и число байтов становятся окончательными |
Разница между синхронным и асинхронным — в том, когда возвращается вызывающий
Отдельного механизма в ядре, предназначенного специально для синхронного ввода-вывода, нет. Синхронный ввод-вывод — это гарантия того, что «вызов не вернётся до завершения». Описанное здесь ожидание завершения возникает, когда запрос переходит в pending; запрос, который можно завершить сразу, может вернуть результат, не дожидаясь более позднего завершения.11
В Win32 вы выбираете синхронную или асинхронную обработку с помощью FILE_FLAG_OVERLAPPED при открытии дескриптора. Заметьте, однако, что сама структура OVERLAPPED нужна по одной на каждую выполняющуюся операцию. Держите «настройку дескриптора» и «состояние отдельной операции» как разные понятия.
Когда эта разница понятна, можно перенести в часть 2 вопросы «почему асинхронность определяется при открытии дескриптора» и «что значит, что то, что должно быть асинхронным, завершается сразу». Причина, по которой async/await в .NET не обязан держать поток занятым только для ожидания ввода-вывода, тоже лежит в этом разделении выдачи и завершения (см. «Практическая таблица решений по C# async/await»).
5.2. Есть и исключения — короткий путь, который не создаёт IRP
Не весь ввод-вывод превращается в IRP. Когда данные файла, находящиеся в кэше, читаются или записываются синхронно, существует короткий путь под названием Fast I/O, который копирует данные напрямую в кэш и из кэша, не собирая IRP.
Когда в Procmon включён расширенный вывод, строки в столбце Operation, начинающиеся с FASTIO_, соответствуют этому пути. Условия, при которых коротким путём воспользоваться нельзя, и его связь с диспетчером кэша разбираются в части 4.
Поэтому при расследовании учитывают и «основной путь с использованием IRP», и «короткий путь, который не создаёт IRP». Нельзя делать вывод, что IRP точно был создан, только потому, что был вызван ReadFile.
6. За CloseHandle — cleanup и close это разные вещи
Закрытие того, что было открыто, тоже нужно разделять на этапы. CloseHandle делает одно: убирает одну запись из таблицы дескрипторов процесса.
У объекта файла есть счётчик дескрипторов, то есть число дескрипторов, и счётчик ссылок, который отслеживает ссылки внутри ядра. Момент, когда дескрипторы исчезли, и момент, когда исчезли все ссылки на объект, не обязательно совпадают.
sequenceDiagram
participant App as Приложение
participant OB as Диспетчер объектов
participant IOM as Диспетчер ввода-вывода
participant FS as Файловая система
App->>OB: CloseHandle(h)
Note over OB: Удалить запись из таблицы дескрипторов<br/>и уменьшить счётчик дескрипторов
alt Это был последний дескриптор
IOM->>FS: IRP_MJ_CLEANUP
Note over FS: Отменить незавершённый ввод-вывод и освободить блокировки<br/>для этого объекта файла
end
Note over OB: Но если ссылки внутри ядра остаются — например,<br/>незавершённый ввод-вывод или секция (отображение в память), —<br/>объект файла всё ещё жив
alt Счётчик ссылок тоже дошёл до нуля
IOM->>FS: IRP_MJ_CLOSE
Note over FS: Выполняется окончательная уборка объекта файла<br/>только теперь он действительно закрыт
end
Рис. 7: Два этапа — cleanup (закрылся последний дескриптор) и close (исчезли и все ссылки)
6.1. Отличайте последний дескриптор от последней ссылки
| Уведомление | Что произошло | Что ещё может оставаться |
|---|---|---|
IRP_MJ_CLEANUP |
Закрылся последний дескриптор этого объекта файла | Ссылки от незавершённого ввода-вывода и подобного |
IRP_MJ_CLOSE |
Счётчик ссылок объекта файла дошёл до нуля | Объект файла находится на этапе освобождения |
В описании IRP_MJ_CLEANUP прямо сказано, что если остаются незавершённые запросы ввода-вывода, объект файла может быть ещё не освобождён.12 IRP_MJ_CLOSE отправляется после этого, но не обязательно сразу после cleanup.13
6.2. При расследовании «файл занят» смотрите и за пределы дескриптора
У отображения в память свой срок жизни, отдельный от дескриптора файла. Пока отображённая секция держит ссылку на объект файла, закрытие исходного дескриптора его не освобождает. Проверьте также, что представление снято с отображения и секция закрыта (см. «Подводные камни разделяемой памяти и практические рекомендации»).
Если дескриптор не найден, расследование не закончено. Файл может удерживаться ссылкой внутри ядра, например отображённым образом. Именно поэтому поиск в Process Explorer охватывает не только дескрипторы, но и DLL (отображённые файлы).
В .NET тоже «когда-нибудь закроется» и «закроется тогда, когда нужно» — разные вещи. Если вы полагаетесь на то, что SafeFileHandle или финализатор когда-нибудь закроет ресурс, забытый дескриптор задерживает cleanup и продлевает нарушения совместного доступа и удержание блокировок.
7. Убедитесь своими глазами
Пространство имён и поток ввода-вывода можно наблюдать на компьютере с Windows, где у вас есть права администратора. Начните с безопасного локального файла и проследите операции открытия, чтения, записи и закрытия.
7.1. Подготовьте всё нужное для наблюдения
| Что нужно | Подготовка и предостережения |
|---|---|
| Права администратора | Process Monitor загружает драйвер ядра, поэтому запускать его нужно от имени администратора. В WinObj тоже часть объектов не видна, если вы не администратор |
| Инструменты Sysinternals | Используйте WinObj и Process Monitor, которые Microsoft распространяет бесплатно. Их можно также получить вместе в составе Sysinternals Suite. Достаточно распаковать архив и запустить exe, установщик не нужен |
| Операция для наблюдения | Достаточно сохранить один файл в «Блокноте» или скопировать один небольшой файл. Не используйте рабочую общую папку или продуктивную машину — попробуйте на локальном диске под рукой |
7.2. С помощью WinObj посмотрите ссылку от имени к реальному объекту
Откройте каталог GLOBAL?? в WinObj и убедитесь, что C: — символическая ссылка на \Device\HarddiskVolumeN. Затем посмотрите под \Device, и вы увидите реальные имена объектов устройств, созданных драйверами. Это процедура сверки имён и ссылок с рисунка 1 на реальном компьютере.15
7.3. С помощью Procmon различите IRP и Fast I/O
Включите в Process Monitor Filter > Enable Advanced Output, и столбец Operation изменится с отображения вроде ReadFile на лексику стороны ядра — IRP_MJ_READ и FASTIO_READ.
При копировании файла ищите операции, описанные в этой статье: IRP_MJ_CREATE → FASTIO_READ / IRP_MJ_READ → IRP_MJ_WRITE → IRP_MJ_CLEANUP → IRP_MJ_CLOSE. Если следить за ними, держа в голове путь чтения и этапы от cleanup до close, трассировка перестаёт быть простым списком имён операций.
Практическое применение Procmon собрано в «Практическое руководство по Process Monitor (ProcMon)».
7.4. Сопоставьте параметры .NET с флагами Win32
В Windows FileStream из C# внутри открывает дескриптор через CreateFileW и хранит его как SafeFileHandle. FileOptions, переданные в конструктор, соответствуют флагам Win32 так.
.NET (FileOptions) |
Win32 (флаг CreateFile) |
Значение (связанная часть) |
|---|---|---|
Asynchronous |
FILE_FLAG_OVERLAPPED |
Открыть дескриптор для асинхронного ввода-вывода (части 2 и 3) |
WriteThrough |
FILE_FLAG_WRITE_THROUGH |
Не позволять кэшу задерживать запись (часть 4) |
SequentialScan |
FILE_FLAG_SEQUENTIAL_SCAN |
Подсказка для упреждающего чтения (часть 4) |
RandomAccess |
FILE_FLAG_RANDOM_ACCESS |
Подсказка для подавления упреждающего чтения (часть 4) |
DeleteOnClose |
FILE_FLAG_DELETE_ON_CLOSE |
Удалить после закрытия последнего дескриптора (применение механизма из главы 6) |
В .NET 6 и более поздних версиях можно также использовать File.OpenHandle и класс RandomAccess и работать в форме «дескриптор плюс ввод-вывод с указанием смещения», без промежуточного FileStream. Понимание правой части этой таблицы позволяет выбирать параметры из левой по желаемому поведению.
8. Итоги
Ввод-вывод в Windows упорядочивается, если проследить его в таком порядке: разрешение имени → состояние открытия → выдача запроса → завершение → освобождение ссылки.
- Разрешите имя и определите адресата.
C:— символическая ссылка,\\.\— указание в пространство имён устройств Win32, а UNC разрешается в редиректор. Основой того, что файлы и устройства можно обрабатывать через один API, служит эта согласованность разрешения имён.45 - Разделяйте три объекта. Драйвер — таблица функций обработки, устройство — адресат, объект файла — состояние одного конкретного открытия.
HANDLEссылается на этот объект файла.6789 - IRP несёт запрос, а завершение может происходить в другой момент. Большинство запросов отправляется по стеку устройств в виде IRP, и каждый драйвер сочетает завершение, передачу дальше и удержание в pending. Время жизни нижнего IRP и исходного IRP различаются, а с Fast I/O есть и путь, который не создаёт IRP.231810
- О синхронном и асинхронном вводе-выводе думайте через соотношение выдачи и завершения. Синхронный ввод-вывод — гарантия того, что вызов не вернётся до завершения. Для запроса, перешедшего в pending, выдача и завершение, наступающее позже по прерыванию или другому событию, — разные события.1110
- Вернуть дескриптор и освободить объект — не одно и то же. Cleanup — это этап, когда исчез последний дескриптор, а close — когда исчезла последняя ссылка. Это различие помогает при расследовании ситуации «закрыл, а он всё ещё занят».1213
Продолжение — часть 2: «Синхронный и асинхронный ввод-вывод — что на самом деле означает OVERLAPPED». В ней разбираются FILE_FLAG_OVERLAPPED, с помощью которого приложение пользуется этим разделением выдачи и завершения, четыре способа получения уведомления о завершении, отмена и условия, при которых «то, что должно быть асинхронным, возвращается синхронно».
Похожие статьи
- Практическое руководство по Process Monitor (ProcMon) — за 10 минут находим «настройки не читаются» и ACCESS DENIED
- Process Explorer / Handle / VMMap на практике — зависания, утечки и «файл занят» по состоянию прямо сейчас
- Расследование длительного сбоя промышленной камеры — утечка дескрипторов
- Основы исключающего управления при файловом взаимодействии — лучшие практики блокировки файлов и атомарного захвата
- Подводные камни разделяемой памяти и практические рекомендации
- Практическая таблица решений по C# async/await — Task.Run и ConfigureAwait
- Подводные камни сетевых дисков и путей UNC — работа с файловым сервером (общими папками) в бизнес-приложениях
- MAX_PATH и подводные камни путей и имён файлов в Windows — ограничение в 260 символов, зарезервированные имена, точка в конце и регистр
Смежные области консультаций
KomuraSoft LLC занимается проектированием и расследованием дефектов, связанных с файловым вводом-выводом в бизнес-приложениях Windows: утечки дескрипторов, «файл занят», замедление ввода-вывода в отдельных окружениях.
- Разработка приложений Windows
- Расследование дефектов и анализ причин
- Повторное использование существующих систем и поддержка миграции
- Связаться с нами
Источники
-
Microsoft Learn, Windows Internals - Sysinternals. Страница, представляющая книгу Windows Internals (в японском переводе — Inside Windows). Это классический труд о внутреннем устройстве Windows, включая архитектуру ядра и систему ввода-вывода, и отправная точка для более глубокого изучения тем этой серии. ↩
-
Microsoft Learn, I/O manager. О том, как диспетчер ввода-вывода в режиме ядра Windows управляет обменом между приложениями и интерфейсами, которые предоставляют драйверы устройств; о том, что устройства работают на скоростях, не совпадающих со скоростью ОС, поэтому обмен между ОС и драйверами идёт в основном через IRP (пакеты запросов ввода-вывода); и о том, что IRP передаётся от ОС драйверу и от драйвера драйверу как нечто похожее на сетевой пакет или сообщение Windows. ↩ ↩2 ↩3
-
Microsoft Learn, I/O request packets. О том, что большинство запросов, отправляемых драйверу устройства, упаковывается в IRP; о том, что компоненты ОС и драйверы отправляют IRP драйверу вызовом IoCallDriver (который принимает указатель на объект устройства и указатель на IRP); о том, что IRP обычно обрабатывается несколькими драйверами, сложенными в стек устройств, и сначала отправляется объекту устройства на вершине стека; и о том, что каждый драйвер может либо обработать и завершить IRP, либо передать его драйверу ниже. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Introduction to MS-DOS device names. О том, что имя MS-DOS-устройства — это символическая ссылка на имя устройства в стиле NT; о том, что пользовательские приложения Windows обращаются к устройствам по именам MS-DOS-устройств (буквам дисков и именам COM-портов), тогда как драйверы и ядро используют имена в стиле NT; и о том, что драйвер создаёт символическую ссылку с \DosDevices\имя на устройство вызовом IoCreateSymbolicLink. ↩ ↩2 ↩3
-
Microsoft Learn, Naming files, paths, and namespaces. О том, что CON, PRN, AUX, NUL, COM1–COM9 и LPT1–LPT9 зарезервированы как имена файлов; о том, что в пространстве имён Win32 есть «пространство имён файлов» и «пространство имён устройств», и префикс “\\.\” означает доступ к пространству имён устройств Win32 (например, \\.\PhysicalDrive0); и о правилах разрешения таких имён. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Introduction to driver objects. О том, что при загрузке драйвера диспетчер ввода-вывода создаёт структуру DRIVER_OBJECT; о том, что объект драйвера хранит точки входа стандартных процедур драйвера (включая массив MajorFunction, то есть таблицу диспетчеризации); и о том, что диспетчер ввода-вывода использует эту таблицу, чтобы вызвать функцию обработки, соответствующую запросу. ↩ ↩2 ↩3
-
Microsoft Learn, Introduction to device objects. О том, что структура DEVICE_OBJECT представляет логическое, виртуальное или физическое устройство и становится целью запроса ввода-вывода; о том, что драйвер создаёт объект устройства вызовом IoCreateDevice; и о том, что объект устройства связан с создавшим его драйвером (объектом драйвера). ↩ ↩2 ↩3
-
Microsoft Learn, Using files in a driver. О том, что в ядре объект файла представляет экземпляр открытого файла (или устройства); и о том, что объект файла создаётся при каждом открытии файла и хранит контекст этого открытия, например текущее смещение в байтах. ↩ ↩2 ↩3
-
Microsoft Learn, File handles. О том, что дескриптор файла, возвращаемый CreateFile, привязан к процессу и связан с открытым объектом файла; о том, что при нескольких открытиях одного и того же файла каждый раз получается отдельный дескриптор (и отдельное состояние открытия); и о том, что дескриптор следует закрывать через CloseHandle, когда он больше не нужен. ↩ ↩2 ↩3
-
Microsoft Learn, Completing IRPs. О том, что операцию ввода-вывода завершает именно вызов IoCompleteRequest; о том, что при завершении по очереди вызываются процедуры IoCompletion, зарегистрированные драйверами выше по стеку; и о том, как завершение запроса происходит в иной момент, чем его выдача, и состояние в итоге возвращается инициатору. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Synchronous and asynchronous I/O. О том, что при синхронном вводе-выводе функция не возвращается до завершения ввода-вывода и поток вынужден ждать, тогда как при асинхронном (overlapped) вводе-выводе функция, выдавшая запрос, возвращается сразу и поток может продолжать другую работу; о том, что для асинхронного ввода-вывода дескриптор нужно открывать с FILE_FLAG_OVERLAPPED; и о нескольких способах получить уведомление о завершении. ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLEANUP. О том, что получение этого запроса означает, что закрыт последний дескриптор объекта файла, связанного с целевым объектом устройства; о том, что из-за незавершённых запросов ввода-вывода объект файла тем не менее может быть ещё не освобождён; и о том, что этот IRP отправляется в контексте процесса, закрывшего дескриптор. ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLOSE. О том, что получение этого запроса означает, что счётчик ссылок объекта файла дошёл до нуля и объект файла собирается быть освобождён; и о том, что он отправляется после запроса cleanup, но не обязательно следует сразу за ним, поскольку ожидает завершения незавершённого ввода-вывода. ↩ ↩2 ↩3
-
Microsoft Learn, CreateFileW function. О том, что CreateFile может открыть и вернуть дескриптор не только файла, но и таких устройств, как физические диски, тома, консоль, порты связи (COM-порты) и каналы; о том, что при открытии устройства используются имена в форме “\\.\”; и о значении различных флагов, начиная с FILE_FLAG_OVERLAPPED. ↩ ↩2
-
Microsoft Learn, WinObj - Sysinternals. О том, что WinObj — это инструмент, отображающий пространство имён диспетчера объектов NT и позволяющий просматривать объекты в этом пространстве имён, включая объекты устройств и символические ссылки. ↩ ↩2
-
Microsoft Learn, IRP major function codes. Список кодов основных функций IRP (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP и так далее), о том, что означает каждый запрос и какой драйвер должен его обрабатывать. ↩
-
Microsoft Learn, I/O stack locations. О том, что диспетчер ввода-вывода предоставляет внутри IRP стековую локацию ввода-вывода для каждого драйвера в цепочке слоистых драйверов; о том, что каждая стековая локация хранит коды основной и дополнительной функций и параметры этого запроса; и о том, что каждый драйвер получает свою стековую локацию вызовом IoGetCurrentIrpStackLocation и узнаёт, что от него требуется. ↩
-
Microsoft Learn, Device nodes and device stacks. О том, что объекты устройств складываются в стек устройств; о том, что IRP сначала отправляется объекту устройства на вершине стека, а затем на каждом уровне обрабатывается или передаётся вниз; и о том, что объекты устройств фильтр-драйверов существуют вставленными в стек. ↩ ↩2
-
Microsoft Learn, Filter Manager Concepts. О том, что диспетчер фильтров — это поставляемый с Windows драйвер режима ядра; о том, что минифильтр-драйверы могут вмешиваться в запросы ввода-вывода к файловой системе с помощью обратных вызовов до (pre) и после (post) операции; и о том, что позиция вмешательства каждого минифильтра (его высота) определяет порядок в стеке ввода-вывода. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Глубины ввода-вывода Windows (часть 4) — диспетчер кэша: когда WriteFile оказывается на диске
Четвёртая часть серии со схемами диспетчера кэша Windows. Разбираем кэш как проекцию файла, упреждающее чтение и отложенную запись, когда...
Глубины ввода-вывода Windows (часть 2) — синхронный и асинхронный ввод-вывод: что на самом деле означает OVERLAPPED
Вторая часть серии с диаграммами разбирает синхронный и асинхронный ввод-вывод (overlapped I/O) в Windows: смысл FILE_FLAG_OVERLAPPED, че...
Глубины ввода-вывода Windows (часть 3) — порты завершения ввода-вывода (IOCP) и пул потоков .NET: подвал под async/await
Третья часть цикла статей со схемами о портах завершения ввода-вывода (IOCP). Разбираются очередь вместе с управлением числом потоков, зн...
Глубины ввода-вывода Windows (часть 6, финал) — фильтры и минифильтры: почему Procmon и антивирусное сканирование могут перехватывать I/O
Финал серии со схемами драйверов фильтров и минифильтров Windows. Диспетчер фильтров и высота (altitude), обратные вызовы pre/post, как P...
Глубины ввода-вывода Windows (часть 5) — внутреннее устройство NTFS: файловая система через MFT
Часть 5 серии со схемами внутреннего устройства NTFS. MFT и записи файлов, несколько потоков данных (Zone.Identifier), жёсткие ссылки и и...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое IRP?
- IRP (I/O Request Packet) — это пакет, в который диспетчер ввода-вывода ядра Windows упаковывает запросы приложения на чтение, запись и другие операции и передаёт их драйверу устройства. В документации Microsoft по разработке драйверов сказано, что большинство запросов, отправляемых драйверу устройства, упаковывается именно в IRP. IRP несёт код основной функции (major function code), указывающий вид запроса (create, read, write, cleanup и так далее), и стековую локацию (I/O stack location) для каждого драйвера, через который он проходит, а обработка идёт по мере передачи сверху вниз по стеку устройств. Каждый драйвер выбирает: завершить IRP самому, передать его драйверу ниже или удержать в состоянии pending и завершить позже. Разработчик приложения никогда не трогает IRP напрямую, но обозначения вроде IRP_MJ_READ в столбце Operation программы Process Monitor — это и есть данный механизм.
- Почему в Windows файл, последовательный порт и принтер открываются одним и тем же вызовом CreateFile?
- Потому что любое имя, переданное в CreateFile, проходит один и тот же путь: в конечном счёте оно разрешается в объект устройства в пространстве имён диспетчера объектов, после чего диспетчер ввода-вывода создаёт объект файла, связанный с этим устройством, и возвращает дескриптор. Буква диска вроде C: на самом деле является символической ссылкой на имя NT-устройства, например \Device\HarddiskVolume3, а обозначение вида \\.\COM1 точно так же разрешается в объект устройства последовательного порта. Каким бы ни было устройство, в которое разрешилось имя, все последующие запросы упаковываются в один и тот же формат IRP и доставляются драйверу — поэтому и файлы, и устройства можно открывать, читать и записывать через один и тот же API. Пути UNC используют тот же механизм и просто разрешаются в устройство сетевого редиректора. Именно этот замысел «пространство имён плюс пакет» и есть настоящий источник согласованности ввода-вывода в Windows.
- Почему файл иногда не освобождается сразу после вызова CloseHandle?
- Потому что CloseHandle возвращает один дескриптор, а не закрывает файл. Объект файла внутри ядра несёт два счётчика: счётчик дескрипторов, то есть число дескрипторов, и счётчик ссылок, то есть число ссылок, удерживаемых компонентами ядра. Как только закрывается последний дескриптор, файловой системе отправляется IRP_MJ_CLEANUP, но пока внутри ядра остаются ссылки — например, незавершённый ввод-вывод или секция файла, отображённого в память, — сам объект файла продолжает жить, и IRP_MJ_CLOSE отправляется только тогда, когда счётчик ссылок становится равным нулю. Многие явления — файл, который нельзя удалить после работы с отображением в память, или сообщение «файл занят» после закрытия приложения — объясняются этим двухступенчатым механизмом.
- Какую пользу знание IRP и стека устройств приносит разработчику приложений?
- Даже если вы никогда не пишете IRP сами, это окупается и при расследовании, и при проектировании. Во-первых, столбец Operation программы Process Monitor (если включён расширенный вывод) показывает именно лексику IRP, например IRP_MJ_CREATE и IRP_MJ_READ, поэтому знание терминов этого уровня позволяет действительно читать журнал. Во-вторых, когда вы знаете, что фильтр-драйверы, такие как антивирус, стоят на всём пути файлового ввода-вывода, у вас появляется отправная точка при расследовании проблемы вида «файловый доступ медленный только в одном конкретном окружении». А когда вы понимаете, что ввод-вывод в Windows устроен так, что выдачу запроса и его завершение можно разделить, и что синхронный ввод-вывод — не более чем гарантия того, что вызов не вернётся до завершения, вы сможете пользоваться асинхронным вводом-выводом, портами завершения ввода-вывода и async/await в .NET, понимая, как они работают.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.