Как выполнить несколько задач (Task) параллельно в 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).

Ответ 18+ 🔞

А, ну ты про параллельность в 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; // Или так, если ты любишь жить опасно и без await.
}

2. Task.WhenAny — кто первый встал, того и тапки

Это когда тебе нужен результат от самого шустрого ублюдка. Например, дернул два апишки за погодой — и какая служба быстрее ответит, ту и считаешь правдой. Остальные посылаешь нахуй, чтобы зря ресурсы не жрали.

public async Task<string> GetFastestResponseAsync()
{
    // Ставим таймер, чтобы через 5 секунд всем стало похуй на наши запросы.
    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 / 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 задач, типа скачивания по сотне урлов. Запустить все сразу — сервер тебя заблокирует или сам сдохнет. Надо делать с умом, с ограничением.

public async Task ProcessUrlsWithThrottlingAsync(List<string> urls, int maxConcurrent)
{
    // Создаём семафор — как турникет в метро. Одновременно пропустит только 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 и WhenAny — это не стартёры, блядь. Они не запускают задачи. Задачи стартуют в момент их создания, если только ты специально не сделал их ленивыми, как студент на паре. А эти методы — просто такие наблюдатели, которые сидят и ждут, пока кто-то из бегунов прибежит или все сразу. Для CPU-bound операций, где надо грузить процессор, юзай Parallel или PLINQ, а не асинхронные таски — это разные вещи, как молоток и микроскоп.