Введение: зачем нужна конкурентность в 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с