Конкурентность и многопоточность в 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