Конкурентность и многопоточность в C#: от А до Я — полное руководство

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: зачем нужна конкурентность в C#?

Современные приложения всё чаще требуют выполнения нескольких операций одновременно. Пользователь ожидает, что интерфейс не "зависнет" при загрузке данных, а сервер сможет обрабатывать тысячи запросов параллельно. В C# (.NET) для решения этих задач используется конкурентность — способность программы выполнять несколько задач в перекрывающиеся промежутки времени, и многопоточность — реализация конкурентности с помощью нескольких потоков выполнения.

В этой статье мы разберём ключевые концепции: от базовых Thread до современных async/await и Parallel. Вы узнаете, как правильно выбирать инструмент для каждой задачи и избегать типичных ошибок, таких как взаимные блокировки (deadlocks) и гонки данных (race conditions).

1. Основы: Thread и ThreadPool

Работа с потоками через Thread

Класс System.Threading.Thread — самый низкоуровневый способ создания потоков. Каждый поток имеет собственный стек и выполняется независимо. Пример создания и запуска потока:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(DoWork);
        thread.Start();
        
        // Главный поток продолжает работу
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Главный поток: {i}");
            Thread.Sleep(100);
        }
        
        thread.Join(); // Ожидаем завершения дочернего потока
    }

    static void DoWork()
    {
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Рабочий поток: {i}");
            Thread.Sleep(150);
        }
    }
}

Важно: Создание нового потока — дорогая операция. Для частого создания короткоживущих потоков лучше использовать ThreadPool.

ThreadPool — пул потоков

ThreadPool управляет набором рабочих потоков, которые переиспользуются. Это эффективнее, чем создавать Thread вручную. Пример:

ThreadPool.QueueUserWorkItem(state =>
{
    Console.WriteLine("Задача выполняется в пуле потоков");
    Thread.Sleep(500);
    Console.WriteLine("Задача завершена");
});

Console.WriteLine("Главный поток продолжает работу");
Console.ReadKey();

Ограничение: Вы не можете напрямую управлять приоритетом или именем потока из пула. Для более тонкого контроля используйте Task.

2. Task и Task Parallel Library (TPL)

Task — современный подход

Начиная с .NET 4.0, Task стал основным инструментом для асинхронных операций. Он абстрагирует потоки и позволяет работать с результатами выполнения. Пример:

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Task<int> task = Task.Run(() => ComputeResult(10));
        
        // Можно делать другую работу
        Console.WriteLine("Ожидаем результат...");
        
        int result = await task;
        Console.WriteLine($"Результат: {result}");
    }

    static int ComputeResult(int value)
    {
        Task.Delay(1000).Wait(); // Имитация долгой операции
        return value * 2;
    }
}

Ключевые преимущества:

  • Возвращает результат через Task<T>
  • Поддерживает отмену через CancellationToken
  • Позволяет комбинировать задачи (ContinueWith, WhenAll, WhenAny)
  • Интегрирован с async/await

Parallel — для параллельных циклов

Класс Parallel упрощает выполнение циклов с распараллеливанием. Пример:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Parallel.For(0, 10, i =>
        {
            Console.WriteLine($"Итерация {i} выполняется в потоке {Task.CurrentId}");
        });
        
        // Аналогично для коллекций
        var numbers = new[] { 1, 2, 3, 4, 5 };
        Parallel.ForEach(numbers, number =>
        {
            Console.WriteLine($"Обработка числа {number}");
        });
    }
}

Важно: Parallel сам определяет оптимальное количество потоков. Не используйте его для IO-операций (чтение файлов, запросы HTTP) — для этого лучше подходит async/await.

3. Async/await — асинхронное программирование

Как работает async/await

Ключевые слова async и await позволяют писать асинхронный код, который выглядит как синхронный. Под капотом компилятор создаёт конечный автомат (state machine). Пример асинхронного чтения файла:

using System;
using System.IO;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string content = await ReadFileAsync("data.txt");
        Console.WriteLine(content);
    }

    static async Task<string> ReadFileAsync(string path)
    {
        using (var reader = new StreamReader(path))
        {
            return await reader.ReadToEndAsync();
        }
    }
}

Правила использования:

  • Метод с async должен возвращать Task, Task<T> или void (только для обработчиков событий)
  • Не блокируйте асинхронный код вызовами .Result или .Wait() — это может вызвать deadlock
  • Используйте ConfigureAwait(false) в библиотечном коде для избежания захвата контекста синхронизации

Сравнение: Task vs Thread

Выбор между Task и Thread зависит от задачи:

  • IO-операции (сеть, диски, базы данных) — используйте async/await с Task. Поток не блокируется и может обслуживать другие запросы.
  • CPU-интенсивные вычисления (математические расчёты, обработка изображений) — используйте Task.Run или Parallel. Они эффективно распределяют нагрузку по ядрам.
  • Фоновые службы (долгоживущие потоки с циклом) — иногда оправдано использование Thread с

Блоги

Рекомендаций по книгам