Перейти к основному содержимому

Класс Thread в C# — создание, Start, фоновые потоки и практика

Разработчику Архитектору

Обзорная глава про System.Threading.Thread в .NET: что происходит при Start, как передать данные, чем foreground- и background-потоки отличаются для процесса, и как это соотносится с Task, пулом потоков и async/await. Базовый контекст по асинхронности и синхронизации — в статье Асинхронность и многопоточность.


Что такое Thread в модели .NET

Thread — управляемая обёртка над потоком операционной системы (kernel thread): у потока есть собственный стек вызовов, свой указатель инструкций и состояние планировщика ОС. CLR создаёт и учитывает этот поток, но планирование (когда именно поток получит процессорное время) выполняет ОС.

Свойства вроде Thread.ManagedThreadId удобны для логов; Thread.CurrentThread в любом месте кода даёт объект потока, который сейчас выполняет этот участок.


Создание экземпляра и запуск

Делегаты ThreadStart и ParameterizedThreadStart

Поток создают, передав в конструктор Thread метод, который будет точкой входа:

  • ThreadStart — сигнатура void();
  • ParameterizedThreadStart — сигнатура void(object? state); при Start(object?) CLR передаёт этот аргумент в делегат.
void Worker() => Console.WriteLine("ok");

void WorkerWithState(object? state)
{
var text = (string)state!;
Console.WriteLine(text);
}

var a = new Thread(Worker);
var b = new Thread(WorkerWithState);

a.Start();
b.Start("hello");

После new Thread(...) поток ОС ещё не обязан существовать в полном смысле рабочей нити: фактический запуск инициируется вызовом Start() (или Start(parameter) для ParameterizedThreadStart).


Что делает Start

Start переводит поток из состояния «создан / ещё не запущен» в состояние «допущен к выполнению»: CLR взаимодействует с ОС, поток попадает в очередь готовых к выполнению. Код после Start() в вызывающем потоке продолжается сразу; тело рабочего метода может начаться через произвольную задержку (микросекунды, миллисекунды) в зависимости от загрузки CPU и политики планировщика, поэтому к следующей строке в инициаторе рабочий метод может ещё не стартовать. Чтобы дождаться завершения фоновой работы перед продолжением, используют Join (см. ниже).


Однократный запуск

У одного объекта Thread Start() вызывают один раз. Повторный вызов приводит к InvalidOperationException. «Перезапустить» тот же экземпляр нельзя — нужен новый Thread.


Передача данных в поток

ParameterizedThreadStart и приведение типов

Аргумент Start(object?) имеет тип object. Внутри метода обычно делают приведение к ожидаемому типу. Ошибка типа проявится уже во время выполнения (InvalidCastException), компилятор это не проверит.


Замыкание (лямбда)

Частый приём — передать лямбду, которая захватывает локальные переменные или поля:

var message = "hello";
var t = new Thread(() => Console.WriteLine(message));
t.Start();

Захват — по ссылке на переменную (для ссылочных типов и полей структур в замыкании), а не «снимок значения на момент Start». Если до чтения в потоке переменную изменят, поток увидит актуальное значение.

Классический подводный камень — цикл for с захватом счётчика без локальной копии:

for (int i = 0; i < 3; i++)
{
new Thread(() => Console.WriteLine(i)).Start(); // может печатать только 3
}

Надёжный приём — ввести локальную копию на итерацию: int j = i; и использовать j внутри лямбды.


Потокобезопасная передача «снимка»

Если нужно передать неизменяемый снимок данных, передают копию (например, record, string, новый объект с зафиксированными полями**) или структуру по значению в замыкании так, чтобы рабочий поток не делился изменяемым состоянием без синхронизации. Общие правила — в разделе про синхронизацию в главе про асинхронность.


Стек, имя, приоритет

Размер стека

У конструктора Thread есть перегрузки с maxStackSize (размер стека в байтах). На практике это нужно редко (глубокая рекурсия, специфичные сценарии). Значение по умолчанию зависит от платформы и режима процесса (типичный порядок — около 1 МБ на поток на настольных 64-bit; точные цифры зависят от ОС и настроек).


Name

Thread.Name полезен в отладчике и в логах. Установить имя лучше до Start, хотя технически присвоение возможно и позже в отдельных конфигурациях; единообразно — до запуска.


Priority

Thread.Priority меняет относительный приоритет потока в ОС. В прикладном коде его часто оставляют по умолчанию: завышение приоритетов множеством потоков ухудшает отзывчивость системы и редко даёт устойчивый выигрыш. Профилирование и архитектура (очереди работ, пул) обычно важнее.


Foreground и background (IsBackground)

По умолчанию IsBackground == false: поток считается foreground. Пока жив хотя бы один foreground-поток, CLR удерживает процесс живым (упрощённо: «процесс не завершится, пока эти потоки не закончились»).

Если IsBackground = true, поток не удерживает процесс: при завершении всех foreground-потоков среда может завершить процесс, а фоновые потоки получат жёсткое обрывание без гарантий «дойти до конца метода». Поэтому фоновые потоки подходят для вспомогательной работы, но критичные сбросы буферов и транзакции должны завершаться в foreground-потоке или через явные точки синхронизации (Join, CancellationToken, shutdown hooks приложения).


Ожидание и пауза

Join

Join() блокирует вызывающий поток до завершения целевого потока (или до таймаута в перегрузке с TimeSpan / миллисекундами). Это простой способ упорядочить наблюдаемое поведение после Start.


Thread.Sleep

Thread.Sleep блокирует текущий поток на указанное время. В библиотечном и серверном коде для ожиданий и таймаутов предпочтительнее await Task.Delay(..., cancellationToken), чтобы не занимать поток пула. Sleep(0) и Thread.Yield() — низкоуровневые подсказки планировщику; в прикладной логике почти не нужны.


Interrupt

Thread.Interrupt() на заблокированном потоке (Wait, Sleep, некоторые I/O) может вызвать ThreadInterruptedException. Это кооперативный сигнал, а не гарантированная «отмена любой работы». В новом коде для отмены доминирует CancellationToken.


Модель квартир (STA / MTA)

Свойство Thread.SetApartmentState / GetApartmentState относится к COM и к старым UI-фреймворкам (WinForms, WPF): UI-поток обычно STA. Для чистого вычислительного кода на современном .NET без COM это редко трогают. Подробности — в документации по interop и UI; ошибка выбора модели проявляется как проблемы маршалинга или «чужой поток обновляет UI».


Отмена и завершение

Thread.Abort удалён

Thread.Abort в современном .NET (начиная с .NET 5 / .NET Core) недоступен. Раньше он выбрасывал ThreadAbortException в произвольной точке стека и ломал инварианты lock, using и библиотечного состояния. Замена — кооперативная отмена через CancellationToken, флаги завершения и корректный выход из циклов.


Пример с отменой

using var cts = new CancellationTokenSource();

var worker = new Thread(() =>
{
while (!cts.Token.IsCancellationRequested)
{
Console.WriteLine("work");
Thread.Sleep(500);
}
Console.WriteLine("exit");
});

worker.IsBackground = true;
worker.Start();

Thread.Sleep(2000);
cts.Cancel();
worker.Join();

Исключения в рабочем потоке

Необработанное исключение в потоке, созданном через Thread, ведёт себя в зависимости от версии и политики приложения: в консоли это часто аварийное завершение процесса после событий вроде AppDomain.UnhandledException (в .NET Framework картина была богаче по настройкам). Практический вывод: оборачивайте тело рабочего метода в try/catch, логируйте, пробрасывайте в общий канал ошибок или завершайте приложение осознанно.


Синхронизация и общие данные

Одновременная запись в одно поле из нескольких потоков без примитивов даёт гонки и «рваные» чтения. Минимальный учебный пример — инкремент счётчика в цикле из двух потоков: результат часто меньше суммы итераций.

Примитивы (lock, Interlocked, Monitor, SemaphoreSlim, коллекции из System.Collections.Concurrent) — в главе про асинхронность и параллелизм. volatile и барьеры памяти — отдельная тема; volatile сам по себе не заменяет взаимное исключение для составных инвариантов.


Когда выбирают Thread, а когда Task и пул

СитуацияТипичный выбор
Долгоживущий выделенный поток с блокирующим циклом (редко)new Thread(...)
Много коротких работ, сервер, UI без блокировокTask, async/await, ThreadPool
CPU-работа из синхронного кодаTask.Run (пул), а не отдельный Thread на каждую мелочь
Параллельный обход с разбиением по ядрамParallel.For / PLINQ (всё равно опираются на пул)

Task.Run ставит работу в очередь пула потоков и переиспользует потоки; new Thread каждый раз просит ОС о отдельном потоке со своим стеком — это дороже при большом числе потоков. Зато Thread даёт предсказуемую модель «ровно один поток на объект», что иногда нужно для совместимости с нативным кодом, отдельной культуры, долгого блокирующего цикла или учебных демонстраций.

async/await освобождает поток на время I/O и хорошо стыкуется с ASP.NET Core и современными API (HttpClient, File.ReadAllTextAsync, EF Core async). Это другой слой абстракции: он не отменяет необходимость думать о гонках, если фоновые задачи трогают общие mutable-данные.


Краткая памятка

  • Start ставит поток в очередь готовых; порядок и момент первого выполнения недетерминированы относительно кода после Start.
  • Join синхронизирует наблюдение «поток закончился».
  • IsBackground определяет, удерживает ли поток процесс живым.
  • Данные удобно передавать лямбдой; следите за захватом переменных цикла.
  • Отмена — через CancellationToken и кооперативные циклы; Thread.Abort в современном .NET отсутствует.
  • Для большинства приложений пул + Task + async/await — основной инструмент; Thread остаётся осознанным низкоуровневым выбором.

Официальная справка: Managed threading, Thread class.


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").