В прошлых уроках мы разобрались, зачем нужна многопоточность и чем она отличается от асинхронности. Хватит теории — пора писать код. Сегодня мы создадим свой первый поток. Но прежде чем что-то создавать — давайте убедимся, что проблема реальна. Вот простая программа с двумя задачами:
using System;
class Program
{
static void PrintWords()
{
string[] words = { "apple", "banana", "cherry" };
Console.WriteLine("Перебираем слова:");
foreach (var word in words)
Console.WriteLine($" {word}");
}
static void CalculateSquares()
{
Console.WriteLine("Считаем квадраты:");
for (int i = 1; i <= 5; i++)
Console.WriteLine($" {i}^2 = {i * i}");
}
static void Main()
{
PrintWords();
CalculateSquares();
Console.WriteLine("Готово.");
}
}
Запустите — и увидите ровно то, что ожидаете. Сначала все слова, потом все квадраты, потом «Готово». Строго по порядку. Второй метод не начнёт работу, пока первый не закончит. Это однопоточное выполнение — предсказуемое и скучное.
А теперь давайте заставим их работать одновременно.
В C# поток — это объект класса Thread из пространства имён System.Threading. Это может показаться странным: мы привыкли, что объекты представляют «вещи» — игрока, заказ, файл. А тут объект представляет последовательность действий, которая выполняется параллельно с другими. Непривычно, но работает ровно так же: создаём объект, вызываем методы.
Создание потока — два шага: передать метод в конструктор, вызвать Start():
Thread thread = new Thread(PrintWords); // Создали — поток знает ЧТО делать
thread.Start(); // Запустили — поток начал работу
Между созданием и запуском поток существует, но ничего не делает — он в состоянии «Unstarted». Только после Start() он начнёт выполнять переданный метод.
Перепишем программу с двумя потоками:
using System;
using System.Threading;
class Program
{
static void PrintWords()
{
string[] words = { "apple", "banana", "cherry" };
Console.WriteLine("Перебираем слова:");
foreach (var word in words)
{
Console.WriteLine($" [Слова] {word}");
Thread.Sleep(100); // Небольшая пауза для наглядности
}
}
static void CalculateSquares()
{
Console.WriteLine("Считаем квадраты:");
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($" [Квадраты] {i}^2 = {i * i}");
Thread.Sleep(100);
}
}
static void Main()
{
Thread wordsThread = new Thread(PrintWords);
Thread squaresThread = new Thread(CalculateSquares);
wordsThread.Start();
squaresThread.Start();
Console.WriteLine("Main: оба потока запущены.");
}
}
Запустите этот код. А теперь — запустите ещё раз. И ещё раз.
Каждый запуск даёт разный результат. Строки из двух методов перемешиваются в случайном порядке. Иногда сначала слова, иногда квадраты, иногда вперемешку. И «Main: оба потока запущены» может появиться в начале, в середине или в конце.
Это не баг. Это суть многопоточности.
Почему порядок непредсказуем? Потому что потоками управляет не ваш код, а планировщик операционной системы. Когда вы вызываете Start(), поток не начинает работать мгновенно — он встаёт в очередь. И в этой очереди стоят не только ваши потоки, но и потоки браузера, мессенджера, антивируса, системных служб — десятки и сотни потоков со всего компьютера.
Планировщик смотрит на всю эту очередь и раздаёт процессорное время маленькими порциями — квантами. Каждый поток получает свой квант (обычно 15–30 миллисекунд), работает, а потом уступает место следующему. Потом снова встаёт в очередь, снова получает квант, и так далее.
Представьте общую кухню в коммуналке. Десять человек хотят готовить, а плита одна (или четыре — по числу ядер). Комендант (планировщик) даёт каждому 30 секунд у плиты: помешал суп — отойди, следующий. Потом снова твоя очередь. Никто не знает заранее, когда именно получит доступ к плите.
Именно поэтому:
— Вы не можете гарантировать, какой поток выполнится первым.
— Вы не можете гарантировать порядок строк в выводе.
— Вы не можете «занять» весь процессор для своей программы.
Это фундаментальное свойство многопоточности, и к нему нужно привыкнуть. Код, который зависит от конкретного порядка выполнения потоков, — это бомба замедленного действия.
Обратите внимание на деталь: строка «Main: оба потока запущены» может появиться до того, как потоки закончат работу. Основной поток вызвал Start() — и побежал дальше, не дожидаясь результата.
При этом программа не завершится, пока потоки работают — потому что они foreground (приоритетные) по умолчанию. Процесс ждёт все foreground-потоки. Но основной поток не знает, когда именно они закончат. Он не может сказать: «выведи это сообщение строго после обоих потоков». Строка Console.WriteLine("Готово") выполнится сразу, а не после завершения потоков.
Как заставить основной поток дождаться конкретного момента завершения — узнаем в следующем уроке, когда познакомимся с методом Join().
Кстати, у каждого потока есть свойства, которые можно прочитать. Давайте заглянем в текущий поток:
Thread current = Thread.CurrentThread;
Console.WriteLine($"Имя: {current.Name ?? "не задано"}");
Console.WriteLine($"ID: {current.ManagedThreadId}");
Console.WriteLine($"Фоновый: {current.IsBackground}");
Console.WriteLine($"Приоритет: {current.Priority}");
Thread.CurrentThread — статическое свойство, которое возвращает объект текущего потока. Через него можно узнать имя, ID, приоритет и другие характеристики. Это бывает полезно при отладке — чтобы понять, в каком потоке выполняется конкретный кусок кода.
Thread — класс из System.Threading. В конструктор принимает метод, Start() запускает выполнение в отдельном потоке.
Порядок выполнения потоков непредсказуем — им управляет планировщик ОС, а не ваш код.
Thread.CurrentThread — статическое свойство, возвращающее объект текущего потока (имя, ID, приоритет).
Thread.Sleep(ms) — приостанавливает текущий поток на указанное количество миллисекунд.
Один объект Thread можно запустить только один раз. Повторный Start() — исключение.