Как стать автором
Обновить
725.11
OTUS
Цифровые навыки от ведущих экспертов

Task Parallel Library в C#

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров3.1K

Привет, Хабр!

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

Теги:
Хабы:
-3
Комментарии4

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS