Привет, Хабр!
Если ты всё ещё пишешь код на 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 для быстрой связи между сервисами. Записаться