Как стать автором
Обновить

О Thread и ThreadPool в .NET подробно (часть 1)

Время на прочтение15 мин
Количество просмотров45K

Ссылка на Часть 2: "О Thread и ThreadPool в .NET подробно (часть 2)"

Этот текст покрывает ответы на некоторые совсем базовые вопросы и вместе с тем сразу погружает в проблематику получения ответа на вопрос: "как работать лучше? однопоточно, многопоточно или многопоточно, но на ThreadPool?". Ответ на этот вопрос может изначально показаться очень простым и понятным, однако реальность совершенно иная: всё как и везде сильно зависит от ситуации: от типа задачи, от её размера, от прочих условий, которые так просто в голову сами собой не придут.

А потому мы пройдёмся в первую очередь по IO-/CPU-bound операциям, стоимости создания потока, базовым основам работы пула потоков (но только основы), а далее -- углубимся в анализ чёрного ящика: от чего зависит производительность пула потоков? Каков объём работы приемлим для того чтобы в него планировать?

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

Также отмечу, что материал постепенно переходит от начального уровня сложности ? через ⚠️ средний уровень к ☠️ высокому, о чём вы сможете узнать по пиктограммам.

Материал начальной сложности

Thread

Что та­кое по­ток? Про­го­во­рим ещё раз. Это не­кая пос­ле­до­ват­ель­ность ком­анд для про­цес­со­ра, ко­то­рые он ис­пол­ня­ет еди­ным по­то­ком пар­ал­лель­но ли­бо псев­до­пар­ал­лель­но от­но­сит­ель­но дру­гих по­то­ков ис­пол­не­ния ко­да. Пар­ал­лель­но — по­то­му что код раз­ных по­то­ков мо­жет ис­пол­нять­ся на раз­ных фи­зич­ес­ких яд­рах. Псев­до­пар­ал­лель­но — по­то­му что код раз­ных по­то­ков мо­жет ис­пол­нять­ся на од­ном фи­зич­ес­ком яд­ре. А по­то­му — что­бы эму­ли­ро­вать пар­ал­лель­ность в гла­зах у поль­зо­ва­те­ля они бью­т­ся по вре­ме­ни ис­пол­не­ния на очень кор­от­кие ин­тер­ва­лы и че­ред­ут­ся, соз­да­вая ил­лю­зию пар­ал­лель­но­го ис­пол­не­ния: это мож­но срав­нить с цвет­ной пе­ча­тью. Если пос­мот­реть на пол­ноц­вет­ную пе­чать под лу­пой (или при по­мо­щи ка­ме­ры смарт­фона), мож­но за­ме­тить мик­рот­оч­ки CMYK (Cyan­, Magneta­, Yellow­, Key­). Их мож­но уви­деть толь­ко при уве­ли­че­нии, но на рас­сто­я­нии они об­ра­зу­ют еди­ное пят­но ито­го­вого цве­та.

Доб­ить­ся то­го, что­бы зав­ла­деть яд­ром про­цес­со­ра мо­ноп­оль­но в .NET нет воз­мож­нос­ти. Да и в ОС нет та­ко­го фун­кци­о­на­ла. А это зна­чит, что лю­бой ваш по­ток бу­дет прер­ван в аб­сол­ют­но лю­бом мес­те: да­же по се­ре­ди­не опе­ра­ции a = b;, ког­да b счи­та­ли, а a ещё не за­пи­са­на прос­то по­то­му, что по­ми­мо вас на том же яр­де ра­бо­та­ет ещё кто-то. И с очень вы­со­кой до­лей ве­ро­ят­нос­ти прер­ва­ны вы бу­де­те на бо­лее дли­тель­ный срок не­же­ли вам от­пу­ще­но на ра­бо­ту: при боль­шом ко­лич­ес­т­ве ак­тив­ных по­то­ков в сис­те­ме по­ми­мо вас на яд­ре их бу­дет нес­коль­ко. А зна­чит вы бу­де­те по чуть-чуть ис­пол­нять­ся в пор­яд­ке не­ко­то­рой оче­реди. Сна­ча­ла вы, по­том все ос­таль­ные и толь­ко по­том — сно­ва вы.

Одна­ко, соз­да­ние по­то­ка — это очень до­ро­гая опе­ра­ция. Ведь что та­кое «соз­дать по­ток»? Для на­ча­ла это об­ра­ще­ние в опе­ра­ци­он­ную сис­те­му. Обра­ще­ние в опе­ра­ци­он­ную сис­те­му — это пре­о­до­ле­ние барь­е­ра меж­ду сло­ем прик­лад­но­го ПО и сло­ем опе­ра­ци­он­ной сис­те­мы. Слои эти обес­пе­чи­ва­ются про­цес­со­ром, а сто­ро­ны барь­е­ров - коль­ца­ми за­щи­ты. Прик­лад­ное прог­рам­м­ное обес­пе­че­ние имеет коль­цо за­щи­ты Ring 3, тог­да как уро­вень ОС за­ни­ма­ет коль­цо Ring 0. Вы­зов ме­то­дов из коль­ца в коль­цо — опе­ра­ция до­ро­гая, а пе­ре­хо­да меж­ду тем два: из Ring 3 в Ring 0 и об­рат­но. Плюс соз­да­ние сте­ков по­то­ка: один для Ring 3, вто­рой — для Ring 0. Плюс соз­да­ние доп­ол­нит­ель­ных струк­тур дан­ных со сто­ро­ны .NET. В об­щем что­бы что-то ис­пол­нить пар­ал­лель­но че­му-то быс­т­ро, для на­ча­ла придётся пот­ра­тить мно­го вре­ме­ни.

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

  1. Ожидание сети по вопросу подключения клиента

  2. Проанализировали запрос, сформировали запрос к БД, отправили

  3. Ожидание ответа от сервера БД

  4. Ответ получен, перевели в ответ от сервиса

  5. Отправили ответ

И пункты (2) и (4) -- не так долго выполняются. Скорее это -- очень короткие по времени исполнения участки кода. А потому стоит задаться вопросом: для чего под них создавать отдельные потоки (тут отсылка к неверному многими трактовании слова асинхронно и повсеместной попытки что-то отработать параллельно)? В конце концов цепочка (1) - (5) работает целиком последовательно, а это значит, что в точках (1), (3) и (5) поток исполнения находится в блокировке ожидания оборудования, т.к. ждёт ответа от сетевой карты. Т.е. не участвует в планировании операционной системой и никак не влияет на её производительность. Тогда что, web-серверу надо создать поток под всю цепочку? А если сервер обрабатывает 1000 подключений в секунду? Мы же помним, что один поток создаётся крайне долго. Значит он не сможет работать с такими скоростями если будет создавать под каждый запрос поток. Работать на уже существующих? Брать потоки в аренду?

ThreadPool

Именно поэтому и возник пул потоков, ThreadPool. Он решает несколько задач:

  • с одной стороны он абстрагирует создание потока: мы этим заниматься не должны

  • создав когда-то поток, он исполняет на нём совершенно разные задачи. Вам же не важно, на каком из них исполняться? Главное чтобы был

    • а потому мы более не тратим время на создание потока ОС: мы работаем на уже созданных

    • а потому нагружая ThreadPool своими делегатами мы можем равномерно загрузить ядра CPU работой

  • либо ограничивает пропускную способность либо наоборот: даёт возможность работать на все 100% от всех процессорных ядер.

Однако, как мы убедимся позже у любой такой абстракции есть масса нюансов, в которых эта абстракция работает очень плохо и может стать причиной серъёзных проблем. Пул потоков можно использовать не во всех сценариях, а во многих он станет серьёзным "замедлителем" процессов. Однако давайте взглянем на ситуацию под другим углом. Зачастую при объяснении какой-либо темы автором перечисляются функционал и возможности, но совершенно не объясняется "зачем": как будто и так всё понятно. Мы же попробуем пойти другим путём. Давайте попробуем понять ThreadPool.

И если взглянуть на вопрос с той стороны что пул потоков -- это инструмент, то как следствие возникает вопрос: какие задачи должен решать этот инструмент?

С точки зрения упрощения менеджмента параллелизма

Управлять параллелизмом - задача не из простых. Вернее, конечно же, это сильно зависит от того, что вам необходимо.... Но когда у вас "ничего особенного" не требуется, вручную создавать потоки и гнать через них поток задач станет переусложнением. Тем более что это достаточно рутинная операция. Поэтому наличие стандартного функционала в библиотеке классов выглядит крайне логично.

Единственное, что команда Microsoft сделала неправильно -- что сделала ThreadPool статическим классом без возможности повлиять на реализацию. Это ограничение создало целый пласт недопонимания между разработчиком runtime и конечным разработчиком потому как у последнего нет никакого ощущения, что можно разработать свои пулы потоков и что ими можно пользоваться в рамках текущей реализации. Что есть абстракции над пулами потоков, отдавая которые во внешние алгоритмы вы заставляете их ими пользоваться вместо того чтобы пользоваться стандартным, общим пулом.

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

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

С точки зрения IO-/CPU-bound операций

Например, может показаться, что пул потоков создан чтобы решить разделение IO-/CPU-bound операции. И частично это так. Я бы сказал, что пул потоков предоставляет возможность разделения IO-/CPU-bound операций второго уровня. В том смысле, что разделение существует и без него и оно находится на более глубоком уровне, на уровне операционной системы.

Для того чтобы "найти" первый слой разделения IO-/CPU-bound операций необходимо вспомнить как работают потоки. Потоки -- это слой виртуализации параллелизма операционной системой. Это значит, что процессор о потоках не знает ничего, о них знает только ОС и всё, что на ней работает. Плюс к этому существуют блокировки, которых почему-то все боятся (спойлер: зря). Блокировка -- это механизм ожидания сигнала от операционной системы что что-то произошло. Например, что отпустили семафор или что отпустили мьютекс. Но ограничивается ли список блокировок примитивами синхронизации? Исходя из знаний рядового .NET разработчика, да. Но на самом деле это не так.

Давайте рассмотрим вызов метода мьютекса, приводящий к состоянию блокировки. Любое взаимодействие с операционной системой приводит к проваливанию в более привилегированное кольцо защиты процессора (Ring 0 для Windows и Ring 2 в Linux), или говоря языком прикладного программного обеспечения, в kernel space. Это значит, что при проваливании происходит ряд операций, обеспечивающих сокрытие данных ОС от уровня прикладного программного обеспечения. Все эти операции, естественно, стоят времени. Если говорить о пользователе, времени это стоит не так много, потому что пользователь мыслит медленно. Однако с точки зрения программы это время достаточно заметно. Далее, если речь идёт о блокировке, срабатывает ряд достаточно простых механизмов, которые сначала проверяют состояние блокировки: установлена ли блокировка или нет, и если есть, поток переносится из очереди готовности к исполнению в список ожидания снятия блокировки. Что это значит? Поскольку операционная система руководствуется списком готовых к исполнению потоков при выборе того потока, который будет исполняться на процессоре, заблокированный поток она не заметит и потому не станет тратить на него ресурсы. Его просто нет в планировании. А потому легко сделать вывод, что когда поток встаёт в блокировку, он исчезает из планирования на исполнение и потому если все потоки находятся в блокировке, уровень загруженности процессора будет равен 0%.

Однако только ли примитивы синхронизации обладают такими свойствами? Нет: такими же свойствами обладает подавляющее большинство IO-bound операций. Да, взаимодействие с оборудованием может происходить без IO-bound операций. Простым примером такого поведения может быть отображение памяти какого-то устройства на адресное пространство оперативной памяти. Например, видеокарты: сконфигурировав видеокарту, обращаясь к функциям BIOS на необходимый режим работы (текстовый, графический VESA и проч.), помимо перевода экрана в необходимый режим BIOS должен сконфигурировать взаимодействие видеокарты с прикладным ПО. В случае текстового режима BIOS открывает отображение адресного пространства видеокарты на адресное пространство оперативной памяти, на диапазон 0xB8000 и далее. Записав по этому адресу код символа (конечно же, это будет работать, например, в DOS или если вы решите написать свою операционную систему, но не в Windows), вы моментально увидите его на экране. И это не приведет ни к каким блокировкам: запись будет моментальная.

Однако в случае большинства оборудования (например, сетевая карта, жёсткий диск и прочие) у вас будет одна задержка на синхронную подачу команды на оборудование (без перехода в блокировку, но с переходом в kernel space) и вторая задержка -- на ожидание ответа от оборудования. Большинство таких операций сопровождается постановкой потока в заблокированное состояние. Причиной этому служит разность в скорости работы процессора и оборудования, с которым производится взаимодействие: пока некоторая условная сетевая карта осуществляет посылку пакета и далее -- ожидает получения пакетов с ответом на процессорном ядре можно совершить миллионы, миллиарды операций. А потому пока идёт ответ от оборудования можно сделать много чего ещё и чтобы этого добиться поток переводится в заблокированное состояние и исключается из планирования, предоставив другим потокам возможность использовать для своей работы процессор.

По сути механизм блокировок и есть встроенный в операционную систему механизм разделения IO-/CPU-bound операций. Но решает ли он все задачи разделения?

Каждая постановка в блокировку снижает уровень параллелизма с точки зрения IO-bound кода. Т.е., конечно же? операции IO-bound и CPU-bound идут в параллель, но с другой стороны когда некий поток уходит в ожидание, он исчезает из планирования и наше приложение меньше задействует процессор, т.к. с точки зрения только CPU-bound операций мы теряем поток исполнения. И уже с точки зрения приложения, а не операционной системы может возникнуть естественное желание занять процессор чем-то ещё. Чтобы это сделать, можно насоздавать потоков и работать на них в параллель, однако это будет достаточно тяжким занятием и потому был придуман следующий очень простой концепт. Создаётся контейнер, который хранит и управляет хранимыми потоками. Этот контейнер, пул потоков, при старте передаёт потоку метод исполнения, который внутри себя в бесконечном цикле разбирает очередь поставленных пулу потоков задач и исполняет их. Какую задачу это решает: программный код построен так, что он, понятно дело исполняется кусочками. Кусочек исполнился, ждём оборудования. Второй кусочек исполнился, ждём оборудования. И так бесконечно. Причем нет же никакой разницы, на каком потоке исполнять вот такие кусочки, верно? А значит их можно отдавать в очередь на исполнение пулу потоков, который будет их разбирать и исполнять.

Но возникает проблема: ожидание от оборудования происходит в потоке, который инициировал это ожидание. Т.е. если мы будем ожидать в пуле потоков, мы снизим его уровень параллелизма. Как это решить? Инженеры из Microsoft создали для этих целей внутри ThreadPool второй пул: пул ожидающих потоков. И когда некий код, работающий в основном пуле решается встать в блокировку, сделать он это должен не тут же, в основном пуле, а специальным образом: перепланировавшись на второй пул (об этом позже).

Работа запланированных к исполнению делегатов (давайте называть вещи своими именами) на нескольких рабочих потоках, а ожидание - на других, предназначенных для "спячки" реализует второй уровень разделения IO-/CPU-bound операций, когда уже приложение, а не операционная система получает механизм максимального занятия работой процессорных ядер. При наличии постоянной работы (нескончаемого списка делегатов в основном пуле CPU-bound операций) приложение может загрузить процессор на все 100%.

Прикладной уровень

На прикладном уровне объяснения пул потоков работает крайне просто. Существует очередь делегатов, которые переданы пулу потоков на исполнение. Очередь делегатов хранится в широко-известной коллекции ConcurrentQueue. Далее, когда вы вызываете ThreadPool.QueueUserWorkItem(() => Console.WriteLine($"Hello from {Thread.CurrentThread.ManagedThreadId}")), то просто помещаете делегат в эту очередь.

ThreadPool внутри себя в зависимости от некоторых метрик, о которых мы поговорим позже создаёт некоторое необходимое ему количество рабочих потоков. Каждый из которых содержит цикл выборки делегатов на исполнение: и запускает их. Если упрощать, внутренний код выглядит примерно так:

void ThreadMethod()
{

    // ...
    while(needToRun)
    {
        if(_queue.TryDequeue(out var action))
        {
            action();
        }
    }
    // ...
}

Конечно же он сложнее, но общая суть именно такая.

Однако помимо кода, который исполняет процессор (т.н. CPU-bound код) существует также код, приводящий к блокировке исполнения потока: ожиданию ответа от оборудования. Клавиатура, мышь, сеть, диск и прочие сигналы от оборудования. Этот код называется IO-bound. Если мы будем ожидать оборудование на потоках пула, это приведёт к тому, что часть потоков в пуле перестенут быть рабочими на время блокировки. Это как минимум испортит пулу потоков статистики которые он считает и как следствие этого пул начнёт работать сильно медленнее. Пока он поймёт, что ему необходимо расширение, пройдёт много времени. У него появится некий "лаг" между наваливанием нагрузки на пул и срабатыванием кода, расширяющего его. А без блокировок-то он отрабатывал бы очень быстро.

Чтобы избежать таких ситуаций и сохранить возможность "аренды" потоков, ввели второй пул потоков. Для IO-bound операций. А потому в стандартном ThreadPool два пула: один -- для CPU-bound операций и второй -- для IO-bound.

Для нас это значит, что в делегате, работающем в ThreadPool вставать блокировку нельзя. Он для этого не предназначен. Вместо этого необходимо использовать следующий код:

ThreadPool.QueueuserWorkItem(
    _ => {

        // ... 1

        // 2
        ThreadPool.RegisterWaitForSingleObject(
            waitObject,    // объект ожидания
            (state) => Console.WriteLine($"Hello from {Thread.CurrentThread.ManagedThreadId}"),  // 3
            null,          // объект состояния для передачи в делегат (state)
            0,             // timeout ожидания объекта состояния (0 = бесконечно)
            true);         // ждать ли ещё раз при срабатывании waitObject 
                           // (например, если waitObject == AutoResetEvent)

        // ... 4

    }, null);
);

Тут конечно же стоит помнить о том, что код, который сработает после RegisterWaitForSingleObject отработает в лучшем случае параллельно с делегатом, переданным в RegisterWaitForSingleObject, а более вероятно - первее делегата, но в любом случае -- в другом потоке. (1), (2), (3), (4) не будут вызваны последовательно. Последовательно будут вызваны только (1), (2) и (4). А (3) - либо параллельно с (4) либо после.

В этом случае второй делегат уйдёт на второй пул IO-bound операций, который не влияет на исполнение CPU-bound пула потоков.

Оптимальная длительность работы делегатов, количества потоков

Каким может стать оптимальное количество потоков? Ведь работать можно как на двух, так и на 1000 потоках. От чего это зависит?

Пусть у вас есть ряд CPU-bound делегатов. Ну, для весомости их пусть будет миллион. Ну и давайте попробуем ответить на вопрос: на каком количестве потоков их выработка будет максимально быстрой?

  1. Пусть длительность работы каждого делегата заметна: например, 100 мс. Тогда результат будет зависеть от того количества процессорных ядер, на которых идёт исполнение. Давайте поступим как физики и возьмём некоторую идеализированную систему, где кроме нас -- никого нет: ни потоков ОС ни других процессов. Только мы и CPU. Ну и возьмём для примера 2-х ядерный процессор. Во сколько потоков имеет смысл работать CPU-bound коду на 2-х процессорной системе? Очевино, ответ = 2, т.к. если один поток на одном ядре, второй -- на втором, то оба они будут вырабатывать все 100% из каждого ядра ни разу не уходя в блокировку. Станет ли код быстрее, увеличь мы количество потоков в 2 раза? Нет. Если мы добавим два потока, что произойдёт? У каждого ядра появится по второму потоку. Поскольку ядро не резиновое, а железное, частота та же самая, то на выходе на каждом ядре мы будем иметь по два потока, исполняющиеся последовательно друг за другом, по, например, 120 мс. А поскольку время исполнения одинаковое, то фактически первый поток стал работать на 50% от изначальной производительности, отдав 50% второму потоку. Добавь мы ещё по два потока, мы снова поделим между всеми это ядро и каждому достанется по 33,33%. С другой стороны если перестать воспринимать ThreadPool как идеальный и без алгоритмов, а вспомнить, что у него под капотом как минимум ConcurrentQueue, то возникает ещё одна проблема: contention, т.е. состояние спора между потоками за некий ресурс. В нашем случае спор будет идти за смену указателей на голову и хвост очереди внутри ConcurrentQueue. А это в свою очередь снизит общую производительность, хоть и практически незаметно: при дистанции в 100 мс очень низка вероятность на разрыв кода методов Enqueue и TryDequeue системным таймером процессора с последующим переключением планировщиком потоков на поток, который также будет делать Enqueue либо TryDequeue (contention у них будет происходить с высокой долей вероятности на одинаковых операциях (например, Enqueue + Enqueue), либо с очень низкой долей вероятности -- на разных).

  2. На малой длительности работы делегатов результат мало того, что не становится быстрее, он становится медленнее, чем на 2 потоках, т.к. на малом времени работы увеличивается вероятность одновременной работы методов очереди ConcurrentQueue, что вводит очередь в состояние contention и как результат -- ещё большее снижение производительности. Однако если сравнивать работу на 1 ядре и на нескольких, на нескольких ядрах работа будет быстрее;

  3. И последний вариант, который относится к сравнению по времени исполнения делегатов -- когда сами делегаты ну очень короткие. Тогда получится, что при увеличении уровня параллелизма вы наращиваете вероятность состояния contention в очереди ConcurrentQueue. В итоге код, который борется внутри очереди за установку Head и Tail настолько неудачно часто срабатывает, что время contention становится намного больше времени исполнения самих делегатов. А самый лучший вариант их исполнения -- выполнить их последовательно, в одном потоке. И это подтверждается тестами: на моём компьютере с 8 ядрами (16 в HyperThreading) код исполняется в 8 раз медленнее, чем на одном ядре.

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

Теги:
Хабы:
Всего голосов 25: ↑24 и ↓1+34
Комментарии11

Публикации

Работа

.NET разработчик
49 вакансий

Ближайшие события