Конкурентность и многопоточность в C#: Полное руководство для .NET разработчиков

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

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

Начать курс

Конкурентность и многопоточность в C#: от Thread до async/await

Современные приложения всё чаще требуют одновременного выполнения нескольких задач: от обработки запросов веб-сервером до параллельных вычислений в десктопных программах. В C# (.NET) для этого существует мощный арсенал средств — от классических потоков (Thread) до высокоуровневых конструкций async/await и библиотеки Task Parallel Library (TPL). В этой статье мы разберём ключевые концепции конкурентности и многопоточности, их практическое применение и лучшие практики.

Что такое конкурентность и многопоточность?

Прежде чем перейти к коду, важно понять разницу между этими понятиями:

  • Конкурентность (Concurrency) — способность программы выполнять несколько задач, переключаясь между ними, создавая иллюзию одновременного выполнения. Это может быть реализовано и на одном ядре процессора.
  • Многопоточность (Multithreading) — форма конкурентности, при которой несколько потоков (threads) выполняются физически одновременно на многоядерном процессоре.
  • Параллелизм (Parallelism) — подмножество многопоточности, когда разные части задачи выполняются строго одновременно на разных ядрах.

В C# вы можете использовать как низкоуровневые потоки, так и абстракции, которые автоматически управляют потоками. Выбор зависит от задачи: для I/O-bound операций (работа с файлами, сетью) лучше подходит async/await, а для CPU-bound (вычисления) — Parallel.For или Task.

Создание и управление потоками: класс Thread

Класс System.Threading.Thread — самый низкоуровневый способ работы с потоками в C#. Он даёт полный контроль, но требует аккуратной синхронизации.

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(); // Ожидаем завершения дочернего потока
        Console.WriteLine("Работа завершена.");
    }

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

Проблемы ручного управления потоками:

  • Сложность синхронизации (гонки данных, deadlocks).
  • Высокие накладные расходы на создание и уничтожение потоков.
  • Отсутствие встроенной поддержки возврата результата.

Поэтому в современном C# предпочитают использовать Task Parallel Library.

Task Parallel Library (TPL) и async/await

Класс Task из пространства имён System.Threading.Tasks представляет асинхронную операцию. В сочетании с ключевыми словами async и await он позволяет писать асинхронный код, который выглядит как синхронный.

Базовый пример с Task

using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("Начало работы.");

        // Запускаем асинхронную задачу
        Task<int> task = CalculateAsync(10);

        // Пока задача выполняется, можем делать что-то ещё
        Console.WriteLine("Делаем другую работу...");

        // Ожидаем результат
        int result = await task;
        Console.WriteLine($"Результат: {result}");
    }

    static async Task<int> CalculateAsync(int number)
    {
        await Task.Delay(1000); // Имитация длительной операции
        return number * number;
    }
}

Как это работает:

  • Метод CalculateAsync не блокирует поток. При встрече await управление возвращается вызывающему коду.
  • Когда асинхронная операция завершается, выполнение продолжается с того же места.
  • Поток не тратится впустую на ожидание — это особенно важно для I/O операций.

Параллельные циклы с Parallel.For

Для CPU-bound задач, которые можно разбить на независимые части, используйте Parallel.For или Parallel.ForEach:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        Parallel.For(0, 10, i =>
        {
            Console.WriteLine($"Итерация {i} выполняется в потоке {Task.CurrentId}");
            // Тяжёлые вычисления
        });
    }
}

Платформа автоматически распределяет итерации по доступным ядрам процессора.

Синхронизация доступа к данным

При работе с общими ресурсами (переменные, коллекции) необходимо синхронизировать доступ, чтобы избежать состояния гонки. Основные инструменты синхронизации в C#:

  • lock — простейший монитор для критической секции.
  • Mutex — синхронизация между процессами.
  • Semaphore/SemaphoreSlim — ограничение количества потоков, одновременно работающих с ресурсом.
  • ReaderWriterLockSlim — для сценариев "много читателей, один писатель".
  • Concurrent-коллекции (ConcurrentDictionary, ConcurrentQueue) — потокобезопасные структуры данных без блокировок.

Пример использования lock

class BankAccount
{
    private readonly object _lock = new object();
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        lock (_lock)
        {
            _balance += amount;
        }
    }

    public decimal GetBalance()
    {
        lock (_lock)
        {
            return _balance;
        }
    }
}

Важно: не используйте lock(this) или lock(typeof(MyClass)) — это может привести к deadlock'ам. Всегда выделяйте отдельный объект для синхронизации.

Parallel LINQ (PLINQ)

Если вы работаете с коллекциями и хотите автоматически распараллелить запросы LINQ, используйте PLINQ. Достаточно вызвать метод AsParallel():

using System;
using System.Linq                

Блоги

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