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

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

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

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

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

  4. yield return #dotnet #il-code

  5. Сказка про For vs Foreach #dotnet #il_code #microoptimization

Про тредпул:

  1. ThreadPool.Intro #dotnet #threadpool

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

  3. ThreadPool.Chain #dotnet #threadpool

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

  1. Reciprocal throughput #microoptimization #low-level

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

Разное:

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

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

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

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

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

— Ты знаешь, как я разочарован. Опять.

В ответ живая фигура, вальяжно сидевшая на диване, устало и недовольно, неприлично глубоко и протяжно вздохнула.

Мучительно длинная тишина снова заполонила помещение. Насколько невыносимо было находиться в этой тишине, настолько же резко и уверенно её разорвал голос того, кто стоял у окна. Лишь один его тёмный силуэт, который единственный был виден на лунном свете из окна, создавал ощущение уверенности, силы. А его голос лишь многократно усиливал это ощущение.

— Ты кто? Мальчишка, который начитался дешевых романов и решил подражать тем, кем он на самом деле не является, и кем ему и не положено быть? Или ты один из тех выскочек и болванов, которым случайно повезло, и которые копируют лишь оболочку, не представляя из себя ничего стоящего внутри? Они потому выскочки и болваны, что не знают, какого это — быть настоящим человеком. Оттого и копируют лишь то, что видят в нас снаружи. А что должно быть внутри, они не знают. Потому что они сами внутри себя являются никем. И даже не догадываются, что нам, настоящим людям, прекрасно видно их зияющую внутреннюю пустоту.

Сидящему на диване слушать такое было неприятно, горько. Но он не смел отвечать.

— Ты же знаешь, когда-нибудь мне придётся передавать дела. И кому-то нужно будет управляться со всем этим. И ты также прекрасно знаешь, что если не тебе, так кому-нибудь другому. Я не потерплю, чтобы кто-то разрушил всё то, что создавал я и мои предшественники. Тебе было велено всего лишь скоординировать работу слуг на сегодняшнем ужине, и ты не справился даже с этим!

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

— Ха, какой вздор! Да, я мог нанять тебе больше слуг. Но ты видимо думаешь, что все проблемы решаются таким образом? Если бы мы всегда просто нанимали новых слуг, были ли бы мы такими богатыми? А ты уверен, что вообще справился бы с управлением такого большого числа рабочих? Это тебе сейчас так кажется, что только найми больше рабочих и сразу все станет работать намного быстрее и эффективнее. Нет, так не работает. И ты должен это усвоить. Ты ведь даже формулируешь задачи для рабочих неправильно!

— А как я дол..

— Что значит как? По-твоему, когда я ещё в самом начале своего пути оказался на месте машиниста поезда, несущегося на встречу обрушенного моста, я позвонил в American Bridge и сказал им «Добрый день, будьте так добры, постройте мне через полчаса новый мост, вот вам сто миллионов долларов»? Нет, я придумал как этот поезд остановить. Ты думаешь, разработчики DOOM сидели и ждали, когда диски будут вместимостью не примерно 500МБ, а побольше, чтобы сделать шедевр всех времён? Нет, они запихали установщик в 2.3MB, а в установленном виде целая преисподняя уместилась всего в 40 MB. Да ради этих ребят в RFC даже порт 666 зарезервировали! … Пока ты не научишься сам решать такие проблемы, ты никогда не станешь настоящим человеком. Так и останешься пустой оболочкой, подражающей тем, кем не являешься на самом деле. А без практики ты не научишься решать такие проблемы.

Как мы уже знаем, в дотнете наши async-методы превращаются в автоматы. И что наш (пользовательский) код, который мы написали при работе с Task'ами, исполняется непрерывно в одном и том же потоке вместе с кодом самого .NET'а, который собственно обслуживает эти автоматы, осуществляет переходы между их состояниями. Вместе с тем кодом, который понимает, что ему встретилось настоящее асинхронное ожидание, и что пора оставить текущую цепочку автоматов, и найти себе какой-то другой автомат, с которым никто не работает прямо сейчас (например, он был тоже оставлен ранее из-за асинхронного ожидания, и наконец-то случилось то, чего он ожидал, после чего можно продолжать работу), чтобы подобрать его на каком-то состоянии и продолжить работу в нём.

Давайте попробуем как-то описать, визуализировать, что происходит. Где-то у нас в процессе выделены какие-то потоки, которые заняты работой с Task'ами (как нашего кода внутри них, так и обслуживающего кода по связям и переходам между ними). Это не поток main-треда. Это не потоки, которые заняты фоновой сборкой мусора (GC-потоки). Это отдельные, специально выделенные для этого потоки.

Раз за работу тасок отвечает какой-то набор потоков, значит он ограниченный. Их точно не бесконечно много. А значит, этим ограниченным ресурсом нужно аккуратно пользоваться. Ведь легко себе представить, как можно занять все имеющиеся у нас под работу с Task'ами потоки. Например, используя синхронные ожидания — они блокируют поток. Что тогда будет делать .NET? И какое количество потоков под работу с Task'ами нам доступно?

Учимся измерять бассейн с нитками

Для начала научимся смотреть, а сколько потоков у нас заняты работой с Task'ами. В этом нам поможет стандартный класс ThreadPool. Раз в секунду будем спрашивать, сколько у нас всего максимум возможно потоков (GetMaxThreads), и сколько ещё можно создать (GetAvailableThreads). Так, с помощью MaxThreads - AvailableThreads, будем высчитывать количество уже созданных потоков (и, вероятнее всего, занятых какой-то работой). Ещё есть количество потоков, которые у нас сразу созданы и готовы к работе (GetMinThreads):

public static (int, int) GetPoolState()
{
    ThreadPool.GetMinThreads(out var minWorkerThreads, out var minIocpThreads);
    ThreadPool.GetMaxThreads(out var maxWorkerThreads, out var maxIocpThreads);
    ThreadPool.GetAvailableThreads(out var availableWorkerThreads, out var availableIocpThreads);
 
    return (minWorkerThreads, maxWorkerThreads - availableWorkerThreads);
}
 
private static void Main(string[] args)
{
    while (true)
    {
        var poolState = GetPoolState();
        Console.WriteLine($"Workers: {poolState.Item2}/{poolState.Item1}");
        Thread.Sleep(1000);
    }
}

Легко убедиться, что такой код не потребляет никаких ресурсов и раз в секунду печатает, сколько потоков у нас заняты:

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

Имитируем http-сервис

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

private static void Main(string[] args)
{
    Task.Run(StartHttpServiceImitation);
 
    while (true)
    {
        var poolState = GetPoolState();
        Console.WriteLine($"Workers: {poolState.Item2}/{poolState.Item1}");
        Thread.Sleep(1000);
    }
}
 
private static void StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(HandleHttpRequest);
        Thread.Sleep(1);
    }
}
 
private static async Task<int> HandleHttpRequest()
{
    //Some cpu work
    await Task.Delay(10000);
    return 42;
}

Что можно сказать, смотря на этот код? Что у нас каждую миллисекунду «прилетает» новый запрос и запускается таска по его обработке. А каждая таска «живёт» по ~10 секунд, и все эти 10 секунд ожидает «ответа от БД». То есть в каждый момент времени (спустя 10 секунд) у нас в процессе «работают» 10.000 живых Task (на самом деле значительно меньше, но всё равно много). Сколько же потоков тредпула будут заняты настоящей работой?

А занятых работой потоков тредпула совсем не 10к. Их очень мало, только те, что именно в данный момент заняты CPU работой нашей Task (которая крайне мала — всего лишь return 42).

А кто внимательный, может заметить, что одна Task у нас не «освобождает» поток никогда — это та таска, в которой работает метод StartHttpServiceImitation, и которая запускает новые таски по «обработке запросов». Она всегда держит поток занятым, поскольку делает Thread.Sleep(). А Thread.Sleep это не async-метод, который мы вызываем с использованием await. То есть Task'а при своей работе не меняет никаких состояний в своём автомате (а напомним, что их смена происходит в моменте await'а). А значит один поток всегда занят работой над одним состоянием автомата этой таски, которому не повезло оказаться «вечным циклом».

Имитируем идеальный http-сервис

Если не рассматривать ту таску, которая работает в методе StartHttpServiceImitation, то можно заметить, что бывают моменты, когда вообще никакой поток тредпула не работает над тасками с «полезной работой» (ожиданием «ответа от БД», то есть Task.Delay()). Я намеренно создал такую ситуацию. И сейчас мы от неё избавимся и посмотрим, что изменится. Избавляться будем таким образом: вместо блокирующего поток Thread.Sleep() используем await Task.Delay(). Важно, что не Task.Delay().Wait(), не Task.Delay().Result, не Task.Delay().GetAwaiter().GetResult(). А именно await Task.Delay(). Именно ключевое слово await заставит .NET отпустить поток, пока мы ждём завершения этого настоящего асинхронного ожидания. (Это не единственный способ, как мы видели в статье про async\await. И не забывайте про то, что не каждый await должен приводить к смене потока в цепочке await-вызовов async-методов.)

private static async Task StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(HandleHttpRequest);
        await Task.Delay(1);
    }
}
 
private static async Task<int> HandleHttpRequest()
{
    //Some cpu work
    await Task.Delay(10000);
    return 42;
}

Как замечательно. Ждущие таски есть, а потоков не занимают. Занимают только тогда, когда надо именно CPU работу сделать:

Можете поиграться сами с длительностью ожидания внутри Task и убедиться, что потребление потоков никак не изменится. Оно всё равно будет около нуля.

Я не зря назвал параграф «идеальным сервисом».

Потому что у по-настоящему идеального сервиса даже при нагрузке в почти 100% CPU потребление потоков из ThreadPool'а не должно превышать количество ядер. Если превышает — значит, вероятно, у вас в приложении есть места, которые могут привести к факапам. Впрочем, достичь такой идеальности на практике в суровой реальности очень сложно.

Нагружаем CPU-работой.

Мы выяснили, что потоки, которые отвечают за работу Task'ов, не напрягаясь могут обрабатывать тысячи Task. Но это если они честно и асинхронно чего-то ждут. Давайте проверим, что будет, если нагрузить Task'и CPU работой?

Пусть наш «хендлер» считает сумму всех простых чисел от 2 до int.MaxValue. Никакого Решета Эратосфена и прочих премудростей — мы хотим намеренно надолго загрузить Task'у CPU работой!

private static async Task StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(GetPrimeNumbersSum);
        await Task.Delay(1);
    }
}
 
private static async Task<int> GetPrimeNumbersSum()
{
    var primes = FindAllPrimeNumbers();
    await Task.Delay(10);
    return primes.Sum();
}
 
//Ты в самом деле будешь читать код этого метода?
//Это же совершенно не важно :)
private static List<int> FindAllPrimeNumbers()
{
    var primes = new List<int>();
    for (long i = 2; i <= int.MaxValue; i++)
    {
        var hasDivider = false;
        for (var j = 2; j*j <= i; j++)
        {
            if (i % j != 0) continue;
 
            hasDivider = true;
            break;
        }
 
        if (!hasDivider)
            primes.Add((int)i);
    }
 
    return primes;
}

Смотрите как интересно, теперь все восемь из восьми потоков заняты!

Ой, нет… 9/8 Потоков?

Они размножаются!

У меня на компьютере всего 4 ядра (и мультитрединг, то есть «типа восемь»). Понятно, что >= 8 потоков могут делать CPU работу и потреблять почти 100% CPU. Давайте пошаримся в процессе:

Потребление CPU действительно близко к 100% (больше ему не даёт ОС, всё-таки надо делать ещё кучу всякой другой работы):

А сколько наш процесс создал настоящих потоков ОС:

Тех, которые начинаются с coreclr_initialize+0x5db80 (и с большим числом Cycles Delta), 18 штук. То есть можно с уверенностью сказать, что действительно, 18 потоков ОС заняты работой с Task'ами.

А что, если потоки ничего не делают, но неправильно (синхронно) ждут?

Давайте проверим, что случится. Вместо «правильного» await DoAsync() можно было бы написать DoAsync().Wait()DoAsync().ResultDoAsync().GetAwaiter().GetResult(). Это всё не await — асинхронные — ожидания. А синхронные, или блокирующие ожидания.

private static async Task StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(SynchronousWait);
        await Task.Delay(1);
    }
}
 
private static async Task<int> SynchronousWait()
{
    Task.Delay(10000).Wait();
    return 42;
}

Вот что у нас с потоками с точки зрения как приложения, так и ОС:

CPU usage у такого процесса, ожидаемо, было около нуля. Всего всех потоков в приложении 108, а в тредпуле с точки зрения самого приложения 102. И даже ровно 6 не-тредпульных (не coreclr_initialize+0x5db80) на скрине и видно. Совпадает!

А как себя чувствует приложение, когда ему не хватает потоков?

Как мы могли заметить, когда все потоки для работы с Task'ами заняты, и есть Task'и, готовые к работе, дотнет вынужден создавать новые потоки. Чтобы было где исполнять готовые к работе Task'и.

Также можно заметить, что Task'и мы создаем достаточно часто — пауза между итерациями всего 1мс. При этом, длительность тасок очень высокая. А вот потоков для их исполнения совсем не много. И их количество растёт как-то медленно. Подозрительно, не правда ли? Кому-то же обязательно не хватает потоков для работы. А всё ли в порядке с приложением, когда оно в таком состоянии?

Давайте посмотрим, насколько свободно и непринуждённо работает приложение, когда у него "все хорошо", то есть в варианте из "Идеального http-сервиса". Когда все таски делают крайне мало CPU работы, честно и асинхронно ждут, и в приложении потоки под Task'и ничем не заняты. Например, давайте будем логировать время между запуском каждой следующей Task'и:

private static void Main(string[] args)
{
    Task.Run(StartHttpServiceImitation);
 
    while (true)
    {
        var poolState = GetPoolState();
        Console.WriteLine($"Workers: {poolState.Item2}/{poolState.Item1}");
        Thread.Sleep(1000);
    }
}
 
private static async Task StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(AsynchronousWait);
        await Task.Delay(1);
        Console.WriteLine(DateTime.UtcNow.ToString("hh:mm:ss.fff tt"));
    }
}
 
private static async Task<int> AsynchronousWait()
{
    await Task.Delay(10000);
    return 42;
}

Консолька свободно и монотонно "бежит", печатая ровно и в одинаковом темпе всё новые и новые строчки:

А теперь давайте применим логирование из метода StartHttpServiceImitation к варианту с синхронными ожиданиями:

Картинка резко изменилась. Пауза между итерациями иногда (и даже очень часто) стала очень большой, вплоть до секунды. Всё дело в том, что наша StartHttpServiceImitation тоже работает на тредпуле — её же запустили в Task.Run. Ей тоже нужны доступные потоки, чтобы в них исполнялось тело этой функции. И когда потоков не достаточно, а её await Task.Delay(1) закончился, некому начать исполнять код после await Task.Delay(1). Пока .NET не создаст новый поток и этому потоку не достанется под исполнение именно та Task'а, которая крутится в цикле в StartHttpServiceImitation.

А вспоминая, что мы имитировали настоящий http-сервис и предполагалось, что метод StartHttpServiceImitation принимает новые http-запросы и запускает их обработку (а мы написали это очень похоже на то, как это делается в реальном мире), получается, что в настоящем приложении некому будет даже начать обрабатывать настоящий http-запрос! То есть, просто написав некорректную работу с Task'ами в пользовательском коде хотя бы в одном месте, можно испортить вообще весь остальной сервис, все остальные работающие библиотеки и фоновые процессы в вашем приложении, как бы хорошо это всё не было написано.

Можно ли обойти эту проблему (хотя бы в текущем примере, для метода StartHttpServiceImitation)? Есть ли способы защищаться от того, что где-то в чужом месте очень плохо обращаются с пулом потоков? О да, и не один! Разберём сейчас только два самых тривиальных. Правда, от того и применимых на практике лишь в очень узких сценариях.

1. Не используйте Task'и для длительных (бесконечных) и периодических работ!

Как уже говорилось, не используйте Task'и для длительных работ ;)

Особенно, если вам действительно нужна точность и регулярность работы, когда не допустимы паузы и простои. Что можно предложить вместо Task? Естественно, отдельный и самостоятельный поток, никак не связанный с потоками из тредпула!

private static void Main(string[] args)
{
    new Thread(() =>
    {
        while (true)
        {
            Task.Run(SynchronousWait);
            Thread.Sleep(1);
            Console.WriteLine(DateTime.UtcNow.ToString("hh:mm:ss.fff tt"));
        }
    }).Start();
 
    while (true)
    {
        var poolState = GetPoolState();
        Console.WriteLine($"Workers: {poolState.Item2}/{poolState.Item1}");
        Thread.Sleep(1000);
    }
}
 
private static async Task StartHttpServiceImitation()
{
    while (true)
    {
        Task.Run(SynchronousWait);
        await Task.Delay(1);
        Console.WriteLine(DateTime.UtcNow.ToString("hh:mm:ss.fff tt"));
    }
}
 
private static async Task<int> SynchronousWait()
{
    Task.Delay(10000).Wait();
    return 42;
}

Так, наш отдельный поток работает стабильно и никак не зависит от того, насколько нагружен пул потоков для работы с Task'ами. Он лишь сам туда докидывает новых Task'ов (в нашем варианте). Вот, посмотрите, пул потоков уже забит (13/8), а наш тред работает неустанно, без тех дурацких пауз, что мы наблюдали ранее:

2. Присоединяйтесь ко злу, украдите поток себе, и никому не отдавайте!

Помните переход от "имитируем http-сервис" к "имитируем идеальный http-сервис"? В первом случае один поток был всегда занят нашим StartHttpServiceImitation. А что, если мы намеренно так сделаем? Заберём один поток себе и никому не отдадим! Будет ли в таком случае этот поток стабильно работать?

private static void Main(string[] args)
{
    Task.Run(StartHttpServiceImitationSynchronously);
 
    while (true)
    {
        var poolState = GetPoolState();
        Console.WriteLine($"Workers: {poolState.Item2}/{poolState.Item1}");
        Thread.Sleep(1000);
    }
}
 
private static void StartHttpServiceImitationSynchronously()
{
    while (true)
    {
        Task.Run(SynchronousWait);
        Thread.Sleep(1);
        Console.WriteLine(DateTime.UtcNow.ToString("hh:mm:ss.fff tt"));
    }
}
 
private static async Task<int> SynchronousWait()
{
    Task.Delay(10000).Wait();
    return 42;
}

Да, будет!

Мы отобрали себе один поток в методе StartHttpServiceImitationSynchronously, который стартанул в пуле потоков и никогда не делает await, крутится в вечном цикле. Соответственно он никогда не отдаёт свой поток другим для работы над другими Task'ами. Получается, что у него есть свой собственный поток и он никак не зависит от того, как быстро освободятся или создадутся новые потоки. Почти как из предыдущего варианта — свой персональный поток. Только не отдельный, а украденный из общего пула. Как грязно, как некрасиво! Но работает.

Что-то наша история затянулась

Да, мы поговорили уже о многом, и даже повеселились с воровством потоков. Пора подводить итог. Что мы знаем о пуле потоков?

Каждый такой поток называют воркером — Worker thread.

Каждый worker ищет себе ту Task'у, которая сейчас разблокирована и готова к работе. Он начинает с ней работать, ходит по разным состояниям автомата этой таски, переходит в другие таски (и их автоматы) по цепочке вызовов. И он продолжает работу над этой цепочкой до тех пор, пока не встретит первое честное асинхронное ожидание — await SmthReallyAsync().

После того, как поток, worker, встретил первое честное асинхронное ожидание, он бросает работу с предыдущей Task'ой и тут же идёт искать себе новую Task'у, которая разблокирована и готова работать.

Если так случается, что готовые к работе Task'и есть, а свободных потоков нет, .NET создаёт новые потоки.

И, естественно, появилось ещё больше вопросов, на некоторые из которых мы, возможно, ответим в дальнейшем. Ну например:

  • Почему потоки создаются не так быстро, как появляются Task'и? Это как-то регулируется, есть ли правила, по которым они создаются?

  • А что плохого в том, чтобы создать сразу много потоков? Почему .NET не создаёт сразу 100500 потоков, и как это сделать самому?

  • А как ещё можно разграничить работу одних Task от других, чтобы они не мешались друг другу? Было упомянуто, что способов сделать такое больше, чем два уже рассмотренных. Можно ли сделать несколько непересекающихся пулов потоков, чтобы если кто-то заполнил и испортил свой, то никак не повлиял на мой пул? (наличие вопроса намекает, что да, можно ;))

  • Некоторые аспекты работы с тредпулом проявляются только на длительном промежутке времени. Как быть, и как доказывать, что приложение в самом деле будет хорошо работать на production?

  • А что ещё за Iocp-треды, информацию о которых можно узнать из класса ThreadPool?

  • А если .NET насоздавал много дополнительных потоков, и потом они стали не нужны, он от них избавится?

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


  1. CrazyElf
    04.07.2023 05:23
    +2

    Странно, что не упомянуто влияние параметра TaskCreationOptions.LongRunning, по идее было бы очень к месту. )


    1. raptor
      04.07.2023 05:23

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

      А вот что действительно странно, что автор не выставил параметр IsBackground  для потока. В его случае программа фактически завершится, когда поток будет остановлен. А т.к. там бесконечный цикл - прервать программу можно будет только убив процесс.


      1. mayorovp
        04.07.2023 05:23
        +1

        Я думаю, он именно что процесс и прибивал в процессе экспериментов, так проще всего. А в реальной программе надо бы не IsBackground использовать — а CancellationToken, это позволит потокам корректно прибраться за собой перед завершением работы.


      1. CrazyElf
        04.07.2023 05:23

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