«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » .NET и C# » Многопоточность: класс Thread и пул потоков

Многопоточность: класс Thread и пул потоков

Пока приложение выполняет всё в одном потоке, жизнь проста: код идёт строка за строкой, состояние предсказуемо. Но стоит появиться долгой операции, скачиванию файла или тяжёлому расчёту, и интерфейс «замирает». Многопоточность решает эту проблему, но приносит свои сложности. В этой статье разберём два базовых механизма .NET: ручное управление через класс Thread и использование пула потоков.

Что такое поток и зачем он нужен

Поток — это последовательность инструкций, которую планировщик операционной системы выполняет независимо от других. В рамках одного процесса несколько потоков делят общую память, а значит, могут одновременно читать и менять одни и те же данные. Отсюда и главная сложность, и главная мощность.

Многопоточность оправдана в нескольких случаях:

  • интерфейс должен оставаться отзывчивым, пока идёт длинная операция;
  • нужно одновременно обслуживать много запросов, например, на сервере;
  • задачу можно разбить на независимые части и распределить по ядрам процессора;
  • идёт ожидание ввода-вывода, и в это время можно делать другую работу.

Если задача простая и быстрая, лишние потоки только усложнят программу и замедлят её из-за накладных расходов на переключение.

Класс Thread: ручной запуск

Самый прямой способ создать поток — класс System.Threading.Thread. Вы передаёте делегат с методом, который нужно выполнить, и вызываете Start.

using System;
using System.Threading;

class Program
{
    static void Work()
    {
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine("Рабочий поток: шаг " + i);
            Thread.Sleep(200);
        }
    }

    static void Main()
    {
        Thread t = new Thread(Work);
        t.Start();
        Console.WriteLine("Главный поток продолжает работу");
        t.Join();
        Console.WriteLine("Рабочий поток завершён");
    }
}

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

Передача параметров

Для передачи данных можно использовать ParameterizedThreadStart либо, что удобнее, лямбда-выражение с замыканием.

int count = 3;
Thread t = new Thread(() =>
{
    for (int i = 0; i < count; i++)
        Console.WriteLine("Итерация " + i);
});
t.Start();

Фоновые и основные потоки

По умолчанию поток, созданный вручную, считается основным. Пока он работает, процесс не завершится, даже если главный поток закончил работу. Если установить свойство IsBackground в true, поток станет фоновым и будет принудительно остановлен при выходе из приложения. Для служебных задач вроде периодической проверки обновлений это обычно то, что нужно.

Недостатки ручного создания потоков

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

  • Много потоков означает много переключений контекста.
  • Каждый поток занимает память даже в простое.
  • Сложно ограничить общее число одновременно работающих потоков.
  • Обработка исключений в отдельном потоке требует отдельной заботы: необработанная ошибка завершит весь процесс.

Именно для таких ситуаций придуман пул потоков.

Пул потоков

Пул — это набор заранее созданных рабочих потоков, которые повторно используются. Вы ставите задачу в очередь, свободный поток берёт её, выполняет и возвращается в пул. Накладные расходы на создание потоков исчезают, а число одновременно работающих потоков регулируется автоматически в зависимости от нагрузки и числа ядер.

ThreadPool.QueueUserWorkItem(state =>
{
    Console.WriteLine("Задача выполняется в потоке пула");
});

Потоки пула всегда фоновые. Это нужно помнить: если главный поток завершится, задачи в пуле будут оборваны. Поэтому для ожидания пригодятся примитивы синхронизации, например, CountdownEvent или ManualResetEvent.

Когда пул не подходит

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

Task как современная надстройка

На практике сегодня чаще пользуются классом Task и ключевыми словами async и await. Task внутри опирается на тот же пул, но даёт удобные возможности: возврат результата, цепочки продолжений, отмену и композицию.

Task<int> task = Task.Run(() =>
{
    int sum = 0;
    for (int i = 1; i <= 100; i++) sum += i;
    return sum;
});
Console.WriteLine("Результат: " + task.Result);

Обращение к свойству Result блокирует вызывающий поток до готовности значения. В интерфейсных приложениях это может вызвать зависание, поэтому там предпочтительнее await.

Синхронизация потоков

Как только два потока работают с общими данными, нужна синхронизация. Простейший пример проблемы — увеличение счётчика:

static int counter = 0;
static readonly object locker = new object();

static void Increment()
{
    for (int i = 0; i < 100000; i++)
    {
        lock (locker)
        {
            counter++;
        }
    }
}

Без lock операция counter++ распадается на чтение, увеличение и запись, и два потока могут перемешать эти шаги, потеряв часть изменений. Блокировка гарантирует, что в критической секции одновременно находится только один поток.

Основные инструменты синхронизации:

СредствоНазначение
lock / MonitorВзаимное исключение внутри процесса
MutexИсключение между процессами
SemaphoreОграничение числа одновременных потоков
InterlockedАтомарные операции над числами
ManualResetEventСигнал одного потока другому

Типичные проблемы

Состояние гонки

Результат зависит от порядка выполнения потоков. Ошибка проявляется случайно, иногда раз на тысячу запусков, и ловить её мучительно. Лечится аккуратным ограничением доступа к общим данным.

Взаимная блокировка

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

Блокировка интерфейса

Элементы управления можно менять только из потока, который их создал. Попытка обновить надпись из рабочего потока приведёт к исключению. Используйте Invoke или Dispatcher, чтобы передать действие в главный поток.

Практические рекомендации

  1. Не создавайте поток вручную без веской причины. Начните с Task и пула.
  2. Минимизируйте общее изменяемое состояние. Чем меньше разделяемых данных, тем меньше проблем.
  3. Держите критические секции короткими и не вызывайте внутри них чужой код.
  4. Всегда обрабатывайте исключения внутри рабочего кода и логируйте их.
  5. Для отмены используйте CancellationToken, а не принудительное завершение потока.
  6. Измеряйте. Часто параллельная версия оказывается медленнее последовательной из-за накладных расходов.

Заключение

Класс Thread даёт полный контроль, но требует дисциплины и подходит для редких долгоживущих задач. Пул потоков и надстройка Task — основной рабочий инструмент: они дешевле, проще и безопаснее. Понимая разницу и помня о синхронизации, вы сможете писать отзывчивые и быстрые приложения, не превращая отладку в охоту за призраками.

СЛ
Сергей Лигин
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».