Готовый перевод Concurrenc in C# Cookbook / Конкурентность в C# книга рецептов: Обзор конкурентного программирования

Внимание! Этот перевод, возможно, ещё не готов, так как модераторы установили для него статус «идёт перевод»

Конкурентность - это ключевой аспект качественной разработки. В течение десятилетий Параллелизм был возможен, но сложен в реализации. Приложения, использующие Конкурентные приложения было тяжело создавать, тяжело отлаживать и тяжело поддерживать. В результате многие разработчики выбирали более простой путь и отказывались от них. Тем не менее благодаря различным библиотекам и языковым средствам доступным современным .NET приложения конкурентность стал гораздо доступнее и проще. Когда была выпущена Visual Studio 2012 Microsoft значительно снизила уровень вхождения в параллельное программирование. Раньше программисты применяющие конкурентность, в подавляющем большинстве, были профессионалами. В настоящее время каждый разработчик может(и должен) овладеть конкурентным программированием.

1.1.Введение в конкурентность

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

Конкурентность

Одновременное выполнение нескольких задач.

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

Во время написания этой книги (2014) большинство разработчиков услышав термин "конкурентность" сразу же думают о многопоточности. Я бы хотел провести грань между этими определениями.

Многопоточность

Разновидность конкурентности при которой используется более одного потока исполнения.

Многопоточность буквально заключается в использовании множества потоков. Как мы увидим в этой книге, многопоточность - это одна из форм конкурентности, но, безусловно, не единственная. Фактически, непосредственное применение низкоуровневых типов потоков не имеет смысла в современной разработке. Высокоуровневые абстракции гораздо эффективнее нежели олдскульные методы многопоточности. Как следствие я минимизирую объяснение устаревших технологий в этой книге. Ни в одном из рецептов многопоточности в этой книге не используются типы Thread или BackgroundWorker, они заменены на современные альтернативы.

Как только вы напечатаете new Thread(), ваш проект сразу же начнет включать в себя legacy-код

Но только не подумайте, что многопоточность умерла! Многопоточность живет в пуле потоков(thread pool), полезном месте для хранения очереди заданий, автоматически подстраивающемся под среду исполнения. В свою очередь, пул потоков обеспечивает еще одну важную форму конкурентности: параллельную обработку.

Параллельная обработка(параллельные вычисления)

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

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

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

Асинхронное программирование

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

Продолжение - это тип представляющий собой некоторую операцию, которая будет выполнена в будущем. Современные типы для продолжений в .NET - Task и Task<TResult>. Старые асинхронные API используют коллбэки или события вместо продолжений. Асинхронное программирование сосредоточено вокруг идеи асинхронных операций - операций, которые запускаются и завершаются через некоторое время. Во время выполнения асинхронной операции поток не блокируется, вместо этого поток который запускает операцию освобождается для других задач.
Когда операция завершается, она уведомляет о своем продолжении или вызывает событие обратного вызова, чтобы приложение узнало, что операция завершена.

Асинхронное программирование - это мощная форма конкурентности, однако до недавнего времени она требовала крайне сложной реализации. Async и await, поддерживаемый в VS2012 делают асинхронное программирование практически таким же простым как и синхронное(неконкурентное) программирование.

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

Реактивное программирование

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

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

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

1.2. Введение в асинхронное программирование

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

Современные асинхронные .NET-приложения используют два ключевых слова: async и await. Ключевое слово async входит в объявление метода, и его основная цель состоит в том, чтобы предоставить возможность использования ключевого слова await в этом методе (ключевые слова были введены в виде пары по причинам обратной совместимости). Асинхронный метод должен иметь возвращаемый тип Task <T>, если он возвращает результат, или Task, в обратном случае. Эти типы представляют собой продолжения; они уведомляют вызывающий код о завершении асинхронного метода.

Не используйте async void! Создать async метод с возвращаемым типов void возможно, но вы должны делать это только в том случае, когда вы создаете асинхронный обработчик событий. Обычный async метод, не возвращающий значения, должен иметь возвращаемый тип Task, а не void.

Давайте рассмотрим пример: листинг1

Асинхронный метод начинает выполняться синхронно, как и любой другой метод. Далее благодаря оператору await происходит асинхронное ожидание его аргумента. Сначала происходит проверка, завершена ли операция, являющаяся операндом await; если это так, он продолжает выполняться (синхронно). В противном случае он приостановит асинхронный метод и вернет незавершенную задачу. Когда эта операция завершится, через некоторое время, асинхронный метод возобновит выполнение.

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

<Когда вы ожидаете задачу (наиболее распространенный сценарий), контекст захватывается, когда оператор await решает приостановить метод. Этот контекст является текущим контекстом SynchronizationContext, если он не равен null, и в этом случае контекст является текущим TaskScheduler.> Метод возобновляет выполнение в этом захваченном контексте. Обычно этим контекстом является контекст пользовательского интерфейса (если вы находитесь в потоке GUI), контекст запроса ASP.NET (если вы обрабатываете запрос ASP.NET) или контекст пула потоков (в большинстве других ситуаций).

Таким образом, в предыдущем коде все синхронные части будут пытаться возобновить исходный контекст. Если вы вызываете DoSomethingAsync из потока пользовательского интерфейса, каждая из его синхронных частей будет работать в потоке пользовательского интерфейса; но если вы вызываете его из пула, каждая из его синхронных частей будет выполняться в пуле потоков.

Вы можете избежать этого поведения по умолчанию, с помощью метода расширения ConfigureAwait если передадите ему в качестве параметра continueOnCapturedContext - false. Следующий код будет запущен в вызывающем потоке, и после его приостановки оператором await он возобновится в пуле потоков. <листинг2>

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

Оператор await не ограничивается работой с задачами; он может работать с любым видом ожидания, который удовлетворяет определенному шаблону. Например, Windows Runtime API определяет свои собственные интерфейсы для асинхронных операций. Они не могут быть преобразованы в Task, но они следуют шаблону ожидания, поэтому вы можете непосредственно ожидать их. Эти виды ожиданий более распространены в приложениях Windows Store, но в большинстве случаев оператор await будем иметь дело с Task или Task<T>.

Существует два основных способа создания экземпляра Task. Некоторые задачи представляют собой реальный код, который должен выполнять ЦП; эти вычислительные задачи должны быть созданы путем вызова метода Task.Run (или TaskFactory.StartNew, <если вам нужно, чтобы они выполнялись в определенном планировщике>). Другие задачи представляют собой уведомления; эти основанные на событиях задачи создаются с помощью TaskCompletionSource <T>. Большинство задач ввода / вывода используют TaskCompletionSource <T>.

Обработка ошибок - обычное дело при использовании async и await. В следующем фрагменте кода PossibleExceptionAsync может вызвать исключение NotSupportedException, но TrySomethingAsync может перехватить исключение естественным образом. Пойманное исключение имеет правильно сохраненную трассировку стека и не является обернутым в TargetInvocationException или AggregateException: <list3>

Когда асинхронный метод генерирует исключение, исключение помещается в возвращаемую задачу, и она завершается. Когда эта задача ожидается, оператор await извлекает это исключение и выбрасывает его таким образом, чтобы сохранить исходную трассировку стека. <Таким образом, подобный код работал бы так, как ожидалось, если бы OptionExceptionAsync был асинхронным методом:> <listing4>

В отношении асинхронных методов есть еще одна важная рекомендация: как только вы начнете использовать async, лучше всего позволить ему распространиться в вашем коде. Если вы вызываете асинхронный метод, вы должны (в конце концов) дождаться выполнения задачи, которую он возвращает. Не поддавайтесь искушению вызова Task.Wait или Task<T>.Result; это может стать причиной дедлока. Рассмотрим этот метод: <listing5>

Этот код приводит к дедлоку, если вызывается из контекста пользовательского интерфейса или контекста ASP.NET. Это связано с тем, что оба эти контекста допускают только один поток. Deadlock вызовет WaitAsync, с которого начинается задержка. Затем Deadlock(синхронно) ожидает завершения этого метода, блокируя поток контекста. Когда задержка завершается, await пытается возобновить WaitAsync в захваченном контексте, но не может, потому что в контексте уже есть поток, заблокированный в контексте, и контекст допускает только один поток за раз. Взаимная блокировка может быть предотвращена двумя способами: вы можете использовать ConfigureAwait(false) в WaitAsync (что приводит к игнорированию его контекста), или вы можете установить ожидание вызова WaitAsync (превратив Deadlock в асинхронный метод).

Если вы используете async - используйте его всегда.

1.3.Введение в параллельное программирование

Параллельное программирование должно использоваться всякий раз, когда у вас есть значительное количество вычислительной работы, которая может быть разделена на независимые части. Параллельное программирование временно увеличивает нагрузку на процессор для повышения пропускной способности; это отлично подходит для клиентских систем, где процессоры часто простаивают, но редко подходят для серверов. Большинство серверных систем имеют встроенный параллелизм; например, ASP.NET обрабатывает несколько запросов параллельно. Написание параллельного кода для серверного приложения, однако,может быть полезно в некоторых ситуациях (если вы заранее знаете, что число пользователей одновременно работающих с сервером будет небольшим), но в большинстве других случаев параллелизм для сервера не принесет какой-либо практической выгоды.

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

Есть несколько способов реализации параллелизма данных. Метод Parallel.ForEach похож на цикл foreach и должен использоваться по возможности. Parallel.ForEach рассматривается в рецепте 3.1. Класс Parallel также предоставляет метод Parallel.For, который похож на цикл for и может использоваться, если обработка данных зависит от индексации. Код с использованием Parallel.ForEach выглядит следующим образом: <list6>

Другой вариант - PLINQ (Parallel LINQ), который предоставляет метод расширения AsParallel для запросов LINQ. Parallel является более дружественным к ресурсам, чем PLINQ; Parallel будет учитывать взаимодействие с другими процессами в системе, в то время как PLINQ (по умолчанию) будет вести себя агрессивно, пытаясь занять собой все доступные процессоры. Однако, PLINQ имеет более элегантный код. Применение PLINQ показано в рецепте 3.5: <listing7>

Независимо от выбранного вами метода, при параллельной обработке данных необходимо следовать единому правилу.

Параллельно обрабатываемые фрагменты должны быть максимально независимыми друг от друга.

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

<Результаты параллельной работы могут быть <обработаны> различными способами. Вы можете поместить их в какую-нибудь конкурентную коллекцию или объединить их в <сводку>. Агрегация - это основа параллельной обработки; этот вид map / reduce функциональности также поддерживается перегрузками метода класса Parallel. Рассмотрим агрегацию детальнее в рецепте 3.2.>

Теперь давайте перейдем к параллелизму задач. Параллелизм данных ориентирован на обработку данных; параллелизм задач - это просто параллельное выполнение задач.

Один из методов класса Parralel реализует параллелизм задач типа разделение/соединение (fork/join) - это метод Parallel.Invoke. Пример его работы показан в рецепте 3.3; нужно всего лишь передать ему делегатов для их параллельного выполнения: <listing8>

Тип Task изначально был предназначен для параллелизма задач, однако теперь он также используется для асинхронного программирования. Экземпляр типа Task при использовании с параллелизмом задач - инкапсулирует в себе часть работы. Чтобы дождаться завершения задачи вы можете использовать метод Wait, в свою очередь, чтобы получить результаты выполнения этой задачи вы можете использовать свойства Result и Exception. Использование класса Task напрямую, сложнее, чем работа с типом Parallel, но он может быть полезен, если структура параллелизма не известна заранее (до начала времени исполнения). <При таком динамическом параллелизме вы не знаете, сколько частей вам нужно выполнить в начале обработки; Вы узнаете об этом по ходу дела>. Как правило, динамическая часть работы должна запускать любые дочерние задачи, в которых она нуждается, и затем ждать их завершения. Тип Task имеет для этого специальный флаг TaskCreationOptions.AttachedToParent. Динамический параллелизм описан в рецепте 3.4.

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

Обработка ошибок одинакова при всех видах параллелизма. Поскольку операции выполняются параллельно, возможно возникновение нескольких исключений одновременно. Они заключаются в экземпляр специального типа AggregateException. Это поведение одинаково для Parallel.ForEach, Parallel.Invoke, Task.Wait и т.д. Тип AggregateException имеет несколько полезных методов Flatten и Handle, предназначенных для упрощения процесса обработки ошибок: <listing9>

Задачи не должны быть ни слишком короткими, ни слишком длинными.

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

Parallel Programming with Microsoft .NET, by Colin Campbell et al. (MSPress).

1.4. Введение в реактивное программирование (Rx)

Реактивное программирование является наиболее сложным в изучении типом конкурентности, его код сложнее поддерживать, если ваши навыки реактивного программирования еще не достаточно высоки. Если вы его изучите, оно станет вашим чрезвычайно мощным инструментом. Реактивное программирование позволяет вам обрабатывать поток событий как поток данных. Как правило, <если вы используете какой-либо из аргументов, передаваемых событию, тогда ваш код выиграет от использования Rx вместо обычного обработчика события.>

Реактивное программирование основано на понятии наблюдаемых потоков. <Когда вы подписываетесь на наблюдаемый поток, вы получите любое количество элементов данных (OnNext), и тогда поток может завершиться с одной ошибкой (OnError) или уведомлением об окончании потока (OnCompleted). Некоторые из наблюдаемых потоков не прекращаются>. Вот как выглядит их интерфейс: <listing9>

<table1>

Внимание! Этот перевод, возможно, ещё не готов.
Его статус: идёт перевод

Переведено на Нотабеноиде
http://notabenoid.org/book/78697/461647

Переводчики: masterofsea147

Настройки

Готово:

61.53% КП = 1.0

Непереведённые фрагменты будут пропущены.