Уявіть, що ви намагаєтеся пояснити, чому ваша C# програма "зависає" при виклику Thread.Sleep(5000). Або чому два потоки, що читають одну і ту саму змінну, іноді отримують різні значення. Або чому Parallel.ForEach на 8-ядерному процесорі інколи працює повільніше за звичайний foreach.
Відповіді на ці запитання лежать не в синтаксисі C#, а в тому, як влаштована операційна система (ОС). Без цього фундаменту вивчення багатопоточності перетворюється на механічне запам'ятовування API без розуміння того, що відбувається "під капотом".
Ця тема — підготовчий фундамент. Ми розглянемо, як ОС керує виконанням програм, що таке процес та потік на рівні операційної системи, і як CPU розподіляє свій час між конкуруючими задачами. Ця теоретична база зробить кожну наступну тему зрозумілою, а не просто "магічною".
Після вивчення цієї теми ви зможете:
Операційна система (ОС, англ. Operating System, OS) — це програмне забезпечення, що виступає посередником між апаратним забезпеченням (hardware) і прикладними програмами (applications).
Уявіть великий ресторан. Є кухня з ресурсами — плитами, продуктами, інструментами. Є клієнти — відвідувачі, що замовляють страви. Між ними стоїть менеджер ресторану, який вирішує:
ОС виконує аналогічну роль для комп'ютера: розподіляє CPU, пам'ять, файли, мережеві з'єднання між програмами, стежить за безпекою та ізоляцією.
Серцем будь-якої ОС є ядро (Kernel) — компонент, що має прямий доступ до апаратного забезпечення та виконується з максимальними привілеями. Все, що відбувається в комп'ютері, проходить через ядро.
Windows розділяє виконання на два рівні:
User Mode (Режим Користувача) — де виконуються ваші програми, включно з .NET Runtime. Тут є обмежений доступ до пам'яті та апаратного забезпечення. Якщо ваша програма спробує напряму звернутися до апаратного забезпечення — ОС негайно завершить її виконання.
Kernel Mode (Режим Ядра) — де виконується ядро ОС та драйвери. Тут є повний доступ до будь-якого ресурсу системи. Перехід між режимами відбувається через System Call (системний виклик) — формальний запит до ядра виконати якусь привілейовану операцію.
File.ReadAllText(), .NET Runtime здійснює системні виклики до ядра Windows. Ось чому I/O операції набагато повільніші за операції в пам'яті — кожен системний виклик потребує переходу між режимами.Процес (Process) — це запущений екземпляр програми. Коли ви запускаєте dotnet run, Windows створює новий процес для вашої програми.
Ключова характеристика процесу — ізоляція. Кожен процес живе у власній "пісочниці":
Зверніть увагу: обидва процеси мають однаковий Virtual Address Space (0x0000 - 0x7FFF). Це не конфлікт — кожен процес "думає", що він єдиний у пам'яті. MMU (Memory Management Unit) процесора транслює ці "уявні" адреси в реальні фізичні адреси RAM.
Кожен Windows-процес складається з кількох ключових компонентів:
Процес — це квартира. У неї є власна площа (адресний простір), власні речі (handles), власні мешканці (потоки). Сусідній процес — це інша квартира. Він не може увійти до вашої квартири без дозволу (IPC механізми), не може бачити ваші речі (ізоляція пам'яті). Якщо в сусідній квартирі пожежа (crash) — ваша квартира залишається неушкодженою.
Потік (Thread) — це одиниця виконання всередині процесу. Якщо процес — це квартира, то потоки — це мешканці цієї квартири. Вони:
Кожен потік має свій власний стек (Stack) — область пам'яті LIFO, де зберігаються локальні змінні, параметри методів та адреси повернення. За замовчуванням у .NET стек одного потоку займає ~1 MB. Саме тому нескінченна рекурсія призводить до StackOverflowException — стек переповнюється.
Крім стеку, кожен потік має свій контекст виконання (Execution Context): набір значень регістрів процесора (RIP — instruction pointer, RSP — stack pointer, RAX, RBX та ін.). Саме ці дані зберігаються та відновлюються при context switch (переключенні контексту).
| Характеристика | Process | Thread | Task |
|---|---|---|---|
| Ізоляція | Повна (власний адресний простір) | Немає (спільна пам'ять) | Немає |
| Вартість створення | Висока (~MB пам'яті + ресурси) | Помірна (~1MB стеку) | Низька (з ThreadPool) |
| Час створення | ~10-100ms | ~1ms | ~мікросекунди |
| Комунікація | Складна (IPC) | Проста (спільна пам'ять) | Проста |
| Аварійне завершення | Не впливає на інші процеси | Може зупинити весь процес | Ізольоване |
| Коли використовувати | Ізоляція, security | CPU-bound задачі | I/O-bound, async |
Сучасний комп'ютер може мати 8, 16 або навіть 64 ядра CPU, але при цьому одночасно виконувати тисячі потоків. Як це можливо?
Відповідь — preemptive multitasking (витісняюча багатозадачність). ОС розбиває процесорний час на маленькі відрізки — quantum (кванти часу, time slice). У Windows один quantum дорівнює приблизно 15-30 мілісекунд (залежить від версії та налаштувань).
Планувальник (Scheduler) — компонент ядра, що вирішує, який потік виконувати в кожний момент:
Context Switch (переключення контексту) — це процес збереження стану поточного потоку та завантаження стану наступного. Що саме зберігається?
Поточні значення всіх регістрів CPU (RIP, RSP, RAX, RBX, RCX, RDX, XMM0-XMM15 та ін.) зберігаються у kernel-структурі KTHREAD (Thread Control Block).
RSP (stack pointer) перемикається на стек ядра, відбувається перехід з user mode в kernel mode.
Планувальник обирає наступний потік для виконання на основі пріоритетів та часу очікування.
Завантажуються регістри нового потоку, відновлюється його адресний простір (якщо потік з іншого процесу), відбувається повернення в user mode.
Один context switch займає ~1-10 мікросекунд. Здається, мало. Але якщо у вас 1000 потоків і вони переключаються 100 разів на секунду — це 100,000 context switches на секунду. Саме тому "більше потоків ≠ швидше" — є оптимальна кількість.
Планувальник Windows використовує 32 рівні пріоритетів (0-31). Потоки з вищим пріоритетом виконуються частіше. Потоки з однаковим пріоритетом чергуються за принципом round-robin.
У .NET ви можете впливати на пріоритет через Thread.Priority, але це лише підказка для ОС, а не гарантія. Детально про це — в темі про потоки.
Ці два терміни часто плутають, хоча вони описують фундаментально різні речі:
Concurrency (Конкурентність) — це властивість системи справлятися з кількома задачами одночасно, не обов'язково виконуючи їх дійсно одночасно. Задачі можуть чергуватися на одному ядрі.
Parallelism (Паралелізм) — це фактичне одночасне виконання кількох задач на різних ядрах CPU.
Аналогія: офіціант у ресторані — конкурентний, але не паралельний. Він приймає замовлення від одного столика, поки клієнти іншого столика чекають. Він справляється з кількома столами, але в будь-який момент обслуговує лише один. Два офіціанти — це вже паралелізм.
async/await забезпечує конкурентність без паралелізму — один потік може обслуговувати тисячі I/O операцій, почергово очікуючи на кожну. Parallel.ForEach забезпечує паралелізм: задачі виконуються на всіх доступних ядрах одночасно.Сучасні CPU мають кілька рівнів "паралельності":
| Рівень | Технологія | Опис |
|---|---|---|
| Physical Cores | AMD/Intel | Незалежні ядра з власними ALU, кешем L1/L2 |
| Hyper-Threading (SMT) | Intel HT / AMD SMT | Кожне фізичне ядро виглядає як 2 логічних |
| NUMA Nodes | Сервери | Групи ядер із спільною RAM; доступ до "чужої" RAM — повільніше |
На 8-ядерному CPU з Hyper-Threading (8C/16T) .NET може ефективно використовувати до 16 паралельних потоків. Більше — context switch з'їсть вигоду.
Закон Амдала (Amdahl's Law) — математична формула, що описує максимальне прискорення програми від паралелізації. Він відповідає на запитання: якщо я додам більше ядер CPU, наскільки швидшим стане мій код?
Формула: S(n) = 1 / ((1 - p) + p/n), де S(n) — прискорення при n ядрах, p — частка коду, що може виконуватися паралельно (0.0 - 1.0), n — кількість ядер.
| Паралельна частка | 2 ядра | 4 ядра | 8 ядер | 16 ядер | ∞ ядер |
|---|---|---|---|---|---|
| 50% | 1.33x | 1.60x | 1.78x | 1.88x | 2.0x |
| 75% | 1.60x | 2.29x | 2.91x | 3.37x | 4.0x |
| 90% | 1.82x | 3.08x | 4.71x | 6.40x | 10.0x |
| 95% | 1.90x | 3.48x | 5.93x | 9.19x | 20.0x |
Саме тому алгоритми паралелізації значну увагу приділяють зменшенню послідовних секцій: ефективна синхронізація, lock-free структури даних, паралельне злиття результатів — все це спрямовано на збільшення p до значень, близьких до 1.0.
Операційна Система
Процес
Потік
Планування та CS
Concurrency vs Parallelism
Закон Амдала
dotnet.exe. Запишіть PID, Working Set та кількість потоків (Threads column).resmon.exe) і на вкладці CPU знайдіть, скільки потоків має Chrome або інший браузер. Поясніть, чому їх так багато.using System.Diagnostics;
var process = Process.GetCurrentProcess();
Console.WriteLine($"Process Name: {process.ProcessName}");
Console.WriteLine($"PID: {process.Id}");
Console.WriteLine($"Threads: {process.Threads.Count}");
Console.WriteLine($"Working Set: {process.WorkingSet64 / 1024 / 1024} MB");
Console.WriteLine($"Started: {process.StartTime}");
На рядку 3 ми отримуємо об'єкт поточного процесу через статичний метод GetCurrentProcess(). Властивість Threads.Count на рядку 5 повертає кількість системних потоків, включно зі службовими .NET Runtime потоками (GC, Finalizer, Threadpool), тому результат буде більшим за 1 навіть для "простої" програми.
Stopwatch та порівнює з теоретичним прискоренням за законом Амдала.Розробіть програму для вимірювання ціни context switch:
Thread.Sleep(0) для примусового переключення.Synchronization Primitives
Глибокий розбір примітивів синхронізації в .NET - lock, Monitor, Mutex, Semaphore, AutoResetEvent, Interlocked та Volatile
Процеси в .NET — API та Запуск
Повний розбір System.Diagnostics.Process — від читання властивостей поточного процесу до запуску зовнішніх програм з перехопленням stdout/stderr в режимі реального часу. Теорія, анатомія та практика з детальними прикладами.