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
{
// Семaфор с 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. При неправильном выборе значений ограничение можно обойти, и к ресурсу пройдут больше потоков, чем задумано.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ