1. Вступ
Mutex і lock — це як бариста, що обслуговує одного клієнта за раз. Але що, якщо у нас не одна самотня кавоварка, а цілих три — і одночасно можна готувати три чашки кави?
Наприклад, у вас є кафе з трьома кавомашинами. Клієнти (потоки) підходять, займають вільну кавомашину, готують каву й ідуть. Якщо всі три кавомашини зайняті, решта чекають, доки звільниться хоч одна.
Питання: Як зробити так, щоб одночасно за кавомашинами працювало не більш як три клієнти, а решта чекали своєї черги?
Відповідь: використати семафор!
Що таке семафор?
Семафор — класичний інструмент синхронізації. Якщо lock/Mutex забезпечують схему «один зайшов — інші чекають», то семафор дозволяє одночасно працювати N потокам.
Семафори запропонував Едсгер Дейкстра у 1965 році. Назва походить із морської сигналізації: як прапорці передавали інформацію, так і семафор у коді повідомляє потокам — можна входити чи треба почекати.
Сценарії використання
- Обмеження кількості потоків, що одночасно працюють із ресурсом.
- Ліміт одночасних під’єднань до БД, кількості паралельних запитів, важких завдань.
2. Огляд класів: Semaphore і SemaphoreSlim
Semaphore
- Важкий клас, використовує обʼєкти ядра ОС (kernel objects).
- Підтримує синхронізацію між потоками різних процесів.
- Можна задати імʼя й використовувати спільно між процесами.
SemaphoreSlim
- Полегшена версія, працює лише в межах одного процесу.
- Швидша й ощадніша щодо ресурсів.
- Практично завжди кращий вибір, якщо міжпроцесна синхронізація не потрібна.
Аналогія: похідний рюкзак (SemaphoreSlim) проти великої валізи (Semaphore). Подорожуєте налегко — беріть рюкзак.
Порівняльна таблиця
| Клас | Міжпроцесний | Швидкодія | Рекомендується |
|---|---|---|---|
|
Так | Повільніше | Коли потрібна синхронізація між процесами |
|
Ні | Швидше | У 99 % випадків — у межах одного процесу |
Основні методи та властивості семафора
Основні параметри
- InitialCount — початкова кількість дозволів.
- MaxCount — максимальна кількість одночасно виданих дозволів.
Ключові методи
- Wait() або WaitAsync() — отримати доступ (зайняти дозвіл).
- Release() — звільнити дозвіл.
Як це працює
Якщо під час виклику Wait() дозволів немає, потік блокується і чекає, доки хтось викличе Release(). Після звільнення один із тих, хто чекає, продовжить виконання.
3. Перший практичний приклад
Додамо в консольний застосунок «паркінг» на 3 місця й спробуємо запустити 10 потоків.
using System;
using System.Threading;
class Program
{
// Семафор з 3 дозволами (3 паркомісця)
static SemaphoreSlim parking = new SemaphoreSlim(3);
static void Main()
{
for (int i = 1; i <= 10; i++)
{
int carNumber = i;
new Thread(() =>
{
Console.WriteLine($"Машина #{carNumber} намагається припаркуватися...");
parking.Wait(); // Очікує вільне місце
Console.WriteLine($"Машина #{carNumber} заїхала на паркінг!");
Thread.Sleep(2000); // Стоїмо на паркінгу 2 секунди
Console.WriteLine($"Машина #{carNumber} виїжджає з паркінгу.");
parking.Release(); // Звільняємо місце
}).Start();
}
}
}
- Одночасно «припаркуються» лише три машини.
- Решта чекатиме звільнення місця.
- Виведення буде перемішаним — це нормально для багатопоточності.
4. Семафор як обмежувач навантаження
Обмежимо кількість одночасно виконуваних важких завдань (наприклад, завантажень) до 5.
static SemaphoreSlim semaphore = new SemaphoreSlim(5); // максимум 5 одночасних завантажень
static void DownloadFile(int fileId)
{
semaphore.Wait();
try
{
Console.WriteLine($"--> Починаю завантаження файлу {fileId}");
Thread.Sleep(1000 + fileId * 100); // Завантажуємо (імітація)
Console.WriteLine($"<-- Файл {fileId} завантажено");
}
finally
{
semaphore.Release();
}
}
static void Main()
{
for (int i = 1; i <= 12; i++)
{
int localId = i;
new Thread(() => DownloadFile(localId)).Start();
}
}
Важливий момент: Wait() ставимо перед блоком try, а Release() — у finally. Так дозвіл точно звільниться навіть у разі винятку.
5. Wait(int millisecondsTimeout) і асинхронні методи
Можна чекати лише обмежений час:
if (semaphore.Wait(500))
{
// Вдалося зайняти дозвіл за пів секунди!
}
else
{
// За 500 мс не дочекалися — перервалися
}
У сучасних застосунках (наприклад, ASP.NET) використовуйте асинхронний варіант: await semaphore.WaitAsync(). Це не блокує потік виконання, поки очікується дозвіл.
Примітка: в асинхронному коді використовуйте саме SemaphoreSlim і його WaitAsync, інакше можна отримати неочікувані блокування типу «deadlock».
6. Приклади неправильної та правильної роботи
Поширена помилка — забути викликати Release(): дозволи «втрачаються», й усе зупиняється.
Погано
static void SomeWork()
{
semaphore.Wait();
// ... обробка, а Release забули!
}
Добре
static void SomeWork()
{
semaphore.Wait();
try
{
// обробка
}
finally
{
semaphore.Release();
}
}
Асинхронний варіант
static async Task SomeAsyncWork()
{
await semaphore.WaitAsync();
try
{
// асинхронна обробка
}
finally
{
semaphore.Release();
}
}
7. Внутрішній устрій семафора (пояснення «на пальцях»)
Семафор — це лічильник. Wait() зменшує його на 1. Якщо він був > 0 — потік проходить; якщо 0 — потік чекає. Release() збільшує лічильник і пробуджує тих, хто чекає.
+-------------------------------+
| Семафор (лічильник = 3) |
+-------------------------------+
| [ ] [ ] [ ] | <--- Дозволи
+----+----+----+----------------+
| | |
Потік Потік Потік
8. Корисні нюанси
Відмінності від інших примітивів
- lock / Monitor / Mutex — пропускають лише один потік (ексклюзивний доступ).
- Semaphore/SemaphoreSlim — пропускають обмежену кількість потоків — N — одночасно.
Семафор не привʼязаний до «власника»: дозвіл може звільняти будь‑який потік. Це особливість, а не помилка.
Застосування в реальному житті
- Ліміт паралельних під’єднань до сервісу або БД.
- Пул: не більше N потоків на ресурс.
- Обмеження одночасно оброблюваних веб‑запитів.
- Ліміт операцій читання/запису, щоб захиститися від перевантаження.
- Обмеження викликів зовнішнього API.
Приклад помилки (Release більше, ніж Wait)
var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // Помилка! Лічильник став 3, перевищуючи MaxCount — буде кинуто SemaphoreFullException.
Тут буде SemaphoreFullException: лічильник перевищив максимум.
Відмінності Semaphore і SemaphoreSlim
- SemaphoreSlim — у межах процесу, швидший і простіший (використовуйте майже завжди).
- Semaphore — потрібен для міжпроцесної синхронізації (рідкісний сценарій).
Навіщо знати про семафори?
Класичне запитання на співбесіді: «Як обмежити кількість потоків, що працюють із ресурсом?» — правильна відповідь: семафор.
- lock — 1 потік.
- Semaphore/SemaphoreSlim — N потоків.
9. Типові помилки та особливості використання семафорів
Помилка № 1: забувають викликати Release(). Якщо потік зайняв дозвіл (Wait() або WaitAsync()), але не звільнив його, інші чекатимуть безкінечно — застосунок «зависне».
Помилка № 2: викликають Release() більше разів, ніж було Wait(). З’являються «зайві» дозволи. Для Semaphore це призведе до SemaphoreFullException і порушеної логіки доступу.
Помилка № 3: змішують різні механізми синхронізації. В одному місці — lock, в іншому — семафор для того самого ресурсу. Це підвищує ризик взаємних блокувань (deadlock).
Помилка № 4: використовують Semaphore в асинхронному коді. Класичний семафор не працює коректно з async/await. Для асинхронних сценаріїв використовуйте SemaphoreSlim і WaitAsync().
Помилка № 5: некоректно задають initialCount і maxCount. За невдалого вибору значень обмеження можна обійти, і до ресурсу проходитиме більше потоків, ніж задумано.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ