Привет, Хабр!
Если ты всё ещё пишешь код на Thread или ThreadPool, пора остановиться и подумать. Зачем вручную управлять потоками, ловить дедлоки и страдать от гонок данных, если можно просто... не страдать?
Сегодня разберём Task Parallel Library (TPL) в C# — единственно правильный способ писать многопоточный код в 2025 году.
Синтаксис Task в C#
Создание и запуск задачи
Самый быстрый способ запустить Task:
using System; using System.Threading.Tasks; class Program { static void Main() { // Создаём и запускаем задачу Task task = Task.Run(() => { Console.WriteLine("Привет, Task!"); }); task.Wait(); // Ждём завершения } }
Task.Run хорош, но иногда нужна явная настройка:
Task task = new Task(() => Console.WriteLine("Я создан вручную!")); task.Start(); task.Wait();
Но в 99% случаев — используйте Task.Run. Так проще.
Возвращаемое значение в Task
Задачи могут возвращать результат:
Task<int> task = Task.Run(() => 42); Console.WriteLine($"Результат задачи: {task.Result}");
Но .Result блокирует поток! Поэтому используем await:
static async Task Main() { int result = await Task.Run(() => 42); Console.WriteLine($"Результат: {result}"); }
Обработка исключений в Task
Ошибки в Task не выбрасываются наружу — они оборачиваются в AggregateException. Поэтому:
Task task = Task.Run(() => throw new InvalidOperationException("Ошибка в задаче")); try { task.Wait(); } catch (AggregateException ex) { foreach (var innerEx in ex.InnerExceptions) { Console.WriteLine($"Ошибка: {innerEx.Message}"); } }
Но если используем await, всё проще:
static async Task Main() { try { await Task.Run(() => throw new InvalidOperationException("Ошибка в Task")); } catch (Exception ex) { Console.WriteLine($"Поймали: {ex.Message}"); } }
Отмена задач (CancellationToken)
Иногда надо остановить задачу. Для этого есть CancellationTokenSource:
using System; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var cts = new CancellationTokenSource(); var token = cts.Token; var task = Task.Run(() => { for (int i = 0; i < 10; i++) { if (token.IsCancellationRequested) { Console.WriteLine("Задача отменена!"); return; } Console.WriteLine($"Работаю... {i}"); Thread.Sleep(500); } }, token); await Task.Delay(2000); cts.Cancel(); // Отменяем задачу await task; } }
Создание Task с TaskFactory
TaskFactory — это альтернативный способ создания задач с дополнительными возможностями:
var factory = new TaskFactory(); Task task = factory.StartNew(() => Console.WriteLine("Задача запущена через TaskFactory")); task.Wait();
Позволяет гибко управлять созданием задач, передавая TaskScheduler и TaskCreationOptions.
Ручное управление задачами (TaskCompletionSource)
Если задача должна завершиться по внешнему событию, можно использовать TaskCompletionSource<T>:
using System; using System.Threading.Tasks; class Program { static async Task Main() { var tcs = new TaskCompletionSource<int>(); Task.Run(() => { Console.WriteLine("Имитация сложной операции..."); Task.Delay(2000).Wait(); tcs.SetResult(42); }); int result = await tcs.Task; Console.WriteLine($"Результат: {result}"); } }
Иерархия задач (Parent-Child Tasks)
По дефолту вложенные Task выполняются независимо, но если передать TaskCreationOptions.AttachedToParent, они будут привязаны к родительскому Task:
Task parent = Task.Run(() => { Console.WriteLine("Родительская задача стартует"); Task child = Task.Factory.StartNew(() => { Console.WriteLine("Дочерняя задача выполняется"); Task.Delay(2000).Wait(); Console.WriteLine("Дочерняя задача завершена"); }, TaskCreationOptions.AttachedToParent); }); parent.Wait(); Console.WriteLine("Родительская задача завершена");
Ограничение числа одновременно выполняемых задач (TaskScheduler)
Если нужно ограничить число одновременно выполняемых задач, можно использовать кастомный TaskScheduler:
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var tasks = new List<Task>(); var semaphore = new SemaphoreSlim(2); // Ограничиваем до 2 задач одновременно for (int i = 0; i < 10; i++) { await semaphore.WaitAsync(); tasks.Add(Task.Run(async () => { try { Console.WriteLine($"Обрабатываю {i}"); await Task.Delay(2000); } finally { semaphore.Release(); } })); } await Task.WhenAll(tasks); } }
Ошибки при работе с Task
Блокировка UI через .Result
var result = Task.Run(() => LongOperation()).Result; // Блокирует UI-поток
Как делать правильно:
var result = await Task.Run(() => LongOperation());
Забытая обработка исключений
Task.Run(() => throw new Exception("Ошибка")); // Упадёт в пустоту
Как делать правильно:
Task.Run(() => throw new Exception("Ошибка")).ContinueWith(t => { if (t.Exception != null) { Console.WriteLine($"Ошибка: {t.Exception.InnerException.Message}"); } });
Три кейса
Обработка изображений в многопоточной среде
Допустим, есть папка с 1000 изображениями, и нужно изменить их размер. Как не надо делать — в одном потоке:
foreach (var file in Directory.GetFiles("images")) { ResizeImage(file); }
Как можно делать — с Task.WhenAll:
using System; using System.IO; using System.Threading.Tasks; class Program { static async Task Main() { string[] files = Directory.GetFiles("images"); // Запускаем обработку всех файлов одновременно Task[] tasks = files.Select(file => Task.Run(() => ResizeImage(file))).ToArray(); await Task.WhenAll(tasks); // Ждём завершения всех задач } static void ResizeImage(string filePath) { Console.WriteLine($"Обрабатываю {filePath}"); Thread.Sleep(1000); // Симуляция работы } }
Теперь обработка выполняется параллельно.
Загрузка данных с нескольких API одновременно
Допустим, есть 3 API, и нужно загрузить данные параллельно.
Как не надо делать:
var data1 = await GetData("https://api1.com"); var data2 = await GetData("https://api2.com"); var data3 = await GetData("https://api3.com");
Как можно делать — Task.WhenAll:
static async Task Main() { var task1 = GetData("https://api1.com"); var task2 = GetData("https://api2.com"); var task3 = GetData("https://api3.com"); var results = await Task.WhenAll(task1, task2, task3); Console.WriteLine("Все API загружены!"); } static async Task<string> GetData(string url) { await Task.Delay(1000); // Симуляция запроса return $"Данные с {url}"; }
Теперь все запросы выполняются параллельно и не ждут друг друга.
Очередь задач с ограничением по количеству потоков
Иногда нельзя запускать больше N задач одновременно. Например, у вас 1000 файлов, но сервер выдержит только 5 потоков.
Используем SemaphoreSlim:
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; class Program { static async Task Main() { var tasks = new List<Task>(); var semaphore = new SemaphoreSlim(5); // Ограничение: не более 5 потоков одновременно for (int i = 0; i < 20; i++) { await semaphore.WaitAsync(); // Ожидаем разрешения на выполнение tasks.Add(Task.Run(async () => { try { await ProcessFile(i); } finally { semaphore.Release(); // Освобождаем место в очереди } })); } await Task.WhenAll(tasks); } static async Task ProcessFile(int fileNumber) { Console.WriteLine($"Обрабатываю файл {fileNumber}"); await Task.Delay(2000); // Симуляция обработки } }
Теперь одновременно работает не более 5 потоков, а новые задачи стартуют только после завершения предыдущих.
Пользуясь случаем, напоминаю об открытых уроках в Otus, которые будут полезны C#-разработчикам:
— 5 мар��а. Clean code и связь с архитектурными паттернами в C#.
После этого вебинара сможете улучшать качество кода в своих проектах с помощью практических инструментов. Записаться— 18 марта. Создание высоконагруженных систем на C#: инструменты и техники.
Освоите Redis для ускорения работы с данными и gRPC для быстрой связи между сервисами. Записаться
