В предыдущих сериях

Микрооптимизации:

  1. Сказка про Method as Parameter #dotnet #methods #gc

  2. Инструменты анализа эффективности работы приложения. PerfView #performance_analysis #trace #perfview

  3. Пародия на замыкания #dotnet #methods #gc

  4. yield return #dotnet #il-code

Про тредпул:

  1. ThreadPool.Intro #dotnet #threadpool

  2. ThreadPool. async/await #dotnet #threadpool #il_code

Про низкоуровневое:

  1. Reciprocal throughput #microoptimization #low-level

  2. Сказка про Branch prediction #microoptimization #low-level

Разное:

  1. Сказка про Guid.NewGuid() #os_specific #dotnet #microoptimization

Я считаю важным понимать, что такое тредпул. Понимание его внутреннего устройства — его назначения, его философии — позволяет правильнее его использовать. Позволяет лучше понимать, почему с программой происходит то или иное.

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

Историцизм на текущую тему. Для того, чтобы узнать самое интересное, читать текст под катом желательно.

ОС даёт нам абстракцию потока (thread). «Внутри потоков» работает наша программа. Она может даже запросить несколько потоков и работать в нескольких потоках одновременно!

Чтобы мы могли одновременно слушать музыку, печатать код в IDE, синхронизировать время на компьютере, и отрисовывать поверх всех окон прилетевшие алерты, ОС как-то жонглирует потоками, уделяя время каждому из них по чуть-чуть. Кому-то больше, кому-то меньше. А если ОС нашла под собой много ядер, она даже может исполнять несколько потоков параллельно!

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

Эта абстракция — пул потоков. Приложение выделяет себе какой-то набор потоков у ОС, и начинает нагружать их всяческими задачами самостоятельно, балансируя каким-то образом нагрузку, распределяя задачи по потокам. Сейчас чуть-чуть http-запросов пообрабатывали, тут немного фоновых процессов поделали, тут метрику отправили, и так далее.

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

И в .Net'е таким инструментом, таким примитивом описания этих задач являются именно Task'и. Но оперировать голыми тасками довольно затруднительно. Кто-нибудь наверняка помнит, или хотя бы видел, как выглядел тредпул и работа с тасками в самых первых версиях C#. Или до сих пор выглядит в некоторых других языках программирования. Нужно было явно описывать цепочки задач, что за чем выполняется, какие коллбеки и при каких условиях нужно исполнять.

И потому работа с тредпулом прочно засела ещё и в сам язык C#. Появились различные ключевые слова, такие как async и await, позволяющие инженеру пользоваться таким замечательным инструментом так легко.

На мой взгляд, полезно понимать, как именно наши таски исполняются на тредпуле. В какой момент исполнения тасок под ними могут меняться треды ОС? Какая часть работы в цепочке вызовов асинхронных методов выполняется синхронно, а какая асинхронно? От этих особенностей многое зависит, а от понимания этих нюансов может зависеть эффективность использования этого инструмента. Давайте разбираться.

Важно: мы пока не рассматриваем то, как работает сам тредпул (ну почти), мы рассматриваем то, как работа тасок маппится на потоки приложения.

Экспериментируем

Рассмотрим какой-нибудь типичный случай. Например, http-сервер. Получив http-запрос, сервер запускает таску (Task) по обработке этого запроса. И в процессе исполнения этой таски обычно происходит обработка какой-то цепочки await-вызовов async-методов.

По цепочке асинхронных вызовов методов иногда можно дойти до «по-настоящему» асинхронных вызовов. Например, обращение к БД или к другому микросервису, которое может длиться сколько угодно долго.

Давайте эмулировать такую цепочку асинхронных вызовов. А в качестве «по-настоящему» асинхронного ожидания возьмём просто Task.Delay();. Ну не городить же в тренировочном примере обращение к БД. Уверяю вас, для нашего примера Task.Delay подойдёт также хорошо, как и честный асинхронный сетевой запрос.

Цепочка будет такой: Demonstrate() -> await DoA() -> await DoB() -> await DoC() -> Task.Delay(). До и после каждого await вызова будем писать в лог, в каком потоке мы находимся и какую таску выполняем.

class ThreadPoolChain
{
    private static Random random = new Random();
 
    public static async Task Demonstrate()
    {
        for (var i = 0; i < 10_000; i++)
        {
            Log($"Cycle {i} begins");
            await DoA(i);
            Log($"Cycle {i} ends");
        }
    }
 
    private static async Task DoA(int index)
    {
        LogBegins("A", index);
        await DoB(index);
        LogEnds("A", index);
    }
 
    private static async Task DoB(int index)
    {
        LogBegins("B", index);
        await DoC(index);
        LogEnds("B", index);
    }
 
    private static async Task DoC(int index)
    {
        LogBegins("C", index);
        await Task.Delay(random.Next(1, 300));
        LogEnds("C", index);
    }
 
    private static void LogBegins(string method, int taskIndex)
    {
        Log($"{method} begins: ThreadId = {Thread.CurrentThread.ManagedThreadId}, Task = {taskIndex}");
    }
 
    private static void LogEnds(string method, int taskIndex)
    {
        Log($"{method} ends  : ThreadId = {Thread.CurrentThread.ManagedThreadId}, Task = {taskIndex}");
    }
 
    private static void Log(string s)
    {
        Console.WriteLine(s);
    }
}

Запустим этот код. Давайте внимательно изучим его вывод, я специально раскрасил и выделил цветами интересные закономерности.

Так, синей линией разделяются «индексы» тасок, которые выполняются. То есть это моменты, когда основной цикл запускает в работу новую цепочку вызовов async-методов.

А красными и жёлтыми прямоугольниками я выделил интересные закономерности. Это сплошные промежутки, когда работал один и тот же поток.

Сначала работал поток номер 1, выполняя всю работу таски номер 0, пока не дошел до настоящего асинхронного ожидания (Task.Delay).

Затем, с момента, как Task.Delay завершился, с нашей цепочкой асинхронных вызовов работал поток номер 4. Он выполнял весь код, который после await-вызовов во всех DoA, DoB, DoC. А потом всё он же начал выполнять весь код по новой цепочке вызовов таски с номером 1 в DoA, DoB, DoC. И на Task.Delay он, скорее всего, тоже прервался. Но ему снова «повезло» быть выбранным для работы и повторить этот цикл снова: завершить работу таски номер 1 и начать работу таски номер 2.

Затем работал поток номер 5. Он успел провернуть всего один "цикл": выполнить весь код после Task.Delay у таски номер 2, и выполнить весь код перед Task.Delay у следующей таски номер 3.

И так далее.

Почему иногда одному и тому же потоку достаётся два раза подряд (как например потоку 4) или даже три раза подряд (как например потоку 6) прокрутить эту цепочку асинхронных вызовов? Скорее всего потому, что в приложении больше никакой активности нет, все потоки ничем не заняты, и продолжать выполнять код после Task.Delay() достаётся «любому свободному». А свободные у нас все, вот иногда и достаётся тому же самому.

Можно попробовать изменить эту ситуацию. Запустим в нашем приложении перед нашим Demonstrate паразитную нагрузку на тредпуле, которая будет постоянно что-то делать и мешаться нам:

private static int X;
private static Random random = new Random();
 
static void Main(string[] args)
{
    ThreadPool.SetMinThreads(100, 100);
 
    for (var i = 0; i < 100; i++)
    {
        Task.Run(async () =>
        {
            while (true)
            {
                await Task.Delay(random.Next(0, 5));
                Interlocked.Increment(ref X);
            }
        });
    }
 
    ThreadPoolChain.Demonstrate().GetAwaiter().GetResult();
}

У нас будет крутиться аж 100 тасок, которые иногда просыпаются, чтобы сделать очень короткую работу, и снова засыпают. Важно, что засыпают они асинхронно, не занимая поток: с помощью await Task.Delay().

ThreadPool.SetMinThreads(100, 100) в самом начале мы сделали для того, чтобы заранее "создать" 100 потоков. Детали и нюансы этого вызова мы разберём когда-нибудь позднее, а пока что это важно сделать, чтобы избежать кое-каких сайд-эффектов и они не мешали нам играться.

Давайте посмотрим, как теперь ведёт себя наш эмулятор цепочек вызовов async-методов:

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

Если попытаться раскрасить в подобные цвета код, для наглядности, получится что-то такое:

Начать смотреть такую «раскраску» удобного с метода DoC.

Пусть мы зашли в метод DoC из потока X при работе с задачей под номером i и дошли до Task.Delay. Здесь поток X прекращает работу с задачей i, потому что встречает асинхронное ожидание.

В DoC после асинхронного ожидания мы продолжаем выполнять весь код после Task.Delay уже в каком-то другом потоке Y и выходим из метода всё в том же потоке Y. И это всё ещё при работе с задачей под номером i.

Выйдя из DoC, мы попали во вторую половину метода DoB и продолжили выполнять её в потоке Y, всё ещё работая с задачей под номером i.

Выйдя из DoB, мы попали во вторую половину метода DoA и продолжили выполнять её в потоке Y, всё ещё работая с задачей под номером i.

Выйдя из DoA, мы попали во вторую половину метода Demonstrate и продолжили выполнять её в потоке Y, завершая работу с задачей под номером i. Затем мы начинаем работать с задачей i+1, причем всё ещё в потоке Y, и вызываем DoA.

Войдя в DoA, мы попали в первую половину метода DoA и выполняли её в потоке Y, работая с задачей i+1, пока не зашли в DoB.

Войдя в DoB, мы попали в первую половину метода DoB и выполняли её в потоке Y, работая с задачей i+1, пока не зашли в DoC.

Войдя в DoC, мы попали в первую половину метода DoC и выполняли её в потоке Y, работая с задачей i+1, пока не дошли до Task.Delay, где поток Y прекращает работу с задачей i+1.

Дальше снова, как сначала, только после Task.Delay поток Y сменится на какой-нибудь другой поток W.

Выводы

Какой можно сделать вывод из нашей игрушки?

  • Наши асинхронные методы выполняют свою работу в каких-то вполне конкретных потоках.

  • Всю цепочку await-вызовов async-методов выполняет один и тот же конкретный поток до тех пор, пока не упрётся в «по-настоящему» асинхронное ожидание. Таковым является, например, Task.Delay(). А вот вызывая метод await httpClient.SendAsync() .NET, на самом деле, выполнит синхронно в этом же потоке ещё тонну кода (внутри http-клиента) перед тем, как упрётся в настоящий асинхронный вызов ожидания ответа от сокета где-то в самых недрах.

  • Когда поток добрался до «по-настоящему» асинхронного ожидания, он прекращает работу в этой цепочке await-вызовов async-методов и идёт искать себе новую работу, например работать над ещё одной такой цепочкой.

  • Когда «по-настоящему» асинхронное ожидание закончило «ожидать», выбирают первый попавший свободный поток и поручают ему выполнять всю дальнейшую работу после этого await'а до следующего ближайшего «по-настоящему» асинхронного ожидания. (Помните же про автомат?)

  • То есть, в теории, во всей цепочке await-вызовов async-методов можно не встретить ни одного «по-настоящему» асинхронного ожидания (например, мы не зайдём в него из-за if'а в самый последний момент) и весь этот код спокойно и совершенно синхронно выполнится в одном и том же потоке, без каких либо прерываний и переключений.

Комментарии (8)


  1. mayorovp
    20.06.2023 05:53
    +4

    Что-то вы обещали рассказать про пул потоков, а в итоге рассказали как оператор await работает. И даже этот рассказ не довели до конца — к примеру, так и не были упомянуты возможные взаимоблокировки или переполнения стека из-за подобного поведения и решение через RunContinuationsAsynchronously.


    1. aegoroff
      20.06.2023 05:53
      +3

      мне тоже показалось что вся суть статьи поместилась в разделе Выводы


    1. deniaa Автор
      20.06.2023 05:53
      +2

      Я нахожу несколько странным отвечать на такие вопросы, но я всё же попробую.

      Что-то вы обещали рассказать про пул потоков, а в итоге рассказали как оператор await работает.

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

      так и не были упомянуты возможные взаимоблокировки или переполнения стека из-за подобного поведения и решение через RunContinuationsAsynchronously

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

      Вы перечисляете в самом деле важные и интересные вещи. Но они - одни из многих других интересных. И я действительно планировал рассмотреть в рамках этой серии статей и эти темы.

      Суммируя всё выше сказанное, да, возможно, Habr не идеально подходит для формата блога взаимосвязанных статей. Или я не умею им пользоваться для успешного решения такой задачи. И я не считаю полезным в каждой статье из серии делать десятки отступлений, что это не полный гайд и не весь возможный набор информации, что это серия, и что были предыдущие статьи на тему, и будут будущие, и что да, вот эти N важных кусков информации здесь не рассмотрены. На мой взгляд достаточно упомининия блока "В предыдущих сериях" в начале. С удовольствие послушаю другую точку зрения на этот счет.


      1. mayorovp
        20.06.2023 05:53
        +1

        В самом начале статьи есть ссылки на все предыдущие. Среди них есть блок про ThreadPool в общем смысле. Эта статья — одна из этого блока.

        Ну вот я и не считаю что эта статья должна быть в блоке про пул потоков.


        Я предпочитаю получать и делиться информацией порционно, не перегружая контент ничем лишним.

        В данном случае лично я ощущаю недогруженность.


        1. deniaa Автор
          20.06.2023 05:53

          Ну вот я и не считаю что эта статья должна быть в блоке про пул потоков.

          Я объяснил свою логику: один из предлагаемых мной шагов в изучении работы тредпула, один из способов понять как он работает - рассмотреть, как выглядит снаружи его работа, с точки зрения его использования через предоставляемый нам синтаксис. И в статье явно продемонстрировано, в каком именно моменте происходит смена потоков из пула, работающих над нашими задачами, как это связано с C#-синтаксисом. По-моему, это в точности о пуле потоков. Излишнюю гранулярность и "недозагруженность" оставим за скобками.

          В данном случае лично я ощущаю недогруженность.

          Не считаю рациональным продолжать разговор в плоскости персонального восприятия загруженности контента. Угодить всем нельзя. А оценку "зашло" или "не зашло" оставлю на интерпретацию статистики, которую могу извлечь из Хабра. В частности, это голоса за статьи и комментарии.

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


  1. Vanirn
    20.06.2023 05:53

    Статья интересная!
    Порционный подход очень нравится - позволяет читать такие выжимки в "перерывах на обед".
    Определённо продолжайте!


    1. deniaa Автор
      20.06.2023 05:53
      +1

      Спасибо :)


  1. kievzenit
    20.06.2023 05:53

    Спасибо вам за ваши статьи! Всегда читаю с большим удовольствием, умеете понятно и просто объяснить сложное!