До сих пор наши программы жили довольно спокойно. Получили данные, обработали их, вывели результат — одна задача закончилась, началась следующая. Для калькулятора или небольшой консольной игры этого порядка вполне хватало.
У реальных приложений такой спокойной жизни обычно нет. Игровой сервер принимает команды от множества игроков, браузер загружает страницу и продолжает реагировать на нажатия, редактор сохраняет файл, не требуя от пользователя замереть вместе с ним. Внутри программы постоянно встречаются действия, которые не должны выстраиваться в одну длинную очередь.
Здесь и появляются многопоточность и асинхронность. На первый взгляд идея простая: если дел несколько, почему бы не выполнять их одновременно? Но вместе с новыми возможностями приходят вопросы. Кто распределяет работу между ядрами? Всегда ли второй поток ускоряет программу? Что случится, если два потока одновременно изменят одни данные? И почему ошибка иногда исчезает сразу после запуска отладчика, словно специально решила над нами поиздеваться?
В этом блоке мы постепенно ответим на эти вопросы. Научимся запускать и останавливать потоки, дожидаться их завершения, защищать общие данные и разберёмся, чем многопоточность отличается от асинхронного ожидания. Но начинать с Thread, lock и async/await было бы рано. Сначала нужно понять, какой поток уже работает в обычной программе и как он добирается до процессора.
Сегодня мы ненадолго заглянем под капот компьютера, а затем подготовим сквозной проект — консольный симулятор автомобильной гонки. Первый заезд получится необычным: три машины исправно пройдут дистанцию, но организаторы выпустят их на трассу по одной. Исправлять старт пока не будем. Сначала выясним, почему программа ведёт себя именно так.
Начнём с самого запуска. Файл с кодом сам по себе ничего не выполняет — он может сколько угодно лежать на диске и совершенно не торопиться на работу. Когда мы запускаем приложение, операционная система загружает его, выделяет память и создаёт процесс. Если в этот момент открыть диспетчер задач, мы увидим там именно его.
У процесса есть собственное пространство для работы: загруженный код, данные приложения, открытые файлы и другие ресурсы. Но наличие мастерской ещё не означает, что в ней кто-то работает. Можно разложить инструменты по идеальным полкам и не собрать ни одной детали.
Команды начинает выполнять поток. Он проходит по коду, входит в методы, возвращается из них и постепенно двигает программу вперёд.
У консольного приложения уже есть как минимум один поток — основной. Именно он входит в Main и начинает выполнять написанные нами команды. Мы ещё не создавали потоки вручную, но всё это время уже работали внутри одного из них.
Возьмём знакомый код:
Console.WriteLine("Начало работы");
LoadSettings();
CreateReport();
Console.WriteLine("Работа завершена");
Основной поток выводит первое сообщение, затем уходит в LoadSettings и полностью выполняет этот метод. Только после возврата он доберётся до CreateReport. Перепрыгнуть через незаконченный вызов, ненадолго заняться отчётом, а потом вернуться к настройкам он сам по себе не может.
И хорошо, что не может. Если отчёт строится только после загрузки настроек, последовательность защищает правильный порядок работы. Однопоточная программа — не урезанная версия «настоящей», а нормальное решение для задач, где действия действительно зависят друг от друга.
Теперь возникает другой вопрос: где поток работает физически? Иногда начинающие разработчики мысленно приклеивают каждый поток к отдельному ядру процессора. Получается удобная картина: четыре ядра, значит, можно создать четыре потока и получить программу в четыре раза быстрее. Жаль, что сам процессор эту картину не видел.
Поток и ядро — не одно и то же. Поток описывает последовательность работы, а логический процессор предоставляет место, где эту работу можно выполнять. Решение о том, какой поток получит это место прямо сейчас, принимает планировщик операционной системы.
На компьютере одновременно готовы к работе потоки нашей программы, браузера, среды разработки, мессенджера и системных служб. Планировщик раздаёт им процессорное время небольшими порциями. Если свободных логических процессоров несколько, часть потоков действительно работает параллельно. Если желающих больше, им приходится быстро сменять друг друга.
Со стороны всё это похоже на одновременную работу, хотя конкретный поток мог несколько раз останавливаться и продолжаться снова. Причём продолжить он может уже на другом логическом процессоре — постоянного «личного ядра» у обычного потока нет.
Отсюда следует важный вывод: большое количество потоков не является рецептом ускорения. Их нужно создавать, переключать, а позже ещё и договариваться о доступе к общим данным. Иногда такое разделение полезно, иногда расходы окажутся больше выигрыша.
Но возможность всё равно очень важная. В одном процессе могут существовать несколько потоков, и каждый будет двигаться по своей последовательности команд. Пока один занят своей работой, другой не обязан стоять рядом и ждать его возвращения.
Потоки одного процесса пользуются общей памятью и видят одни объекты. Это удобно: результаты не приходится переносить между отдельными программами. Правда, если два потока одновременно решат исправить одну и ту же таблицу результатов, удобство быстро закончится. До этой проблемы мы ещё доберёмся, и гоночная трасса предоставит для неё достаточно поводов.
Пока достаточно удержать общую картину:
операционная система запускает процесс → внутри процесса работает основной поток → планировщик передаёт потокам процессорное время → при необходимости программа может создать дополнительные потоки.
Как создать дополнительный поток, запустить его и потом аккуратно дождаться завершения, разберём позже. Сначала подготовим программу, в которой хорошо видно поведение уже знакомого основного потока.
Готовим гоночный проект
Открываем новый консольный проект. Наша первая модель автомобиля будет совсем небольшой: имя и метод Drive, который проводит машину по заданному количеству кругов. Ни скорости, ни топлива, ни пит-стопов пока нет — автоспорт явно знал времена и получше, но для первого запуска этого достаточно.
using System;
class RaceCar
{
public string Name { get; }
public RaceCar(string name)
{
Name = name;
}
public void Drive(int totalLaps)
{
for (int lap = 1; lap <= totalLaps; lap++)
{
Console.WriteLine($"{Name} завершает круг {lap}.");
}
Console.WriteLine($"{Name} финиширует.");
}
}
class Program
{
static void Main()
{
RaceCar[] cars =
{
new RaceCar("Молния"),
new RaceCar("Комета"),
new RaceCar("Вихрь")
};
foreach (RaceCar car in cars)
{
car.Drive(3);
}
Console.WriteLine("Заезд завершён.");
}
}
Запускаем проект. «Молния» проходит три круга и финиширует. Затем те же действия повторяет «Комета», а после неё — «Вихрь». Если судить только по строкам в консоли, перед нами не гонка, а очень дисциплинированная очередь автомобилей.
Причина теперь должна быть понятна. Массив перебирает основной поток. Он входит в car.Drive(3) и не переходит к следующему элементу foreach, пока метод не закончится. В коде пока нет второй последовательности выполнения, которая могла бы заняться другим автомобилем.
На этом шаге всё идёт по плану. Мы получили рабочую основу: класс автомобиля, список участников и один метод для прохождения дистанции. В следующих уроках не придётся выбрасывать проект и начинать заново — мы будем менять способ запуска уже знакомого Drive и смотреть, что из этого получается.
Соберите этот вариант проекта у себя и запустите его. Нам важно сохранить именно такой исходный заезд: три автомобиля, три круга и последовательный вызов Drive. Уже в следующем уроке мы вернёмся к этой очереди и разберёмся, какие вообще способы «делать несколько дел сразу» существуют. А затем начнём менять код.
Обычное консольное приложение с самого начала работает не «само по себе». Операционная система создаёт для него процесс, выделяет память и ресурсы, а основной поток входит в Main и начинает выполнять наши команды. Когда потоков становится несколько, процессорное время между ними распределяет планировщик: часть работы действительно может идти параллельно, а часть — быстро чередоваться.
Количество потоков при этом ничего не ускоряет автоматически. Каждый новый поток нужно обслуживать, переключать и позже согласовывать с остальными. Поэтому первая версия гоночного симулятора намеренно остаётся однопоточной. Сейчас автомобили проходят дистанцию по очереди не из-за ошибки в RaceCar, а потому что один основной поток последовательно вызывает Drive. Именно этот способ запуска мы и начнём менять дальше.