GraphLMS

ОС
Начать

ОС · Конкурентность · 13 мин

Семафоры

Что такое семафор простыми словами

Представь подземную парковку, у которой ровно N мест и один шлагбаум на въезде. Перед шлагбаумом висит электронное табло «свободно: 5». Машина подъезжает, табло уменьшается на единицу, шлагбаум поднимается, машина заезжает. Когда свободных мест 0 — шлагбаум не поднимается, и новые машины стоят в очереди перед въездом, ждут. Как только кто-то выезжает, табло увеличивается на единицу, и первая машина из очереди заезжает.

Вот это табло со шлагбаумом и очередью — и есть семафор. Формально это счётчик с двумя атомарными операциями:

  • wait (исторически зовётся P, от голландского proberen — «пробовать»): уменьшить счётчик на 1. Если после уменьшения он стал отрицательным — заблокироваться (встать в очередь и уснуть).
  • signal (исторически V, от verhogen — «увеличивать»): увеличить счётчик на 1. Если кто-то спал в очереди — разбудить одного.

«Атомарные» значит неделимые: операция выполняется целиком, без вмешательства других потоков (см. атомарные операции в Go). Никто не сможет заглянуть в момент, когда табло «наполовину обновилось».

Семафор придумал Эдсгер Дейкстра в 1960-х. Слова P и V — его, и их до сих пор используют в учебниках.

        Семафор = парковка с N местами и шлагбаумом
 
   очередь машин          шлагбаум        парковка (N=3)
   ┌───┐┌───┐┌───┐         ┌──┐        ┌────┬────┬────┐
   │ C ││ B ││ A │ ──────► │▓▓│ ─────► │ ?? │ ?? │ ?? │
   └───┘└───┘└───┘         └──┘        └────┴────┴────┘
   (ждут, спят)        wait: счётчик--      signal: счётчик++
                       если <0 — стоп       разбудить ждущего
 
   табло «свободно»: 3 → 2 → 1 → 0 → (новые встают в очередь)

Что нарисовано: машины — это потоки/горутины, парковочные места — единицы ресурса, табло — значение счётчика, очередь — спящие потоки, которых разбудит signal.

Значение счётчика: что означает число

Главная интуиция, которую стоит запомнить:

  • Положительное значение S означает «есть S свободных единиц ресурса, столько потоков ещё пройдут wait без блокировки».
  • Ноль — ресурс исчерпан, следующий wait уснёт.
  • Отрицательное значение -K означает «K потоков прямо сейчас спят в очереди и ждут signal».

Начальное значение задаёт смысл семафора. От него зависит всё поведение.

Начальное значение Что получается Зачем
1 бинарный семафор = мьютекс взаимное исключение, 1 поток в критсекции
N (N>1) считающий семафор пускать максимум N потоков сразу (пул, лимит)
0 семафор-«ожидание» один поток ждёт сигнала от другого (синхронизация)

Бинарный семафор = мьютекс

Если инициализировать семафор значением 1, получится замок. Первый поток делает wait (счётчик 10, проходит), заходит в критическую секцию. Второй поток делает wait (счётчик 0-1, блокируется). Когда первый выходит и зовёт signal (счётчик -10), второй просыпается.

        Бинарный семафор (init=1) как мьютекс
 
 поток A:  wait()  ── S:1→0 ──► [критическая секция] ── signal() ─► S:0→1

 поток B:  wait()  ── S:0→-1 ─► спит ───┘ просыпается по signal A

                                   (S: -1→0)

Что нарисовано: счётчик ходит между 1 и 0 при одиночном доступе; когда заходит второй, уходит в минус и поток засыпает до signal.

В Go ты обычно пишешь sync.Mutex, а не «бинарный семафор». Это языковой слой поверх той же идеи. Но есть важная разница в семантике: у мьютекса есть владелец (кто залочил — тот и должен разлочить), а у семафора владельца нет — signal может позвать совсем другой поток. Именно поэтому семафор удобен для сигнализации между потоками, а не только для защиты данных.

Считающий семафор: ограничить число одновременных

Вот где семафор сияет и мьютекс бессилен. Задача: «не больше N запросов к базе одновременно», «максимум 10 параллельных воркеров», «пул из 5 соединений». Мьютекс пускает строго одного. Семафор с init=N пускает ровно N.

     Лимит параллелизма: семафор init=3, воркеров 6
 
   sem = 3
   W1 wait → sem 2 ─► [работает]──signal→ sem растёт
   W2 wait → sem 1 ─► [работает]
   W3 wait → sem 0 ─► [работает]
   W4 wait → sem -1 ─► СПИТ ───┐
   W5 wait → sem -2 ─► СПИТ    │ ждут, пока кто-то из W1..W3
   W6 wait → sem -3 ─► СПИТ ───┘ позовёт signal
 
   как только W1 закончил: signal → sem -2, просыпается W4

Что нарисовано: три «слота» заняты сразу, остальные три воркера спят в очереди; каждый завершившийся воркер освобождает слот ровно для одного ждущего.

Producer / consumer через семафоры

Классическая задача: producer кладёт элементы в буфер ограниченного размера, consumer забирает. Нужно: producer ждёт, если буфер полон; consumer ждёт, если буфер пуст. Это решается тремя семафорами.

        Ограниченный буфер (capacity = N)
 
   empty = N   ← сколько ещё пустых ячеек (для producer)
   full  = 0   ← сколько заполненных ячеек (для consumer)
   mutex = 1   ← защита самого буфера от гонки
 
   PRODUCER:                    CONSUMER:
   wait(empty)  // есть место?  wait(full)   // есть данные?
   wait(mutex)  // захват       wait(mutex)  // захват
     put(item)                    item = get()
   signal(mutex)                signal(mutex)
   signal(full) // +1 данных    signal(empty)// +1 места

Идея: empty и full — это «зеркальные» счётчики, в сумме всегда дают N. empty считает свободные места (producer уменьшает, кладя; consumer увеличивает, забирая), full — наоборот. Третий, бинарный mutex, защищает сам массив, чтобы два потока не писали в одну ячейку.

Внимание к порядку: сначала wait(empty/full), потом wait(mutex). Если поменять местами — поток сначала захватит mutex, потом уснёт на wait(empty), держа замок, и никто не сможет ни положить, ни забрать. Это классический deadlock (см. Взаимные блокировки): захват ресурсов в неправильном порядке. Запомни правило: «считающий семафор снаружи, mutex внутри».

Как семафор устроен под капотом

Семафор не магия — он строится из лока и условной переменной (см. Условные переменные). Условная переменная — это механизм «уснуть до наступления условия и быть разбуженным». Псевдокод:

type Semaphore:
    value int
    lock  Mutex
    cond  CondVar   // очередь спящих
 
func wait(s):
    lock(s.lock)
    s.value--
    while s.value < 0:        // пока единиц нет
        cond_wait(s.cond, s.lock)   // уснуть, ОТПУСТИВ lock
    unlock(s.lock)
 
func signal(s):
    lock(s.lock)
    s.value++
    cond_signal(s.cond)        // разбудить одного ждущего
    unlock(s.lock)

Почему while, а не if? Проснувшийся поток должен перепроверить условие: между «разбудили» и «реально захватил lock» мог влезть другой поток и снова утащить ресурс. Это правило «всегда проверяй условие в цикле» — общее для условных переменных.

А на самом нижнем уровне, в ядре Linux, эта «очередь спящих» реализована через futex (fast userspace mutex): пока ресурс свободен, всё происходит в user-space на атомарных операциях (быстро, без вызова ядра); и только когда надо реально уснуть/разбудить — делается системный вызов futex(). Об этой оптимизации «не ходить в ядро без нужды» — глава Цена системного вызова.

   Быстрый путь vs медленный путь (futex)
 
   ресурс свободен:  wait/signal = атомарный CAS в user-space  ⚡ наносекунды
   надо уснуть:      syscall futex(WAIT) ─► ядро усыпляет поток 🐢 микросекунды
   надо разбудить:   syscall futex(WAKE) ─► ядро будит ждущего

Что нарисовано: пока конкуренции нет, семафор работает на лету без захода в ядро; цена ядра платится только при реальной блокировке.

Семафоры в Go

В Go нет встроенного типа «семафор» как отдельной сущности — он не нужен, потому что буферизированный канал и есть готовый считающий семафор. Ёмкость канала = начальное значение счётчика.

// Семафор на N через канал. Отправка = wait, приём = signal.
sem := make(chan struct{}, N)   // N "слотов"
 
sem <- struct{}{}   // wait: занять слот (блокируется, если буфер полон)
// ... работа ...
<-sem               // signal: освободить слот

struct{}{} — пустая структура, занимает 0 байт: нам важен сам факт занятости слота, а не данные. Когда буфер заполнен (N элементов), отправка sem <- ... блокирует горутину — ровно как wait на исчерпанном семафоре.

Для более богатого API (взвешенные захваты, отмена через context) есть пакет golang.org/x/sync/semaphore:

sem := semaphore.NewWeighted(int64(N))
if err := sem.Acquire(ctx, 1); err != nil { return err } // wait + context
defer sem.Release(1)                                       // signal

Подробнее про семафор как идиому ограничения параллелизма — в главе курса Go Паттерны конкурентности. А про сами каналы и selectканалы и select.

Механизм Это Когда
sync.Mutex бинарный семафор с владельцем защита одной критсекции
chan struct{} ёмкости N считающий семафор лимит параллелизма, пул
x/sync/semaphore взвешенный семафор + context отмена, разные «веса» задач
Проверь себя· Семафоры

Семафор инициализирован значением 1. Что это даёт?

Что означает значение счётчика семафора, равное -3?

В задаче producer/consumer вы поменяли местами wait(mutex) и wait(empty), захватив mutex ПЕРВЫМ. Что произойдёт?

Как в Go идиоматично ограничить число одновременных воркеров до N?

Сколько семафоров нужно для классического решения producer/consumer с ограниченным буфером?

Из каких примитивов строится семафор под капотом и что используется в ядре Linux?

Что спрашивают на собесе

  • Чем семафор отличается от мьютекса? Мьютекс — это частный случай (бинарный семафор, init=1) и у него есть владелец: разлочить должен тот же поток. Семафор владельца не имеет, signal может позвать кто угодно, и счётчик может быть больше 1 — это позволяет пускать N потоков сразу.
  • Что означает отрицательное значение счётчика? Сколько потоков сейчас спят в очереди и ждут signal. Положительное — сколько ещё свободных единиц.
  • Как реализовать ограничение «не более N одновременных запросов»? Считающий семафор с init=N; в Go — буферизированный канал chan struct{} ёмкости N или x/sync/semaphore.
  • Задача producer/consumer на семафорах. Три семафора: empty=N, full=0, mutex=1. Важен порядок захвата: считающий снаружи, mutex внутри — иначе deadlock.
  • Почему в реализации семафора проверка условия в while, а не if? Из-за возможного перехвата ресурса между пробуждением и захватом лока; проснувшийся обязан перепроверить условие.
  • Как семафор работает под капотом? Лок + условная переменная; в Linux — через futex с быстрым путём в user-space и заходом в ядро только при реальной блокировке.
Условные переменныеВзаимоблокировки: теория и борьба