Основи багатопоточності .NET Framework



Багатопоточність — одна з найбільш складних тем в програмуванні, з нею постійно виникає маса проблем. Без чіткого розуміння внутрішніх механізмів буде дуже важко передбачити результат роботи програми, що використовує декілька потоків. Ми не будемо тут дублювати масу теоретичної інформації, якої дуже багато в мережі і розумних книгах. Замість цього зосередимося на конкретних і найбільш важливі проблеми, на які потрібно звертати особливу увагу і обов'язково пам'ятати про них у процесі розробки.

Потоки
Як всі, напевно, знають, потік .NET Framework представлений у вигляді класу Thread. Розробники можуть створювати нові потоки, давати їм осмислені імена, змінювати пріоритет, запускати, очікувати завершення роботи або зупиняти.

Потоки поділяються на background (фоновий) і foreground (основний, той, що на передньому плані). Основна відмінність між ними в тому, що foreground-потоки перешкоджають завершенню програми. Як тільки всі foreground-потоки зупинені, система автоматично зупинить background і завершить виконання програми. Щоб визначити, чи є потік фоновим чи ні, необхідно викликати наступне властивість поточного потоку:

Thread.CurrentThread.IsBackground


За замовчуванням, при створенні потоку за допомогою класу 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 based), в той час як I/O-потоки — на роботі з пристроями вводу/виводу: файлова система, мережева карта та інші. Якщо намагатися виконувати I/O-операцію на робочому потоці (CPU based), то це буде марна трата ресурсів, так як потік буде перебувати в стані очікування завершення I/O-операції. Для таких задач призначені окремі I/O-потоки. При використанні пулу потоків це приховано у явному вигляді від розробників. Отримати кількість різних потоків у пулі можна за допомогою коду:

ThreadPool.GetAvailableThreads(out workerThreads, out competitionPortThreads);


Для того, щоб визначити, чи є поточний потік узятим з пулу або створеним вручну, необхідно скористатися конструкцією:

Thread.CurrentThread.IsThreadPoolThread


Запустити завдання на виконання за допомогою потоку, взятого в пулі, можна з допомогою:

  • класу ThreadPool: ThreadPool.QueueUserWorkItem
  • асинхронних делегатів (пара методів делегата: BeginInvoke() і EndInvoke())
  • класу BackgroundWorker
  • TPL (Task Parallel Library, про яку ми ще поговоримо нижче)
Такі конструкції також використовують пул потоків, але роблять це неявно, про що важливо знати і пам'ятати:

Корисно тримати в голові наступні моменти:

  • Потоків з пулу неможливо призначити ім'я
  • Потоки з пулу завжди фонові (background)
  • Блокування потоків з пулу може призвести до запуску додаткових потоків і падіння продуктивності
  • Ви можете змінити пріоритет потоку з пулу, але він повернеться в дефолтний значення (normal) після повернення в пул


Синхронізація
При побудові багатопотокового додатку необхідно гарантувати, що будь-яка частина поділюваних даних захищена від можливості зміни їх значень безліччю потоків. Враховуючи, що керована купа є одним з поділюваних потоками ресурсів, а всі потоки в AppDomain мають паралельний доступ до подільних даними додатка, очевидно, що доступ до таких загальних даних необхідно синхронізувати. Це гарантує, що в один момент часу доступ до певного блоку коду отримає лише один потік (або зазначена кількість, у разі використання Семафора). Таким чином, ми можемо гарантувати цілісність даних, а також їх актуальність у будь-який момент часу. Давайте розглянемо можливі варіанти синхронізації і часті проблеми. Говорячи про синхронізації, зазвичай виділяють 4 види:

  • Блокування вхідного коду
  • Конструкції, що обмежують доступ до шматків коду
  • Сигналізують конструкції
  • Неблокирующая блокування
Blocking
Під блокуванням розуміють очікування одним потоком завершення іншого або знаходження в режимі очікування протягом якогось часу. Зазвичай реалізується за допомогою методів класу Thread: Sleep() Join(), методу EndInvoke() асинхронних делегатів або за допомогою тасков (Task) та їх механізмів очікування. Такі конструкції є прикладами поганого підходу до реалізації очікування:

while (!proceed);
while (DateTime.Now < nextStartTime);


Подібні конструкції вимагають багато ресурсів процесора, хоча не виконують ніякої корисної роботи. У той же час ОС і CLR думають, що наш потік зайнятий виконанням важливих розрахунків і виділяють для нього необхідні ресурси. Даного підходу слід завжди уникати.

Схожим прикладом може бути наступна конструкція:

while (!proceed) Thread.Sleep(10);


Тут викликає потік періодично засинає на короткий час, але його достатньо для того, щоб система могла переключити контексти і виконувати паралельно інші завдання. Даний підхід набагато краще попереднього, але все ж не ідеальний. Основна проблема виникає в той момент, коли необхідно змінювати прапор proceed з різних потоків. Подібна конструкція буде ефективним рішенням в тому випадку, коли ми очікуємо, що умова в циклі буде задоволено через дуже короткий час і спричинить за собою невелика кількість ітерацій. Якщо ітерацій багато, то системі потрібно постійно перемикати контекст даного потоку і витрачати на це додаткові ресурси.

Locking
Ексклюзивна блокування застосовується для того, щоб упевнитися, що тільки один потік буде виконувати конкретну ділянку коду. Це необхідно для гарантування актуальності даних в кожен момент часу. В .NET Framework існує досить багато механізмів, які дозволяють реалізувати блокування доступу до ділянок коду, але ми розглянемо тільки найбільш популярні. А заодно розберемо найбільш часті помилки, пов'язані із застосуванням подібних конструкцій.



У таблиці представлені найбільш популярні механізми для організації блокувань. За допомогою Мютексов можна реалізувати межпроцессорную блокування (а не тільки для декількох потоків одного процесу). Семафор відрізняється від Мютекса тим, що дозволяє вказати кількість потоків або процесів, які можуть отримати одночасний доступ до конкретної ділянки коду. Конструкція lock, яка є викликом пари методів: Monitor.Enter() Monitor.Exit(), застосовується дуже часто, тому розглянемо можливі проблеми та рекомендації щодо її використання.

Статичні члени класів, якими часто оперують розробники, завжди потоконебезопасны, і доступ до даних потрібно обов'язково синхронізувати. Відмінністю може бути тільки статичний конструктор, так як CLR блокує всі звернення зі сторонніх потоків до статичних членів класу до тих пір, поки не завершить свою роботу статичний конструктор.

При використанні блокування за допомогою ключового слова lock слід пам'ятати такі правила:

  • необхідно уникати блокування типів:
    lock(typeof(object)) {...} 

    <Справа в тому, що кожен тип зберігається в єдиному екземплярі в рамках одного домену і подібний підхід може призвести до взаимоблокировкам. Тому варто уникати подібних конструкцій.
  • необхідно уникати блокування об'єкта this:
    lock(this) {...}

    Даний підхід також може призвести до глухий кут.
  • як об'єкт синхронізації можна використовувати додаткове поле в конкретному класі:
    lock(this.lockObject) {...}
  • потрібно застосовувати конструкцію Monitor.TryEnter(this.lockObject, 3000) коли ви сумніваєтеся, і потік може бути заблокований. Подібна конструкція дозволить вийти з блокування після закінчення зазначеного інтервалу часу.
  • необхідно використовувати клас Interlocked для атомарних операцій замість подібних конструкцій:
    lock (this.lockObject) { this.counter++; }


Signaling
Даний механізм дозволяє потоку зупинитися і чекати до тих пір, поки він не отримає повідомлення з іншого потоку про можливості продовжити роботу.



У таблиці наведені найбільш поширені конструкції, які використовуються при «сигналинге». Використання даного підходу часто буде більш ефективним, ніж попередні.

Nonblocking synchronization

Крім перерахованих вище механізмів .NET Framework надає конструкції, які можуть виконувати прості операції без блокування, зупинки або очікування інших потоків. За рахунок відсутності блокувань і перемикання контекстів код буде працювати швидше, але при цьому дуже легко допустити помилку, яка загрожує труднонаходимыми проблемами. У кінцевому рахунку ваш код може стати навіть повільніше, ніж якщо б ви застосували поширений підхід з використанням lock. Одним з варіантів такої синхронізації є застосування так званих бар'єрів пам'яті (Thread.MemoryBarrier()), які перешкоджають оптимізацій, кешированию регістрів CPU і перестановок програмних інструкцій.

Іншим підходом є використання ключового слова volatile, яким позначаються необхідні поля класу. Воно змушує компілятор генерувати бар'єри пам'яті при кожному читанні і запису в змінну, позначеної volatile. Даний підхід хороший у тому випадку, коли у вас один потік, або одні потоки тільки читають, а інші тільки записують. Якщо ж вам необхідно читати і змінювати в одному потоці, то варто скористатися оператором lock.

Обидві перераховані вище можливості досить складними для розуміння, вимагають чітких знань в області моделей пам'яті і оптимізацій на різних рівнях, тому використовуються досить рідко. Так і застосовувати їх потрібно дуже акуратно і тільки тоді, коли ви розумієте, що робите і навіщо.

Найпростіший і рекомендований підхід для атомарних операцій — застосування класу Interlocked, про який згадувалося вище. За лаштунками також генеруються бар'єри пам'яті, і нам не потрібно піклуватися про додаткові блокування. Цей клас має досить багато методів для атомарних операцій, таких як збільшення, зменшення, зміна, зміна з порівнянням і т. д.

Collections

Корисно знати, що в просторі імен System.Collections.Concurrent визначено досить багато потокобезопасных колекцій для різних завдань. Найпоширеніші з них:

BlockingCollection
ConcurrentBag
ConcurrentDictionary<TKey, TValue>
ConcurrentQueue
ConcurrentStack

В більшості випадків немає сенсу в реалізації власної подібної колекції — набагато простіше і розумніше використовувати готові протестовані класи.

Асинхронність
Окремо хотілося б виділити, так звану, асинхронність, яка, з одного боку, завжди безпосередньо пов'язана з запуском додаткових потоків, а з іншого — з додатковими питаннями і теорією, на яких теж варто зупинитися.

Покажемо на наочному прикладі різницю між синхронним і асинхронним підходами.

Припустимо, ви хочете пообідати піцою в офісі і у вас є два варіанти:

1-й, синхронний варіант: прогулятися пішки в піцерію, вибрати цікаву для вас піцу, зробити замовлення, дочекатися, поки його принесуть, дістатися з піцою в офіс або пообідати безпосередньо в піцерії, після чого ви повернетеся і продовжуйте працювати. У процесі прогулянки і очікування замовлення ви будете перебувати в режимі очікування і не зможете займатися іншою корисною роботою (для простоти, тут розуміється саме робота в офісі, яка приносить гроші і яку ви не можете виконувати поза робочого місця).

2-й, асинхронний варіант: замовити піцу по телефону. Після замовлення ви не заблоковані, можете виконувати корисну роботу на робочому місці, поки ваше замовлення обробляється і доставляється в офіс.

Еволюція
По мірі розвитку .NET Framework було багато нововведень і підходів для запуску асинхронних операцій. Першим рішенням для асинхронних завдань став підхід під назвою APM (Asynchronous Programming Model). Він заснований на асинхронних делегатах, які використовують кілька методів з іменами BeginOperationName EndOperationName, які відповідно починають і завершують асинхронну операцію OperationName. Після виклику методу BeginOperationName додаток може продовжити виконання інструкцій в зухвалій потоці, поки асинхронна операція виконується в іншому. Для кожного виклику методу BeginOperationName у додатку також повинен бути присутнім виклик методу EndOperationName, щоб отримати результати операції.

Даний підхід можна зустріти в безлічі технологій і класів, але він загрожує ускладненням і надмірністю коду.

У версії 2.0 була введена нова модель під назвою EAP (Event-based Asynchronous Pattern). Клас, який підтримує асинхронну модель, засновану на події, буде містити один або кілька методів MethodNameAsync. Він може відображати синхронні версії, які виконують ту ж дію з поточним потоком. Також у цьому класі може міститися подія MethodNameCompleted і метод MethodNameAsyncCancel (або CancelAsync), для скасування операції. Даний підхід поширений при роботі з сервісами. У Silverlight застосовується для звернення до серверної частини, а Ajax по суті представляє з себе реалізацію даного підходу. Варто побоюватися довгих ланцюжків пов'язаних викликів подій, коли по завершенні однієї довгострокової операції в події її завершення викликається наступна, потім ще наступна і так далі. Це загрожує дэдлоками і непередбаченими результатами. Обробка виключень і результати асинхронної операції доступні лише в оброблювачі події допомогою відповідних властивостей параметри: Error Result.

В .NET Framework 4.0 була впроваджена удосконалена модель під назвою TAP (Task-based Asynchronous Model), яка базується на завданнях. На них також побудовані TPL і PLINQ, але про них поговоримо докладно в наступний раз. Дана реалізація асинхронної моделі базується на типах Task
Task<TResult>System.Threading.Tasks
, які використовуються для надання довільних асинхронних операцій. TAP — це рекомендований асинхронний шаблон для розробки нових компонентів. Дуже важливо розуміти різницю між потоком (Thread), і завданням (Task), які сильно відрізняються. Thread (потік) являє собою інкапсуляцію потоку виконання, в той час як Task є роботою (або просто асинхронної операцією), яка може бути виконана паралельно. Для виконання завдання використовується вільний потік з пулу потоків. По завершенні роботи потік буде повернений назад в пул, а користувач класу отримає результат завдання. Якщо вам потрібно запустити тривалу операцію і ви не хочете надовго блокувати один з потоків пулу, то можете це зробити за допомогою параметра TaskCreationOptions.LongRunning. Створювати і запускати завдання можна різними способами, і часто незрозуміло, який з них вибрати. Різниця, в основному, лише в зручності використання і кількості параметрів з параметрами, які є доступними в тому чи іншому способі.

В останніх версіях фреймворку з'явилися нові можливості на основі тих же завдань, які спрощують написання асинхронного коду і роблять його більш читабельним і зрозумілим. Для цього введені нові ключові слова async await, якими позначаються асинхронні методи та їх виклики. Асинхронний код стає дуже схожим на синхронний: ми просто викликаємо потрібну операцію і весь код, який слід за її викликом, автоматично буде загорнуто в якийсь «колбек», який викликається після завершення асинхронної операції. Також даний підхід дозволяє обробляти винятки в синхронній манері; явно чекати завершення операції; визначати дії, які повинні бути виконані, і відповідні умови. Наприклад, ми можемо додати код, який буде виконаний тільки в тому випадку, якщо асинхронної операції було згенеровано виняток. Але не все так просто, навіть незважаючи на масу інформації на цю тему.

async\await
Розглянемо основні рекомендації по використанню цих ключових слів, а також деякі цікаві приклади. Найчастіше рекомендується використовувати асинхронності «від початку до кінця». Це передбачає використання тільки одного підходу в конкретному виклик або функціональному блоці, не змішуйте синхронні виклики з асинхронними. Класичний приклад даної проблеми:

public static class DeadlockDemo
{
private static async Task DelayAsync()
{
await Task.Delay(1000);
}
public static void Test()
{
var delayTask = DelayAsync();
delayTask.Wait();
}
}


Даний код відмінно працює в консольному додатку, але при виклику методу DeadlockDemo.Test() з GUI потоку виникне глухий кут. Це пов'язано з тим, як await обробляє контексти. За промовчанням, коли очікується незавершений Task, поточний контекст захоплюється і використовується для відновлення методу по закінченні виконання завдання. Контекстом є поточний SynchronizationContext, якщо тільки він не дорівнює null, як у випадку з консольними додатками. Там це поточний TaskScheduler (контекст пулу потоків). GUI — і ASP.NET-додатки мають SynchronizationContext, який дозволяє одночасно виконувати тільки одну порцію коду. Коли вираз await завершує виконання, воно намагається виконати решту async-методу в рамках захопленого контексту. Але він вже має потік, який (синхронно) очікує завершення async-методу. Виходить, що кожен з них чекає один одного, викликаючи взаимоблокировку.

Також рекомендується уникати конструкцій виду async void (асинхронний метод, який нічого не повертає). Async-методи можуть повертати значення Task,
Task<TResult>
і void. Останній варіант був залишений для підтримки зворотної сумісності і дозволяє додавати асинхронні обробники подій. Але варто пам'ятати про деякі специфічні відмінності подібних методів, а саме:

  • Вийнятки не можна перехопити стандартними засобами
  • Оскільки подібні методи не повертають завдання, то ми обмежені в роботі з такими конструкціями. Приміром, ми не зможемо очікувати завершення подібних завдань стандартними засобами або створити ланцюжок виконання, як у випадку з об'єктами Task.
  • Подібні методи складно тестувати, так як вони мають відмінності в обробці помилок і композиції.
Завжди намагайтеся конфігурувати контекст, коли це можливо. Як вже говорилося, код всередині асинхронного методу після виклику await вимагатиме контекст синхронізації, в якому він був викликаний. Це дуже корисна можливість, особливо в GUI додатках, але іноді це не обов'язково. Наприклад, коли кодом не потрібно звертатися до елементів користувальницького інтерфейсу. Попередній приклад з дэдлоком можна легко виправити, змінивши лише один рядок:

await Task.Delay(1000).ConfigureAwait(false);


Дана рекомендація дуже актуальна при розробці будь-яких бібліотек, які нічого не знають про GUI.

Розглянемо ще декілька прикладів застосування нових ключових слів, а також деякі особливості їх використання:

1)
private static async Task Test()
{
Thread.Sleep(1000);
Console.Write("work");
await Task.Delay(1000);
}
private static void Demo()
{
var child = Test();
Console.Write("started");
child.Wait();
Console.Write("finished");
}


На екрані спочатку з'явиться «work», потім «started», і тільки потім «finished». На перший погляд здається, що першим має бути виведено слово «started». Не забувайте, що в даному коді присутня проблема з дэдлоком, яку ми розглянули. Це пов'язано з тим, що метод, позначений ключовим словом async, не запускає додаткових потоків і обробляється синхронно до тих пір, поки не зустріне всередині ключове слово await. Тільки після цього буде створено новий об'єкт типу Task і запущена відкладена завдання. Щоб виправити дану поведінку в наведеному прикладі, досить замінити рядок Thread.Sleep(...) await Task.Delay(...).

2)
async Task Demo()
{
Console.WriteLine("Before");
Task.Delay(1000);
Console.WriteLine("After");
}


Можна припустити, що ми будемо чекати 1 секунду перед другим виводом на екран, але це не так — обидва повідомлення будуть виведені без затримок. Це пов'язано з тим, що метод Task.Delay(), як і багато інших асинхронні методи, повертає об'єкт типу Task, але ми проігнорували цю задачу. Ми не очікуємо її завершення ні одним з можливих способів, що тягне за собою негайне виведення на екран обох повідомлень.

3)
Console.WriteLine("Before");
await Task.Factory.StartNew(async () => { await Task.Delay(1000); });
Console.WriteLine("After");


Як і в минулому прикладі, висновок на екран не буде припинений на одну секунду. Це пов'язано з тим, що метод StartNew() приймає делегат і повертає
Task<T>
, де T — це тип, що повертається делегатом. У прикладі наш делегат повертає Task. В результаті ми отримуємо результат у вигляді
Task<Task>
. Використання слова await «чекає» тільки завершення зовнішньої задачі, яка відразу ж повертає внутрішній Task, створений в делегате, який далі ігнорується. Виправити цю проблему можна, переписавши код наступним чином:

await Task.Run(async () => { await Task.Delay(1000); });


4)
async Task TestAsync()
{
await Task.Delay(1000);
}
void Handler()
{
TestAsync().Wait();
}


Незважаючи на використання ключових слів, даний код не є асинхронним і виконується синхронно, тому що ми створюємо завдання і явно очікуємо її виконання. В даному випадку викликає потік заблокований і очікує завершення запущеної завдання.

Висновок
Як бачите, у розробників є досить багато можливостей для роботи з багато-додатками. Важливо знати не тільки теорію, але і вміти застосовувати ефективні підходи для вирішення конкретних завдань. Наприклад, використання класу Thread майже однозначно говорить про те, що у вас застарілий код в проекті, хоча ймовірність виникнення необхідності його використання дуже мала. У звичайних ситуаціях використання пулу завжди виправдано, зі зрозумілих причин.

Використання багатопоточності в додатках з GUI зазвичай тягне за собою додаткові обмеження, не забувайте про них!

Також варто пам'ятати і про інші готові реалізації, такі як потокобезопасные колекції. Це позбавляє від написання програмного коду та запобігає можливі помилки реалізації. Ну і не забувайте про особливості нових ключових слів.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.