В данной статье мы рассмотрим в различные способы реализации неблокирующизх семафоров в Go. Рассмотрим как преимущества той или иной реализации, так и недостатки.
Что такое неблокирующий семафор?
Семафор — это механизм синзронизации, которой ограничивает доступ к общему ресурсу. В отличае от мьютекса, семафор пеализован таким образом, чтобы ограничить количество одновременных пользователей ресурса.
Неблокирующий семафор отличается тем, что попытка захвата ресурса не блокирует исполняющую горутину. Горутина либо немедленно получает ресурс, либо получает отказ.
Зачем нужны неблокирующие семафоры?
- Предотвращение взаимоблокировок (deadlocks)
- Контроль над параллелизмом без остановки выполнения
- Реализация механизмов ограничения (rate limiting)
- Управление пулами ресурсов с немедленной обратной связью
Общие требования к семафору:
- Структура реализующая семафор, должна иметь неблокирующую функцию попытки взятия семафора, возвращающую логическое значение результата с информацией об успешности операции.
- Функцию освобождения семафора.
Реализация 1: Буферизованный канал
Самая простая реализация неблокирующего семафора основана на использовании буфферизированного канала и оператора select, при помощи которого мы и достигаем эффекта неблокируемости исполняющей горутины.
Ниже представлена реализация:
package main
type ChannelSemaphore struct {
sem chan struct{}
}
func NewSemaphoreSemaphore(capacity int) *ChannelSemaphore {
return &ChannelSemaphore{
sem: make(chan struct{}, capacity),
}
}
func (s *ChannelSemaphore) TryAcquire() bool {
select {
case s.sem <- struct{}{}:
return true
default:
return false
}
}
func (s *ChannelSemaphore) Release() {
select {
case <-s.sem:
default:
// Игнорируем, если семафор уже пустой
}
}
func (s *ChannelSemaphore) Available() int {
return cap(s.sem) - len(s.sem)
}
func (s *ChannelSemaphore) Capacity() int {
return cap(s.sem)
}
Плюсы
✅ Идиоматичный подход — использует стандартные конструкции Go
✅ Простота реализации — минимальный код, легко понять
✅ Встроенная потокобезопасность — каналы в Go по своей природе безопасны для конкурентного использования
✅ Интеграция с select — легко комбинировать с другими каналами
✅ Нулевое состояние полезно — нулевой канал блокирует все операции
Минусы
❌ Ограниченная функциональность — базовый вариант без расширенных возможностей
❌ Нет информации о времени ожидания в стандартной реализации
❌ Потенциальные утечки горутин при неправильном использовании
❌ Менее гибкий по сравнению с другими подходами
Измерим производительность:
package main
import (
"testing"
"time"
)
func BenchmarkChannelSemaphore(b *testing.B) {
sem := NewChannelSemaphore(10)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if sem.TryAcquire() {
// Имитация работы
time.Sleep(1 * time.Nanosecond)
sem.Release()
}
}
})
}
Запустим бенчмарк следующей командой:
go test -bench=BenchmarkChannelSemaphore -benchmem
Получим следующий результат:
cpu: AMD Ryzen 7 7700 8-Core Processor
BenchmarkChannelSemaphore-16 | 21574400 | 56.89 ns/op | 0 B/op | 0 allocs/op
PASS
ok Go 1.287s
Неплохо! Мы обошлись вообще без аллокаций памяти и в среднем отработали за 55 наносекунд. Рассмотрим другие подходы и сравним производительность.
Реализация 2: Атомарные операции
package main
import (
"sync/atomic"
)
type AtomicSemaphore struct {
capacity int64
current int64
}
func NewAtomicSemaphore(capacity int64) *AtomicSemaphore {
return &AtomicSemaphore{
capacity: capacity,
current: 0,
}
}
func (s *AtomicSemaphore) TryAcquire() bool {
current := atomic.LoadInt64(&s.current)
if current >= s.capacity {
return false
}
return atomic.CompareAndSwapInt64(&s.current, current, current+1)
}
func (s *AtomicSemaphore) Release() {
newVal := atomic.AddInt64(&s.current, -1)
if newVal < 0 {
// Корректируем если ушли в отрицательные значения
atomic.CompareAndSwapInt64(&s.current, newVal, 0)
}
}
func (s *AtomicSemaphore) Available() int64 {
return s.capacity - atomic.LoadInt64(&s.current)
}
func (s *AtomicSemaphore) ForceRelease() {
s.Release()
}
Выше представлен не блокирующий семафор на атомарных операциях.
Принцип работы:
- capacity — максимальное число одновременных захватов
- current — текущее число захватов (атомарный счётчик)
Методы:
- TryAcquire() — неблокирующий захват:
- Читает current, проверяет лимит
- Возвращает true при успехе, false при неудаче
- Release() — неблокирующее освобождение:
- Атомарно уменьшает счётчик (AddInt64)
- Корректирует значение, если оно стало отрицательным
Измерим производительность и приятно удивимся:
cpu: AMD Ryzen 7 7700 8-Core Processor
BenchmarkChannelSemaphore-16 | 614230852 | 2.020 ns/op | 0 B/op | 0 allocs/op
PASS
ok Go 1.443s
Итого, семафор на атомартных операциях показал себя в 27.5 раз быстрее. Но у него есть свои минусы.
Плюсы
✅ Максимальная производительность — атомарные операции очень быстрые
✅ Минимальное использование памяти — только два int64
✅ Предсказуемое поведение — нет скрытых состояний
✅ Расширяемость — легко добавить дополнительные функции
✅ Отсутствие блокировок — только атомарные CAS операции
Минусы
❌ Сложнее в реализации — нужно правильно работать с CAS
❌ Нет интеграции с контекстами — сложнее отменять операции
❌ Риск голодания — при высокой конкуренции некоторые горутины могут долго не получать ресурс
К сожалению, таких наивных реализаций недостаточно в продакшн-системах. Нашему семафору нехватает работы с контекстами и таймаутами для более гибкого управления работы системы.
Когда использовать контекст:
- HTTP обработчики — для отмены при разрыве соединения
- Фоновые задачи — для graceful shutdown
- Распределенные системы — для учета сетевых задержек
- Пользовательские интерфейсы — для отмены по требованию пользователя
- Пайплайны обработки — для каскадной отмена цепочек
Когда контекст не нужен:
- Внутренние синхронизации с гарантированно коротким временем выполнения
- Критические секции где блокировка обязательна
- Очень высоконагруженные участки где накладные расходы неприемлемы
Ниже приведена реализация lock-free семафора, использующего контексты и таймауты, построенного на атомарных операциях.
package main
import (
"context"
"errors"
"sync/atomic"
"time"
)
var (
// ErrTimeout возникает при истечении таймаута при попытке захвата семафора
ErrTimeout = errors.New("semaphore: timeout exceeded")
// ErrContextCancelled возникает при отмене контекста
ErrContextCancelled = errors.New("semaphore: context cancelled")
// ErrCapacityExceeded возникает при попытке захвата когда семафор переполнен
ErrCapacityExceeded = errors.New("semaphore: capacity exceeded")
)
type AtomicSemaphore struct {
capacity int64
current int64
}
func NewAtomicSemaphore(capacity int64) *AtomicSemaphore {
return &AtomicSemaphore{
capacity: capacity,
current: 0,
}
}
// TryAcquire пытается захватить семафор без блокировки.
// Возвращает true при успехе, false если семафор переполнен.
func (s *AtomicSemaphore) TryAcquire() bool {
current := atomic.LoadInt64(&s.current)
if current >= s.capacity {
return false
}
// Одна попытка CAS - неблокирующий подход
return atomic.CompareAndSwapInt64(&s.current, current, current+1)
}
// Acquire блокирующе захватывает семафор с повторными попытками.
// Использует экспоненциальный backoff для снижения contention.
func (s *AtomicSemaphore) Acquire() {
backoff := time.Nanosecond
maxBackoff := 10 * time.Microsecond
for {
if s.TryAcquire() {
return
}
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// AcquireWithTimeout пытается захватить семафор в течение указанного времени.
// Возвращает ошибку ErrTimeout если таймаут истёк.
func (s *AtomicSemaphore) AcquireWithTimeout(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
backoff := time.Nanosecond
maxBackoff := 10 * time.Microsecond
for {
if time.Now().After(deadline) {
return ErrTimeout
}
if s.TryAcquire() {
return nil
}
time.Sleep(backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// AcquireWithContext пытается захватить семафор с поддержкой контекста.
// Возвращает ошибку если контекст отменён или истёк таймаут.
func (s *AtomicSemaphore) AcquireWithContext(ctx context.Context) error {
backoff := time.Nanosecond
maxBackoff := 10 * time.Microsecond
for {
// Проверяем контекст перед попыткой
select {
case <-ctx.Done():
return ErrContextCancelled
default:
}
if s.TryAcquire() {
return nil
}
// Проверяем контекст перед ожиданием
select {
case <-ctx.Done():
return ErrContextCancelled
case <-time.After(backoff):
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
func (s *AtomicSemaphore) Release() {
// Неблокирующий Release: используем AddInt64 и корректируем при необходимости
newVal := atomic.AddInt64(&s.current, -1)
if newVal < 0 {
// Корректируем если ушли в отрицательные значения
atomic.CompareAndSwapInt64(&s.current, newVal, 0)
}
}
// Available возвращает количество доступных слотов в семафоре.
func (s *AtomicSemaphore) Available() int64 {
return s.capacity - atomic.LoadInt64(&s.current)
}
// Capacity возвращает максимальную ёмкость семафора.
func (s *AtomicSemaphore) Capacity() int64 {
return s.capacity
}
// Current возвращает текущее количество захваченных слотов.
func (s *AtomicSemaphore) Current() int64 {
return atomic.LoadInt64(&s.current)
}
func (s *AtomicSemaphore) ForceRelease() {
s.Release()
}
Заключение
Рассматривая все изложенное выше, становится очевидным тот факт, что реализация семафора на основе каналов сильно уступает своему конкуренту на основе CAS-операций в плане производительности. Стоит учитывать это при построении высоконагруженных систем.
Однако выбор реализации семафора — это всегда компромисс между производительностью, простотой и функциональностью. Наши бенчмарки показали, что атомарная реализация демонстрирует впечатляющую производительность — около 2 нс на операцию, что в 27 раз быстрее канальной реализации. Это делает её идеальным выбором для высоконагруженных систем, где каждая наносекунда на счету.
Но не стоит сбрасывать со счетов и канальный подход. Его главные преимущества — простота и идиоматичность для Go. Для многих приложений, где производительность не является критическим фактором, канальная реализация предлагает элегантное и легко поддерживаемое решение.
Расширенная реализация с поддержкой контекстов и таймаутов открывает возможности для построения более отзывчивых и управляемых систем. Возможность отмены операций через контекст особенно ценна в распределенных системах, HTTP-серверах и scenarios, где важна реакция на внешние события.
Ключевые рекомендации:
- Для максимальной производительности используйте атомарную реализацию
- Для простоты и читаемости выбирайте канальный подход
- Для production-систем с требованием гибкого управления рассматривайте расширенную версию с контекстами
- Всегда тестируйте под нагрузкой — реальная производительность может отличаться в зависимости от паттернов доступа
Помните, что преждевременная оптимизация может привести к излишней сложности кода. Начинайте с простой реализации и переходите к более сложным только при доказанной необходимости. Правильно выбранная реализация семафора не только улучшит производительность вашего приложения, но и сделает код более надежным и поддерживаемым.
В конечном счете, понимание сильных и слабых сторон каждой реализации позволит вам принимать взвешенные архитектурные решения и строить эффективные системы на Go.