JavaRush /Курси /C# SELF /Семафори: Semaphore...

Семафори: Semaphore і SemaphoreSlim

C# SELF
Рівень 56 , Лекція 3
Відкрита

1. Вступ

Mutex і lock — це як бариста, що обслуговує одного клієнта за раз. Але що, якщо у нас не одна самотня кавоварка, а цілих три — і одночасно можна готувати три чашки кави?

Наприклад, у вас є кафе з трьома кавомашинами. Клієнти (потоки) підходять, займають вільну кавомашину, готують каву й ідуть. Якщо всі три кавомашини зайняті, решта чекають, доки звільниться хоч одна.

Питання: Як зробити так, щоб одночасно за кавомашинами працювало не більш як три клієнти, а решта чекали своєї черги?
Відповідь: використати семафор!

Що таке семафор?

Семафор — класичний інструмент синхронізації. Якщо lock/Mutex забезпечують схему «один зайшов — інші чекають», то семафор дозволяє одночасно працювати N потокам.

Семафори запропонував Едсгер Дейкстра у 1965 році. Назва походить із морської сигналізації: як прапорці передавали інформацію, так і семафор у коді повідомляє потокам — можна входити чи треба почекати.

Сценарії використання

  • Обмеження кількості потоків, що одночасно працюють із ресурсом.
  • Ліміт одночасних під’єднань до БД, кількості паралельних запитів, важких завдань.

2. Огляд класів: Semaphore і SemaphoreSlim

Semaphore

  • Важкий клас, використовує обʼєкти ядра ОС (kernel objects).
  • Підтримує синхронізацію між потоками різних процесів.
  • Можна задати імʼя й використовувати спільно між процесами.

SemaphoreSlim

  • Полегшена версія, працює лише в межах одного процесу.
  • Швидша й ощадніша щодо ресурсів.
  • Практично завжди кращий вибір, якщо міжпроцесна синхронізація не потрібна.

Аналогія: похідний рюкзак (SemaphoreSlim) проти великої валізи (Semaphore). Подорожуєте налегко — беріть рюкзак.

Порівняльна таблиця

Клас Міжпроцесний Швидкодія Рекомендується
Semaphore
Так Повільніше Коли потрібна синхронізація між процесами
SemaphoreSlim
Ні Швидше У 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/SemaphoreSlimN потоків.

9. Типові помилки та особливості використання семафорів

Помилка № 1: забувають викликати Release(). Якщо потік зайняв дозвіл (Wait() або WaitAsync()), але не звільнив його, інші чекатимуть безкінечно — застосунок «зависне».

Помилка № 2: викликають Release() більше разів, ніж було Wait(). З’являються «зайві» дозволи. Для Semaphore це призведе до SemaphoreFullException і порушеної логіки доступу.

Помилка № 3: змішують різні механізми синхронізації. В одному місці — lock, в іншому — семафор для того самого ресурсу. Це підвищує ризик взаємних блокувань (deadlock).

Помилка № 4: використовують Semaphore в асинхронному коді. Класичний семафор не працює коректно з async/await. Для асинхронних сценаріїв використовуйте SemaphoreSlim і WaitAsync().

Помилка № 5: некоректно задають initialCount і maxCount. За невдалого вибору значень обмеження можна обійти, і до ресурсу проходитиме більше потоків, ніж задумано.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ