Конкурентность в C#: потоки, асинхронность и горутины на практике

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

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

Начать курс

Конкурентность в C#: как не дать программе заскучать

Представьте, что вы — шеф-повар. У вас три конфорки, духовка и миксер. Если вы будете делать всё последовательно — сначала нарезать лук, потом жарить, потом взбивать крем — обед затянется на вечность. Гораздо эффективнее включить духовку, пока режете овощи, и взбивать крем, пока мясо тушится. Это и есть конкурентность — умение выполнять несколько задач «одновременно», переключаясь между ними так, чтобы ресурсы не простаивали.

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

Потоки (Thread): тяжёлая артиллерия

Поток — это самый низкоуровневый способ организовать конкурентность. Каждый поток — отдельный «лёгкий процесс» внутри вашей программы, который имеет собственный стек и контекст. Создать поток в C# очень просто:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread thread = new Thread(DoWork);
        thread.Start();
        Console.WriteLine("Главный поток работает");
        thread.Join(); // ждём завершения
    }

    static void DoWork()
    {
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine($"Поток: {i}");
            Thread.Sleep(200);
        }
    }
}

Здесь мы создали отдельный поток, который считает до пяти, а главный поток тем временем выводит сообщение. Без Join программа могла бы завершиться раньше, чем добежит до пяти.

Минусы потоков

  • Тяжёлый вес. Каждый поток резервирует около 1 МБ памяти под стек. Создать 1000 потоков — значит занять 1 ГБ просто так.
  • Сложность синхронизации. Чтобы два потока не испортили общие данные, нужны lock'и, мьютексы и семафоры. Ошибка — и вы получаете deadlock или race condition.
  • Переключение контекста. Операционная система тратит время на переключение между потоками. Если потоков больше, чем ядер CPU, это становится узким местом.

Потоки хороши, когда вам нужен настоящий параллелизм на многоядерном процессоре для тяжёлых вычислений. Но для ввода-вывода (работа с файлами, сетью, базой данных) они избыточны.

Task и async/await: современный стандарт

Начиная с .NET 4.5, Microsoft предложила элегантную альтернативу — Task Parallel Library (TPL) и ключевые слова async/await. Это не потоки в чистом виде, а обещания (promises): вы говорите компилятору «запусти эту работу, а когда она закончится — верни результат». Пока работа выполняется, поток не блокируется, а возвращается в пул.

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        Console.WriteLine("Начинаем загрузку...");
        string data = await DownloadDataAsync();
        Console.WriteLine($"Загружено: {data.Length} символов");
    }

    static async Task<string> DownloadDataAsync()
    {
        using HttpClient client = new HttpClient();
        string result = await client.GetStringAsync("https://example.com");
        return result;
    }
}

Обратите внимание: await не блокирует поток. Пока данные качаются по сети, поток возвращается в пул и может обслуживать другие запросы. Это идеально для веб-серверов и GUI-приложений — интерфейс не зависает.

Как это работает под капотом?

Когда компилятор видит async, он превращает метод в конечный автомат. На каждом await метод «запоминает» своё состояние и возвращает управление вызывающему коду. Когда задача завершается, выполнение продолжается с того же места. Никакого создания потоков — только переключение между задачами внутри одного или нескольких потоков из пула.

Когда использовать async/await?

  • Любая операция ввода-вывода (HTTP-запросы, чтение файлов, запросы к БД).
  • Работа с UI (WinForms, WPF, MAUI) — чтобы интерфейс оставался отзывчивым.
  • Веб-приложения ASP.NET Core — для обработки множества запросов без создания тысяч потоков.

Горутины в C#: итераторы и yield return

Термин «горутина» пришёл из Go, но в C# есть нечто похожее — итераторы с yield return. Это ленивые последовательности, которые могут приостанавливать и возобновлять своё выполнение. Формально это не горутины, но концептуально — очень близко: вы можете «заморозить» метод, отдать значение, а потом продолжить с того же места.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        foreach (int number in GenerateNumbers())
        {
            Console.WriteLine(number);
            if (number >= 5) break;
        }
    }

    static IEnumerable<int> GenerateNumbers()
    {
        for (int i = 0; i < 10; i++)
        {
            Console.WriteLine($"Генератор: пауза на {i}");
            yield return i;
        }
    }
}

Каждый раз, когда цикл foreach запрашивает следующее значение, метод GenerateNumbers «просыпается» и продолжает выполнение с того места, где остановился. Никаких потоков, никакого лишнего выделения памяти — только ленивая генерация.

Горутины vs async/await

Главное отличие: yield предназначен для генерации последовательностей, а async — для асинхронных операций. Но если вы хотите сделать горутину по-настоящему асинхронной (например, с ожиданием между шагами), придётся комбинировать оба подхода:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        await foreach (var item in GenerateAsync())
        {
            Console.WriteLine(item);
        }
    }

    static async IAsyncEnumerable<int> GenerateAsync()
    {
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(300); // имитация работы
            yield return i;
        }
    }
}

Это уже полноценные асинхронные горутины. Они доступны в C# 8.0 и выше. Используйте их, когда нужно обрабатывать поток данных, который приходит частями (например, чтение большого файла или данные с сенсора).

Сравнение подходов: что выбрать?

Блоги

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

Критерий Thread