1. Введение
Если представить процесс как супермаркет, то потоки (thread) — это кассиры на разных кассах, которые одновременно обслуживают покупателей. Все кассиры работают в одном магазине, но каждый из них выполняет свою задачу параллельно, благодаря чему работа идет быстрее и эффективнее. Потоки внутри процесса позволяют выполнять несколько задач одновременно, разделяя общие ресурсы и координируя работу внутри одного приложения. Главное преимущество — потоки действительно могут работать параллельно, если процессор поддерживает многозадачность.
Практическая необходимость потоков
Зачем нам вообще запускать несколько потоков? Вот лишь пара ситуаций из реальной жизни:
- Вы пишете приложение с графическим интерфейсом и не хотите, чтобы оно "зависло" во время долгой операции.
- Вам надо одновременно загружать несколько файлов.
- В игре противники должны думать своей головой независимо от других.
Удивительно, но во многих программах по-прежнему встречаются "зависания" из-за неопытной работы с потоками. Сегодня мы научимся избегать таких недоразумений.
Класс Thread: основа ручной многопоточности
Класс Thread — динозавр среди многопоточного программирования в .NET. Несмотря на появление более современных инструментов (Task, async/await), работа с Thread по-прежнему имеет смысл, особенно если хочется почувствовать себя повелителем потоков "с чистого листа".
Схема создания потока
- Создать объект класса Thread, передав ему метод, который будет выполняться в потоке.
- Запустить поток с помощью метода Start().
- (Необязательно) Посмотреть, что происходит — ведь всё может работать параллельно!
2. Запуск потока с помощью Thread
Давайте попробуем на деле почувствовать магию параллелизма. Добавим в наше условное приложение небольшой класс, который будет считать до определённого числа и выводить прогресс. Мы научимся запускать такую работу в отдельном потоке.
using System;
using System.Threading;
class Program
{
static void Main()
{
Console.WriteLine("Главный поток стартовал!");
// Создаём объект потока, указывая на метод для выполнения
Thread workerThread = new Thread(CountToTen);
// Запускаем новый поток
workerThread.Start();
// Главный поток тоже что-то делает: пишет точки...
for (int i = 0; i < 5; i++)
{
Console.Write(".");
Thread.Sleep(500); // Задержка для наглядности
}
Console.WriteLine("\nГлавный поток завершён!");
}
static void CountToTen()
{
for (int i = 1; i <= 10; i++)
{
Console.WriteLine($"[Поток] Считаем: {i}");
Thread.Sleep(400);
}
Console.WriteLine("[Поток] Готово!");
}
}
Что происходит?
Вы увидите в консоли, что точки и "Считаем: X" появляются вперемешку. Это — первый признак многопоточности! Главный поток пишет свои точки, а новый поток считает до 10. Они друг другу не мешают, как два музыканта в группе: один играет на барабане, другой — на фортепиано. Каждый звучит по-своему, и вместе получается музыка.
3. Как передать данные в поток?
Иногда поток должен знать не только, что делать, но и с чем работать. Если метод для потока принимает параметры, как их туда передать?
Вариант 1: С помощью лямбды (анонимного метода)
int bounds = 7;
Thread t = new Thread(() => CountToNumber(bounds));
t.Start();
static void CountToNumber(int n)
{
for (int i = 1; i <= n; i++)
{
Console.WriteLine($"[Поток] {i} / {n}");
Thread.Sleep(300);
}
}
Здесь мы оборачиваем вызов нужного нам метода в лямбду, чтобы передать параметры. Это очень распространённая практика, ведь Thread ждёт метод без параметров (ThreadStart).
Вариант 2: Используем ParameterizedThreadStart
Можно воспользоваться специальным делегатом ParameterizedThreadStart, который принимает один параметр типа object.
Thread t = new Thread(CountToNumberObject);
t.Start(12);
static void CountToNumberObject(object? n)
{
int max = (int)n!;
for (int i = 1; i <= max; i++)
{
Console.WriteLine($"[Поток] {i} / {max}");
Thread.Sleep(200);
}
}
Да, тип параметра — object, поэтому потребуется приведение типа. Плохо, но зато работает! Хотя современный C# предпочитает вариант с лямбдой.
4. Управление жизнью потока
Давайте разберёмся, какие полезные штуки предоставляет класс Thread.
| Свойство / Метод | Назначение |
|---|---|
|
Запускает поток (метод, указанный при создании) |
|
Ожидает завершения потока (блокирует вызывающий поток до конца работы другого потока) |
|
Показывает, работает ли поток сейчас (true/false) |
|
Позволяет давать потоку имя (полезно для отладки) |
|
Получить объект, представляющий текущий поток |
|
Останавливает текущий поток на ms миллисекунд |
Пример: Ждём завершения потока
Иногда нужно, чтобы главный поток дождался окончания работы вспомогательного потока.
Thread t = new Thread(() =>
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"[Второй поток] {i}");
Thread.Sleep(300);
}
});
t.Start();
Console.WriteLine("[Главный поток] Ждём завершения второго потока...");
t.Join(); // Главный поток ждёт здесь
Console.WriteLine("[Главный поток] Второй поток завершился!");
Без Join() программа могла бы закончиться, даже если поток работает. С Join() главный поток терпеливо подождёт, когда все дела будут завершены.
5. Полезные нюансы
Именование потоков: чтобы не запутаться
Для отладки можно давать потокам имена:
Thread t = new Thread(() =>
{
Console.WriteLine($"Это выполняется в потоке: {Thread.CurrentThread.Name}");
});
t.Name = "Поток-Счётчик";
t.Start();
Это поможет, когда потоков станет много и каждый будет делать своё дело.
Ограничения и реальное будущее
Сразу стоит сказать: в современных приложениях ручное управление потоками через Thread — редкость. На практике часто используют более мощные и умные инструменты (Task, async/await), которые будут подробно рассмотрены чуть позже. Но понимание основ работы с потоками важно для:
- Понимания "закулисья" C# и .NET.
- Собеседований (иногда вас попросят объяснить разницу между Thread и Task).
- Диагностики и устранения сложных проблем в больших, "наследуемых" приложениях.
Итоговая схема: жизненный цикл потока
stateDiagram-v2
[*] --> New: Thread создан
New --> Running: Start()
Running --> Stopped: Метод завершён
Stopped --> [*]
Теперь вы умеете самостоятельно создавать и запускать потоки в C#. Вы уже не просто пассажир в поезде, а машинист, управляющий сразу несколькими вагонами! Впереди — жизненный цикл потока, его управление, синхронизация и новые горизонты параллелизма.
6. Типичные ошибки и трюки при работе с Thread
Ошибка №1: не запускать поток.
Часто создают объект Thread, но забывают вызвать метод Start(). В результате поток не начинает работу, и причины этого сложно сразу понять.
Ошибка №2: изменение общих данных без синхронизации.
Если несколько потоков работают с одной и той же переменной без защиты, ждите проблем! Это как если два кассира раздают сдачу из одной коробки — быстро возникнет путаница и ошибки.
Ошибка №3: использование устаревших и небезопасных методов.
Не применяйте методы Thread.Suspend(), Thread.Resume() и подобные — они признаны опасными и устаревшими. Управляйте жизненным циклом потоков другими способами.
Ошибка №4: необработанные исключения внутри потоков.
Если в потоке происходит исключение, которое не поймали, поток завершится, а основной поток об этом может и не узнать! Оборачивайте код потока в блок try-catch, чтобы ловить ошибки и логировать их.
Thread t = new Thread(() =>
{
try
{
// ... ваш код
}
catch (Exception ex)
{
// Логируем ошибку
Console.WriteLine($"[Поток] Ошибка: {ex.Message}");
}
});
t.Start();
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ