Сервис ходит в базу, у которой лимит 50 соединений. Если под нагрузкой тысяча
горутин одновременно полезет в базу, половина получит ошибку «too many
connections». Нужно пропускать не больше N горутин, а остальных держать в
очереди. Мьютекс тут не годится, он пускает ровно одну.
Для этого есть семафор. Его придумал Эдсгер Дейкстра в 1960-х, и это самый
старый из примитивов синхронизации, которые до сих пор в ходу.
Удобная картинка — парковка на N мест с табло «свободно: 5» на въезде. Машина
въезжает — табло уменьшается. Мест 0 — шлагбаум закрыт, машины ждут в очереди.
Кто-то выехал — табло увеличилось, и одна машина из очереди заезжает.
Формально семафор — это целочисленный счётчик и две атомарные операции:
wait (у Дейкстры P, от голландского proberen, «пробовать»;
встречается также как down и acquire): если счётчик больше нуля,
уменьшить его и пройти; если ноль — уснуть, пока не станет больше;
signal (у Дейкстры V, от verhogen, «увеличивать»; up, release):
увеличить счётчик и, если кто-то спит, разбудить одного.
Атомарность здесь значит, что две горутины не могут одновременно увидеть
«осталось одно место» и обе проехать.
Поведение семафора целиком задаётся начальным значением счётчика.
Начальное значение
Что получается
Пример
1
двоичный семафор, работает как мьютекс
защита критической секции
N > 1
считающий семафор
пул из N соединений, лимит параллелизма
0
семафор-сигнал
поток A ждёт, пока поток B что-то сделает
Двоичный семафор (init = 1). Первый wait уменьшает 1 → 0 и проходит.
Второй видит 0 и засыпает. Первый закончил и сделал signal: 0 → 1, второй
просыпается, уменьшает обратно до 0 и входит. Получился мьютекс.
Семафор-сигнал (init = 0). Главная горутина запустила фоновую и должна
дождаться, пока та загрузит конфиг. Главная делает wait и засыпает (счётчик
0). Фоновая загрузила конфиг и делает signal. Если фоновая успела раньше,
счётчик стал 1, и wait главной пройдёт сразу. Сигнал не потеряется, в этом
отличие от условной переменной.
В учебниках встречаются две конвенции, и их легко спутать.
В исходном описании Дейкстры wait сначала уменьшает счётчик и засыпает, если
тот стал отрицательным. Тогда отрицательное значение -K означает «K потоков
спят в очереди». Например, семафор на 3 места и 6 воркеров: значения пройдут
3 → 2 → 1 → 0 → −1 → −2 → −3, и −3 говорит, что трое ждут.
В большинстве реализаций (и в коде ниже) счётчик ниже нуля не опускается: wait
сначала дожидается, пока значение станет положительным, и только потом
уменьшает. Число ждущих тогда хранится отдельно, в очереди. Снаружи поведение
одинаковое, отличается только то, что показывает счётчик.
Собрать семафор можно из того, что уже было: мьютекс защищает счётчик, условная
переменная хранит спящих.
package mainimport ( "fmt" "sync" "time")// Semaphore — счётчик + мьютекс + условная переменная.type Semaphore struct { mu sync.Mutex cond *sync.Cond value int // сколько свободных единиц; не бывает меньше нуля}func NewSemaphore(n int) *Semaphore { s := &Semaphore{value: n} s.cond = sync.NewCond(&s.mu) return s}func (s *Semaphore) Acquire() { // P, wait s.mu.Lock() for s.value <= 0 { s.cond.Wait() } s.value-- s.mu.Unlock()}func (s *Semaphore) Release() { // V, signal s.mu.Lock() s.value++ s.cond.Signal() s.mu.Unlock()}func main() { sem := NewSemaphore(3) // не больше трёх «запросов к базе» одновременно var ( mu sync.Mutex running int // сколько воркеров сейчас внутри peak int // максимум за всё время wg sync.WaitGroup ) for i := 1; i <= 8; i++ { wg.Add(1) go func(id int) { defer wg.Done() sem.Acquire() defer sem.Release() mu.Lock() running++ if running > peak { peak = running } mu.Unlock() time.Sleep(20 * time.Millisecond) // «запрос к базе» mu.Lock() running-- mu.Unlock() }(i) } wg.Wait() fmt.Println("воркеров: 8, пик одновременно:", peak)}
воркеров: 8, пик одновременно: 3
Восемь воркеров, но внутри никогда не больше трёх: остальные спят в Acquire.
Частая ошибка при написании такого кода — сначала уменьшить, потом ждать
отрицательного значения:
Acquire: value--; while value < 0: wait ← так нельзя Release: value++; signal
Прогоним с value = 0 и двумя ждущими A и B. A: value = −1, спит. B:
value = −2, спит. Кто-то делает Release: value = −1, будит A. A проверяет
value < 0 — да, −1 — и засыпает обратно. Единица ресурса освободилась, но её
никто не взял, и при одном освобождении система встала. Цикл while проверяет
то же, что и уменьшение, поэтому уменьшать надо после ожидания, как в коде выше.
В Linux POSIX-семафоры (sem_wait, sem_post) устроены по той же схеме, что
мьютекс из главы про futex: счётчик лежит в памяти
процесса, sem_wait при положительном счётчике уменьшает его атомарным CAS без
системного вызова, а futex_wait вызывается, только когда надо уснуть.
Интересная деталь про Go: sync.Mutex в рантайме сам построен на семафоре.
Когда горутина не смогла захватить мьютекс, она вызывает внутренний
runtime_SemacquireMutex и паркуется в очереди семафора, а Unlock при
наличии ждущих делает runtime_Semrelease. Мьютекс — частный случай семафора не
только в теории.
Ограниченный буфер на N мест из прошлой главы на семафорах пишется тремя
семафорами:
empty = N — сколько свободных ячеек (их ждёт производитель) full = 0 — сколько заполненных ячеек (их ждёт потребитель) mutex = 1 — защита самого массива ПРОИЗВОДИТЕЛЬ ПОТРЕБИТЕЛЬ wait(empty) wait(full) wait(mutex) wait(mutex) положить элемент взять элемент signal(mutex) signal(mutex) signal(full) signal(empty)
empty и full работают как два табло: производитель занимает свободную ячейку
и объявляет заполненную, потребитель наоборот. В сумме они всегда дают N (если не
считать момент внутри критической секции). Условие «буфер полон» нигде не
проверяется явно — его проверяет сам счётчик empty.
Порядок захвата важен. Поменяем у производителя первые две строки: сначала
wait(mutex), потом wait(empty). Буфер полон, производитель взял мьютекс и
уснул на empty, не отпустив мьютекс. Потребитель, который мог бы освободить
место, первым делом делает wait(mutex) и тоже засыпает. Оба ждут друг друга
вечно — это взаимоблокировка. Правило: сначала семафор
ресурса, потом мьютекс; внутри мьютекса не спать в ожидании.
Ещё одна классическая задача. Есть общая структура (например, конфиг или
кэш), которую часто читают и редко меняют. Читатели друг другу не мешают, им
можно работать одновременно. Писатель должен быть один, и пока он пишет, читать
нельзя.
Readers-writer lock (RW-лок) решает это так: первый вошедший читатель
захватывает «семафор записи», последний вышедший его отпускает. Писатель
захватывает тот же семафор и ждёт, пока читатели не разойдутся.
У простой версии есть ловушка: если читатели идут непрерывным потоком, счётчик
читателей никогда не падает до нуля, и писатель голодает. Поэтому реальные
реализации отдают приоритет писателю. В Go это sync.RWMutex: как только
писатель вызвал Lock, новые RLock блокируются, пока писатель не отработает.
Отсюда практическое следствие: RLock внутри RLock в одной горутине (например,
рекурсивный вызов) может зависнуть, если между ними писатель встал в очередь.
RWMutex выигрывает, когда чтение заметно дольше и чаще записи. На коротких
критических секциях обычный Mutex часто не хуже, потому что учёт читателей
тоже стоит атомарных операций над общей кэш-линией (см.
конкурентные структуры).
Отдельного типа «семафор» в стандартной библиотеке нет. Его роль выполняет
буферизованный канал: ёмкость канала — это число мест.
sem := make(chan struct{}, 50) // не больше 50 одновременноfor _, req := range requests { sem <- struct{}{} // wait: занять место (блокируется, если 50 заняты) go func(r Request) { defer func() { <-sem }() // signal: освободить место handle(r) }(req)}
struct{}{} — пустая структура размером 0 байт: важен сам факт занятого места,
данные не нужны. Обратите внимание, что место занимается до go, а не внутри
горутины. Иначе цикл мгновенно запустит тысячу горутин, и они будут ждать
своей очереди, занимая память, — ограничено будет число работающих, но не
созданных.
Если нужна отмена по context или разный «вес» задач (тяжёлый запрос занимает
три места), есть golang.org/x/sync/semaphore:
sem := semaphore.NewWeighted(50)if err := sem.Acquire(ctx, 1); err != nil { return err // контекст отменён, пока ждали}defer sem.Release(1)
Ещё короче для групп задач: errgroup.Group с методом SetLimit(n) из
golang.org/x/sync/errgroup — внутри тот же семафор. Подробнее про этот приём —
в Go-курсе: паттерны конкурентности,
каналы.
Механизм
Что это
Когда
sync.Mutex
двоичный семафор
одна критическая секция
chan struct{} ёмкости N
считающий семафор
лимит параллелизма
x/sync/semaphore
взвешенный семафор с context
отмена, задачи разного веса
sync.RWMutex
RW-лок с приоритетом писателя
много чтений, редкие записи
Про «владельца» лока. В POSIX-мьютексе с проверкой ошибок отпустить лок может
только тот поток, что его взял, а у семафора владельца нет: signal может
сделать кто угодно, на этом построен семафор-сигнал. В Go sync.Mutex тоже не
привязан к горутине — документация явно разрешает захватить его в одной
горутине и отпустить в другой. Но так почти никогда не пишут: «кто взял, тот и
отпускает» остаётся соглашением, которое делает код читаемым.
Проверь себя· Семафоры
Семафор инициализирован значением 1. Что это даёт?
В классической модели семафора (как в учебниках) значение счётчика равно -3. Что это означает?
В задаче producer/consumer вы поменяли местами wait(mutex) и wait(empty), захватив mutex ПЕРВЫМ. Что произойдёт?
Как в Go идиоматично ограничить число одновременных воркеров до N?
Сколько семафоров нужно для классического решения producer/consumer с ограниченным буфером?
Чем семафор с начальным значением 0 полезен и почему сигнал не потеряется, если signal случится раньше wait?
Что означает значение −3 в конвенции Дейкстры?
Почему у производителя порядок wait(empty) → wait(mutex) нельзя менять?
Семафор на 2 места, пять горутин по очереди делают wait, потом первые две — signal. Выпишите значение счётчика после каждой операции в обеих конвенциях. Сколько горутин спит в конце?
Показать ответы
Ответы. (1) Это ожидание события; счётчик запоминает сигнал, и следующий wait пройдёт сразу. (2) Три потока спят в очереди. (3) Иначе производитель уснёт на полном буфере, держа мьютекс, и потребитель не сможет освободить место. (4) Дейкстра: 2 → 1 → 0 → −1 → −2 → −3, затем −2 и −1 (каждый signal будит одного). Реализация из главы: 2 → 1 → 0, третья, четвёртая и пятая горутины спят при 0; каждый signal поднимает счётчик до 1, разбуженная горутина тут же уменьшает его до 0. В конце спит одна горутина, в обеих конвенциях.
Чем семафор отличается от мьютекса? Счётчик может быть больше 1, значит
внутрь пускается N потоков. Сигнал запоминается в счётчике. Отпустить семафор
может кто угодно — на этом построен семафор-сигнал.
Что означает отрицательное значение? В конвенции Дейкстры — сколько
потоков спит в очереди. В большинстве реализаций счётчик не уходит ниже нуля.
Как ограничить число одновременных запросов? Считающий семафор с init = N.
В Go — канал chan struct{} ёмкости N, x/sync/semaphore или
errgroup.SetLimit.
Producer/consumer на семафорах.empty = N, full = 0, mutex = 1.
Сначала семафор ресурса, потом мьютекс, иначе deadlock.
Как семафор устроен внутри? Мьютекс + условная переменная, проверка в
цикле; в Linux — атомарный счётчик в памяти и futex для сна.
Что такое readers-writer lock и где его ловушка? Много читателей или один
писатель. Без приоритета писателя тот голодает; в Go RWMutex отдаёт
приоритет писателю, поэтому рекурсивный RLock опасен.