Как собрать завершения ввода-вывода так, чтобы небольшое число потоков обслуживало большое число соединений? Кто выполняет работу, пока вы ждёте на await, и в каком потоке возобновится код после завершения?
В этой части мы проследим путь от того, как порт завершения ввода-вывода (IOCP) собирает уведомления о завершении, до того, как .NET принимает эти уведомления и выполняет продолжение await. Если разделить «получение завершения» и «выбор места, где выполняется продолжение», то ConfigureAwait(false) и голодание пула потоков встают на свои места как части одного процесса.
В прошлый раз (часть 2) мы разобрали, как выдаётся асинхронный ввод-вывод, и четыре пути получения завершения. IOCP — тот из них, который мы разбираем здесь как механизм приёма множества одновременных завершений ввода-вывода небольшим числом потоков.
Это третья часть цикла «Глубины ввода-вывода Windows». Общая структура изложена в начале части 1.
Сразу к нужному разделу
Если вы читаете подряд: глава 2 показывает, где упирается схема с отдельным потоком на каждое соединение, главы 3 и 4 посвящены Win32, а глава 5 переносит всё это в .NET. Если вы разбираетесь с конкретной проблемой, приведённая ниже таблица укажет нужное объяснение.
| Что нужно узнать или в чём проблема | Где читать |
|---|---|
| Почему тысячам одновременных соединений не нужно столько же потоков | Глава 2: число соединений и число потоков, глава 3: очередь завершения и управление потоками |
| Как определить, какая операция ввода-вывода на каком дескрипторе завершилась | Раздел 3.1: CompletionKey и OVERLAPPED |
| Получение завершения вернуло FALSE, и непонятно, безопасна ли очистка | Раздел 3.2: как отличить неудачный ввод-вывод от неудачного получения |
| Как связаны FIFO, LIFO и значение concurrency | Разделы 3.3 и 3.4: как выбирается ожидающий поток и число выполняемых потоков |
| Как обрабатывать уведомления о завершении работы, пакетное получение и синхронное завершение | Глава 4: API по назначению |
| Хочется проследить await ReadAsync от выдачи до возобновления | Раздел 5.1: один оборот await |
| В каких границах верно утверждение «ожидание ввода-вывода не потребляет поток» | Раздел 5.2: ожидающий поток и обрабатывающий поток |
| Интерфейс подвисает или недоступен даже с ConfigureAwait(false) | Раздел 5.3: место продолжения и вынос работы на CPU |
| Асинхронная обработка в целом замедляется с ростом нагрузки | Раздел 5.4: синхронное ожидание и голодание пула потоков |
1. Сначала выводы
За что отвечает IOCP
- IOCP объединяет «очередь уведомлений о завершении» и «управление числом потоков». Пакеты завершения помещаются в очередь по FIFO, а рабочие потоки извлекают их вызовом
GetQueuedCompletionStatus(глава 3).1 - Потоки пробуждаются по LIFO. «Разогретый» поток, работавший мгновение назад, забирает и следующий пакет, поэтому пока очередь заполнена, переключений контекста почти не происходит (раздел 3.3).1
- Значение concurrency ограничивает число выполняемых потоков. Рекомендуемая отправная точка — число процессоров (0 означает число процессоров). Если выполняющийся поток блокируется, ожидающий поток пробуждается и закрывает нехватку (раздел 3.4).12
Что важно при выборе API
- Порт может нести и ваши собственные уведомления.
PostQueuedCompletionStatusпозволяет поместить в очередь пакеты, не связанные ни с какой операцией ввода-вывода, поэтому и задания для рабочих потоков, и команды на завершение работы идут через ту же очередь (глава 4).3 - Для новой серверной реализации вместо «сырого» IOCP рекомендуется Windows API пула потоков (
CreateThreadpoolIo). Внутри это по-прежнему IOCP, и он снимает с вас управление потоками (глава 4).1
Что различать в .NET и await
- Пул потоков .NET — двухэтажная конструкция из рабочих потоков и потоков завершения ввода-вывода, и дескрипторы асинхронного ввода-вывода привязываются к собственному IOCP пула. На время ожидания ввода-вывода в
awaitпотока не существует; на поток «садится» только продолжение после завершения (глава 5).456 - Куда попадёт продолжение, решает «захваченный контекст». await в потоке интерфейса — и продолжение вернётся в поток интерфейса; если захватывать нечего, оно продолжается в пуле потоков (или в потоке, который завершил задачу).
ConfigureAwait(false)— указание прекратить захват, а не гарантия перехода в пул потоков (раздел 5.3).6
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 32, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Где ломается схема «бить потоками»
Сначала убедимся, какую задачу должен был решить IOCP. Простой сервер можно написать по принципу «одно соединение — один поток»: синхронно читать, обрабатывать, записывать. Схема понятная.
Проблема начинается, когда соединений становится много. Сравните на рисунке 1 схему, в которой число потоков растёт вместе с числом ожидающих соединений, и схему, в которой небольшим числом потоков обрабатывается только то, что уже завершилось.
flowchart TB
subgraph A["Одно соединение — один поток (синхронный ввод-вывод)"]
T1["Поток 1: ожидание чтения по соединению 1"]
T2["Поток 2: ожидание чтения по соединению 2"]
T3["Поток 3: ожидание чтения по соединению 3"]
TN["…потоков столько же, сколько соединений<br/>большинство просто спит в ожидании ввода-вывода"]
end
subgraph B["Модель IOCP (асинхронный ввод-вывод)"]
Q["Очередь завершения<br/>(сюда собираются уведомления о завершении от всех соединений)"]
W1["Рабочий поток 1"]
W2["Рабочий поток 2"]
WN["Рабочих потоков немного — примерно по числу процессоров"]
Q --> W1
Q --> W2
Q --> WN
end
Рисунок 1: слева потоков становится столько же, сколько соединений. Справа небольшим числом потоков обрабатывается только то, что произошло.
Даже поток, который только ждёт, стоит ресурсов
Каждый поток занимает стек (по умолчанию резервируется 1 МБ) и объект ядра, а с ростом их числа растёт нагрузка на планировщик и на переключения контекста. Тысячи соединений, то есть тысячи потоков, обходятся дорого, даже если большинство из них всего лишь «спит в ожидании данных для чтения».
Увеличение числа выполняемых потоков не добавляет процессоров
Физически одновременно выполняться может столько потоков, сколько есть процессоров. Если сделать выполняемыми больше потоков, вырастут только затраты на переключение между ними.
В части 2 мы видели способы получать уведомления о завершении ввода-вывода через «события» и «APC». Но подход с событиями упирается в ограничение WaitForMultipleObjects в 64 объекта и усложняет проектирование ожиданий, а APC привязаны к потоку, выдавшему операцию. IOCP — это механизм, изначально спроектированный под форму много операций ввода-вывода при небольшом числе потоков.1
3. Устройство IOCP — очередь и управление потоками в одном механизме
3.1. Два лица CreateIoCompletionPort
CreateIoCompletionPort вопреки названию выполняет две работы: создаёт новый порт и связывает дескриптор с уже существующим портом.2
flowchart LR
subgraph SRC["Связанные дескрипторы (сколько угодно)"]
H1["Файл"]
H2["Сокет"]
H3["Именованный канал"]
end
subgraph PORT["Порт завершения ввода-вывода"]
Q["Очередь пакетов завершения (FIFO)<br/>пакет = число переданных байт +<br/>CompletionKey + указатель на OVERLAPPED"]
C["Управление параллелизмом<br/>выполняемых потоков не больше предела"]
end
subgraph W["Рабочие потоки"]
G1["Ожидание в GetQueuedCompletionStatus"]
G2["Ожидание в GetQueuedCompletionStatus"]
end
H1 --> Q
H2 --> Q
H3 --> Q
Q --> G1
Q --> G2
C -.управляет.-> W
Рисунок 2: устройство IOCP. Завершения множества дескрипторов собираются в одну очередь, и даже число потоков, которые их забирают, контролируется.
CompletionKey, который передаётся при связывании, — это произвольное значение, сообщающее рабочему потоку «это завершение пришло от такого-то дескриптора». Обычно в него помещают указатель на объект соединения.2
В пакете завершения операция определяется сочетанием трёх сведений.27
| Получаемые сведения | Что они определяют или подтверждают |
|---|---|
| CompletionKey | От какого дескриптора или соединения пришло завершение |
| Указатель на OVERLAPPED | Какая именно операция по этому соединению завершилась |
| Число переданных байт | Сколько данных было передано |
Так «карточка операции» из части 2 подбирается в момент завершения.
Объект не ограничивается «файлами». Сокеты, именованные каналы, почтовые слоты и так далее — связать можно любой дескриптор, который умеет работать с overlapped I/O.1 Принцип «всё выглядит как файл» из части 1 работает и здесь.
3.2. Путь пакета завершения
sequenceDiagram
participant DRV as Ядро (завершение IRP)
participant Q as Очередь порта (FIFO)
participant W as Рабочий поток
Note over W: Ожидание в GetQueuedCompletionStatus
DRV->>Q: Помещает пакет завершения<br/>(число байт / CompletionKey / OVERLAPPED)
Q->>W: Будит один ожидающий поток и передаёт пакет
Note over W: Смотрит на пакет и выполняет обработку завершения<br/>(запускает продолжение, выдаёт следующий ввод-вывод и так далее)
W->>Q: Заканчивает обработку и снова вызывает GetQueuedCompletionStatus
Note over Q: Если в очереди остались пакеты<br/>принимает следующий, не ожидая
Рисунок 3: пакеты завершения помещаются по FIFO, а рабочий поток выполняет цикл «забрать и обработать».
Когда асинхронная операция ввода-вывода завершается, пакет завершения помещается в очередь порта в порядке FIFO. Рабочий поток повторяет следующий цикл.17
- Принимает один пакет вызовом
GetQueuedCompletionStatus. - Выполняет обработку завершения для полученной операции.
- Закончив, снова вызывает
GetQueuedCompletionStatus.
Есть и GetQueuedCompletionStatusEx, который забирает несколько пакетов сразу и сокращает число вызовов при интенсивном вводе-выводе.8
Не выходите из цикла по одному лишь FALSE
Даже когда GetQueuedCompletionStatus возвращает FALSE, пакет завершения неудачной операции ввода-вывода может быть уже получен. Определяйте это, сочетая возвращённое значение с указателем на OVERLAPPED.7
| Возвращённое значение | OVERLAPPED | Смысл и действия |
|---|---|---|
FALSE |
не NULL | Получено завершение неудачной операции. Нужны обработка ошибки и освобождение карточки операции и буфера |
FALSE |
NULL | Пакет получить не удалось. Истёк таймаут, порт закрыт и так далее |
Неудачная операция тоже требует очистки. Если обрабатывать это одним лишь if (!GetQueuedCompletionStatus(...)) break;, неудачные операции ввода-вывода ускользают и приводят к утечке. Управление временем жизни карточки операции и буфера из части 2 касается не только успешных завершений.
Порядок проверок в цикле рабочего потока
В приведённом ниже каркасе сначала выполняются две проверки из таблицы выше, а затем обрабатываются пакет завершения работы и обычные завершения.
/* Каркас цикла рабочего потока IOCP (C / Win32) */
for (;;) {
DWORD bytes = 0;
ULONG_PTR key = 0;
OVERLAPPED *ov = NULL;
BOOL ok = GetQueuedCompletionStatus(port, &bytes, &key, &ov, INFINITE);
if (!ok && ov == NULL) {
/* Пакет получить не удалось (порт закрыт и т. п.). Только это условие позволяет выйти */
break;
}
if (!ok) {
/* ov != NULL -> получен «пакет завершения неудачной операции».
Очистка (обработка ошибки, освобождение карточки и буфера) всё равно нужна, поэтому обрабатываем, а не выходим */
DWORD err = GetLastError();
handle_failed_io(key, ov, err);
continue;
}
if (key == SHUTDOWN_KEY) {
/* Пакет завершения работы, помещённый PostQueuedCompletionStatus (глава 4) */
break;
}
handle_completed_io(key, ov, bytes); /* Обычная обработка завершения. Держите её короткой (раздел 3.4) */
}
Суть не в том, чтобы разобраться с ok == FALSE одной ветвью, а в том, чтобы разделить её на две по признаку, равен ли ov NULL. Проверки те же и при использовании таймаута (любое значение кроме INFINITE): истечение времени проявляется как ok == FALSE при ov == NULL.
Заметьте: когда поток впервые вызывает GetQueuedCompletionStatus, он связывается с этим портом (поток может быть связан только с одним портом одновременно).1 Картина «за портом закреплена своя команда рабочих потоков» точна, и её стоит держать в голове.
3.3. Потоки пробуждаются по LIFO
Здесь посмотрите отдельно на порядок попадания пакетов в очередь и на порядок пробуждения ожидающих потоков.1
| Объект | Порядок | На что обратить внимание |
|---|---|---|
| Пакеты завершения | FIFO | Порядок, в котором уведомления о завершении помещаются в очередь |
| Ожидающие потоки | LIFO | Первым пробуждается поток, вошедший в ожидание последним |
Поскольку порядок для пакетов и для потоков различается, следующий пакет забирает тот же поток, который работал мгновение назад.
flowchart TB
Q["Очередь хранит P1, P2, P3 в порядке FIFO"]
subgraph TH["Ожидающие потоки (стек LIFO)"]
A["Поток A (работал до этого момента, разогретый)"]
B["Поток B (спит уже некоторое время)"]
C["Поток C (спит всё время)"]
end
Q -->|"P1, P2 и P3 идут к потоку A,<br/>если он свободен"| A
B -.->|"Только когда A занят"| Q
C -.->|"Почти никогда не пробуждается"| Q
Рисунок 4: освобождение по LIFO. Чем выше нагрузка, тем дольше крутится один и тот же поток, а простаивающие потоки могут оставаться спящими.
У такой схемы два преимущества.
- Переключений контекста не происходит. Пока в очереди остаются пакеты, поток, завершивший обработку и вызвавший
GetQueuedCompletionStatus, получает следующий пакет сразу, не ожидая, и продолжает работать. В документации прямо сказано, что в сценарии со значением concurrency, равным 1, «переключения потоков не происходит».1 - Кэш остаётся разогретым. Поскольку крутится один и тот же поток, его стек и состояние, связанное с планированием, с большей вероятностью остаются в кэше процессора. Спящие потоки дёшево содержатся как резерв на пиковую нагрузку.
3.4. Значение concurrency — подсчёт «выполняемых»
NumberOfConcurrentThreads, передаваемый при создании порта, — это значение concurrency. Оно считает выполняемые потоки, связанные с этим портом. Пока предел достигнут, ни один дополнительный поток не может получить пакет.1
Передача 0 использует число процессоров системы. Документация также называет число процессоров наилучшим максимальным значением в целом, так что с него и стоит начинать.21
Это не предел общего числа вместе с ожидающими потоками. Что происходит, когда кто-то переходит в состояние ожидания, смотрите на рисунке 5.
flowchart TB
P["В очередь приходит пакет"]
Q{"Число выполняемых потоков<br/>меньше значения concurrency?"}
RUN["Разбудить ожидающий поток и поручить ему обработку"]
HOLD["Никого не будить и оставить пакет в очереди<br/>(выполняющийся поток придёт и заберёт его)"]
BLK["Выполняющийся поток перешёл<br/>в состояние ожидания по другой причине"]
COMP["Разбудить столько ожидающих потоков,<br/>сколько нужно для восполнения"]
P --> Q
Q -->|меньше| RUN
Q -->|на пределе| HOLD
BLK --> COMP
Рисунок 5: управление параллелизмом. Предел задан для «числа выполняемых», поэтому при блокировке система восполняет его автоматически.
Вошедший в ожидание рабочий поток компенсируется другим ожидающим потоком
Когда выполняющийся рабочий поток входит в какое-либо ожидание (блокировка, ошибка страницы, случайно синхронный вызов ввода-вывода), число выполняемых потоков падает, и система пробуждает ожидающий поток, закрывая нехватку.1 Поэтому создавать рабочих потоков ровно «по числу процессоров» — не правило; правильнее держать в ожидании потоков больше, чем значение concurrency. Если в обработке есть длительные вычисления, можно поднять и само значение concurrency, а позиция документации такова, что подбирать его следует в конечном счёте профилированием.1
Предел может быть временно превышен, поэтому держите обработку завершения короткой
Впрочем, восполнение не всесильно. Если заблокированный поток позже проснётся, в этот момент число выполняемых потоков превысит предел (документация тоже упоминает такое превышение).1 Держать обработку завершения короткой — главное правило, и в .NET оно проявляется в разделе 5.4 в точно таком же виде.
4. Набор инструментов — API, поддерживающие порт
4.1. Запросы на работу и команды завершения в одной очереди
PostQueuedCompletionStatus — позволяет поместить в очередь собственный пакет завершения, не выдавая никакой операции ввода-вывода.3 Выдача работы рабочим потокам, команды на завершение (помещается столько пакетов завершения — в обиходе их называют «poison-pill» — сколько рабочих потоков), уведомления из других потоков: возможность обрабатывать завершения ввода-вывода и собственные сообщения в одной очереди и одном цикле заметно упрощает схему.
4.2. Пакетное получение завершений при интенсивном вводе-выводе
GetQueuedCompletionStatusEx — забирает несколько пакетов завершения сразу. Эффективно при интенсивном вводе-выводе, где начинают сказываться накладные расходы на один вызов по каждому пакету.8
4.3. Пропуск уведомления при синхронном завершении
SetFileCompletionNotificationModes — для случая из главы 5 части 2, «выдано асинхронно, но завершилось синхронно», позволяет выбрать режим, при котором пакет в порт не помещается (FILE_SKIP_COMPLETION_PORT_ON_SUCCESS). Результат синхронного завершения уже известен на месте, поэтому повторный проход через очередь — чистые потери; отсюда и оптимизация.9
4.4. Передача создания потоков и управления ими
Windows API пула потоков — CreateThreadpoolIo и StartThreadpoolIo используют IOCP внутри и при этом снимают с вас создание и управление потоками. Microsoft рекомендует новым серверным приложениям сначала рассмотреть именно их, а «сырой» IOCP применять только тогда, когда нужен явный контроль над значением concurrency или над управлением потоками.1 А пул потоков .NET — это ровно такое «IOCP плюс автоматическое управление потоками», реализованное как часть среды выполнения .NET.
4.5. Три правила времени жизни, не зависящие от выбранного API
- Не блокируйтесь надолго внутри рабочего потока. Восполнение из раздела 3.4 лишь смягчает деградацию.
- Различайте уровень дескриптора и уровень операции. CompletionKey привязан к дескриптору,
OVERLAPPED— к операции. «Карточку» из части 2 нельзя освобождать до завершения. - Не закрывайте дескриптор, пока остались незавершённые операции. Поведение при очистке (глава 6 части 1) и правила отмены (глава 6 части 2) действуют здесь без изменений.
5. Пул потоков .NET — двухэтажная конструкция над IOCP
Дальше мы сопоставляем уведомления о завершении Win32 с кодом .NET. Порядок такой: поток, принимающий завершение, затем один оборот await, затем время ожидания, затем место выполнения продолжения.
Пул потоков .NET предоставляет потоки двух ролей.4
| Вид | Роль, которую мы разбираем в этой статье |
|---|---|
| Рабочий поток | Выполняет Task.Run и продолжения |
| Поток завершения ввода-вывода | Принимает завершение асинхронных операций ввода-вывода |
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads) точно так же возвращает эти две величины отдельно. Ниже мы прослеживаем «сторону, принимающую завершения» и «сторону, выполняющую работу» как разные вещи.4
В Windows пул потоков имеет собственный порт завершения ввода-вывода. Низкоуровневый API для связывания дескриптора операционной системы с этим портом — ThreadPoolBoundHandle.BindHandle. Асинхронный ввод-вывод по связанному дескриптору выполняется вместе с NativeOverlapped — аналогом OVERLAPPED из части 2 на стороне .NET.5
Давний ThreadPool.BindHandle остался для той же роли, но в новом коде следует использовать ThreadPoolBoundHandle.BindHandle. Когда FileStream или Socket открывает дескриптор в асинхронном режиме, такая привязка происходит внутри.5
Если разделить выдачу и завершение, соответствие с Win32 выглядит так.
- «Дескриптор в асинхронном режиме плюс OVERLAPPED» из части 2 — механизм выдачи
- IOCP из этой статьи — механизм приёма завершений
- Потоки завершения ввода-вывода пула потоков .NET — команда рабочих, выполняющая цикл
GetQueuedCompletionStatus
При таком соответствии картина Win32 становится картиной .NET как есть.
5.1. Один оборот await ReadAsync целиком
В части 2, на рисунке 7, остался блок «настоящий асинхронный ввод-вывод»; на этот раз мы проследим его содержимое вплоть до продолжения после завершения. На рисунке 6 посмотрите отдельно на поток, который выдаёт операцию, на поток, который принимает уведомление о завершении, и на место, где выполняется код после await.
sequenceDiagram
participant U as Вызывающий поток<br/>(например, поток интерфейса)
participant K as Ядро<br/>(от выдачи IRP до завершения)
participant Q as IOCP пула потоков
participant IO as Поток завершения ввода-вывода
participant C as Место выполнения продолжения
U->>K: ReadAsync выдаёт асинхронное чтение<br/>(с аналогом OVERLAPPED)
K-->>U: ERROR_IO_PENDING (сразу возвращает управление)
Note over U: await регистрирует продолжение у незавершённой задачи<br/>и отдаёт поток (в интерфейсе — переход к следующему сообщению)
Note over K: Устройство работает<br/>всё это время нигде нет ожидающего потока
K->>Q: Помещает пакет завершения
Q->>IO: Пробуждает один поток по LIFO и передаёт пакет
Note over IO: Определяет результат (число байт, состояние)<br/>завершает задачу и планирует продолжение
IO->>C: Возвращает его в захваченный контекст<br/>(в поток интерфейса или в пул потоков, если контекста нет)
Note over C: Выполняется код после await
Рисунок 6: весь один оборот await. Потоки работают только при «выдаче» и «после завершения» — время ожидания обходится без единого потока.
5.2. Точный смысл утверждения «ожидание ввода-вывода не потребляет поток»
Ради одного лишь ожидания выделенный поток не создаётся
Главное, что должен донести этот рисунок: между выдачей и завершением нет ни одного потока — ни в пользовательском режиме, ни в ядре, — который существовал бы только ради ожидания этого завершения. Разбор асинхронности от самой Microsoft (Async in depth) говорит о том же применительно к задачам, ограниченным вводом-выводом, спускаясь вплоть до драйверов устройств и прерываний.6
С другой стороны, в ядре есть моменты, когда драйвер передаёт часть работы системному рабочему потоку. Это работа, чтобы продвинуть запрос вперёд, и она отличается от потока, который блокируется и продолжает ждать завершения. «Не потребляет поток» — утверждение о времени ожидания.
Если пересказать на основе всего, что мы собрали с части 1: IRP остаётся в стеке устройств как структура данных, а не как поток (часть 1); выдача сразу возвращает управление с ERROR_IO_PENDING (часть 2); завершение приходит как цепочка событий — прерывание, затем пакет завершения (эта статья). Схема устроена так, что состояние «ожидание» поддерживается без дорогого ресурса, которым является поток.
Отличайте это от работы на CPU и от ложной асинхронности
Поэтому приложение, которое правильно использует async/await, поддерживает «10 000 операций ввода-вывода одновременно в полёте» десятком с небольшим потоков. Но из этого следует и обратное: это свойство присуще только задачам, ограниченным вводом-выводом. Работа на CPU, обёрнутая в Task.Run, естественно занимает один рабочий поток, а «ложная асинхронность» из главы 7 части 2 точно так же усыпляет поток за кулисами.
5.3. Где выполняется продолжение
Последняя стрелка на рисунке 6 — «после получения завершения, где выполняется продолжение». Рассматривайте необходимость возврата в контекст и место для работы, использующей CPU, как два отдельных вопроса.6
flowchart TB
A["Задача завершилась, и нужно выполнить продолжение"]
Q1{"В точке await был захвачен<br/>SynchronizationContext или<br/>TaskScheduler, отличный от стандартного?"}
Q2{"Был указан<br/>ConfigureAwait(false)?"}
UI["Вернуть в захваченное место<br/>например, в цикл сообщений потока интерфейса<br/>или в тот TaskScheduler"]
TP["Обязанности возвращаться в конкретное место нет<br/>продолжить синхронно в завершившем потоке<br/>или выполнить в потоке пула потоков"]
A --> Q2
Q2 -->|"да"| TP
Q2 -->|"нет"| Q1
Q1 -->|"да (например, поток интерфейса WPF/WinForms)"| UI
Q1 -->|"нет (консольное приложение, ASP.NET Core и так далее)"| TP
Рисунок 7: куда попадает продолжение. Возможность сразу обращаться к интерфейсу после await объясняется тем, что управление возвращается в захваченный контекст.
Захваченный контекст и случай синхронного продолжения
- При
awaitв потоке интерфейса WPF или WinForms захватываетсяSynchronizationContext, и продолжение возвращается в поток интерфейса. Поэтому обращение к элементу управления сразу послеawaitне вызывает нарушения межпоточного доступа. Практическая сторона этой схемы разобрана в статье «WPF и WinForms: async/await и поток UI». - Захватывается не только
SynchronizationContext: еслиawaitвыполняется в нестандартномTaskScheduler, захватывается и он. Там, где нет ни того, ни другого (консольное приложение, ASP.NET Core, код, уже работающий в пуле потоков), обязанности возвращаться в конкретное место нет, поэтому продолжение либо выполняется в пуле потоков, либо продолжается синхронно прямо в том потоке, который завершил задачу. ConfigureAwait(false)— это явное указание «возвращаться не нужно», а не гарантия «всегда перейти в пул потоков». Если await применяется к уже завершённой задаче (сюда же относятся синхронные завершения из части 2), ожидания не возникает и выполнение продолжается в текущем потоке. О выборе в библиотечном коде читайте в статье «Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait».
Плохой пример: когда кажется, что ConfigureAwait(false) вас перенёс
Проверим последнее на коде. Следующий пример написан в предположении, что ConfigureAwait(false) — это указание перейти в пул потоков.
// Плохой пример: заблуждение, будто «раз указан ConfigureAwait(false), дальше всё выполняется в пуле потоков»
private async void OnLoadClick(object sender, EventArgs e)
{
string csv = await File.ReadAllTextAsync(path).ConfigureAwait(false);
// Ожидание: здесь пул потоков, значит интерфейс не подвиснет
// Реальность: если к моменту await задача уже завершена, ожидания не возникает и
// выполнение продолжается в потоке интерфейса -> эта тяжёлая работа подвешивает интерфейс
var rows = ParseHeavy(csv);
// А если операция завершится асинхронно, продолжение выполнится не в потоке интерфейса
resultLabel.Text = $"{rows.Count} строк"; // -> может привести к исключению межпоточного доступа
}
Всё, что говорит ConfigureAwait(false), — это «возвращаться в захваченный контекст не нужно». Где именно выполняется код, он не указывает, поэтому выполнение может продолжиться и в потоке интерфейса, и в потоке завершения ввода-вывода, и в потоке пула потоков. Возможны оба варианта — вот почему этот код сломан.
Хороший пример: возврат в интерфейс и место для тяжёлой работы указываются отдельно
Разделите код интерфейса и библиотечный код и выразите каждое намерение явно. У await, возвращающего управление в интерфейс, и у Task.Run, выносящего работу на CPU в пул потоков, разные задачи.
// Хороший пример: «возвращать в интерфейс или нет» и «где выполняется тяжёлая работа» указываются отдельно
private async void OnLoadClick(object sender, EventArgs e)
{
// В коде интерфейса оставляем захват (продолжение вернётся в поток интерфейса)
string csv = await File.ReadAllTextAsync(path);
// Если работу на CPU нужно вынести в пул потоков, укажите это явно через Task.Run
var rows = await Task.Run(() => ParseHeavy(csv));
// Здесь точно поток интерфейса. Обращаться к элементам управления безопасно
resultLabel.Text = $"{rows.Count} строк";
}
// В библиотечном коде (коде без интерфейса) утверждаем обратное: «возвращаться не нужно»
public async Task<string> ReadConfigAsync(string path)
{
string text = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return text.Trim(); // не зависит от контекста вызывающего кода
}
Решение распадается на три части.
| Цель | Выбор в этом коде |
|---|---|
| Обращаться к интерфейсу после await | В коде интерфейса оставить захват контекста |
| Вынести тяжёлую работу на CPU в пул потоков | Указать явно через Task.Run |
| Не нужно возвращаться в контекст вызывающего кода внутри библиотеки | Прекратить захват через ConfigureAwait(false) |
ConfigureAwait(false) не задаёт поток, в котором что-то выполняется. Относитесь к нему как к инструменту библиотечной стороны, позволяющему работать независимо от контекста вызывающего кода, и держите его отдельно от размещения работы с интерфейсом и работы на CPU.
5.4. Что на самом деле создаёт затор — голодание пула потоков
Синхронное ожидание блокирует поток, который выполнил бы продолжение
Если ждать синхронно внутри продолжения или рабочего потока, этот поток остаётся занятым. Синхронный ввод-вывод, Task.Result/Wait() и долгое ожидание блокировки — всё это один и тот же шаблон.
Собственное восполнение IOCP (раздел 3.4) срабатывает мгновенно, пока остаются свободные ожидающие потоки. Но за той точкой, где резерв исчерпан, начинается область, в которой пул потоков добавляет новые потоки лишь медленно.
Когда в момент появления нагрузки возникает голодание — «продолжение нужно выполнить, а потока для него нет», — всё приложение начинает тормозить.
Изучайте свободные счётчики вместе с тем, где на самом деле происходит блокировка
Есть две точки входа для разбора.
- Посмотреть доступность рабочих потоков и потоков завершения ввода-вывода через
ThreadPool.GetAvailableThreads.4 - Понять реальное состояние пула потоков и блокировок с помощью трассировки событий.
Порядок трассировки изложен в статье «Как найти причину «тормозов» с PerfView и dotnet-trace — практика анализа производительности .NET».
Принцип профилактики — провести асинхронный путь асинхронным до конца и не примешивать sync-over-async. Даже если при ожидании ввода-вывода поток удаётся отдать, синхронное ожидание внутри продолжения снова блокирует поток.
6. Итоги
- IOCP — механизм, который объединяет очередь завершения (FIFO) и управление числом потоков. Завершения множества дескрипторов собираются в один порт и обрабатываются в цикле
GetQueuedCompletionStatus.17 - Потоки освобождаются по LIFO, поэтому чем выше нагрузка, тем дольше крутится один и тот же поток, а переключения контекста и промахи кэша сводятся к минимуму.1
- Значение concurrency ограничивает число выполняемых потоков, а отправная точка — число процессоров (передача 0). Если выполняющийся поток блокируется, ожидающие потоки восполняют нехватку, но держать обработку завершения короткой остаётся главным правилом.12
PostQueuedCompletionStatusпозволяет проводить через порт и собственные пакеты. В новой реализации первый кандидат — Windows API пула потоков (внутри IOCP).31- Пул потоков .NET — двухэтажная конструкция из рабочих потоков и потоков завершения ввода-вывода, и асинхронные дескрипторы привязываются к IOCP пула. На время ожидания ввода-вывода в
awaitпотока не существует; на поток «садится» только продолжение после завершения.456 - Куда попадёт продолжение, зависит от захваченного контекста (возврат в поток интерфейса или продолжение в пуле потоков).
ConfigureAwait(false)— указание прекратить этот захват.6 - Источник затора почти всегда — голодание пула потоков из-за примешавшегося синхронного ожидания. Проводите асинхронный путь асинхронным до конца.
Далее — часть 4, «Диспетчер кэша — когда ваш WriteFile на самом деле попадает на диск». В части 2 было сказано, что «если данные уже в кэше, завершение происходит синхронно», и в этой статье тень кэша тоже мелькала несколько раз. Дальше мы займёмся самим кэшем: отложенная запись, опережающее чтение, FILE_FLAG_NO_BUFFERING и условия, при которых «данные, которые вы считали записанными, исчезают при отключении питания».
Похожие статьи
- Глубины ввода-вывода Windows (часть 1) — каждое чтение и запись становятся IRP: общая картина системы ввода-вывода
- Глубины ввода-вывода Windows (часть 2) — синхронный и асинхронный ввод-вывод: что на самом деле означает OVERLAPPED
- Практическая таблица решений для C# async/await — Task.Run и ConfigureAwait
- WPF и WinForms: async/await и поток UI
- TCP не отдаёт Receive теми же порциями, что Send — приём как байтовый поток
- Как найти причину «тормозов» с PerfView и dotnet-trace — практика анализа производительности .NET
- Практическое руководство: soft real-time на обычной Windows
Смежные области консультаций
Komura Software LLC занимается проектированием приложений и серверов Windows, работающих с большим числом одновременных соединений и параллельного ввода-вывода, и разбором причин проблем производительности вроде «пул потоков забивается» или «сделали асинхронно, а быстрее не стало».
- Разработка приложений Windows
- Расследование дефектов и анализ причин
- Разработка Windows-приложений для soft real-time
- Связаться с нами
Справочные материалы
-
Microsoft Learn, I/O Completion Ports. О том, что порты завершения ввода-вывода дают эффективную модель работы с потоками для обработки большого числа асинхронных запросов ввода-вывода на многопроцессорных системах; что при завершении асинхронной операции пакеты завершения помещаются в очередь порта в порядке FIFO; что объектом могут быть не только файлы на диске, но и любой дескриптор, поддерживающий overlapped I/O, — сокеты, именованные каналы, почтовые слоты; что потоки, ожидающие на порту, освобождаются в порядке LIFO и при значении concurrency, равном 1, и непустой очереди переключения потоков не происходит; что поток связывается с портом при первом вызове GetQueuedCompletionStatus и может быть связан только с одним портом одновременно; что значение concurrency ограничивает число выполняемых потоков, а наилучшим максимальным значением в целом является число процессоров; что ожидающий поток может обработать пакет завершения, когда выполняющийся поток переходит в состояние ожидания по другой причине (и что проснувшийся заблокированный поток может временно превысить предел); и о том, что новым серверным приложениям следует сначала рассмотреть Windows API пула потоков (CreateThreadpoolIo и другие, использующие IOCP внутри). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20
-
Microsoft Learn, CreateIoCompletionPort function. О том, что CreateIoCompletionPort и создаёт новый порт завершения ввода-вывода, и связывает дескриптор с уже существующим портом; что при связывании можно указать CompletionKey (пользовательское значение), который попадает в пакет завершения; и что NumberOfConcurrentThreads ограничивает число потоков, способных параллельно обрабатывать пакеты завершения, а при значении 0 используется число процессоров системы. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, PostQueuedCompletionStatus function. О том, что PostQueuedCompletionStatus позволяет поместить определённый приложением пакет завершения в очередь порта завершения ввода-вывода, не начиная никакой асинхронной операции ввода-вывода, и что благодаря этому порт может служить каналом связи от других потоков процесса, помимо приёма завершений ввода-вывода. ↩ ↩2 ↩3
-
Microsoft Learn, The managed thread pool. О том, что пул потоков .NET предоставляет рабочие потоки и потоки для завершения асинхронного ввода-вывода; что ThreadPool.GetAvailableThreads позволяет получать доступное число рабочих потоков и потоков завершения ввода-вывода по отдельности; и что потоки пула потоков не следует блокировать надолго. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, ThreadPoolBoundHandle.BindHandle method. О том, что ThreadPoolBoundHandle.BindHandle возвращает ThreadPoolBoundHandle, связывающий дескриптор операционной системы с системным пулом потоков (и его портом завершения ввода-вывода); что низкоуровневый асинхронный ввод-вывод по связанному дескриптору выполняется вместе с NativeOverlapped; и что завершение асинхронных операций после этого обрабатывается пулом потоков. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Async in depth (.NET). О том, что для задачи, ограниченной вводом-выводом, после передачи вызова операционной системе нет выделенного потока, ожидающего завершения (идея «потока нет»); что завершение сигнализируется через драйверы устройств и прерывания, а зарегистрированное продолжение выполняется; что await по умолчанию захватывает текущий контекст (например, SynchronizationContext) и выполняет продолжение в нём, переходя в пул потоков, если захватывать нечего; и что ConfigureAwait(false) отключает этот захват. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, GetQueuedCompletionStatus function. О том, что GetQueuedCompletionStatus извлекает один пакет завершения из очереди порта (ожидая, если его нет); что в полученном результате есть число переданных байт, CompletionKey и указатель на OVERLAPPED; и что возврат FALSE вместе с ненулевым указателем OVERLAPPED означает «получен пакет завершения неудачной операции ввода-вывода», тогда как NULL-указатель OVERLAPPED сам по себе означает, что пакет получить не удалось (например, из-за таймаута). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, GetQueuedCompletionStatusEx function. О том, что GetQueuedCompletionStatusEx может извлекать несколько пакетов завершения сразу и что возвращается число извлечённых записей. ↩ ↩2
-
Microsoft Learn, SetFileCompletionNotificationModes function. О том, что FILE_SKIP_COMPLETION_PORT_ON_SUCCESS позволяет не помещать пакет в порт завершения, когда операция успешно завершается сразу и результат уже известен на месте. ↩
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Глубины ввода-вывода Windows (часть 4) — диспетчер кэша: когда WriteFile оказывается на диске
Четвёртая часть серии со схемами диспетчера кэша Windows. Разбираем кэш как проекцию файла, упреждающее чтение и отложенную запись, когда...
Глубины ввода-вывода Windows (часть 2) — синхронный и асинхронный ввод-вывод: что на самом деле означает OVERLAPPED
Вторая часть серии с диаграммами разбирает синхронный и асинхронный ввод-вывод (overlapped I/O) в Windows: смысл FILE_FLAG_OVERLAPPED, че...
Глубины ввода-вывода Windows (часть 1) — каждое чтение и запись становятся IRP: общая картина системы ввода-вывода
Первая часть серии о вводе-выводе Windows с самого низа: пространство имён диспетчера объектов, драйвер, объекты устройства и файла, жизн...
Как работать с токенами олицетворения Windows — олицетворение на уровне потока и безопасный откат
В статье собраны практические правила безопасной работы с токенами олицетворения Windows: токен доступа, первичный токен, токен потока, у...
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Что такое порт завершения ввода-вывода (IOCP)?
- Это механизм ядра Windows, который сводит уведомления о завершении множества асинхронных операций ввода-вывода в одну очередь и заодно управляет тем, сколько потоков могут обрабатывать их одновременно. Порт создаётся вызовом CreateIoCompletionPort, с ним связывают дескрипторы файлов, сокетов и других объектов, и всякий раз, когда асинхронная операция ввода-вывода завершается, в FIFO-очередь порта помещается пакет завершения. Рабочие потоки извлекают пакеты из очереди вызовом GetQueuedCompletionStatus и обрабатывают их. Ключевой момент в том, что это не просто очередь уведомлений: она ещё и работает как механизм планирования, который удерживает число выполняемых потоков на уровне значения concurrency или ниже. Это позволяет эффективно обслуживать большое число одновременных операций ввода-вывода небольшим числом потоков и лежит в основе как серверных реализаций Windows, так и пула потоков .NET.
- Каким должно быть значение concurrency (уровень параллелизма) у IOCP?
- Документация Microsoft указывает, что наилучшее максимальное значение в целом — число процессоров компьютера. Если передать 0 в параметре NumberOfConcurrentThreads вызова CreateIoCompletionPort, будет использовано число процессоров системы, так что 0 — это отправная точка, если вы не уверены. Это значение ограничивает число выполняемых потоков, а не число ожидающих потоков. Если выполняющийся поток по какой-либо причине переходит в состояние ожидания, система разбудит другой ожидающий поток, чтобы закрыть нехватку, поэтому если в обработке есть длительные вычисления или блокировки, можно выбрать и большее значение concurrency, чтобы за раз обрабатывалось больше пакетов. В конечном счёте рекомендуется подбирать его вместе с профилированием.
- Почему можно утверждать, что ожидание ввода-вывода в async/await не потребляет поток?
- Потому что между выдачей операции ввода-вывода и её завершением нигде нет выделенного потока, который присматривал бы за этой операцией. Как мы видели в частях 1 и 2, выданный запрос уходит вниз по стеку устройств в виде IRP, а вызов сразу возвращает управление с ERROR_IO_PENDING. await в этот момент лишь регистрирует продолжение у ещё не завершённой задачи Task и отдаёт поток. Пока устройство выполняет свою работу как оборудование, ни в пользовательском режиме, ни в ядре нет потока, который просто ждёт. Когда операция завершается, пакет завершения помещается в IOCP пула потоков, и только тогда поток завершения ввода-вывода ненадолго запускается, чтобы запланировать зарегистрированное продолжение. Иными словами, поток используется только в момент выдачи и при обработке после завершения; само время ожидания проходит без единого потока.
- На каком потоке выполняется продолжение await?
- По умолчанию захватывается SynchronizationContext (или TaskScheduler), действующий в точке await, и продолжение возвращается в него. Если await выполняется в потоке интерфейса WPF или WinForms, продолжение выполняется в потоке интерфейса — поэтому после await можно сразу обращаться к элементам управления. Когда захватывать нечего (консольное приложение, ASP.NET Core, код, уже работающий в пуле потоков), продолжение либо выполняется в потоке пула потоков, либо продолжается прямо в том потоке, который завершил задачу Task. Указание ConfigureAwait(false) прекращает захват, но это не гарантия перехода в пул потоков, а предписание, что продолжению не нужно возвращаться в определённое место. Если await применяется к уже завершённой задаче Task, ожидания не возникает и выполнение синхронно продолжается в текущем потоке. В библиотечном коде ConfigureAwait(false) рекомендуется, чтобы избежать лишних возвратов в поток интерфейса и отсечь зависимость от конкретного контекста и предпосылки взаимных блокировок.
- Что будет, если надолго заблокироваться внутри рабочего потока IOCP или потока завершения ввода-вывода .NET?
- Мгновенной поломки не произойдёт, но производительность упадёт, потому что вы вышли за рамки, на которые рассчитан механизм. IOCP будит ожидающий поток для восполнения всякий раз, когда выполняющийся поток переходит в состояние ожидания, но каждое восполнение увеличивает уровень параллелизма и число переключений контекста. Если блокировки становятся постоянными, пакеты накапливаются в очереди и обработка завершений в целом задерживается. В .NET всё так же: синхронное ожидание внутри потока завершения ввода-вывода или продолжения — на синхронном вводе-выводе или, например, на Task.Result — ведёт к голоданию пула потоков. Правило — держать обработку завершений и продолжения короткими, а тяжёлую работу выносить в другое место. Наблюдать свободные рабочие потоки и потоки завершения ввода-вывода можно через ThreadPool.GetAvailableThreads, что полезно при разборе узкого места.
Об авторе
Страница с профилем автора статьи.
Го Комура
Представитель KomuraSoft LLC
Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.