Конкурентність у .NET — Вступний погляд через Формулу 1

Якщо ви вже розробляли застосунок, який потребував виконання кількох задач "одночасно", то ви, ймовірно, стикалися з концепцією конкуренції. Приємно, що в .NET є кілька способів працювати з цим.

У цій статті ми поговоримо про деякі з них, спираючись на книгу Concurrency in C# Cookbook і деякі статті, використовуючи як приклад ситуацію, пов'язану з Формулою 1.

Що таке конкуренція?

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

Конкуренція vs. Мультипоточність

  • Мультипоточність — це лише один зі способів досягнення конкуренції. Вона передбачає використання кількох thread для одночасного виконання частин коду.

Якщо ви не знайомі з терміном thread, ось коротке пояснення: _Thread — це послідовність інструкцій, які можуть бути_ виконані незалежно від іншого коду. Наприклад, веб-застосунок починає обробку одного _request в одному thread, і якщо надійде новий request під час обробки першого, він почне оброблятися в іншому thread.
Або можна визначити як_ шлях виконання в програмі, що дозволяє розробникам використовувати конкурентне виконання в їхніх додатках.

  • Конкуренція — це більш широке поняття, яке включає асинхронність (async/await), реактивне програмування та інші механізми, які не обов'язково створюють кілька threads явно.

pic

Конкуренція x Паралелізм

Паралельне програмування (Parallel Programming)

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

  • Parallel.For та Parallel.ForEach: це готові методи для паралелізації циклів (for і foreach). Вони використовують thread pool для автоматичного створення та управління threads.
  • PLINQ (Parallel LINQ): ще один спосіб паралелізувати запити до колекцій. Просто використовуйте .AsParallel() і дозвольте .NET подбати про поділ та виконання.

Наприклад, уявіть, що у вас є алгоритм обробки зображень, який повинен застосувати кілька фільтрів до кожного pixel. Якщо ви використовуєте Parallel.ForEach для списку зображень, кожне зображення може оброблятися в окремому thread, що значно прискорить загальний час.

pic

Приклад застосунку з паралельним програмуванням

Асинхронне програмування (Async/Await)

Ще один популярний спосіб конкуренції сьогодні — це асинхронне програмування з async/await. Ідея тут не стільки "розділяти" задачу на кілька частин, що виконуються паралельно, скільки не блокувати під час очікування, коли операція займає час (наприклад, HTTP-запит).

Перевага полягає в тому, що не блокуються threads, поки чекаємо результат, що покращує відгук і масштабованість, працюючи таким чином у .NET.

  • async: ключове слово, яке ви додаєте до підпису методу, щоб дозволити використання await і вказати компілятору, що метод буде виконуватись асинхронно.
  • await: команда, яка говорить "чекай результат цієї задачі, але я продовжую виконувати інші дії, не блокуючи thread". Коли задача завершується, код продовжує виконуватись з того місця.
  • Tasks та Task: це основа асинхронного програмування.
    Одна Task представляє операцію, яка виконується (або запланована на виконання), в той час як Task може повернути значення по завершенню.
  • IAsyncEnumerable та IAsyncEnumerator: дозволяють створювати асинхронні потоки даних, що повертають кілька значень з часом.

Якщо ви, наприклад, будуєте app для здійснення кількох викликів до зовнішніх API (наприклад, для отримання даних від погодного сервісу, новинного сервісу тощо), з async/await ви можете ініціювати кілька запитів майже одночасно і очікувати їх без заморожування інтерфейсу або блокування сервера.

Нижче наведено приклад використання async/await на основі сценарію, пов'язаного з Формулою 1, де ми шукаємо команду за id за допомогою методу GetTeamAsync, і отримуємо список пілотів за назвою команди через GetPilotsByTeamAsync.

public async Task GetInfoByTeamAsync(string id)  
{  
 // Асинхронні виклики, які не блокують thread  
 var team = await GetTeamAsync(id);  
 var pilots = await GetPilotsByTeamAsync(team.Name);  

 Console.WriteLine($"Selected team: {team.Name}");  
 Console.WriteLine($"Pilots from this team: {string.Join(", ", pilots)}");  
}

Зверніть увагу, що в підписі методу ми використовуємо ключове слово ‘async’, як ми згадували вище, а також використання ‘await’ перед викликом методів, що повертають результати.
Інша конвенція (не правило) для асинхронних методів — це додавати суфікс ‘Async’ до кінця методу, як у: GetTeamAsync та GetPilotsByTeamAsync, що робить код більш читабельним.

Уникайте async void! Можна мати асинхронний метод, що повертає void, але робити це слід тільки в тому випадку, якщо ви пишете асинхронний обробник події async. Звичайний асинхронний метод без значення повернення повинен бути Task.

pic

Порівняння thread під час виконання синхронних і асинхронних викликів

Реактивне програмування (Reactive Programming)

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

Логіка програми описується декларативно, як послідовність реакцій на події.

  • Приклади подій: кліки миші, введення користувача, сповіщення з бази даних, повідомлення, отримані від брокера повідомлень тощо.
  • Багато реалізацій використовують Observables та Observers (підхід Observer).
    У .NET найвідоміший інструмент для цього — це Reactive Extensions (Rx.NET), який дозволяє створювати потоки (streams) даних і застосовувати оператори як "фільтрувати", "групувати", "трансформувати" та інші, в дуже декларативному вигляді.

Якщо ви, наприклад, будуєте систему моніторингу в реальному часі (наприклад, панель порівняння часів кола пілотів Ф1, телеметрії, панель аналізу фондових бірж або дашборд Інтернету Речей (IoT) з датчиками, що надсилають дані безперервно), реактивне програмування дуже допомагає, адже ви "підписуєтесь" на потік даних, і ваш код виконується, щойно надходить нова інформація.

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

pic

Простий потік подій у реактивному програмуванні.

Сценарій Комбінованих Підходів

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

  • Мультитрединг (Multithreading): керується thread pool для паралельних або асинхронних задач.
  • Асинхронний (async/await): для того, щоб зберегти потік виконання вільним від блокувань під час операцій вводу/виводу (I/O).
  • Реактивний (Reactive): коли потрібно працювати з безперервними та непередбачуваними потоками подій.

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

У наступному розділі ми застосуємо ці комбіновані стратегії у сценарії, пов'язаному з стратегіями PitStop для Ф1.
Поїхали!

Практичний Сценарій: Приклад (Стратегія PitStop)

pic

Уявімо, що ви розробляєте сервіс для розрахунків стратегій піт-стопів для гонок Ф1, що називається PitStopConcurrency.

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

Наш сервіс матиме функціональності:

  1. Моніторинг даних у реальному часі щодо зносу шин та рівня пального (задача, яка може включати складні обчислення або валідації в базі даних, але вона не буде розглянута в цій статті).
  2. Запит до зовнішнього API для отримання інформації про погодні умови (наприклад, дощ, температура навколишнього середовища, ймовірність шторму).
  3. Оновлення інтерфейсу (наприклад, дашборд для команди гонщиків), що показує в реальному часі стан шин, пального та рекомендації щодо pitstop.

У цьому сценарії ми комбінуємо підходи до конкуренції таким чином:

1. Паралельне програмування

  • для обробки великого обсягу даних одночасно (наприклад, складні розрахунки щодо зносу шин для автомобілів команди), можна використовувати Parallel.ForEach або PLINQ для паралельного виконання цих обчислень і прискорення часу відповіді.

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

2. Асинхронне програмування

  • При запиті до зовнішнього API щодо погоди, ми не хочемо, щоб наш сервіс був "заблокований" в очікуванні відповіді. Тому методи з async/await можуть бути ідеальними.

Таким чином, поки сервіс чекає інформацію про дощ, температуру та інші змінні (що можуть затримуватись через зовнішні фактори), він продовжує вільно обробляти інші завдання або оновлювати дашборд.

3.

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

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

Кожен новий "подія" (наприклад, зчитування датчика, що інформує про новий стан шини або пального) сповіщає всі підписані компоненти (дашборд, команда тощо), щоб вони відреагували негайно.

Перед тим як перейти до практики, давайте підсумуємо кілька важливих моментів щодо кожного підходу в цьому сценарії:

Паралельне програмування (Parallel): максимізує використання ресурсів апаратного забезпечення, задіюючи кілька ядер процесора;
Асинхронне програмування (async/await): запобігає блокуванню сервісу під час отримання зовнішніх даних;
Реактивне програмування (Reactive): підтримує потік подій, що полегшує оновлення дашборду в реальному часі, дозволяючи команді швидко приймати рішення.

Проект був розроблений з використанням версії .NET 9, переконайтеся, що вона встановлена на вашій машині для його запуску.
Проект можна переглянути/завантажити за посиланням.
Не забувайте поставити "зірочку" і "лайк" на пост 😉

Повертаючись до розробки, оскільки проект вийшов дещо великим, я зосереджусь лише на тих частинах, де використовується конкуренція за допомогою програмування (паралельного, асинхронного або реактивного), пояснюючи правила та техніки, що застосовуються. Як я згадував раніше, посилання на проект надано вище, але я полегшу і зроблю це більш очевидним 🙂
https://github.com/rfulgencio3/PitStopConcurrency

pic

Explorer рішення з папками та проектами, створеними для проекту.

Реалізація конкуренції в архітектурі проекту

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

Domain:
Містить бізнес-правила та основну логіку.

Конкуренція: Не використовується. Домен має залишатись чистим і вільним від технічних деталей конкуренції.

Application:
Оркеструє операції домену, реалізує випадки використання, координує репозиторії та сервіси.

Конкуренція:
Паралельне програмування (Parallel): Може бути використане для одночасної обробки кількох операцій, таких як розрахунки стратегій для кількох автомобілів.
Асинхронне програмування (async/await): Широко використовується для виклику зовнішніх сервісів або репозиторіїв без блокування.
Реактивне програмування (Reactive): Реалізоване тут для управління потоками подій або даних в реальному часі, що впливають на випадки використання.

Infrastructure:
Реалізує технічні деталі, такі як доступ до даних, зовнішні інтеграції, сервіси обміну повідомленнями.

Конкуренція:
Асинхронне програмування (async/await): Необхідне для операцій вводу/виводу, таких як виклики до зовнішніх API або бази даних.
Реактивне програмування (Reactive): Може бути використане для обробки потоків даних або подій з зовнішніх джерел, таких як сенсори або брокери повідомлень.

WebAPI (Presentation):
Експонування HTTP-ендпоінтів, обробка запитів та відповідей.

Конкуренція:
Асинхронне програмування (async/await): Використовується в контролерах для виклику асинхронних методів з шарів застосунку, що дозволяє уникнути блокування HTTP-запитів.
Реактивне програмування: Його можна інтегрувати тут для надання оновлень у реальному часі на фронтенд за допомогою таких технологій, як SignalR.

Використання паралельного програмування

Розташування: Шар Application, зокрема в сервісах або handlers (обробники), що обробляють кілька операцій одночасно.

Приклад: Обчислення стратегій для піт-стопів кількох автомобілів паралельно, як це реалізовано в RaceApplicationService.cs

using System.Collections.Concurrent;  
using System.Threading.Tasks;  
using PitStopConcurrency.Domain.Entities;  
using PitStopConcurrency.Domain.Interfaces;  
using PitStopConcurrency.Domain.Services;  
using PitStopConcurrency.Application.Models;  

namespace PitStopConcurrency.Application.Services  
{  
 public class RaceApplicationService  
 {  
 private readonly IRaceRepository _raceRepository;  
 private readonly ICarRepository _carRepository;  
 private readonly IWeatherApi _weatherApi;  

 public RaceApplicationService(  
 IRaceRepository raceRepository,  
 ICarRepository carRepository,  
 IWeatherApi weatherApi)  
 {  
 _raceRepository = raceRepository;  
 _carRepository = carRepository;  
 _weatherApi = weatherApi;  
 }  

 public async Task ProcessRaceStatusAsync(Guid raceId)  
 {  
 var race = await _raceRepository.GetByIdAsync(raceId);  
 if (race == null)  
 throw new InvalidOperationException("Race not found");  

 var weatherDto = await _weatherApi.GetCurrentWeatherAsync(race.Circuit);  
 var weatherCondition = new WeatherCondition(weatherDto.Temperature, weatherDto.IsRaining);  

 // Використання Parallel.ForEach для паралельної обробки кожного автомобіля  
 Parallel.ForEach(race.Cars, async car =>  
 {  
 var shouldPitStop = PitStopCalculationService.ShouldPitStop(car, weatherCondition);  
 if (shouldPitStop)  
 {  
 car.EnterPit();  
 car.ChangeTires();  
 car.Refuel(50);  
 await _carRepository.UpdateAsync(car);  
 }  
 });  

 // Альтернативно, використовуючи Task.WhenAll для кращого контролю конкуренції  
 var tasks = race.Cars.Select(async car =>  
 {  
 var shouldPitStop = PitStopCalculationService.ShouldPitStop(car, weatherCondition);  
 if (shouldPitStop)  
 {  
 car.EnterPit();  
 car.ChangeTires();  
 car.Refuel(50);  
 await _carRepository.UpdateAsync(car);  
 }  
 });  

 await Task.WhenAll(tasks);  
 }  
 }  
}

Parallel.ForEach корисний для швидкої обробки операцій, але погано працює з асинхронними методами. Тому_ Task.WhenAll може бути більш підходящим, коли йдеться про асинхронні операції.

Безпека для потоків (Thread Safety): При використанні паралелізму переконайтеся, що спільні об'єкти є thread-safe або використовуйте спеціалізовані колекції, такі як ConcurrentBag, якщо це необхідно.

Використання асинхронного програмування

Розташування: Application та Infrastructure.
Необхідність для операцій вводу/виводу, таких як виклики до зовнішніх API або доступ до бази даних.

Приклади:
Application: Обробники запитів, які асинхронно викликають репозиторії або зовнішні сервіси в GetCarStatusQueryHandler.cs та SchedulePitStopCommandHandler.cs
Infrastructure: Сервіси, які споживають зовнішні API за допомогою HttpClient асинхронно або інші реалізації, як у TelemetryService.cs.

Нижче наведено приклад використання в GetCarStatusQueryHandler.cs

using System.Threading.Tasks;  
using PitStopConcurrency.Application.Models;  
using PitStopConcurrency.Application.Queries;  
using PitStopConcurrency.Domain.Interfaces;  

namespace PitStopConcurrency.Application.Handlers  
{  
 public class GetCarStatusQueryHandler  
 {  
 private readonly ICarRepository _carRepository;  

 public GetCarStatusQueryHandler(ICarRepository carRepository)  
 {  
 _carRepository = carRepository;  
 }  

 public async Task Handle(GetCarStatusQuery query)  
 {  
 var car = await _carRepository.GetByIdAsync(query.CarId);  
 if (car == null)  
 throw new InvalidOperationException($"Car with Id {query.CarId} not found.");  

 return new CarDto  
 {  
 Id = car.Id,  
 Name = car.Name,  
 TireWearPercentage = car.TireWear.Percentage,  
 FuelLevelLiters = car.FuelLevel.Liters,  
 IsInPit = car.IsInPit,  
 CurrentLap = car.CurrentLap,  
 TotalLaps = car.TotalLaps  
 };  
 }  
 }  
}

Async/Await: дозволяє виконувати ці операції без блокування основного потоку, покращуючи масштабованість додатку.

Обробка винятків: Винятки можна обробляти глобально (як у middleware) або локально в обробниках, залежно від потреб.

HttpClient: Коли реалізуєте реальні виклики до зовнішніх API, використовуйте HttpClient з async/await.

ПОРАДА: Політика повторних спроб: Розгляньте використання бібліотек, таких як Polly, для впровадження політик retry та стійкості в зовнішніх викликах.

Використання реактивного програмування

Розташування: Переважно в шарі Infrastructure, але також може впливати на шар Application. Використовується для управління безперервними потоками даних або подій.

Приклади:
Infrastructure: Споживання даних з датчиків в реальному часі, Налаштування TelemetryService для реактивного потоку

using System;  
using System.Reactive.Linq;  
using System.Reactive.Subjects;  
using System.Threading.Tasks;  
using PitStopConcurrency.Domain.Interfaces;  

namespace PitStopConcurrency.Infrastructure.ExternalServices  
{  
 public class TelemetryService : ITelemetryService  
 {  
 private readonly Subject _telemetrySubject = new Subject();  

 public TelemetryService()  
 {  
 // Імітує безперервне отримання даних телеметрії  
 Task.Run(async () =>  
 {  
 var random = new Random();  
 while (true)  
 {  
 await Task.Delay(1000); // Імітує інтервал у 1 секунду  

 var telemetry = new TelemetryData  
 {  
 CarId = Guid.NewGuid(),  
 TireWearPercentage = random.Next(0, 100),  
 FuelLevelLiters = random.Next(0, 100),  
 EngineTemperature = 90 + random.NextDouble() * 10,  
 Timestamp = DateTime.UtcNow  
 };  

 _telemetrySubject.OnNext(telemetry);  
 }  
 });  
 }  

 /// 
    /// Повертає обсервабель, який випромінює дані телеметрії в реальному часі.  

///   
 public IObservable GetTelemetryStream()  
 {  
 return _telemetrySubject.AsObservable();  
 }  

 public Task GetCarTelemetryAsync(Guid carId)  
 {  
 // Попередня реалізація асинхронного виклику (не реактивна)  
 throw new NotImplementedException();  
 }  
 }  
}

Subjects: Subject є комбінацією IObserver і IObservable, що дозволяє випромінювати дані для багатьох підписників.

IObservable: Представляє послідовність значень, які випромінюються протягом часу.

Кінцеві поради

  • Уникайте створення потоків вручну за допомогою new Thread(). Використовуйте більш сучасні ресурси, такі як Task, Parallel і пул потоків (thread pool), щоб скористатися тим, що вже пропонує платформа .NET.
  • Тестуйте і моніторьте додаток, оскільки "конкурентність" також включає в себе вирішення проблем, таких як гонки даних, зависання (deadlocks) і управління ресурсами.
  • Комбінуйте техніки. Часто найкраще рішення для додатку — це поєднання паралельного і асинхронного програмування. І якщо важливі реальні події в режимі реального часу, реактивне програмування також може бути корисним.

Висновок

Конкурентність у .NET розвивалася значно за останні роки. Якщо раніше ми вручну створювали свої потоки (threads), то тепер маємо зручність використовувати пул потоків (thread pool) і абстракції, такі як Task, async/await, а також реактивні парадигми для роботи з дедалі складнішими сценаріями.

  • Паралельне програмування максимізує використання кількох ядер, розподіляючи великі завдання на частини, які виконуються одночасно.
  • Асинхронне програмування дозволяє виконувати операції без блокування потоку, що забезпечує відгук і масштабованість, особливо в сценаріях з інтенсивним вводу/виводу.
  • Реактивне програмування йде далі, дозволяючи реагувати на потоки подій, які можуть траплятися в будь-який момент, декларативно і розширювано.

Конкурентність у .NET є великою темою, але варто занурюватися в неї і "мати репертуар", як говорить великий Elemar Júnior, тому що додатки все більше потребують бути швидкими, масштабованими і чуйними, набагато більше, ніж просто "додавати більше інфраструктури".

Якщо ви дійшли до цього місця, сподіваюся, вам сподобалося! Велике спасибі 🙂

Посилання

  • Книга: Concurrency in C# Cookbook. 2-ге видання.
    Автор: Stephen Cleary. O’Reilly Media, 2019.
  • Книга: Pro Asynchronous Programming with .NET
    Автори: Richard Blewett та Andrew Clymer. Apress, 2013.
  • Конкурентність у .NET: Concurrency in .NET
  • Асинхронне програмування з async/await: Async Programming
  • Паралельне програмування з Parallel.For та PLINQ: Parallel Programming
  • Реактивне програмування та Rx.NET: Reactive Extensions (Rx.NET)
  • Використання бібліотеки Task Parallel Library (TPL) для паралельного програмування:
    Parallel Programming Guide

Перекладено з: Concorrência em .NET — Uma Visão Introdutória com Fórmula 1

Leave a Reply

Your email address will not be published. Required fields are marked *