Multithreading Fundamentals
Multithreading Fundamentals (Основи Багатопотоковості)
Вступ та Контекст
Проблема: Чому Нам Потрібна Багатопотоковість?
Уявіть, що ви розробляєте desktop-додаток для обробки великих файлів. Користувач натискає кнопку "Обробити", і... інтерфейс замерзає на 30 секунд. Навіть переміщення вікна або натискання кнопки "Скасувати" не працює. Це класичний симптом однопотокової архітектури (single-threaded design).
// ❌ Поганий приклад: UI thread заблокований
private void ProcessButton_Click(object sender, EventArgs e)
{
// Користувач натискає кнопку
ProcessLargeFile("data.csv"); // Займає 30 секунд
// UI повністю заморожений весь цей час!
ShowCompletionMessage();
}
void ProcessLargeFile(string path)
{
// Важка обчислювальна робота
for (int i = 0; i < 10_000_000; i++)
{
// Обробка кожного рядка...
Thread.Sleep(1); // Симуляція роботи
}
}
Багатопотоковість (multithreading) вирішує цю проблему, дозволяючи виконувати важкі операції у фоновому потоці (background thread), залишаючи головний потік (UI thread) вільним для взаємодії з користувачем.
Еволюція: Від Однопроцесорних Систем до Multi-Core
Чому це важливо?
| Ера | Підхід до продуктивності | Роль багатопотоковості |
|---|---|---|
| До 2005 | Підняти частоту CPU | Опціональна (UI responsiveness) |
| Після 2005 | Додати ядра | Обов'язкова для продуктивності |
| Сьогодні | Гетерогенні системи | Критична для будь-якого C application |
Process vs Thread vs Task
Перш ніж заглиблюватись у потоки, розберімо ключову термінологію:
| Концепція | Визначення | Ресурси | Створення |
|---|---|---|---|
| Process (Процес) | Екземпляр запущеної програми | Власний address space, handles, threads | Дороге (~MB пам'яті) |
| Thread (Потік) | Одиниця виконання всередині процесу | Спільний address space, власний stack | Помірне (~1MB stack) |
| Task | Абстракція асинхронної операції | Може переключатись між threads | Легке (pooled threads) |
- Process — ізоляція (sandbox, security boundaries)
- Thread — CPU-bound parallel tasks, низькорівневий контроль
- Task — I/O-bound async operations, високорівневе API (рекомендовано для 95% випадків)
Клас Thread: Фундаментальний API
Створення та Запуск Потоків
Клас System.Threading.Thread — це базовий будівельний блок багатопотоковості в .NET. Він надає прямий доступ до managed threads, що виконуються на рівні операційної системи.
ThreadStart: Найпростіший Спосіб
ThreadStart — делегат без параметрів і повернення:
using System;
using System.Threading;
class Program
{
static void Main()
{
// Створюємо новий потік з методом DoWork
Thread workerThread = new Thread(DoWork);
// Потік створено, але ще НЕ запущено!
Console.WriteLine($"Thread state: {workerThread.ThreadState}"); // Unstarted
// Запускаємо потік
workerThread.Start();
// Головний потік продовжує виконання паралельно
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Main thread: {i}");
Thread.Sleep(100);
}
// Чекаємо завершення worker thread
workerThread.Join(); Console.WriteLine("Worker thread завершився");
}
static void DoWork()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"Worker thread: {i}");
Thread.Sleep(150);
}
}
}
Декомпозиція коду:
- Рядок 9:
new Thread(DoWork)— створюємо об'єкт Thread, передаючи делегатThreadStart. МетодDoWorkбуде entry point нового потоку. - Рядок 16:
Start()— фактично запускає потік. До цього виклику потік існує лише як об'єкт у пам'яті. - Рядок 25:
Join()— блокує поточний потік (Main) до завершенняworkerThread. Без цьогоMainможе завершитись раніше.
ParameterizedThreadStart: Передача Даних
Для передачі параметрів у потік використовується ParameterizedThreadStart:
using System;
using System.Threading;
class Program
{
static void Main()
{
// Створюємо потік з параметризованим делегатом
Thread thread = new Thread(ProcessData);
// Передаємо об'єкт як параметр
thread.Start("Hello from Main!");
thread.Join();
}
// Метод приймає object? (може бути null)
static void ProcessData(object? data)
{
if (data is string message)
{
Console.WriteLine($"Received: {message}");
}
}
}
ParameterizedThreadStart:- Приймає тільки один параметр типу
object? - Потребує boxing для value types
- Потребує cast у методі
Lambda Expressions: Сучасний Підхід (Рекомендовано)
using System;
using System.Threading;
class Program
{
static void Main()
{
string fileName = "data.csv";
int maxLines = 1000;
// Lambda захоплює локальні змінні (closure)
Thread thread = new Thread(() => {
ProcessFile(fileName, maxLines);
});
thread.Start();
thread.Join();
Console.WriteLine("Processing complete");
}
static void ProcessFile(string path, int limit)
{
Console.WriteLine($"Processing {path} with limit {limit}");
// Імітація роботи
Thread.Sleep(1000);
}
}
Переваги lambda:
- ✅ Типобезпечність — компілятор перевіряє типи
- ✅ Множинні параметри — можна захопити будь-яку кількість
- ✅ Читабельність — код поруч з логікою створення потоку
// ❌ ПОМИЛКА: всі потоки побачать i = 5!
for (int i = 0; i < 5; i++)
{
new Thread(() => Console.WriteLine(i)).Start();
}
// ✅ ПРАВИЛЬНО: створюємо локальну копію
for (int i = 0; i < 5; i++)
{
int localI = i; // Копія для кожної ітерації
new Thread(() => Console.WriteLine(localI)).Start();
}
i, а не її значення на момент створення closure.Отримання Результатів з Потоку
Thread не має вбудованого механізму повернення значення. Ось кілька підходів:
Підхід 1: Shared Variable (Спільна Змінна)
using System;
using System.Threading;
class Program
{
static int _result; // Спільна змінна для результату
static void Main()
{
Thread thread = new Thread(Calculate);
thread.Start();
thread.Join(); // Чекаємо завершення
Console.WriteLine($"Result: {_result}"); // Result: 42
}
static void Calculate()
{
// Важкі обчислення...
Thread.Sleep(500);
_result = 42;
}
}
Join(). При конкурентному доступі потрібна синхронізація.Підхід 2: Object Instance (Об'єкт-контейнер)
using System;
using System.Threading;
class Calculator
{
public int Result { get; private set; }
public void Calculate()
{
Thread.Sleep(500);
Result = 42;
}
}
class Program
{
static void Main()
{
var calculator = new Calculator();
Thread thread = new Thread(calculator.Calculate);
thread.Start();
thread.Join();
Console.WriteLine($"Result: {calculator.Result}"); // Result: 42
}
}
Підхід 3: Task (Рекомендовано)
using System;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// Task<T> надає вбудовану підтримку результатів
Task<int> task = Task.Run(() =>
{
Thread.Sleep(500);
return 42;
});
int result = await task; // Або task.Result (блокуючий)
Console.WriteLine($"Result: {result}");
}
}
Task<T> замість Thread для операцій, що повертають результат. Thread доречний для fine-grained control або специфічних сценаріїв (наприклад, STA apartment).Background vs Foreground Threads
Ключова Відмінність
.NET розрізняє два типи потоків за їх впливом на життєвий цикл процесу:
| Характеристика | Foreground Thread | Background Thread |
|---|---|---|
| Завершення процесу | Процес чекає завершення | Примусово завершується |
| Default | ✅ new Thread() | ❌ Потрібно встановити |
| ThreadPool threads | ❌ Ніколи | ✅ Завжди |
| Use case | Критична робота | Допоміжні операції |
Приклад: Вплив на Завершення Процесу
using System;
using System.Threading;
class Program
{
static void Main()
{
// Foreground thread (default)
Thread foregroundThread = new Thread(() =>
{
Console.WriteLine("Foreground: starting work");
Thread.Sleep(3000); // 3 секунди роботи
Console.WriteLine("Foreground: work complete");
});
// Background thread
Thread backgroundThread = new Thread(() =>
{
Console.WriteLine("Background: starting work");
Thread.Sleep(5000); // 5 секунд роботи
Console.WriteLine("Background: work complete"); // НЕ ВИКОНАЄТЬСЯ!
});
backgroundThread.IsBackground = true;
foregroundThread.Start();
backgroundThread.Start();
Console.WriteLine("Main: exiting");
// Після виходу Main, процес чекає foreground thread (3 сек)
// Background thread буде примусово завершено
}
}
/* Вивід:
Main: exiting
Foreground: starting work
Background: starting work
Foreground: work complete
(процес завершується, background aborted)
*/
Декомпозиція:
- Рядок 24:
IsBackground = true— перетворює потік на background - Процес завершується після
Main+ всі foreground threads - Background thread не встигає завершити 5-секундну роботу
- Логування та телеметрія
- Кешування та prefetch
- Heartbeat та health checks
- Будь-яка робота, яку можна безпечно перервати
- Запис у файл або базу даних (втрата даних!)
- Мережеві транзакції
- Будь-яка операція, що вимагає атомарності
Життєвий Цикл Потоку (Thread Lifecycle)
ThreadState Enum
System.Threading.ThreadState — це flags enum, що описує поточний стан потоку:
[Flags]
public enum ThreadState
{
Running = 0, // Виконується
StopRequested = 1, // Запит на зупинку
SuspendRequested = 2, // Запит на призупинення (deprecated)
Background = 4, // Фоновий потік
Unstarted = 8, // Ще не запущений
Stopped = 16, // Завершений
WaitSleepJoin = 32, // Очікує (Sleep, Join, lock)
Suspended = 64, // Призупинений (deprecated)
AbortRequested = 128, // Запит на Abort (deprecated)
Aborted = 256 // Перерваний (deprecated)
}
Thread.Suspend(), Thread.Resume(), Thread.Abort() є obsolete у .NET Core/.NET 5+ через небезпечність (можуть залишити об'єкти в inconsistent state). Використовуйте CancellationToken для cooperative cancellation.Діаграма Переходів Станів
Перевірка Стану Потоку
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(Worker);
PrintState(thread, "After new"); // Unstarted
thread.Start();
Thread.Sleep(50); // Дати час запуститись
PrintState(thread, "After Start"); // Running або WaitSleepJoin
thread.Join();
PrintState(thread, "After Join"); // Stopped
}
static void Worker()
{
Thread.Sleep(100);
}
static void PrintState(Thread t, string label)
{
Console.WriteLine($"{label}: {t.ThreadState}");
}
}
ThreadState є flags enum, тому потік може мати кілька станів одночасно. Наприклад, Background, WaitSleepJoin означає фоновий потік, що очікує.Thread Priorities (Пріоритети Потоків)
ThreadPriority Enum
public enum ThreadPriority
{
Lowest = 0, // Мінімальний пріоритет
BelowNormal = 1, // Нижче нормального
Normal = 2, // Стандартний (default)
AboveNormal = 3, // Вище нормального
Highest = 4 // Максимальний пріоритет
}
Як Працює Scheduler
Операційна система використовує preemptive scheduling — вона виділяє процесорний час потокам на основі пріоритетів:
Key Points:
- Пріоритет — це підказка, не гарантія
- Потоки з однаковим пріоритетом чергуються (round-robin)
- Високопріоритетні потоки можуть "голодувати" (starve) низькопріоритетні
- Пріоритет успадковується від батьківського потоку за замовчуванням
Приклад: Вплив Пріоритету
using System;
using System.Threading;
class Program
{
static long _lowCount = 0;
static long _highCount = 0;
static bool _running = true;
static void Main()
{
Thread lowThread = new Thread(LowPriorityWork)
{
Priority = ThreadPriority.Lowest,
IsBackground = true
};
Thread highThread = new Thread(HighPriorityWork)
{
Priority = ThreadPriority.Highest,
IsBackground = true
};
lowThread.Start();
highThread.Start();
// Даємо потокам працювати 2 секунди
Thread.Sleep(2000);
_running = false;
lowThread.Join();
highThread.Join();
Console.WriteLine($"Low priority iterations: {_lowCount:N0}");
Console.WriteLine($"High priority iterations: {_highCount:N0}");
Console.WriteLine($"Ratio: {(double)_highCount / _lowCount:F2}x");
}
static void LowPriorityWork()
{
while (_running)
{
Interlocked.Increment(ref _lowCount);
}
}
static void HighPriorityWork()
{
while (_running)
{
Interlocked.Increment(ref _highCount);
}
}
}
/* Типовий вивід (залежить від системи):
Low priority iterations: 12,345,678
High priority iterations: 98,765,432
Ratio: 8.00x
*/
Best Practices для Пріоритетів
| Рекомендація | Обґрунтування |
|---|---|
❌ Не використовуйте Highest | Може заблокувати інші потоки |
| ❌ Не покладайтесь на пріоритети для логіки | Поведінка різна на різних ОС |
✅ Залишайте Normal для 99% випадків | OS scheduler зазвичай оптимальний |
✅ BelowNormal для background tasks | Менше впливає на UI responsiveness |
| ✅ Використовуйте Thread Pool замість пріоритетів | Pool сам оптимізує розподіл |
Ключові Методи Thread
Thread.Sleep(): Призупинення Виконання
// Призупинити поточний потік на вказаний час
Thread.Sleep(1000); // 1 секунда
Thread.Sleep(TimeSpan.FromSeconds(1.5)); // 1.5 секунди
// Спеціальні значення:
Thread.Sleep(0); // Yield: віддати timeslice, але залишитись ready
Thread.Sleep(-1); // Infinite: еквівалент Timeout.Infinite
Sleep(0)— поступається іншим потокам будь-якого пріоритетуYield()— поступається тільки потокам рівного або вищого пріоритету на тому ж процесорі
Thread.Join(): Очікування Завершення
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
Console.WriteLine("Worker: starting");
Thread.Sleep(2000);
Console.WriteLine("Worker: complete");
});
thread.Start();
// Варіант 1: Чекати безмежно
thread.Join();
// Варіант 2: Чекати з timeout
bool completed = thread.Join(1000); // Чекати макс. 1 сек
if (!completed)
{
Console.WriteLine("Thread не завершився за 1 секунду");
}
// Варіант 3: TimeSpan timeout
thread.Join(TimeSpan.FromSeconds(5));
}
}
threadB.Join(), а потік B викликає threadA.Join(), обидва заблокуються назавжди — це deadlock.Thread.Interrupt(): Переривання Очікування
using System;
using System.Threading;
class Program
{
static void Main()
{
Thread thread = new Thread(() =>
{
try
{
Console.WriteLine("Worker: going to sleep...");
Thread.Sleep(10000); // 10 секунд
Console.WriteLine("Worker: woke up normally");
}
catch (ThreadInterruptedException) {
Console.WriteLine("Worker: was interrupted!");
}
});
thread.Start();
Thread.Sleep(1000); // Чекаємо 1 секунду
thread.Interrupt(); // Перериваємо Sleep thread.Join();
}
}
/* Вивід:
Worker: going to sleep...
Worker: was interrupted!
*/
Interrupt() працює тільки коли потік у стані WaitSleepJoin. Якщо потік активно виконується, виняток буде викинуто при наступному блокуючому виклику.Thread-Local Storage (Локальне Сховище Потоку)
Іноді потрібні дані, унікальні для кожного потоку. .NET надає кілька механізмів:
ThreadStatic Attribute
using System;
using System.Threading;
class Program
{
[ThreadStatic] static int _threadId; // Унікальне значення для кожного потоку
static void Main()
{
Thread t1 = new Thread(() => { _threadId = 1; PrintId("T1"); });
Thread t2 = new Thread(() => { _threadId = 2; PrintId("T2"); });
t1.Start();
t2.Start();
t1.Join();
t2.Join();
}
static void PrintId(string name)
{
Thread.Sleep(100); // Дати іншому потоку час
Console.WriteLine($"{name}: _threadId = {_threadId}");
}
}
/* Вивід:
T1: _threadId = 1
T2: _threadId = 2
*/
- Не можна ініціалізувати в оголошенні (
static int _id = 1;— кожен потік побачить 0!) - Не працює з instance fields
- Немає підтримки async/await контексту
ThreadLocal: Сучасна Альтернатива
using System;
using System.Threading;
class Program
{
// Ініціалізатор викликається для кожного потоку окремо
static ThreadLocal<int> _threadId = new ThreadLocal<int>(() => {
return Thread.CurrentThread.ManagedThreadId;
});
static void Main()
{
Thread t1 = new Thread(() => Console.WriteLine($"T1: {_threadId.Value}"));
Thread t2 = new Thread(() => Console.WriteLine($"T2: {_threadId.Value}"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
// Важливо: звільнити ресурси
_threadId.Dispose();
}
}
Переваги ThreadLocal<T>:
- ✅ Підтримує lazy initialization
- ✅ Можна ініціалізувати в оголошенні
- ✅ Реалізує
IDisposable - ✅ Має властивість
Valuesдля доступу до всіх потоків
AsyncLocal: Для Async/Await
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
// Значення "перетікає" через await boundaries
static AsyncLocal<string> _correlationId = new AsyncLocal<string>();
static async Task Main()
{
_correlationId.Value = "REQ-123";
Console.WriteLine($"Before await: {_correlationId.Value}");
await Task.Delay(100); // Може виконатись в іншому потоці!
Console.WriteLine($"After await: {_correlationId.Value}"); // Все ще "REQ-123"
await ProcessAsync();
}
static async Task ProcessAsync()
{
Console.WriteLine($"In ProcessAsync: {_correlationId.Value}"); // "REQ-123"
}
}
Порівняння та Рекомендації
Thread vs Task: Коли Що Використовувати
| Сценарій | Рекомендація | Обґрунтування |
|---|---|---|
| CPU-bound robota | Task.Run() | Використовує ThreadPool, ефективніше |
| I/O-bound операції | async/await | Не блокує потоки |
| Fine-grained control | Thread | Пріоритети, apartment state |
| STA COM objects | Thread | Потрібен STA apartment |
| Long-running background | Thread або Task.Factory.StartNew(TaskCreationOptions.LongRunning) | Не виснажує ThreadPool |
| Legacy code interop | Thread | Сумісність з старим кодом |
Антипаттерни
// ПОГАНО: витрачає CPU на нічого
while (!dataReady)
{
// Spinning - навантажує процесор
}
// ПРАВИЛЬНО: використовуйте signaling
AutoResetEvent signal = new AutoResetEvent(false);
signal.WaitOne(); // Потік засинає до сигналу
// ПОГАНО: deprecated, небезпечно
thread.Abort(); // Може залишити об'єкти в corrupt state
// ПРАВИЛЬНО: cooperative cancellation
CancellationTokenSource cts = new CancellationTokenSource();
Thread thread = new Thread(() =>
{
while (!cts.Token.IsCancellationRequested)
{
// Робота...
}
});
cts.Cancel(); // Сигналізуємо про скасування
// ПОГАНО: виняток у потоці вбиває весь процес!
Thread thread = new Thread(() =>
{
throw new Exception("Oops"); // Unhandled - process crash
});
// ПРАВИЛЬНО: обробляйте винятки
Thread thread = new Thread(() =>
{
try
{
// Робота...
}
catch (Exception ex)
{
Logger.Error(ex);
}
});
Практичні Завдання
Рівень 1: Початковий
Створіть програму, яка:
- Запускає 3 потоки
- Кожен потік рахує від 1 до 10, друкуючи свій номер та поточне число
- Головний потік чекає завершення всіх трьох
Очікуваний вивід (порядок може відрізнятись):
Thread 1: 1
Thread 2: 1
Thread 3: 1
Thread 1: 2
...
Main: All threads complete
Напишіть програму, що демонструє різницю між foreground та background threads:
- Створіть два потоки, кожен працює 5 секунд
- Один — foreground, інший — background
- Main thread завершується через 2 секунди
Поясніть у коментарях, що станеться з кожним потоком.
Рівень 2: Середній
Реалізуйте клас ThreadSafeCounter з методами:
Increment()— атомарно збільшує лічильникDecrement()— атомарно зменшує лічильникValue— повертає поточне значення
Протестуйте з 10 потоками, кожен робить 10000 increment та 10000 decrement операцій. Фінальне значення має бути 0.
Створіть програму, яка:
- Виводить початковий стан ThreadPool (min/max threads)
- Запускає 100 tasks, кожна спить 1 секунду
- Кожну секунду виводить кількість активних thread pool threads
- Чекає завершення всіх tasks
Використовуйте ThreadPool.GetAvailableThreads() та ThreadPool.GetMinThreads().
Рівень 3: Просунутий
Реалізуйте класичний паттерн Producer-Consumer:
- Один producer thread додає числа до черги
- Два consumer threads читають та обробляють числа
- Використовуйте
Thread, НЕBlockingCollection - Реалізуйте правильне завершення (graceful shutdown)
Підказка: використовуйте Monitor.Wait()/Monitor.Pulse() для сигналізації.
Підсумки
У цьому розділі ми розглянули фундаментальні концепції низькорівневого багатопотокового програмування:
- Thread — базовий примітив для створення потоків
- Foreground vs Background — вплив на життєвий цикл процесу
- ThreadState — стани потоку та переходи між ними
- ThreadPriority — підказки для OS scheduler
- Thread-local storage — дані, унікальні для кожного потоку
Корисні Посилання
Source Generators: Compile-Time Code Generation
Roslyn Source Generators для генерації C# коду на етапі компіляції. IIncrementalGenerator API, syntax receivers, трансформації, debugging, та production use cases.
Synchronization Primitives
Глибокий розбір примітивів синхронізації в .NET - lock, Monitor, Mutex, Semaphore, AutoResetEvent, Interlocked та Volatile