1. Введение
Многопоточность — это как параллельная работа нескольких работников в офисе: один печатает документы, другой звонит клиенту, третий варит кофе (и, конечно, все они — программисты). Если бы у нас был только один работник, он делал бы всё по очереди, и офис бы захлебнулся от скуки и очереди за кофе. В программировании ситуация аналогична: однопоточная программа может выполнять только одну задачу за раз.
Представим, что наше приложение выполняет длительную операцию, например, скачивает файл из интернета или рассчитывает огромную таблицу. Всё остальное в этот момент "замораживается" — кнопки не нажимаются, анимация не двигается, слова "не отвечает" выводятся во всплывающем окне.
Многопоточность позволяет приложению делать несколько вещей одновременно: интерфейс остается отзывчивым, операции выполняются параллельно, и мы не срываемся на монолог в стиле "Компьютер, ты опять завис?!".
Основные понятия и терминология
Прежде чем окунуться с головой, разберёмся, что такое поток (thread) и чем он отличается от процесса.
- Процесс (Process): Самостоятельная программа с собственным адресным пространством, переменными, ресурсами. Например, каждое запущенное приложение в Windows — отдельный процесс.
- Поток (Thread): Единица выполнения внутри процесса. Процесс может содержать один или несколько потоков, которые работают с одними и теми же ресурсами (памятью, переменными).
Почему многопоточность вызывает столько вопросов?
Потому что потоки — это азартные ребята: они могут начать выполнение в любой момент, перемешать данные, прервать друг друга и вообще устроить беспредел в памяти, если не следить за порядком. Если кажется, что это похоже на детсад без воспитателя — совершенно верно! Дисциплина и забота о потоках — основа написания надёжных многопоточных программ.
2. История и роль многопоточности в C# и .NET
В далёкие времена C# был однопоточным, а программы — простыми. С ростом требований к производительности, появлением многоядерных процессоров и необходимостью создавать отзывчивые приложения без зависаний интерфейса, в .NET появились средства для многопоточности. Сначала это был классический System.Threading.Thread, позже появились задачи (Task), асинхронные методы (async/await), параллельная обработка данных (PLINQ) и высокоуровневые примитивы синхронизации.
C# вырос в мощную платформу, где многопоточность — не диковинка, а повседневность.
Визуально: процесс и потоки
Вот простая схема:
+--------------------------------------------------+
| Процесс (ваша программа) |
| +-------------+ +-------------+ |
| | Поток 1 | | Поток 2 | |
| +-------------+ +-------------+ |
| ... |
| +-------------+ |
| | Поток N | |
| +-------------+ |
+--------------------------------------------------+
Все потоки внутри процесса видят общие переменные и ресурсы.
3. Как создать поток в C#?
Начнем с самого базового: класса Thread из пространства имен System.Threading.
Пример: Запускаем второй поток
Пусть у нас есть какая-то длительная задача — например, подсчёт суммы чисел от 1 до 10_000_000. Пока идет подсчёт, основной поток пусть пишет приветствие пользователю.
using System;
using System.Threading;
class Program
{
// Метод для второй задачи
static void CalculateSum()
{
long sum = 0;
for (int i = 1; i <= 10_000_000; i++)
sum += i;
Console.WriteLine($"[Поток 2] Сумма: {sum}");
}
static void Main()
{
// Создаём поток, указываем делегат на метод
Thread thread = new Thread(CalculateSum);
thread.Start(); // Запускаем второй поток
// Основной поток продолжает работать
Console.WriteLine("[Поток 1] Привет! Работаем параллельно...");
// Ждём завершения второго потока перед выходом
thread.Join();
Console.WriteLine("[Поток 1] Всё завершено!");
}
}
Что произойдёт?
На экране появится строка "[Поток 1] Привет! Работаем параллельно...", а затем, когда поток с подсчётом закончит работу, он выведет сумму.
Типичная проблема: Кто первый, тот и вывел
Попробуйте несколько раз запустить этот код — порядок вывода строк может быть разным! Иногда сумма появляется первой, иногда приветствие. Это и есть настоящая многопоточность — программы становятся менее предсказуемыми, как настроение кота по понедельникам.
4. Зона памяти: что видят потоки?
Все потоки внутри процесса имеют доступ к одним и тем же переменным (если они не локальные для метода). Если изменить переменную в одном потоке — её увидят остальные!
Пример: Общая переменная
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 1000; i++)
counter++;
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"counter = {counter}");
}
}
Сколько ожидаем увидеть в counter? Логика подсказывает 2000, ведь каждый поток увеличивает по 1000 раз.
А вот и нет! Запустите несколько раз — увидите разные значения: 1782, 1935, 1999…
Почему? Это классическая проблема Race Condition — потоки "перехватывают" друг у друга управление между чтением -> увеличением -> записью, и часть инкрементов теряется.
5. Полезные нюансы
Как потоки взаимодействуют с интерфейсом?
В современных desktop-приложениях (WinForms/WPF/MAUI) основной поток обслуживает графический интерфейс пользователя. Все действия пользователя (клики, ввод) — в этом потоке. Фоновые задачи должны выполняться в других потоках, но при этом, по правилам, нельзя напрямую "трогать" интерфейс из других потоков. Это сделано для предотвращения хаоса.
В консоли такого ограничения нет, можно писать в Console.WriteLine из любого потока. Однако в реальных приложениях без правильной синхронизации интерфейс может "поехать".
Последовательность и параллельность
Таблица — чтобы закрепить различия.
| Однопоточный код | Многопоточный код |
|---|---|
| Выполняет задачи по очереди | Задачи могут выполняться одновременно |
| UI "висит" при долгих задачах | UI остаётся отзывчивым |
| Просто читать и писать переменные | Требует контроля доступа к данным |
| Отлаживается легко | Может быть сложно отлаживать |
Важные моменты при работе с потоками
- Общие переменные — общий риск. Как уже показали выше, если несколько потоков используют общую переменную — без синхронизации возможны ошибки! (Подробнее в следующих лекциях.)
- Поток можно "ждать" через Join(). Метод Join() позволяет "приостановить" основной поток до завершения фонового. Используйте его, если нужно дождаться результата.
- Поток нельзя запустить дважды. После выполнения потока его нельзя снова стартовать — придётся создавать новый объект Thread.
- Завершение потока. Поток завершается, когда заканчивается выполнение его метода. Принудительно "убивать" потоки — плохая идея (метод Abort() давно считается вредным и устаревшим).
Для чего нужна многопоточность на практике?
- UI-приложения: не замораживать интерфейс, когда идет фоновая загрузка или вычисления.
- Серверы и службы: одновременно обрабатывать запросы многих клиентов.
- Высокопроизводительные вычисления: разбивать большую задачу (например, обработку миллионов записей) на части и выполнять их параллельно.
- Игры, симуляции, обработка данных: моделировать сложные системы, не теряя в производительности.
Проблемы многопоточности
Многопоточность дает мощь, но и добавляет сложности:
- Race Condition (состояние гонки): когда несколько потоков одновременно меняют одни и те же данные, результат зависит от порядка инструкций, который непредсказуем.
- Deadlock (взаимная блокировка): потоки ждут друг друга, и никто не может продолжить работу.
- Starvation (голодание): один из потоков постоянно "обделяют" доступом к ресурсу.
В этой лекции мы только упомянули основные проблемы многопоточности, а в следующих научимся их распознавать и избегать. Пока — просто запомните, что если всё "зависло", виноваты могут быть не только баги, но и потоки, которые "устроили вечеринку".
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ