Нет никакого потока

Original author: Stephen Cleary
  • Translation
Важная правда об асинхронности в своей первозданной форме: нет никакого потока.

Тех, кто возразит, несть числа. «Нет», кричат они, «если я ожидаю операцию, должен быть поток, в котором выполняется ожидание! Возможно это поток из пула. Или поток операционной системы! Или что-то, связанное с драйвером устройства...»

Не внемлем этим крикам. Если операция по-настоящему асинхронная, то никакого потока нет.

Скептики не убеждены. Высмеем же их.

Проследим выполнение асинхронной операции вплоть до железа, уделив особое внимание платформе .NET и драйверу устройства. Нам придется упростить это описание, опустив некоторые детали, но мы не уйдем далеко от истины.

Рассмотрим некоторую операцию «записи» (в файл, сетевой поток, USB-тостер, куда угодно). Наш код прост:

private async void Button_Click(object sender, RoutedEventArgs e)
{
  byte[] data = ...
  await myDevice.WriteAsync(data, 0, data.Length);
}


Мы уже знаем, что UI поток не блокируется во время ожидания. Вопрос: существует ли другой поток, который приносит себя в жертву на Алтаре Блокировки, чтобы UI поток мог жить?

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

Первая остановка: библиотека (например, код BCL). Мы предполагаем, что WriteAsync реализован с использованием стандартной системы асинхронного ввода-вывода в .NET фреймворке, которая основана на overlapped I/O*. Т.о. запускается Win32 overlapped I/O операция с указанием ДЕСКРИПТОРА устройства.

ОС в свою очередь обращается к драйверу устройства и просит его начать операцию записи. Эта просьба представляет собой объект, который описывает операцию записи; такой объект называется пакетом запроса ввода-вывода (I/O Request Packet, IRP).

Драйвер получает IRP пакет и отдает устройству команду начать запись данных. Если устройство поддерживает режим прямого доступа к памяти (DMA), то выполнение команда заключается всего лишь в записи адреса буфера в регистр устройства. Это все, что может сделать драйвер; он помечает IRP пакет как «выполняющийся» и возвращает управление ОС.

image

Здесь заключается суть: драйверу устройства не разрешено блокировать управление во время обработки IRP пакета. Это значит, что если IRP пакет не может быть обработан немедленно, он должен быть обработан асинхронно. Это справедливо даже для синхронных методов! На уровне драйвера устройства все (нетривиальные) запросы являются асинхронными.

Цитируя Тома Мудрости, «Вне зависимости от типа I/O запроса, все операции ввода-вывода, порученные драйверу приложением, выполняются асинхронно.»

С IRP пакетом в статусе «выполняется» ОС возвращается в библиотеку, которая возвращает незавершенную задачу в обработчик нажатия кнопки, что в свою очередь приостанавливает выполнение метода, и UI поток продолжает свое исполнение.

Мы проследовали за запросом в самую бездну системы, до вплоть до физического устройства.

Теперь операция записи в процессе выполнения. Сколько потоков выполняют ее?

Нисколько.

Ни поток драйвера устройства, ни поток ОС, ни поток BCL, ни поток из пула не выполняют эту операцию записи. Нет никакого потока.

Теперь давайте проследим за ответом, следующим из владений демонов назад в мир смертных.

Через некоторое время после начала операции устройство завершает запись. И уведомляет об этом процессор при помощи прерывания.

Вызывается обработчик прерывания драйвера. Прерывание является событием уровня процессора, поэтому любая работа, которой был занят процессор, временно приостанавливается, вне зависимости от того, какой поток в данный момент выполнялся. Можно считать, что обработчик прерывания «одалживает» выполняющийся поток, однако я придерживаюсь мнения, что обработчики прерываний выполняются на таком низком уровне, что понятие «потока» не существует; они выполняются «под» всеми потоками, так сказать.

Если обработчик прерываний написан правильно, то все, что он делает, это говорит устройству «Спасибо за прерывание» и ставит в очередь объект отложенного вызова процедуры (Deferred Procedure Call, DPC).

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

DPC берет IRP пакет, который представляет собой запрос на запись, и помечает его как «завершенный». Однако этот статус существует только на уровне операционной системы; процесс имеет собственное адресное пространство, и его тоже нужно уведомить. Поэтому ОС создает специальный-уровня-ядра объект асинхронного вызова процедуры (APC) и помещает его в очередь того потока, который владеет ДЕСКРИПТОРОМ.

Поскольку библиотека/BCL использует стандартный механизм overlapped I/O, то она уже привязала дескриптор к порту завершения ввода-вывода (I/O Completion Port), который является частью пула потоков. Поэтому для выполнения APC используется поток из пула потоков ввода-вывода**, который и уведомляет задачу о том, что она завершена.

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

Итак, мы видим, что пока операция выполнялась, не было никакого потока. Когда операция завершилась, разные потоки использовались для быстрого выполнения различных задач. Порядок времени выполнения таких задачи — от миллисекунды (например, выполнение APC в потоке пула) до микросекунды (например, обработка прерывания). Но никакой поток не был заблокирован, ожидая завершения операции.

image

Цепочка выполнения, которую мы проследили, является «стандартной», в чем-то упрощенной. Существует бесчисленное количество вариаций, однако суть остается той же.

Идея о том, что «где-то должен быть поток, выполняющий асинхронную операция», является ошибочной.

Освободите свой разум. Не пытайтесь найти этот «асинхронный поток» — это невозможно. Вместо этого, осознайте истину:

Нет никакого потока.



* — однозначного перевода на русский язык термина «overlapped I/O» мне не встречалось. Близким по смыслу является термин «асинхронный ввод-вывод» (прим. пер.).
** — в CLR существует два пула потоков: пул рабочих потоков и пул потоков ввода-вывода (прим. пер.).
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 24

    +2
    Я думаю, многие считают, что где-то есть какой-то заблокированный поток просто потому что сами только так и пишут… Из опыта объяснения асинхронности коллегам получается такой вот вывод.
      0
      А можете объяснить, что именно вы пытаетесь объяснить?

      Потому что в вашем объяснении то и время встречаются различные потоки либо прерывания, которые так или иначе участвуют в операции ввода/вывода. Возможно какой-то отдельный поток не создается на уровне приложения, однако где-то на уровне ОС и драйверов какие-то потоки все же задействованы. И если назвать процесс записи внутри устройства потоком, то он-то как раз и жертвует собой, в то время, когда другие процессы/потоки не заняты этой операцией?)
        +5
        Поток — это вполне определенный объект ядра операционной системы. Процесс записи внутри устройства не является потоком, потому что записи о нем в таблице потоков уровня ОС нет.

        Автор пытается объяснить вот это: «Но никакой поток не был заблокирован, ожидая завершения операции.»
          +5
          На уровне ОС и драйверов потоки действительно есть. И их, вы не поверите, ровно N штук, где N — число ядер в вашей системе. Просто потому, что каждый процессор в каждый момент времени чего-то там делает. Раз что-то там работает — вот вам и поток исполнения.

          Но обслуживать «эта ваша ОС» может тысячи запросов одновременно! При этом если вы используете TUX, то ничего, кроме «ядрёных» N потоков у вас так и не будет. Если используете Nginx — будет ещё столько же «пользовательских» потоков. И всё. Там больше ничего нет!

          Но такой подход используют только «настоящие джигиты». Большинство разработчиков предпочитают использовать потоки, связанны с запросами. Но нужно понимать, что как раз вот таких потоков в природе нет. Как раз потоки, связанные с запросами — это, действительно, иллюзия, создающаяся ядром ОС, рантаймом .NET и т.п. — исключительно для удобства программиста и ни для чего более.
            +2
            Спасибо за уточнение.

            Автор привязывается в описании к потоку при этом очевидно не раскрывая контекст. Если принять за контекст ограничение количества потоков, то статья приобретает смысл.

            Но опять же порождая массу уточнений. Каждый из потоков выполнит свою задачу (отработав ровно столько времени, сколько для этого потребуется) и освободится, и ни один из них не будет простаивать. При этом в определенных ситуациях у них все равно могут возникнуть взаимные блокировки из-за внешних ограничений (н-р пропускная способность шины). Так что говорить об абсолютной свободе или отсутствии потоков в асинхронном I/O не приходится.
              0
              Зато можно говорить о потребляемых ресурсах. Как правило, отложенная задача занимает меньше памяти, чем занимал бы отдельный поток своим стеком.
            0
            На мой взгляд это подробное рассмотрение одного из возможных примеров, чем оператор await лучше — что переключение контекста происходит не где случайно получилось, а только там, где оно нужно, притом это определяется на самом «глубинном» уровне, на котором нужность переключения контекста известна лучше всего. И при этом для верхних уровней обеспечивается полная абстракция, обеспечивающая независимость от конкретных железа и ОСей.
            0
            Если устройство поддерживает режим прямого доступа к памяти (DMA), то выполнение команда заключается всего лишь в записи адреса буфера в регистр устройства.

            Но если оно не поддерживает, то поток всё же есть?
              +3
              Совершенно не обязательно. Возможна отправка данных устройству по прерываниям, когда устройство усвоило очередную порцию данных, то происходит прерывание, в обработчике которого скармливается очередная порция. Просто в отсутствии поддержки DMA эти порции будут сильно меньше.
                +1
                При наличии DMA оно часто тоже будет выполняться не в один DMA request. В этом плане запись по байту/слову и по N-байт через DMA (где N ограничено, например, размером DMA буфера и/или регистра для указания количества байт в данной DMA опреации) отличается только количеством прерываний, которое словит процессор. А какие кошеr'ные ошибки бывают в аппаратной реализации DMA…

                пример косяков в DMA STM32F40x/41x
                DMA2 data corruption when managing AHB and APB peripherals in a
                concurrent way


                When the DMA2 is managing AHB Peripherals (only peripherals embedding FIFOs) and also APB transfers in a concurrent way, this generates a data corruption (multiple DMA access).

                When this condition occurs:
                • The data transferred by the DMA to the AHB peripherals could be corrupted in case of a FIFO target.
                • For memories, it will result in multiple access (not visible by the Software) and the data is not corrupted.
                • For the DCMI, a multiple unacknowledged request could be generated, which implies an unknown behavior of the DMA.

                AHB peripherals embedding FIFO are DCMI, CRYPTO, and HASH. On sales types without CRYPTO, only the DCMI is impacted. External FIFO controlled by the FSMC is also impacted.

                www.st.com/st-web-ui/static/active/en/resource/technical/document/errata_sheet/DM00037591.pdf
              0
              del
                +1
                Это на дотнете нет. В Mono вполне себе есть отдельный I/O поток, вечно висящий в блокирующем вызове epoll.
                  0
                  Справедливое замечание. Но все же 1 поток для epoll — это далеко не то же самое, что и по потоку на операцию.
                  +2
                  Поток есть в любом случае. Просто потому, что сама концепция IOCP основана на потоках. Другое дело, что он действительно не блокируется в процессе обработки запроса. И вполне понятно почему — большинство операций ввода/вывода на физическом уровне являются асинхронными (возврат результата через прерывание). Однако посредник в виде потока всё же имеется.

                  А если говорить про .net, то там скорее всего будет вообще два потока — IOCP и собственно тот, в котором обрабатываем результаты.

                  Реальная же асинхронность без потоков возможна только если разрешить обработку прерываний из пользовательского софта (как в DOS'e), но это уже совсем другая тема…
                    0
                    Да, тут автор немножко схитрил. Просто один IOCP ждет завершения очень большого количества операций, а автор сосредоточился на развеивании заблуждения «один асинхронная операция — один заблокированный поток».
                      0
                      Просто потому, что сама концепция IOCP основана на потоках
                      Но при этом есть и режим Overlapped IO без IOCP — вот там используется APC и потоков и правда нет. Правда, в .NET чистый APC использовать нельзя…
                        0
                        Так все-таки, получается, есть поток, кроме вызывающего? Кто-то же должен все-таки запустить продолжение выполнения метода после получения результата асинхронной операции в каком-нибудь доступном потоке? А если речь не о чтении файла, а запросе к БД, кто, например, отсчитывает таймаут?
                          0
                          Тайм-аут отсчитывает таймер. Есть такая штука в виндах, похожа на поток, но не поток. На линуксе, наверное, это будет сигнал SIGALRM (точно не смотрел).

                          В случае Overlapped IO за запуск APC отвечает система. Он работает примерно как сигнал в никсах. В случае Overlapped IO через IOCP есть один выделенный поток который ждет на IOCP.
                            0
                            Если можно, продолжу, хотелось бы разобраться до конца в этой теме(= Все эти штуки, «похожие на поток», ну и IOCP, они же занимают процессорное время? Т. е., в простом случае однопроцессорной, одноядерной машины выполнение других задач блокируется на время запуска коллбэка, обработку из очереди IOCP задачи на запуск коллбэка или очередной тик таймера (хотя тут тоже не пойму, таймер то должен в реальном времени работать)?
                              0
                              Процессорное время занимает только выполняющийся код. IOCP — это системный объект, а не код. Таймер — не код.

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

                              Колбек — это код, он, конечно же, тратит процессорное время когда до него дошло дело.
                      0
                      Если рассмотреть на уровне голого WinAPI без всяких ".NET", то узким местом, по-моему, является APC. В пользовательском режиме вызов APC на потоке возможен только тогда, когда этот поток заблокирован (Alertable Wait). Некрасиво. Если вы поставили асинхронную операцию в очередь и ожидаете ее завершения по уведомлению через APC — то какой-то поток должен либо ждать в режиме Alertable Wait, либо периодически опрашивать статус операции, что еще хуже, т.к. нерационально использует процессорное время.

                      Вместо этого лучше использовать уведомление не по APC, а по Event. Event можно сигнализировать прямо из ядра, из DPC. Ваш поток делает следующее:

                      1) Заказывает некоторое количество асинхронных операций ввода-вывода с уведомлением по Event
                      2) Выполняет какие-то другие операции, например, обрабатывает данные из завершившихся ранее операций ввода-вывода;
                      3) Проверяет, завершились ли какие-либо из запущенных ранее операций ввода-вывода. Если завершились — переход к п.1.
                      4) Если работы нет и операции ввода-вывода не завершились — ожидание с помощью WaitForMultipleObjects. Поток может быть разбужен как по завершению операций ввода-вывода, так и по другим событиям.
                      5) Переход к п.1.
                        +1
                        Во-первых, как вы собираетесь проверять состояние события на 3м шаге? Насколько я знаю, это можно сделать только через ожидание с нулевым тайм-аутом, однако такое ожидание будет Alertable Wait. Таким образом, задачи «проверить, не установлено ли событие» и «проверить, не пришли ли APC» решаются одинакого — но тогда в чем принципиальная разница?

                        Или вы будете проверять операции ввода-вывода по одной? Но это же будет еще дольше, чем ожидание с нулевым тайм-аутом.

                        Во-вторых, есть такая вещь, как IO Completion Port. Фактически, это и есть то самое событие, которое вы и предлагали сделать. И, кстати, .NET, как было справедливо замечено выше, использует именно его.

                        В-третьих, несмотря на всю асинхронность, .NET никогда не был однопоточной платформой, и наличие отдельного потока, занимающегося ожиданием на IOCP, не является недостатком.
                          0
                          как вы собираетесь проверять состояние события на 3м шаге?

                          GetOverlappedResult. Ну, можно и через ожидание с нулевым таймаутом.
                          однако такое ожидание будет Alertable Wait

                          Зависит от флага, передаваемого функции ожидания (bAlertable). Может быть и просто ожидание без вызова APC (non-alertable)
                          задачи «проверить, не установлено ли событие» и «проверить, не пришли ли APC» решаются одинакого — но тогда в чем принципиальная разница?

                          Пожалуй, вы правы. Принципиальной разницы нет. Ну разве что, может быть, вызов GetOverlappedResult более дешев, чем вызов SleepEx с последующим вызовом APC. Но это надо исходники винды смотреть или хотя бы ReactOS.
                          Или вы будете проверять операции ввода-вывода по одной? Но это же будет еще дольше, чем ожидание с нулевым тайм-аутом.

                          Действительно, вы правы. По одной операции проверять долго. И если их запущено много — то наверно лучше пользоваться APC. Можно, конечно, назначить каждой операции по одному объекту Event и потом ждать нескольких с помощью WaitForMultipleObjects — но далеко не факт, что это будет эффективнее, чем через APC.
                            0
                            Зависит от флага, передаваемого функции ожидания (bAlertable). Может быть и просто ожидание без вызова APC (non-alertable)
                            Я имел в виду, что если делать любое ожидание — то можно заодно и «прочитать» очередь APC совершенно бесплатно.

                      Only users with full accounts can post comments. Log in, please.