Как выполнить несколько задач (Task) параллельно в C#?

«Как выполнить несколько задач (Task) параллельно в C#?» — вопрос из категории Многопоточность, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Для параллельного выполнения независимых задач в C# есть несколько основных подходов. Выбор зависит от необходимости ожидания результатов и обработки исключений.

1. Task.WhenAll — ожидание завершения всех задач

Используется, когда нужно запустить задачи параллельно и дождаться результатов всех из них.

public async Task ProcessDataInParallelAsync()
{
    var task1 = DownloadFileAsync("url1");
    var task2 = ProcessImageAsync("image.jpg");
    var task3 = QueryDatabaseAsync();

    // Все задачи выполняются параллельно. Ожидаем завершения всех.
    await Task.WhenAll(task1, task2, task3);

    // Получаем результаты (если задачи возвращают значение).
    var file = await task1; // await здесь мгновенный, т.к. задача уже завершена.
    var result = task2.Result; // Альтернатива для уже завершенной задачи.
}

2. Task.WhenAny — ожидание первой завершенной задачи

Полезно для реализации таймаутов или выбора самого быстрого источника данных.

public async Task<string> GetFastestResponseAsync()
{
    var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    var tasks = new List<Task<string>>
    {
        CallServiceAAsync(cts.Token),
        CallServiceBAsync(cts.Token)
    };

    // Ожидаем, пока любая из задач завершится успешно.
    Task<string> completedTask = await Task.WhenAny(tasks);

    // Отменяем оставшиеся задачи.
    cts.Cancel();

    // Возвращаем результат первой завершенной задачи.
    return await completedTask;
}

3. Parallel.For / Parallel.ForEach (для CPU-bound операций)

Используется для параллельной обработки данных в коллекциях, когда операции требуют интенсивных вычислений.

public void TransformDataInParallel(List<DataItem> items)
{
    var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount };

    Parallel.ForEach(items, parallelOptions, item =>
    {
        // Вычислительно сложная операция над каждым элементом.
        item.Value = HeavyComputation(item.RawData);
    });
}

4. Параллельный запуск с ограничением степени параллелизма (SemaphoreSlim)

Когда нужно выполнить много I/O-bound задач (например, HTTP-запросов), но с ограничением на их одновременное количество.

public async Task ProcessUrlsWithThrottlingAsync(List<string> urls, int maxConcurrent)
{
    var semaphore = new SemaphoreSlim(maxConcurrent);
    var tasks = urls.Select(async url =>
    {
        await semaphore.WaitAsync();
        try
        {
            return await DownloadStringAsync(url);
        }
        finally
        {
            semaphore.Release();
        }
    });

    await Task.WhenAll(tasks);
}

Ключевой момент: Task.WhenAll и Task.WhenAny не запускают задачи — они лишь создают задачи-ожидания. Задачи начинают выполняться в момент их создания (если только они не созданы с опцией TaskCreationOptions.RunContinuationsAsynchronously). Для CPU-bound операций предпочтительнее Parallel или Parallel LINQ (PLINQ).