🔷 C#

Многопоточность и асинхронность в 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 — маркер метода, который может использовать await
  • await — приостанавливает выполнение текущего метода до завершения 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("Работаю...");
    }
}

Лучшие практики и советы профессионалов

  1. Используйте async/await вместо Thread для I/O операций (работа с файлами, сетью, БД). Это эффективнее и безопаснее.
  2. Для CPU-интенсивных задач используйте Task.Run или Parallel, чтобы не блокировать UI-поток.
  3. Избегайте .Result и .Wait() — они могут вызвать deadlock, особенно в UI-приложениях.
  4. Всегда используйте CancellationToken для длительных операций, чтобы пользователь мог отменить их.
  5. Не создавайте потоки вручную — используйте ThreadPool или Task. Создание потока — дорогая операция.
  6. Используйте потокобезопасные коллекции (ConcurrentDictionary, ConcurrentQueue) вместо ручной синхронизации.
  7. Для синхронизации начинайте с lock — это самый простой и безопасный вариант.
  8. Не блокируйте this или строки — используйте отдельный объект для блокировки.
  9. Тестируйте многопоточный код — ошибки могут проявляться нерегулярно. Используйте специальные инструменты (например, CHESS от Microsoft).
  10. Измеряйте производительность — многопоточность не всегда даёт ускорение. Иногда накладные расходы на синхронизацию перевешивают выгоду.
Золотое правило: Многопоточность — это сложно. Если можно обойтись без неё — обходитесь. Если нельзя — начинайте с самого простого решения (async/await для I/O, Parallel для CPU) и усложняйте только по необходимости.

Вопросы для самопроверки

  1. Вопрос: В чём разница между многопоточностью и асинхронностью?

    Ответ: Многопоточность — это выполнение нескольких задач одновременно в разных потоках. Асинхронность — это эффективное использование одного потока без блокировок во время ожидания (например, ответа от сервера).

  2. Вопрос: Что такое GIL в Python и почему в C# такой проблемы нет?

    Ответ: GIL (Global Interpreter Lock) — это блокировка в Python, которая позволяет выполнять только один поток за раз. В C# такой блокировки нет, поэтому потоки могут работать на разных ядрах процессора одновременно.

  3. Вопрос: Что произойдёт, если вызвать .Result у Task в UI-потоке?

    Ответ: Возможен deadlock. UI-поток заблокируется в ожидании Task, а Task может ожидать завершения UI-потока (например, для продолжения). В результате программа зависнет.

  4. Вопрос: Как отменить асинхронную операцию?

    Ответ: Использовать CancellationTokenSource и CancellationToken. Передавать токен во все асинхронные методы и периодически проверять ct.IsCancellationRequested или вызывать ct.ThrowIfCancellationRequested().

  5. Вопрос: Что такое состояние гонки (race condition) и как с ним бороться?

    Ответ: Состояние гонки — это ситуация, когда результат работы зависит от порядка выполнения потоков. Бороться можно с помощью синхронизации (lock, Monitor, Interlocked) или использования потокобезопасных коллекций.

  6. Вопрос: В каком случае Parallel.For будет работать медленнее обычного for?

    Ответ: Когда тело цикла очень маленькое (например, одна операция сложения). Накладные расходы на управление потоками перевешивают выгоду от параллелизма.

  7. Вопрос: Что такое ThreadPool и зачем он нужен?

    Ответ: ThreadPool — это пул заранее созданных потоков, которые можно переиспользовать. Он нужен, чтобы избежать затрат на создание и уничтожение потоков для каждой задачи.

  8. Вопрос: Как сделать потокобезопасный счётчик?

    Ответ: Использовать Interlocked.Increment(ref counter) или lock (_lock) { counter++; }. Первый способ быстрее для простых операций.

  9. Вопрос: Что такое deadlock и как его избежать?

    Ответ: Deadlock — это ситуация, когда два или более потоков ждут друг друга, и ни один не может продолжить. Избегать: захватывать блокировки в одном порядке, использовать таймауты (Monitor.TryEnter), избегать вложенных блокировок.

  10. Вопрос: Чем отличается Task от Thread?

    Ответ: Task — это абстракция над асинхронной операцией. Он может выполняться в ThreadPool, а может и вовсе не использовать поток (например, ожидание I/O). Thread — это конкретный поток операционной системы. Task легче, безопаснее и удобнее.

Итог и что изучить дальше

Мы разобрали фундаментальные концепции многопоточности и асинхронности в C#. Вы узнали:

  • Чем отличаются потоки, задачи и асинхронные операции
  • Как использовать Thread, Task, async/await, Parallel, PLINQ
  • Как синхронизировать доступ к данным и избегать гонок
  • Какие типичные ошибки совершают новички
  • Как отличается работа с многопоточностью в C# и Python

Это только начало пути. Многопоточность — одна из самых сложных тем в программировании. Чтобы стать настоящим профессионалом, рекомендую изучить дальше:

  1. Продвинутые паттерны синхронизации: SpinLock, Barrier, CountdownEvent, ManualResetEvent
  2. Асинхронные потоки (IAsyncEnumerable): как обрабатывать последовательности данных а