1. Вступ
Багатопоточність — це як паралельна робота кількох працівників в офісі: один друкує документи, інший телефонує клієнтові, третій варить каву (і, звісно, всі вони — програмісти). Якби в нас був лише один працівник, він робив би все по черзі, і офіс захлинувся б від нудьги й черги за кавою. У програмуванні ситуація аналогічна: однопотокова програма може виконувати лише одне завдання за раз.
Уявімо, що наш застосунок виконує тривалу операцію, наприклад, завантажує файл з інтернету або обчислює величезну таблицю. Усе інше в цей момент «заморожується» — кнопки не натискаються, анімація не рухається, фраза «не відповідає» зʼявляється у спливаючому вікні.
Багатопоточність дає змогу застосунку робити кілька речей одночасно: інтерфейс лишається відгукливим, операції виконуються паралельно, і ми не зриваємося на монолог у стилі «Компʼютере, ти знову завис?!».
Базові поняття й термінологія
Перш ніж пірнати з головою, розберімося, що таке потік (thread) і чим він відрізняється від процесу.
- Процес (Process): Самостійна програма з власним адресним простором, змінними, ресурсами. Наприклад, кожен запущений застосунок у Windows — окремий процес.
- Потік (Thread): Одиниця виконання всередині процесу. Процес може містити один або кілька потоків, що працюють з одними й тими самими ресурсами (памʼяттю, змінними).
Чому багатопоточність викликає стільки питань?
Бо потоки можуть стартувати будь-якої миті, перетасовувати дані, перебивати одне одного і влаштувати безлад у памʼяті, якщо не стежити за порядком. Якщо здається, що це схоже на дитсадок без вихователя — абсолютно в яблучко! Дисципліна й охайність у роботі з потоками — основа написання надійних багатопотокових програм.
2. Історія та роль багатопоточності в C# і .NET
Раніше типові програми були простими й часто однопотоковими. Зі зростанням вимог до продуктивності, появою багатоядерних процесорів і потребою створювати відгукливі застосунки без зависань інтерфейсу у .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 разів.
А от і ні! Запустіть кілька разів — побачите різні значення: 1 782, 1 935, 1 999…
Чому? Це класична проблема Race Condition — потоки «перехоплюють» одне в одного керування між читанням -> збільшенням -> записом, і частина інкрементів втрачається.
5. Корисні нюанси
Як потоки взаємодіють із інтерфейсом?
У сучасних настільних застосунках (WinForms/WPF/MAUI) основний потік обслуговує графічний інтерфейс користувача. Усі дії користувача (клацання, введення) — у цьому потоці. Фонові завдання мають виконуватися в інших потоках, але водночас за правилами не можна безпосередньо змінювати інтерфейс з інших потоків. Це зроблено, щоб уникнути хаосу.
У консолі такого обмеження немає, можна виводити через Console.WriteLine з будь-якого потоку. Утім, у реальних застосунках без правильної синхронізації інтерфейс може працювати некоректно.
Послідовність і паралельність
Невелика таблиця для закріплення різниці.
| Однопотоковий код | Багатопотоковий код |
|---|---|
| Виконує завдання по черзі | Завдання можуть виконуватися одночасно |
| UI «зависає» під час довгих задач | UI лишається відгукливим |
| Просто читати й писати змінні | Потребує контролю доступу до даних |
| Налагодження просте | Налагодження може бути складним |
Важливі моменти під час роботи з потоками
- Спільні змінні — спільний ризик. Як уже показано вище, якщо кілька потоків використовують спільну змінну — без синхронізації можливі помилки! (Детальніше — в наступних лекціях.)
- Очікування потоку через Join(). Метод Join() дає змогу «призупинити» основний потік до завершення фонового. Використовуйте його, якщо треба дочекатися результату.
- Потік не можна запустити двічі. Після виконання потоку його не можна знову запустити — доведеться створювати новий обʼєкт Thread.
- Завершення потоку. Потік завершується, коли закінчується виконання його методу. Примусово «вбивати» потоки — погана ідея (метод Abort() давно вважається шкідливим і застарілим).
Для чого потрібна багатопоточність на практиці?
- UI-застосунки: не заморожувати інтерфейс під час фонового завантаження або обчислень.
- Сервери та служби: одночасно обробляти запити багатьох клієнтів.
- Високопродуктивні обчислення: ділити велику задачу (наприклад, обробку мільйонів записів) на частини й виконувати їх паралельно.
- Ігри, симуляції, обробка даних: моделювати складні системи без втрати продуктивності.
Проблеми багатопоточності
Багатопоточність дає потужність, але й додає складнощів:
- Race Condition (стан гонки): коли кілька потоків одночасно змінюють одні й ті самі дані, результат залежить від порядку інструкцій, який непередбачуваний.
- Deadlock (взаємне блокування): потоки чекають одне одного, і ніхто не може продовжити роботу.
- Starvation (голодування): один із потоків постійно «обділений» доступом до ресурсу.
У цій лекції ми лише згадали основні проблеми багатопоточності, а в наступних навчимося їх розпізнавати й уникати. Поки що — просто запамʼятайте: якщо все «зависло», винні можуть бути не лише помилки, а й потоки, які «влаштували вечірку».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ