1. Главная страница » Компьютеры

C параллельное выполнение кода

Автор: | 16.12.2019

Парадигма параллелизма задач, рассмотренная ранее, в первую очередь относится к задачам. Основной целью парадигмы параллелизма данных является полное устранение задач из поля зрения и их замена высокоуровневой абстракцией — параллельными циклами. Иными словами, распараллеливается не реализация алгоритма, а данные, которыми она оперирует. Библиотека Task Parallel Library предлагает несколько вариантов поддержки параллелизма данных.

Методы Parallel.For и Parallel.ForEach

Циклы for и foreach часто являются отличными кандидатами для распараллеливания. В действительности, еще на заре развития параллельных вычислений предпринимались попытки автоматического распараллеливания таких циклов. Некоторые попытки воплотились в языковые конструкции или расширения, такие как стандарт OpenMP (описывающий такие директивы, как #pragma omp parallel for, обеспечивающую распараллеливание циклов for). Библиотека Task Parallel Library предоставляет поддержку распараллеливания циклов посредством явных методов, очень близких своим языковым эквивалентам. Речь идет о методах Parallel.For() и Parallel.ForEach(), максимально близко имитирующих поведение циклов for и foreach.

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

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

Читайте также:  Meizu и xiaomi это одно и тоже

Заменив языковую инструкцию цикла вызовом метода, мы автоматически обеспечили параллельное выполнение итераций цикла. Кроме того, метод Parallel.For — это не простой цикл, генерирующий задачи в каждой итерации или для каждого фрагмента данных определенного размера. Вместо этого Parallel.For не спеша приспосабливается к темпу выполнения отдельных итераций, учитывая количество задач, выполняющихся в каждый момент, и исключает вероятность дробления диапазона на слишком мелкие фрагменты, производя его деление динамически.

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

Существует похожий метод и для цикла foreach, который с успехом можно использовать, когда объем источника данных заранее неизвестен и может даже оказаться бесконечным. Представьте, что нам потребовалось загрузить из Интернета множество полос RSS, объявленных как IEnumerable . В этом случае общая структура цикла могла бы иметь следующий вид:

Данный цикл легко можно распараллелить механической заменой инструкции foreach вызовом метода Parallel.ForEach. Обратите внимание, что источник данных (коллекция rssFeeds) необязательно должен быть потокобезопасным, потому что Parallel.ForEach автоматически синхронизирует операции обращения к нему из разных потоков:

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

Класс ParallelLoopState представляет состояние параллельного цикла и позволяет прервать его. Например:

Обратите внимание: метод Stop() не гарантирует, что итерация, вызвавшая его, будет последней — итерации, уже запущенные к этому моменту, будут выполнены до конца (если они не проверяют свойство ParallelLoopState.ShouldExitCurrentIteration). Но гарантирует, что никакие другие итерации не будут запланированы на выполнение.

Одним из недостатков метода ParallelLoopState.Stop() является отсутствие гарантий, что все итерации, предшествующие данной, будут выполнены. Например, при обработке списка из 1000 заказчиков таким способом может получиться так, что заказчики с 1 по 100 будут обработаны полностью, заказчики с 101 по 110 вообще не будут обработаны, и заказчик 111 окажется последним обработанным перед вызовом Stop(). Если необходимо обеспечить выполнение всех итераций, предшествующих данной (даже если они еще не были запущены!), следует использовать метод ParallelLoopState.Break().

Parallel LINQ (PLINQ)

Пожалуй, самой высокоуровневой абстракцией параллельных вычислений является возможность объявить: «Я хочу, чтобы этот фрагмент кода выполнялся параллельно», — и переложить все хлопоты на используемый фреймворк. Именно это позволяет фреймворк Parallel LINQ. Но сначала вспомним, что такое LINQ.

LINQ (Language Integrated Query — язык интегрированных запросов) — это фреймворк и набор расширений языка, появившийся в версии C# 3.0 и .NET 3.5, стирающий грань между императивным и декларативным программированием там, где требуется выполнять итерации через данные. Например, следующий запрос LINQ извлекает из источника customers — который может быть обычной коллекцией в памяти, таблицей в базе данных или иметь более экзотическое происхождение — имена и возраст клиентов, проживающих в Москве, сделавших не менее трех покупок на сумму более 100 руб. за последние десять месяцев, и выводит эти данные в консоль:

Первое, на что следует обратить внимание, — большая часть запроса определена декларативно, подобно запросу на языке SQL. Здесь не используются циклы для фильтрации объектов или группировки объектов из разных источников. Часто вам не придется даже волноваться о синхронизации итераций, выполняемых запросом, потому что запросы LINQ являются исключительно функциональными и не имеют побочных эффектов — они преобразуют одну коллекцию (IEnumerable ) в другую, не изменяя никакие объекты в процессе работы.

Чтобы распараллелить запрос, представленный выше, достаточно лишь изменить тип коллекции источника с обобщенного IEnumerable на ParallelQuery . Для этого можно воспользоваться методом AsParallel() расширения и получить в результате следующий элегантный код:

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

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

Главное преимущество PLINQ перед методом Parallel.ForEach() заключается в автоматическом объединении результатов, полученных разными потоками. В примере поиска простых чисел с использованием Parallel.ForEach)_ мы были вынуждены вручную добавлять результаты работы каждого потока в глобальную коллекцию. При этом необходимо было использовать механизм синхронизации и тем самым увеличивать накладные расходы. Тот же результат легко можно получить с помощью PLINQ:

Настройка параллельных циклов и PLINQ

Параллельные циклы (Parallel.For и Parallel.ForEach) и PLINQ поддерживают несколько методов для выполнения настройки, которые делают эти инструменты чрезвычайно гибкими и близкими в богатстве и выразительности к механизму параллельных задач, обсуждавшемуся выше. Методы параллельных циклов принимают объект ParallelOptions с различными свойствами, определяющими дополнительные параметры, а фреймворк PLINQ — дополнительные методы объектов ParallelQuery . В число настраиваемых параметров входят:

ограничение степени параллелизма (максимально возможное количество задач, выполняемых параллельно);

передача признака отмены для остановки параллельных задач;

принудительное определение порядка получения результатов параллельных запросов;

управление буферизацией вывода (режимом слияния) результатов параллельных запросов.

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

Асинхронные методы в C# 5

До сих пор мы рассматривали приемы распараллеливания, которые могут быть выражены с использованием классов и методов из библиотеки Task Parallel Library. Однако существует еще одна среда параллельных вычислений, основанная на расширениях языка и позволяющая получить еще большую выразительность там, где методы выглядят несколько неуклюже или недостаточно выразительно. В этом разделе мы познакомимся с нововведениями в языке C# 5, предназначенными для решения задач параллельного программирования и упрощающими реализацию продолжений (continuations). Но сначала познакомимся с продолжениями с точки зрения асинхронного выполнения.

Достаточно часто возникает необходимость связать с некоторой задачей продолжение (continuation), или обратный вызов (callback), то есть некоторый код, который должен быть выполнен по завершении задачи. Имея контроль над задачей — то есть, когда вы явно управляете ее запуском — вы можете встроить обратный вызов в саму задачу, но когда вы получаете задачу от какого-то другого метода, необходимо использовать явный API продолжения.

Библиотека TPL предлагает метод экземпляра ContinueWith и статические методы ContinueWhenAll() и ContinueWhenAny() (их имена говорят сами за себя) для управления продолжениями в некоторых ситуациях. Используя класс TaskScheduler можно запланировать выполнение продолжения только при определенных обстоятельствах (например, только когда задача завершилась успешно или только когда в задаче возникло исключение) и в определенном потоке (группе потоков).

Продолжения — удобный способ программирования асинхронных приложений и очень ценный, при выполнении асинхронных операций ввода/вывода в приложениях с графическим интерфейсом. Например, чтобы обеспечить высокую отзывчивость приложений для Windows 8 с интерфейсом в стиле Метро (Metro), WinRT (Windows Runtime) API в Windows 8 поддерживает только асинхронные версии всех операций, длительность которых может составить больше 50 миллисекунд. При наличии множества асинхронных вызовов, выполняемых друг за другом, вложенные продолжения могут стать неудобными в использовании.

Архитекторы C# 5 решили устранить эти проблемы, введением в синтаксис языка двух новых ключевых слов, async и await. Асинхронный метод должен быть отмечен ключевым словом async и может возвращать значение типа void, Task или Task . Внутри асинхронного метода можно использовать оператор await, чтобы реализовать продолжение без использования метода ContinueWith(). Взгляните на следующий пример:

Здесь выражение "await locTask" реализует продолжение для задачи, возвращаемой вызовом GetCurrentLocationAsync(). Собственно продолжением является оставшаяся часть тела метода (начиная с инструкции присваивания переменной loc), а значением выражения await является результат выполнения задачи, в данном случае — объект Location. Кроме того, продолжение неявно планируется для выполнения в потоке управления пользовательским интерфейсом, о чем, при использовании TaskScheduler, необходимо было позаботиться явно.

Заботу обо всех синтаксических особенностях тела метода берет на себя компилятор C#. Например, в только что написанном методе имеется блок try. finally, спрятанный под покровом инструкции using. Компилятор переделает продолжение так, что метод Dispose() переменной location будет вызван независимо от успешности завершения задачи. Эта особенность делает замену вызовов синхронных методов их асинхронными аналогами почти тривиальным делом. Компилятор поддерживает обработку исключений, сложные циклы, рекурсивные вызовы методов — языковые конструкции, которые плохо сочетаются с явным механизмом продолжений.

Всего лишь две языковые особенности (не очень сложные в реализации!) существенно снизили порог вхождения в асинхронное программирование и упростили работу с методами, возвращающими задачи и управляющими ими. Кроме того, реализация оператора await несовместима с библиотекой Task Parallel Library; низкоуровневый WinRT API В Windows 8 возвращает экземпляры типа IAsyncOperation , а не задачи Task (которые являются управляемой концепцией), которые, тем не менее, с успехом можно передавать оператору await.

Дополнительные шаблоны в TPL

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

Первым приемом оптимизации, который может использоваться при распараллеливании циклов с общим состоянием, является агрегирование (иногда называется сверткой (reduction)). Когда в параллельном цикле используется общее состояние, масштабируемость часто утрачивается из-за необходимости синхронизировать доступ к общим данным; чем больше ядер в процессоре оказывается доступно, тем меньше выигрыш из-за синхронизации (этот эффект является прямым следствием закона Амдала (Amdahl Law), который часто называют законом убывающей отдачи (The Law of Diminishing Returns)). Значительный прирост производительности часто достигается за счет создания локальных состояний потоков или задач, выполняющих параллельные итерации цикла, и их объединения в конце. Методы из библиотеки TPL, используемые для организации циклов, имеют перегруженные версии, обслуживающие такого рода локальные агрегаты.

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

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

Еще одно место, где можно применить оптимизацию, — итерации цикла, слишком короткие, чтобы их эффективно можно было распараллелить. Даже при том, что механизм параллелизма данных объединяет несколько итераций иногда тело цикла может выполняться настолько быстро, по скорости превосходят вызов делегата, необходимый для вызова тела цикла в каждой итерации. В этом случае можно использовать класс Partitioner и с его помощью вручную группировать итерации, уменьшая количество вызовов делегатов:

За дополнительной информацией о разбиении циклов на фрагменты, являющемся важным способом оптимизации, обращайтесь к статье «Пользовательские разделители для PLINQ и TPL» на сайте MSDN.

Наконец, существуют приложения, в которых могут пригодиться собственные планировщики задач. В качестве примеров можно привести планирование заданий в потоке управления пользовательским интерфейсом, назначение приоритетов задачам, планируя их с помощью высокоприоритетного планировщика, и связывание задач с определенным процессором, планируя их с помощью планировщика, использующего потоки, привязанные к определенному процессору. Реализовать собственные планировщики можно путем наследования класса TaskScheduler. Пример такой реализации можно найти в статье «Практическое руководство. Создание планировщика заданий, ограничивающего степень параллелизма» на сайте MSDN.

Уважаемые читатели, в этой статье я хочу рассказать о таком важном средстве многозадачного программирования среды .NET, как многопоточность. Данная статья содержит начальные сведения, и предназначена для быстрого освоения азов многопоточности на языке C#. Однако не буду разглагольствовать о преимуществах параллельного выполнения задач, и перейду к примеру кода.

На что стоит обратить внимание в этой программе. В первую очередь это пространство имен System.Threading. Это пространство имен содержит в себе классы поддерживающие многопоточное программирование. И именно там содержится класс Thread, который мы используем далее в коде.
Далее мы создаем объект потока.

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

Итак, главный и второстепенный поток параллельно выполняют почти идентичный код они считают до 9 (как повелось, с нуля), перед этим назвав собственный номер. Обратите внимание на статический метод Thread.Sleep(0). Он приостанавливает поток вызвавший его на количество миллисекунд указанных в параметре. Но в данном случае ему передан параметр 0. Это означает что поток должен приостановиться для того чтобы дать возможность выполнения другому потоку.

И для того чтобы консоль не закрылась раньше времени, мы будем ожидать ввода с клавиатуры (Console.Read).В результате ваша программа выведет в консоль примерно следующее:
Поток 1 выводит 0
Поток 2 выводит 0
Поток 1 выводит 1
Поток 1 выводит 2
Поток 1 выводит 3
Поток 2 выводит 1
и т.д.

Хотя результат вывода каждый раз будет разным. Это зависит от множества факторов.Стоит принять во внимание, что потоки бывают двух видов: приоритетные и фоновые. Фоновые потоки автоматически завершаются при завершении приоритетных. По умолчанию в приоритетном потоке запускается функция Main а остальные потоки создаются фоновыми. Именно поэтому мы должны следить за тем, чтобы главный поток не завершился до окончания производных. Для того чтобы не допустить этого мы можем использовать поле IsAlive, которое возвращает true если поток активен. Либо метод Join(), который заставляет поток в котором он вызван ожидать завершения работы потока которому он принадлежит.

Однако мы можем и сами менять виды потоков. Свойство потока IsBackground определяет является ли поток фоновым. Таким образом, мы можем сделать поток приоритетным. myThread.IsBackground = false; Однако, нам необязательно создавать новый поток внутри метода Main. Давайте создадим класс который будет открывать новый поток в собственном конструкторе. Созданный нами класс по-прежнему будет заниматься счетом, но на этот раз он будет считать до указанного числа, за счет передачи потоку параметров.

Разберем данный пример. Конструктор нашего класса myThread принимает 2 параметра: строку, в которой мы определяем имя потока, и номер до которого будет вестись счет в цикле. В конструкторе мы создаем поток, связанный с функцией func данного объекта. Далее мы присваиваем нашему потоку имя, используя поле Name созданного потока. И запускаем наш поток, передав функции Start аргумент.

Обратите внимание на то, что функция вызываемая потоком может принимать только 1 аргумент, и только типа object. В функции func цикл for досчитывает до числа, переданного ему как аргумент, и поток завершается. В функции Main мы создаем 3 пробных объекта, работа которых выведет на консоль примерно следующий текст.

Thread 1 выводит 0
Thread 1 выводит 1
Thread 2 выводит 0
Thread 2 выводит 1
Thread 2 выводит 2
Thread 2 завершился

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

Уроки программирования, алгоритмы, статьи, исходники, примеры программ и полезные советы

ОСТОРОЖНО МОШЕННИКИ! В последнее время в социальных сетях участились случаи предложения помощи в написании программ от лиц, прикрывающихся сайтом vscode.ru. Мы никогда не пишем первыми и не размещаем никакие материалы в посторонних группах ВК. Для связи с нами используйте исключительно эти контакты: vscoderu@yandex.ru, https://vk.com/vscode

Потоки в C# для начинающих: разбор, реализация, примеры

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

Если говорить простым языком, то поток – это некая независимая последовательность инструкций для выполнения того или иного действия в программе. В одном конкретном потоке выполняется одна конкретная последовательность действий.
Совокупность таких потоков, выполняемых в программе параллельно называется многопоточностью программы.

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

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

Стандартно в проектах Visual Studio существует только один основной поток – в методе Main. Всё, что в нём выполняется – выполняется последовательно строка за строкой. Но при необходимости можно “распараллелить” выполняемые процессы при помощи потоков.

Для лучшего осознания можно представить следующий пример. Допустим, что наша программа и данные, которые в ней содержатся – это офис с различными предметами (папками, столами, стульями, ручками), а потоки – это работники данного офиса (изначально у нас только один работник, выполняющий всю работу), и каждый работник занимается теми делами, которые ему было сказано выполнять. Работники могут выполнять одинаковые задания, а могут и различные. В случае выполнения какой-либо одной задачи, несколько работников справятся быстрее, чем один.

Например, если один работник будет собирать шкаф час, то вдвоём они могут управиться уже за полчаса. Однако не стоит переусердствовать в количестве работников (потоков). Математически, если нанять 4 работника, то шкаф соберется за 15 минут, если нанять 60 работников – за 1 минуту, а если нанять 3600, то вообще за секунду, но ведь на деле это неверно. Работники будут только мешать друг другу, толкаться, отнимать друг у друга детали, и процесс сборки шкафа может затянуться очень надолго.

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

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

Язык C# имеет встроенную поддержку многопоточности, а среда .NET Framework предоставляет сразу несколько классов для работы с потоками, что в купе очень помогает гибко и правильно реализовывать и настраивать многопоточность в проектах.

В среде .NET Framework существует два типа потоков: основной и фоновый (вспомогательный). В целом отличие между ними одно – если первым завершится основной поток, то фоновые потоки в его процессе будут также принудительно остановлены, если же первым завершится фоновый поток, то это не повлияет на остановку основного потока – тот будет продолжать функционировать до тех пор, пока не выполнит всю работу и самостоятельно не остановится. Обычно при создании потока ему по-умолчанию присваивается основной тип. О том, как узнать, к какой разновидности относится тот или иной поток, как придать потоку нужный тип, что такое приоритеты и как их устанавливать, и как в целом работать с потоками в C# мы поговорим ниже.

Как создавать потоки в C#

Перво-наперво для работы с потоками в C# необходимо подключить специальную директиву:

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *