что такое пул потоков
Пул управляемых потоков
Характеристики пула потоков
Потоки из пула являются фоновыми. Для каждого потока используется размер стека по умолчанию, поток запускается с приоритетом по умолчанию и находится в многопотоковом подразделении. Когда поток в пуле завершает свою задачу, он возвращается в очередь потоков в состоянии ожидания. С этого момента его можно использовать вновь. Повторное использование позволяет приложениям избежать дополнительных затрат на создание новых потоков для каждой задачи.
Для каждого процесса существует только один пул потоков.
Исключения в потоках из пула потоков
Необработанные исключения в потоках из пула приводят к завершению процесса. Есть три исключения из этого правила:
Максимальное число потоков в пуле потоков
Число операций, которое можно поставить в очередь в пуле потоков, ограничено только доступной памятью. Однако пул потоков имеет ограничение на число потоков, которое можно активировать в процессе одновременно. Если все потоки в пуле заняты, дополнительные рабочие элементы помещаются в очередь и ожидают их освобождения. Размер по умолчанию пула потоков для процесса зависит от нескольких факторов, таких как размер виртуального адресного пространства. Процесс может вызвать метод ThreadPool.GetMaxThreads для определения количества потоков.
Вы можете управлять максимальным количеством потоков с помощью методов ThreadPool.GetMaxThreads и ThreadPool.SetMaxThreads.
Минимальные значения пула потоков
Пул потоков предоставляет новые рабочие потоки или потоки завершения ввода-вывода по запросу, пока не будет достигнут заданный минимум для каждой категории. Для получения этих минимальных значений можно использовать метод ThreadPool.GetMinThreads.
Если потребность низкая, фактическое количество потоков из пула потоков может быть ниже минимальных значений.
При достижении минимума пул потоков может создавать дополнительные потоки или ожидать завершения некоторых задач. Пул потоков создает и уничтожает рабочие потоки в целях оптимизации пропускной способности, которая определяется как количество задач, завершаемых за единицу времени. Слишком малое количество потоков может препятствовать оптимальному использованию доступных ресурсов, тогда как слишком большое их количество может усиливать конкуренцию за ресурсы.
Для увеличения минимального количества бездействующих потоков можно использовать метод ThreadPool.SetMinThreads. Однако необоснованное увеличение этих значений может привести к снижению производительности. Если одновременно запускается слишком много задач, все они могут выполняться слишком медленно. В большинстве случаев пул потоков работает наилучшим образом, если он использует собственный алгоритм выделения потоков.
Использование пула потоков
Пул потоков также можно использовать путем вызова ThreadPool.QueueUserWorkItem из управляемого кода (или ICorThreadpool::CorQueueUserWorkItem из неуправляемого кода) и передачи делегата System.Threading.WaitCallback, представляющего метод, который выполняет задачу.
Другим способом использования пула потоков является помещение в очередь рабочих элементов, которые имеют отношение к операции ожидания, с помощью метода ThreadPool.RegisterWaitForSingleObject и передача дескриптора System.Threading.WaitHandle, который вызывает метод, представленный делегатом System.Threading.WaitOrTimerCallback, при получении сигнала или истечении времени ожидания. Потоки из пула потоков используются для вызова методов обратного вызова.
Примеры см. по ссылкам на страницы API.
Пропуск проверок безопасности
Пул потоков также предоставляет методы ThreadPool.UnsafeQueueUserWorkItem и ThreadPool.UnsafeRegisterWaitForSingleObject. Используйте эти методы только в том случае, если вы уверены, что стек вызывающего объекта не важен для проверок безопасности, осуществляемых во время выполнения задачи в очереди. ThreadPool.QueueUserWorkItem и ThreadPool.RegisterWaitForSingleObject перехватывают стек вызывающего объекта, который объединяется со стеком потока из пула потоков, когда поток начинает выполнять задачу. Если требуется проверка безопасности, проверяется весь стек. Несмотря на обеспечение безопасности, такая проверка также влияет на производительность.
Когда не следует использовать потоки из пула потоков
Существует ряд сценариев, в которых следует создавать собственные потоки и работать с ними, а не использовать потоки из пула:
Пулы потоков
Потоки (thread) в приложении можно разделить на три категории:
Нагружающие процессор (CPU bound).
Блокирующие ввод-вывод (Blocking IO).
Неблокирующие ввод-вывод (Non-blocking IO).
У каждой из этих категорий своя оптимальная конфигурация и применение.
Для задач, требующих процессорного времени, нужен пул с заранее созданными потоками с количеством потоков равным числу процессоров. Единственная работа, которая будет выполняться в этом пуле, — вычисления на процессоре, и поэтому нет смысла превышать их количество, если только у вас не какая-то специфическая задача, способная использовать Hyper-threading (в таком случае вы можете использовать удвоенное количество процессоров). Обратите внимание, что в старом подходе «количество процессоров + 1» речь шла о смешанной нагрузке, когда объединялись CPU-bound и IO-bound задачи. Мы не будем такого делать.
Проблема с фиксированным пулом потоков заключается в том, что любая блокирующая операция ввода-вывода (да и вообще любая блокирующая операция) «съест» поток, а поток — очень ценный ресурс. Получается, что нам нужно любой ценой избегать блокировки CPU-bound пула. Но к сожалению, это не всегда возможно (например, при использовании библиотек с блокирующим вводом-выводом). В этом случае всегда следует переносить блокирующие операции (ввод-вывод и другие) в отдельный пул. Этот отдельный пул должен быть кэшируемым и неограниченным, без предварительно созданных потоков. Честно говоря, такой пул очень опасен. Он не ограничивает вас и позволяет создавать все больше и больше потоков при блокировке других, что очень опасно. Обязательно стоит убедиться, что есть внешние ограничения, то есть существуют высокоуровневые проверки, гарантирующие выполнение в каждый момент времени только фиксированного количества блокирующих операций (это часто делается с помощью неблокирующей ограниченной очереди).
Последняя категория потоков (если у вас не Swing / SWT) — это асинхронный ввод-вывод. Эти потоки в основном просто ожидают и опрашивают ядро на предмет уведомлений асинхронного ввода-вывода, и пересылают эти уведомления в приложение. Для этой задачи лучше использовать небольшое число фиксированных, заранее выделенных потоков. Многие приложения для этого используют всего один поток! У таких потоков должен быть максимальный приоритет, поскольку производительность приложения будет ограничена ими. Однако вы должны быть осторожны и никогда не выполнять какую-либо работу в этом пуле! Никогда, никогда, никогда. При получении уведомления вы должны немедленно переключиться обратно на CPU-пул. Каждая наносекунда, потраченная на поток (потоки) асинхронного ввода-вывода, добавляет задержки в ваше приложение. Поэтому производительность некоторых приложений можно немного улучшить, сделав пул асинхронного ввода-вывода в 2 или 4 потока, а не стандартно 1.
Глобальные пулы потоков
Относитесь осторожно к любому фреймворку или библиотеке, затрудняющему настройку пула потоков или устанавливающему по умолчанию пул, которым вы не можете управлять.
Как это работает в мире java. Пул потоков
Основной принцип программирования гласит: не изобретать велосипед. Но иногда, чтобы понять, что происходит и как использовать инструмент неправильно, нам нужно это сделать. Сегодня изобретаем паттерн многопоточного выполнения задач.
Представим, что у вас которая вызывает большую загрузку процессора:
Мы хотим как можно быстрее обработать ряд таких задач, попробуем*:
Время выполнения 104 сек.
Как вы заметили, загрузка одного процессора на один java-процесс с одним выполняемым потоком составляет 100%, но общая загрузка процессора в пользовательском пространстве составляет всего 2,5%, и у нас есть много неиспользуемых системных ресурсов.
Давайте попробуем использовать больше, добавив больше рабочих потоков:
ThreadPoolExecutor
Для ускорения мы использовали ThreadPool — в java его роль играет ThreadPoolExecutor, который может быть реализован непосредственно или из одного из методов в классе Utilities. Если мы заглянем внутрь ThreadPoolExecutor, мы можем найти очередь:
в которой задачи собираются, если запущено больше потоков чем размер начального пула. Если запущено меньше потоков начального размера пула, пул попробует стартовать новый поток:
Каждый addWorker запускает новый поток с задачей Runnable, которая опрашивает workQueue на наличие новых задач и выполняет их.
ThreadPoolExecutor имеет очень понятный javadoc, поэтому нет смысла его перефразировать. Вместо этого, давайте попробуем сделать наш собственный:
Теперь давайте выполним ту же задачу, что и выше, с нашим пулом.
Меняем строку в MultithreadClient:
Время выполнения практически одинаковое — 15 секунд.
Размер пула потоков
Попробуем еще больше увеличить количество запущенных потоков в пуле — до 100.
Мы можем видеть, что время выполнения увеличилось до 28 секунд — почему это произошло?
Существует несколько независимых причин, по которым производительность могла упасть, например, из-за постоянных переключений контекста процессора, когда он приостанавливает работу над одной задачей и должен переключаться на другую, переключение включает сохранение состояния и восстановление состояния. Пока процессор занято переключением состояний, оно не делает никакой полезной работы над какой-либо задачей.
Количество переключений контекста процесса можно увидеть, посмотрев на csw параметр при выводе команды top.
На 8 потоках:
На 100 потоках:
Как выбрать размер пула?
Размер зависит от типа выполняемых задач. Разумеется, размер пула потоков редко должен быть захардокожен, скорее он должен быть настраиваемый а оптимальный размер выводится из мониторинга пропускной способности исполняемых задач.
Предполагая, что потоки не блокируют друг друга, нет циклов ожидания I/O, и время обработки задач одинаково, оптимальный пул потоков = Runtime.getRuntime().availableProcessors() + 1.
Если потоки в основном ожидают I/O, то оптимальный размер пула должен быть увеличен на отношение между временем ожидания процесса и временем вычисления. Например. У нас есть процесс, который тратит 50% времени в iowait, тогда размер пула может быть 2 * Runtime.getRuntime().availableProcessors() + 1.
Другие виды пулов
Пул потоков с ограничением по памяти, который блокирует отправку задачи, когда в очереди слишком много задач MemoryAwareThreadPoolExecutor
Пулы потоков
Пул потоков — это коллекция рабочих потоков, которые эффективно выполняют асинхронные обратные вызовы от имени приложения. Пул потоков в основном используется для сокращения числа потоков приложения и обеспечения управления рабочими потоками. Приложения могут ставить в очередь рабочие элементы, связывать работу с ожидающими дескрипторами, автоматически ставить в очередь на основе таймера и выполнять привязку с помощью операций ввода-вывода.
Архитектура пула потоков
Использование пула потоков может быть выгодным для следующих приложений:
исходный пул потоков был полностью изменен в Windows Vista. Новый пул потоков улучшен, так как он предоставляет один тип рабочего потока (поддерживает операции ввода-вывода и без ввода-вывода), не использует поток таймера, предоставляет одну очередь таймера и предоставляет выделенный постоянный поток. Он также предоставляет группы очистки, более высокую производительность, несколько пулов для каждого процесса, которые планируются независимо, и новый API пула потоков.
Архитектура пула потоков состоит из следующих компонентов:
Рекомендации
Новый API пула потоков обеспечивает большую гибкость и управление, чем Исходный API пула потоков. Однако существует несколько незначительных, но важных отличий. В исходном API ожидание сброса было автоматическим; в новом API ожидание должно быть явным образом сброшено каждый раз. Исходный API, автоматически обрабатывающий олицетворение, передает контекст безопасности вызывающего процесса в поток. В новом API приложение должно явно задать контекст безопасности.
Ниже приведены рекомендации по использованию пула потоков.
Потоки процесса совместно используют пул потоков. Один рабочий поток может выполнять несколько функций обратного вызова по одной за раз. Эти рабочие потоки управляются пулом потоков. Поэтому не следует завершать поток из пула потоков путем вызова TerminateThread в потоке или путем вызова ExitThread из функции обратного вызова.
Очистите все ресурсы, созданные в функции обратного вызова перед возвратом из функции. К ним относятся TLS, контексты безопасности, приоритет потоков и регистрация COM. Функции обратного вызова также должны восстанавливать состояние потока перед возвратом.
Отслеживайте дескрипторы ожидания и связанные с ними объекты до тех пор, пока пул потоков не сообщит о завершении дескриптора.
Пометьте все потоки, ожидающие длительные операции (такие как очистка ввода-вывода или очистка ресурсов), чтобы пул потоков мог выделить новые потоки вместо ожидания этого.
Перед выгрузкой библиотеки DLL, использующей пул потоков, отмените все рабочие элементы, операции ввода-вывода, ожидания и таймеры и дождитесь завершения выполнения ответных вызовов.
Избегайте взаимоблокировок, удалив зависимости между рабочими элементами и обратными вызовами, убедившись, что обратный вызов не ждет завершения и, сохраняя приоритет потока.
Не ставить слишком много элементов слишком быстро в процесс с другими компонентами, использующими пул потоков по умолчанию. Существует один пул потоков по умолчанию для каждого процесса, включая Svchost.exe. По умолчанию каждый пул потоков имеет максимум 500 рабочих потоков. Пул потоков пытается создать больше рабочих потоков, когда число рабочих потоков в состоянии «Готово» или «выполняется» должно быть меньше числа процессоров.
Избегайте модели однопотокового апартамента COM, так как она несовместима с пулом потоков. STA создает состояние потока, которое может повлиять на следующий рабочий элемент для потока. Как правило, STA является длительным и имеет сходство потоков, что является противоположностью пула потоков.
Создание нового пула потоков для управления приоритетом и изоляцией потоков, создание пользовательских характеристик и, возможно, повышение скорости реагирования. Однако для дополнительных пулов потоков требуются дополнительные системные ресурсы (потоки, память ядра). Слишком большое количество пулов повышает вероятность состязаний за использование ЦП.
По возможности используйте ожидающий объект, а не механизм на основе APC для сигнализации потока пула потоков. APC не работают с потоками пула потоков в качестве других механизмов сигнализации, поскольку система управляет временем существования потоков пула потоков, поэтому поток может быть завершен до доставки уведомления.
Используйте расширение отладчика пула потоков,! TP. Эта команда имеет следующие сведения об использовании:
Для пула, ожидания и рабочей роли, если адрес равен нулю, команда создает дампы всех объектов. Для ожидающих и рабочих ролей пропуск адреса в дампе текущего потока. Определены следующие флаги: 0x1 (однострочный вывод), 0x2 (элементы дампа) и 0x4 (Рабочая очередь пула дампа).
Многопоточность — одна из самых сложных тем в программировании, с ней постоянно возникает масса проблем. Без четкого понимания внутренних механизмов будет очень трудно предсказать результат работы приложения, использующего несколько потоков. Мы не будем здесь дублировать массу теоретической информации, которой очень много в сети и умных книгах. Вместо этого сконцентрируемся на конкретных и наиболее важных проблемах, на которые нужно обращать особое внимание и обязательно помнить о них в процессе разработки.
Потоки
Потоки делятся на background (фоновый) и foreground (основной, тот, что на переднем плане). Основное отличие между ними в том, что foreground-потоки препятствуют завершению программы. Как только все foreground-потоки остановлены, система автоматически остановит все background и завершит выполнение приложения. Чтобы определить, является поток фоновым или нет, необходимо вызвать следующее свойство текущего потока:
По умолчанию, при создании потока при помощи класса Thread мы получим foreground-поток. Для того, чтобы его поменять на фоновый, мы можем воспользоваться свойством thread.IsBackground.
В приложениях, которые имеют пользовательский интерфейс (UI), всегда есть как минимум один главный (GUI) поток, который отвечает за состояние компонентов интерфейса. Важно знать, что возможность изменять состояние представления есть только у этого, так называемого «UI-потока», который создается для приложения обычно в единственном экземпляре (хотя и не всегда).
Стоит также упомянуть про исключительные ситуации, которые могут возникать в дочерних потоках. В такой ситуации приложение будет экстренно завершено, и мы получим Unhandled Exception, даже если обернем код запуска потока в блок try/catch. В таком случае, обработку ошибок необходимо вынести в код дочернего потока, в котором уже можно будет отреагировать на конкретную исключительную ситуацию.
Применяя глобальную обработку исключений (Application_Error в ASP.NET, Application.DispatcherUnhandledException в WPF, Application.ThreadException в WinForms и т.д.) важно помнить, что при таком подходе мы сможем «ловить» исключительные ситуации, которые произошли ТОЛЬКО в UI потоке, то есть мы не «поймаем» исключения из дополнительных фоновых потоков. Также мы можем воспользоваться AppDomain.CurrentDomain.UnhandledException и вклиниться в процесс обработки всех необработанных исключительных ситуаций в рамках домена приложения, но мы никак не сможем воспрепятствовать процессу завершения приложения.
Потоки — это дорогостоящие объекты, которые занимают память, могут использовать различные ресурсы системы и находиться в разных состояниях. Для их создания требуется время. В сравнении с процессами они менее ресурсоемки, но все же требуют довольно больших затрат на создание и уничтожение. Более того, за освобождение занимаемых конкретным потоком ресурсов отвечает разработчик. Например, для выполнения массы небольших задач неэффективно запускать множество потоков, так как издержки на их запуск могут превысить выгоду от использования. Для того, чтобы иметь возможность повторно использовать уже запущенные потоки и избавиться от издержек на создание, был введен так называемый пул-потоков (ThreadPool).
ThreadPool
В рамках каждого процесса CLR создает одну дополнительную абстракцию, которая называется пул потоков. Он представляет собой набор потоков, которые находятся в режиме ожидания и готовы выполнять любую полезную работу. При запуске приложения пул-потоков запускает минимальное количество потоков, которые находятся в состоянии ожидания новых задач. Если активных потоков недостаточно для эффективного выполнения задач в пуле, он запускает новые и использует их по тому же принципу повторного использования. Пул довольно умный и умеет определять необходимое эффективное количество потоков, а также останавливать лишние или запускать дополнительные. Можно задавать максимальное и минимальное количества потоков, но на практике это делают редко.
Потоки внутри пула разделяются на две группы: worker и I/O-потоки. Рабочие потоки фокусируются на работе, связанной с загрузкой CPU (CPU based), в то время как I/O-потоки — на работе с устройствами ввода/вывода: файловая система, сетевая карта и другие. Если пытаться выполнять I/O-операцию на рабочем потоке (CPU based), то это будет напрасная трата ресурсов, так как поток будет находиться в состоянии ожидания завершения I/O-операции. Для подобных задач предназначены отдельные I/O-потоки. При использовании пула потоков это скрыто в явном виде от разработчиков. Получить количество разных потоков в пуле можно при помощи кода:
Для того, чтобы определить, является текущий поток взятым из пула или созданным вручную, необходимо воспользоваться конструкцией:
Запустить задачу на выполнение при помощи потока, взятого в пуле, можно с помощью:
Синхронизация
При построении многопоточного приложения необходимо гарантировать, что любая часть разделяемых данных защищена от возможности изменения их значений множеством потоков. Учитывая, что управляемая куча является одним из разделяемых потоками ресурсов, а все потоки в AppDomain имеют параллельный доступ к разделяемым данным приложения, очевидно, что доступ к таким общим данным необходимо синхронизировать. Это гарантирует, что в один момент времени доступ к определенному блоку кода получит лишь один поток (или указанное количество, в случае использования Семафора). Таким образом, мы можем гарантировать целостность данных, а также их актуальность в любой момент времени. Давайте рассмотрим возможные варианты синхронизации и частые проблемы. Говоря о синхронизации, обычно выделяют 4 вида:
Blocking
Под блокировкой понимается ожидание одним потоком завершения другого или нахождение в режиме ожидания в течение некоего времени. Обычно реализуется при помощи методов класса Thread: Sleep() и Join(), метода EndInvoke() асинхронных делегатов или при помощи тасков (Task) и их механизмов ожидания. Следующие конструкции являются примерами плохого подхода к реализации ожидания:
Подобные конструкции требуют много ресурсов процессора, хотя не выполняют никакой полезной работы. В то же время ОС и CLR думают, что наш поток занят выполнением важных расчетов и выделяют для него необходимые ресурсы. Данного подхода следует всегда избегать.
Похожим примером может быть следующая конструкция:
Здесь вызывающий поток периодически засыпает на короткое время, но его достаточно для того, чтобы система могла переключить контексты и выполнять параллельно другие задачи. Данный подход гораздо лучше предыдущего, но все же не идеален. Основная проблема возникает в тот момент, когда необходимо изменять флаг proceed из разных потоков. Подобная конструкция будет эффективным решением в том случае, когда мы ожидаем, что условие в цикле будет удовлетворено через очень короткое время и повлечет за собой небольшое количество итераций. Если итераций много, то системе потребуется постоянно переключать контекст данного потока и тратить на это дополнительные ресурсы.
Locking
В таблице представлены самые популярные механизмы для организации блокировок. При помощи Мютексов можно реализовать межпроцессорную блокировку (а не только для нескольких потоков одного процесса). Семафор отличается от Мютекса тем, что позволяет указать количество потоков или процессов, которые могут получить одновременный доступ к конкретному участку кода. Конструкция lock, которая является вызовом пары методов: Monitor.Enter() и Monitor.Exit(), применяется очень часто, поэтому рассмотрим возможные проблемы и рекомендации по её использованию.
Статические члены классов, которыми часто оперируют разработчики, всегда потоконебезопасны, и доступ к таким данным нужно обязательно синхронизировать. Отличием может быть только статический конструктор, так как CLR блокирует все обращения из сторонних потоков к статическим членам класса до тех пор, пока не завершит свою работу статический конструктор.
При использовании блокирования при помощи ключевого слова lock следует помнить о следующих правилах:
В большинстве случаев нет смысла в реализации собственной подобной коллекции — намного проще и разумней использовать готовые протестированные классы.
Асинхронность
Отдельно хотелось бы выделить так называемую асинхронность, которая, с одной стороны, всегда непосредственно связана с запуском дополнительных потоков, а с другой — с дополнительными вопросами и теорией, на которых тоже стоит остановиться.
Покажем на наглядном примере разницу между синхронным и асинхронным подходами.
Предположим, вы хотите пообедать пиццей в офисе и у вас есть два варианта:
1-й, синхронный вариант: прогуляться пешком в пиццерию, выбрать интересующую вас пиццу, сделать заказ, дождаться, пока его принесут, добраться с пиццей в офис или пообедать непосредственно в пиццерии, после чего вы вернетесь и продолжите работать. В процессе прогулки и ожидания заказа вы будете находиться в режиме ожидания и не сможете заниматься другой полезной работой (для простоты, здесь понимается именно работа в офисе, которая приносит деньги и которую вы не можете выполнять вне рабочего места).
2-й, асинхронный вариант: заказать пиццу по телефону. После заказа вы не заблокированы, можете выполнять полезную работу на рабочем месте, пока ваш заказ обрабатывается и доставляется в офис.
Эволюция
Данный подход можно встретить во множестве технологий и классов, но он чреват усложнением и избыточностью кода.
В версии 2.0 была введена новая модель под названием EAP (Event-based Asynchronous Pattern). Класс, поддерживающий асинхронную модель, основанную на событиях, будет содержать один или несколько методов MethodNameAsync. Он может отражать синхронные версии, которые выполняют то же действие с текущим потоком. Также в этом классе может содержаться событие MethodNameCompleted и метод MethodNameAsyncCancel (или просто CancelAsync) для отмены операции. Данный подход распространен при работе с сервисами. В Silverlight применяется для обращения к серверной части, а Ajax по сути представляет из себя реализацию данного подхода. Стоит опасаться длинных цепочек связанных вызовов событий, когда по завершении одной долгосрочной операции в событии ее завершения вызывается следующая, потом еще следующая и так далее. Это чревато дэдлоками и непредвиденными результатами. Обработка исключений и результаты асинхронной операции доступны только в обработчике события посредством соответствующих свойств параметра: Error и Result.
В последних версиях фреймворка появились новые возможности на основе все тех же задач, которые упрощают написание асинхронного кода и делают его более читабельным и понятным. Для этого введены новые ключевые слова async и await, которыми помечаются асинхронные методы и их вызовы. Асинхронный код становится очень похожим на синхронный: мы просто вызываем нужную операцию и весь код, который следует за ее вызовом, автоматически будет завернут в некий «колбек», который вызовется после завершения асинхронной операции. Также данный подход позволяет обрабатывать исключения в синхронной манере; явно дожидаться завершения операции; определять действия, которые должны быть выполнены, и соответствующие условия. Например, мы можем добавить код, который будет выполнен только в том случае, если в асинхронной операции было сгенерировано исключение. Но не все так просто, даже несмотря на массу информации на эту тему.
async\await
Рассмотрим основные рекомендации по использованию этих ключевых слов, а также некоторые интересные примеры. Чаще всего рекомендуется использовать асинхронности «от начала до конца». Это подразумевает использование только одного подхода в конкретном вызове или функциональном блоке, не смешивайте синхронные вызовы с асинхронными. Классический пример данной проблемы:
Данный код отлично работает в консольном приложении, но при вызове метода DeadlockDemo.Test() из GUI потока возникнет взаимоблокировка. Это связано с тем, как await обрабатывает контексты. По умолчанию, когда ожидается незавершенный Task, текущий контекст захватывается и используется для возобновления метода по окончании выполнения задачи. Контекстом является текущий SynchronizationContext, если только он не равен null, как в случае с консольными приложениями. Там это текущий TaskScheduler (контекст пула потоков). GUI- и ASP.NET-приложения имеют SynchronizationContext, который разрешает единовременно выполнять только одну порцию кода. Когда выражение await завершает выполнение, оно пытается выполнить остальную часть async-метода в рамках захваченного контекста. Но он уже имеет поток, который (синхронно) ожидает завершения async-метода. Получается, что каждый из них ждет друг друга, вызывая взаимоблокировку.
Также рекомендуется избегать конструкций вида async void (асинхронный метод, который ничего не возвращает). Async-методы могут возвращать значения Task, Task и void. Последний вариант был оставлен для поддержки обратной совместимости и позволяет добавлять асинхронные обработчики событий. Но стоит помнить про некоторые специфичные отличия подобных методов, а именно:
Данная рекомендация очень актуальна при разработке каких-либо библиотек, которые ничего не знают про GUI.
Рассмотрим еще несколько примеров применения новых ключевых слов, а также некоторые особенности их использования:
На экране сначала появится «work», затем «started», и только потом «finished». На первый взгляд кажется, что первым должно быть выведено слово «started». Не забывайте, что в данном коде присутствует проблема с дэдлоком, которую мы рассмотрели. Это связано с тем, что метод, помеченный ключевым словом async, не запускает дополнительных потоков и обрабатывается синхронно до тех пор, пока не встретит внутри ключевое слово await. Только после этого будет создан новый объект типа Task и запущена отложенная задача. Чтобы исправить данное поведение в приведенном примере, достаточно заменить строку с Thread.Sleep(…) на await Task.Delay(…).
Можно предположить, что мы будем ожидать 1 секунду перед вторым выводом на экран, но это не так — оба сообщения будут выведены без задержек. Это связано с тем, что метод Task.Delay(), как и многие другие асинхронные методы, возвращает объект типа Task, но мы проигнорировали эту задачу. Мы не ожидаем ее завершения ни одним из возможных способов, что влечет за собой немедленное выведение на экран обоих сообщений.
Несмотря на использование ключевых слов, данный код не является асинхронным и выполняется синхронно, потому что мы создаем задачу и явно ожидаем ее выполнения. В данном случае вызывающий поток заблокирован и ожидает завершения запущенной задачи.
Заключение
Как видите, у разработчиков имеется довольно много возможностей для работы с многопоточными приложениями. Важно не только знать теорию, но и уметь применять эффективные подходы для решения конкретных задач. Например, использование класса Thread почти однозначно говорит о том, что у вас устаревший код в проекте, хотя вероятность возникновения необходимости его использования весьма мала. В обычных ситуациях использование пула всегда оправданно, по понятным причинам.
Использование многопоточности в приложениях с GUI обычно влечет за собой дополнительные ограничения, не забывайте о них!
Также стоит помнить и про другие готовые реализации, такие как потокобезопасные коллекции. Это избавляет от написания дополнительного кода и предотвращает возможные ошибки реализации. Ну и не забывайте про особенности новых ключевых слов.