Многопоточность и асинхронность в C# (.NET): подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представьте, что вы — шеф-повар на кухне. У вас есть одна газовая плита с одной конфоркой. Если вы готовите суп, то пока он варится, вы стоите и ждёте. Вы не можете в это время нарезать салат или пожарить котлеты. Это — синхронная работа. Программа работает точно так же: выполняет одну команду за другой, и пока, например, идёт загрузка файла из интернета, весь интерфейс программы «замирает» и ждёт.
Теперь представьте, что у вас появилась вторая конфорка. Вы включили суп на первой, а на второй начали жарить яичницу. Вы следите за обеими конфорками, переключаетесь между ними. Это — многопоточность. Каждая конфорка — это отдельный поток выполнения. Программа может делать несколько дел одновременно: в одном потоке загружать данные, в другом — рисовать анимацию на экране.
А теперь представьте, что у вас снова одна конфорка, но вы очень умный повар. Вы поставили суп вариться, и вместо того чтобы стоять и смотреть на кастрюлю, вы отошли нарезать салат. Когда суп закипит, вы услышите свисток и вернётесь к нему. Вы не делаете два дела одновременно, но вы не тратите время на ожидание. Это — асинхронность. Программа не блокируется в ожидании, а уходит делать другую работу, а потом возвращается, когда результат готов.
Важно: Многопоточность — это про параллельное выполнение (реально одновременно, если у процессора несколько ядер). Асинхронность — про эффективное использование одного потока без простоев. В C# они часто работают вместе, но это разные концепции.
Зачем это нужно в программировании? Представьте, что ваше приложение должно:
- Загрузить 100 фотографий из интернета
- Обработать каждую (изменить размер, применить фильтр)
- Показать результат пользователю
Если делать это синхронно, пользователь будет сидеть и смотреть на пустой экран 5 минут. Приложение «зависнет». С многопоточностью мы можем загружать 4 фотографии одновременно (по числу ядер процессора). С асинхронностью мы можем запустить загрузку и не блокировать интерфейс — пользователь сможет листать уже загруженные фото, пока остальные ещё грузятся.
Теория: как это работает изнутри
Процессы и потоки
Когда вы запускаете программу, операционная система создаёт для неё процесс — это изолированная среда с собственной памятью. Внутри процесса могут работать потоки (threads). Поток — это наименьшая единица выполнения, которой операционная система выделяет время процессора.
У каждого потока есть:
- Стек вызовов — память для локальных переменных и вызовов методов
- Регистры — текущее состояние выполнения
- Приоритет — насколько часто поток получает время процессора
Если у процессора 4 ядра, он может выполнять до 4 потоков реально одновременно. Если потоков больше, чем ядер, операционная система создаёт иллюзию параллельности, быстро переключаясь между потоками (это называется вытесняющая многозадачность).
Потоки в .NET
В .NET есть два уровня работы с потоками:
- Managed threads — управляемые потоки, которыми управляет среда CLR (Common Language Runtime). Это то, с чем работаем мы как разработчики.
- OS threads — потоки операционной системы. Каждый managed thread обычно соответствует одному OS thread.
Создание нового потока — дорогая операция (занимает около 1 микросекунды и требует около 1 МБ памяти под стек). Поэтому в .NET существует пул потоков (ThreadPool) — набор заранее созданных потоков, которые можно переиспользовать.
Асинхронность и Task
Асинхронность в C# построена на концепции Task (задача). Task — это обещание выполнить работу в будущем. Когда вы вызываете асинхронный метод, он возвращает Task немедленно, а реальная работа может выполняться в другом потоке или вообще без потока (например, ожидание ответа от сервера не требует потока вообще).
Ключевые слова:
async— маркер метода, который может использоватьawaitawait— приостанавливает выполнение текущего метода до завершения Task, но не блокирует поток
Как это работает под капотом: Когда компилятор видитasyncметод, он превращает его в конечный автомат (state machine). Метод разбивается на части: до первогоawait, междуawait, после. Когда выполнение доходит доawait, метод «запоминает» своё состояние и возвращает управление вызывающему коду. Когда Task завершается, метод продолжается с того же места.
Синхронизация и гонки данных
Когда несколько потоков одновременно обращаются к одним данным, возникает состояние гонки (race condition). Например, два потока одновременно увеличивают счётчик на 1. Операция «прочитать, увеличить, записать» не атомарна — один поток может прочитать значение, второй тоже прочитает то же самое, оба увеличат и запишут — в итоге счётчик увеличится на 1, а не на 2.
Для защиты данных используются:
lock— блокировка, которая пропускает только один потокMonitor,Mutex,Semaphore— более сложные примитивы синхронизацииInterlocked— атомарные операции для простых случаевConcurrentCollection— потокобезопасные коллекции
Синтаксис в C# (.NET): все формы и варианты
1. Класс Thread (низкоуровневая работа)
// Создание и запуск потока
Thread thread = new Thread(() =>
{
Console.WriteLine("Работа в отдельном потоке");
});
thread.Start(); // Запуск
// Ожидание завершения
thread.Join(); // Главный поток ждёт, пока thread завершится
// Фоновый поток (не阻止 завершение программы)
Thread backgroundThread = new Thread(SomeMethod);
backgroundThread.IsBackground = true;
backgroundThread.Start();
2. ThreadPool (пул потоков)
// Простая постановка задачи в пул
ThreadPool.QueueUserWorkItem(state =>
{
Console.WriteLine($"Задача выполняется в потоке {Thread.CurrentThread.ManagedThreadId}");
});
// С передачей параметра
ThreadPool.QueueUserWorkItem(ProcessData, "данные");
void ProcessData(object? data)
{
Console.WriteLine($"Обработка: {data}");
}
3. Task (современный подход)
// Создание и запуск задачи
Task task = Task.Run(() =>
{
Console.WriteLine("Асинхронная работа");
});
// Ожидание
task.Wait(); // Блокирует текущий поток (плохо для UI)
// Задача с результатом
Task<int> calculationTask = Task.Run(() =>
{
Thread.Sleep(1000);
return 42;
});
int result = calculationTask.Result; // Тоже блокирует
4. async/await (асинхронные методы)
// Асинхронный метод
async Task<string> DownloadDataAsync(string url)
{
using HttpClient client = new HttpClient();
string content = await client.GetStringAsync(url); // Не блокирует поток!
return content;
}
// Вызов
string data = await DownloadDataAsync("https://example.com");
Console.WriteLine(data);
5. Parallel (параллельные циклы)
// Параллельный for
Parallel.For(0, 100, i =>
{
Console.WriteLine($"Итерация {i} в потоке {Thread.CurrentThread.ManagedThreadId}");
});
// Параллельный foreach
Parallel.ForEach(Enumerable.Range(0, 100), item =>
{
// Обработка item
});
// С контролем степени параллелизма
ParallelOptions options = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(0, 100, options, i => { /* ... */ });
6. PLINQ (Parallel LINQ)
var numbers = Enumerable.Range(0, 1000);
// Параллельная фильтрация
var evenNumbers = numbers.AsParallel()
.Where(n => n % 2 == 0)
.ToList();
// С сохранением порядка
var ordered = numbers.AsParallel()
.AsOrdered()
.Select(n => n * 2)
.ToList();
7. Примитивы синхронизации
// lock - самый простой
private readonly object _lock = new object();
int counter = 0;
void Increment()
{
lock (_lock)
{
counter++;
}
}
// SemaphoreSlim - ограничение доступа
SemaphoreSlim semaphore = new SemaphoreSlim(3, 3); // Максимум 3 потока
async Task AccessResourceAsync()
{
await semaphore.WaitAsync();
try
{
// Работа с ресурсом
}
finally
{
semaphore.Release();
}
}
// ReaderWriterLockSlim - для частого чтения и редкой записи
ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
void ReadData()
{
rwLock.EnterReadLock();
try { /* чтение */ }
finally { rwLock.ExitReadLock(); }
}
void WriteData()
{
rwLock.EnterWriteLock();
try { /* запись */ }
finally { rwLock.ExitWriteLock(); }
}
8. Потокобезопасные коллекции
// ConcurrentDictionary - словарь для многопоточного доступа
var dict = new ConcurrentDictionary<int, string>();
dict.TryAdd(1, "один");
dict.TryUpdate(1, "ONE", "один");
// ConcurrentQueue - очередь
var queue = new ConcurrentQueue<int>();
queue.Enqueue(1);
queue.TryDequeue(out int result);
// BlockingCollection - для producer-consumer
var collection = new BlockingCollection<int>(boundedCapacity: 10);
// Producer
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
collection.Add(i);
collection.CompleteAdding();
});
// Consumer
Task.Run(() =>
{
foreach (var item in collection.GetConsumingEnumerable())
Console.WriteLine(item);
});
Примеры кода — от простого к сложному
Пример 1: Синхронное vs асинхронное выполнение
// Синхронно - программа ждёт
Console.WriteLine("Начало");
Thread.Sleep(2000); // Блокировка на 2 секунды
Console.WriteLine("Конец");
// Асинхронно - без блокировки
async Task AsyncExample()
{
Console.WriteLine("Начало");
await Task.Delay(2000); // Не блокирует поток
Console.WriteLine("Конец");
}
Пример 2: Запуск нескольких потоков
// Создаём 5 потоков, каждый выводит свой номер
for (int i = 0; i < 5; i++)
{
int threadNumber = i; // Важно: захватываем переменную
Thread thread = new Thread(() =>
{
Console.WriteLine($"Поток {threadNumber} начал работу");
Thread.Sleep(1000); // Имитация работы
Console.WriteLine($"Поток {threadNumber} завершил работу");
});
thread.Start();
}
Console.WriteLine("Главный поток продолжает работу");
// Результат: порядок вывода может быть любым!
Пример 3: Асинхронная загрузка данных
using System.Net.Http;
async Task<string> DownloadWebsiteAsync(string url)
{
using HttpClient client = new HttpClient();
Console.WriteLine($"Начинаем загрузку {url}");
// await освобождает поток, пока идёт загрузка
string content = await client.GetStringAsync(url);
Console.WriteLine($"Загружено {content.Length} символов");
return content;
}
// Использование
async Task MainAsync()
{
// Запускаем две загрузки параллельно
Task<string> googleTask = DownloadWebsiteAsync("https://google.com");
Task<string> yandexTask = DownloadWebsiteAsync("https://yandex.ru");
// Ждём обе
string[] results = await Task.WhenAll(googleTask, yandexTask);
Console.WriteLine($"Google: {results[0].Length} символов");
Console.WriteLine($"Yandex: {results[1].Length} символов");
}
Пример 4: Параллельная обработка массива
// Обработка массива чисел с возведением в квадрат
int[] numbers = Enumerable.Range(1, 100).ToArray();
int[] results = new int[numbers.Length];
// Параллельный for - быстрее на многоядерных процессорах
Parallel.For(0, numbers.Length, i =>
{
// Имитация сложных вычислений
Thread.Sleep(10);
results[i] = numbers[i] * numbers[i];
});
Console.WriteLine($"Обработано {results.Length} элементов");
// Сравнение с обычным for:
// Обычный: 100 * 10ms = 1000ms
// Параллельный (4 ядра): ~250ms
Пример 5: Producer-Consumer с BlockingCollection
// Классический паттерн: производитель создаёт данные, потребитель обрабатывает
BlockingCollection<int> queue = new BlockingCollection<int>(boundedCapacity: 5);
// Производитель (Producer)
Task producer = Task.Run(() =>
{
for (int i = 0; i < 20; i++)
{
queue.Add(i);
Console.WriteLine($"Произведено: {i}");
Thread.Sleep(100); // Имитация создания данных
}
queue.CompleteAdding(); // Сигнал, что данных больше не будет
});
// Потребитель (Consumer)
Task consumer = Task.Run(() =>
{
foreach (int item in queue.GetConsumingEnumerable())
{
Console.WriteLine($"Потреблено: {item}");
Thread.Sleep(200); // Имитация обработки
}
});
Task.WaitAll(producer, consumer);
Console.WriteLine("Готово!");
Пример 6: Асинхронный семафор
// Ограничение доступа к ресурсу (например, к API с лимитом запросов)
SemaphoreSlim semaphore = new SemaphoreSlim(3, 3); // Одновременно не больше 3 запросов
async Task AccessApiAsync(int requestId)
{
Console.WriteLine($"Запрос {requestId} ожидает семафор...");
await semaphore.WaitAsync();
try
{
Console.WriteLine($"Запрос {requestId} начал работу");
await Task.Delay(1000); // Имитация запроса к API
Console.WriteLine($"Запрос {requestId} завершён");
}
finally
{
semaphore.Release();
}
}
// Запускаем 10 запросов
var tasks = Enumerable.Range(1, 10).Select(id => AccessApiAsync(id));
await Task.WhenAll(tasks);
Пример 7: Отмена асинхронной операции
// Использование CancellationToken для отмены
async Task LongRunningOperationAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 100; i++)
{
// Проверяем, не запрошена ли отмена
cancellationToken.ThrowIfCancellationRequested();
Console.WriteLine($"Шаг {i}");
await Task.Delay(500, cancellationToken); // Тоже поддерживает отмену
}
}
// Создаём токен отмены
using CancellationTokenSource cts = new CancellationTokenSource();
// Запускаем задачу
Task operation = LongRunningOperationAsync(cts.Token);
// Через 2 секунды отменяем
await Task.Delay(2000);
Console.WriteLine("Отменяем операцию...");
cts.Cancel();
try
{
await operation;
}
catch (OperationCanceledException)
{
Console.WriteLine("Операция отменена");
}
Сравнение с Python: чем похоже и чем отличается
| Характеристика | C# (.NET) | Python |
|---|---|---|
| Многопоточность | Настоящая (OS threads). Использует все ядра процессора. | Ограничена GIL (Global Interpreter Lock). Реально выполняется только один поток за раз. Для CPU-задач использует multiprocessing. |
| Асинхронность | async/await на основе Task. Эффективна для I/O операций. |
async/await на основе asyncio. Тоже эффективна для I/O. |
| Пул потоков | Встроенный ThreadPool. Используется по умолчанию для Task.Run. | ThreadPoolExecutor из concurrent.futures. Требует явного создания. |
| Параллельные циклы | Parallel.For, Parallel.ForEach — встроенные. |
Нет встроенных. Используется multiprocessing.Pool или concurrent.futures. |
| Синхронизация | lock, Monitor, Semaphore, ReaderWriterLock. |
threading.Lock, threading.Semaphore. Меньше вариантов. |
| Потокобезопасные коллекции | ConcurrentDictionary, ConcurrentQueue, BlockingCollection и др. | queue.Queue, queue.PriorityQueue. Меньше выбора. |
| Производительность | Высокая. Компилируется в машинный код (JIT или AOT). | Ниже. Интерпретируемый язык. |
| Простота использования | Сложнее из-за большего количества возможностей и нюансов. | Проще для начинающих, но GIL создаёт свои сложности. |
Ключевое отличие: В C# многопоточность реальна и эффективна для CPU-задач. В Python для CPU-задач нужно использовать multiprocessing (отдельные процессы), а threading подходит только для I/O. Асинхронность в обоих языках работает похоже, но в C# она глубже интегрирована в экосистему.
Типичные ошибки и подводные камни
1. Deadlock (взаимная блокировка)
// Классический deadlock: два потока ждут друг друга
object lock1 = new object();
object lock2 = new object();
void Thread1()
{
lock (lock1)
{
Thread.Sleep(100);
lock (lock2) { /* ... */ }
}
}
void Thread2()
{
lock (lock2)
{
Thread.Sleep(100);
lock (lock1) { /* ... */ }
}
}
// Решение: всегда захватывать блокировки в одном порядке
2. Race condition (состояние гонки)
int counter = 0;
// Проблема: два потока одновременно увеличивают счётчик
Parallel.For(0, 1000, i => counter++);
Console.WriteLine(counter); // Не 1000, а меньше!
// Решение: использовать Interlocked или lock
Parallel.For(0, 1000, i => Interlocked.Increment(ref counter));
Console.WriteLine(counter); // Точно 1000
3. Захват переменной в цикле
// Ошибка: все потоки увидят последнее значение i
for (int i = 0; i < 5; i++)
{
Thread thread = new Thread(() => Console.WriteLine(i));
thread.Start();
}
// Вывод: 5 5 5 5 5 (или другой порядок, но все 5)
// Решение: копировать переменную
for (int i = 0; i < 5; i++)
{
int copy = i;
Thread thread = new Thread(() => Console.WriteLine(copy));
thread.Start();
}
// Вывод: 0 1 2 3 4 (порядок может быть разным)
4. Блокировка UI-потока
// В WPF или WinForms: .Result или .Wait() в UI-потоке
private void Button_Click(object sender, EventArgs e)
{
// ПЛОХО: блокирует UI-поток
string data = DownloadDataAsync().Result;
textBox.Text = data;
}
// ХОРОШО: используем async/await
private async void Button_Click(object sender, EventArgs e)
{
string data = await DownloadDataAsync();
textBox.Text = data;
}
5. Игнорирование исключений в Task
// Исключение в Task "проглатывается", если не ожидать
Task.Run(() => throw new InvalidOperationException("Ошибка"));
Thread.Sleep(1000); // Исключение не выброшено!
// Решение: всегда ожидать Task или обрабатывать исключения
Task task = Task.Run(() => throw new InvalidOperationException("Ошибка"));
try
{
await task;
}
catch (Exception ex)
{
Console.WriteLine($"Поймано: {ex.Message}");
}
6. Слишком много потоков
// ПЛОХО: создаём 1000 потоков
for (int i = 0; i < 1000; i++)
{
new Thread(() => { /* работа */ }).Start();
}
// Потребление памяти: ~1 ГБ только на стеки!
// ХОРОШО: используем Task или ThreadPool
Parallel.For(0, 1000, i => { /* работа */ });
7. Забыли про CancellationToken
// Бесконечная операция без возможности отмены
async Task InfiniteLoopAsync()
{
while (true)
{
await Task.Delay(1000);
Console.WriteLine("Работаю...");
}
}
// Правильно: поддерживать отмену
async Task InfiniteLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(1000, ct);
Console.WriteLine("Работаю...");
}
}
Лучшие практики и советы профессионалов
- Используйте async/await вместо Thread для I/O операций (работа с файлами, сетью, БД). Это эффективнее и безопаснее.
- Для CPU-интенсивных задач используйте Task.Run или Parallel, чтобы не блокировать UI-поток.
- Избегайте .Result и .Wait() — они могут вызвать deadlock, особенно в UI-приложениях.
- Всегда используйте CancellationToken для длительных операций, чтобы пользователь мог отменить их.
- Не создавайте потоки вручную — используйте ThreadPool или Task. Создание потока — дорогая операция.
- Используйте потокобезопасные коллекции (ConcurrentDictionary, ConcurrentQueue) вместо ручной синхронизации.
- Для синхронизации начинайте с lock — это самый простой и безопасный вариант.
- Не блокируйте this или строки — используйте отдельный объект для блокировки.
- Тестируйте многопоточный код — ошибки могут проявляться нерегулярно. Используйте специальные инструменты (например, CHESS от Microsoft).
- Измеряйте производительность — многопоточность не всегда даёт ускорение. Иногда накладные расходы на синхронизацию перевешивают выгоду.
Золотое правило: Многопоточность — это сложно. Если можно обойтись без неё — обходитесь. Если нельзя — начинайте с самого простого решения (async/await для I/O, Parallel для CPU) и усложняйте только по необходимости.
Вопросы для самопроверки
-
Вопрос: В чём разница между многопоточностью и асинхронностью?
Ответ: Многопоточность — это выполнение нескольких задач одновременно в разных потоках. Асинхронность — это эффективное использование одного потока без блокировок во время ожидания (например, ответа от сервера).
-
Вопрос: Что такое GIL в Python и почему в C# такой проблемы нет?
Ответ: GIL (Global Interpreter Lock) — это блокировка в Python, которая позволяет выполнять только один поток за раз. В C# такой блокировки нет, поэтому потоки могут работать на разных ядрах процессора одновременно.
-
Вопрос: Что произойдёт, если вызвать .Result у Task в UI-потоке?
Ответ: Возможен deadlock. UI-поток заблокируется в ожидании Task, а Task может ожидать завершения UI-потока (например, для продолжения). В результате программа зависнет.
-
Вопрос: Как отменить асинхронную операцию?
Ответ: Использовать CancellationTokenSource и CancellationToken. Передавать токен во все асинхронные методы и периодически проверять ct.IsCancellationRequested или вызывать ct.ThrowIfCancellationRequested().
-
Вопрос: Что такое состояние гонки (race condition) и как с ним бороться?
Ответ: Состояние гонки — это ситуация, когда результат работы зависит от порядка выполнения потоков. Бороться можно с помощью синхронизации (lock, Monitor, Interlocked) или использования потокобезопасных коллекций.
-
Вопрос: В каком случае Parallel.For будет работать медленнее обычного for?
Ответ: Когда тело цикла очень маленькое (например, одна операция сложения). Накладные расходы на управление потоками перевешивают выгоду от параллелизма.
-
Вопрос: Что такое ThreadPool и зачем он нужен?
Ответ: ThreadPool — это пул заранее созданных потоков, которые можно переиспользовать. Он нужен, чтобы избежать затрат на создание и уничтожение потоков для каждой задачи.
-
Вопрос: Как сделать потокобезопасный счётчик?
Ответ: Использовать Interlocked.Increment(ref counter) или lock (_lock) { counter++; }. Первый способ быстрее для простых операций.
-
Вопрос: Что такое deadlock и как его избежать?
Ответ: Deadlock — это ситуация, когда два или более потоков ждут друг друга, и ни один не может продолжить. Избегать: захватывать блокировки в одном порядке, использовать таймауты (Monitor.TryEnter), избегать вложенных блокировок.
-
Вопрос: Чем отличается Task от Thread?
Ответ: Task — это абстракция над асинхронной операцией. Он может выполняться в ThreadPool, а может и вовсе не использовать поток (например, ожидание I/O). Thread — это конкретный поток операционной системы. Task легче, безопаснее и удобнее.
Итог и что изучить дальше
Мы разобрали фундаментальные концепции многопоточности и асинхронности в C#. Вы узнали:
- Чем отличаются потоки, задачи и асинхронные операции
- Как использовать Thread, Task, async/await, Parallel, PLINQ
- Как синхронизировать доступ к данным и избегать гонок
- Какие типичные ошибки совершают новички
- Как отличается работа с многопоточностью в C# и Python
Это только начало пути. Многопоточность — одна из самых сложных тем в программировании. Чтобы стать настоящим профессионалом, рекомендую изучить дальше:
- Продвинутые паттерны синхронизации: SpinLock, Barrier, CountdownEvent, ManualResetEvent
- Асинхронные потоки (IAsyncEnumerable): как обрабатывать последовательности данных а