Каждый production circuit breaker, который я рассматривал, рано или поздно порождает фоновый поток. Это может быть Go goroutine, Java ScheduledExecutorService или Rust tokio task. Задача всегда одна и та же: просыпаться каждые несколько секунд, проверять, восстановился ли downstream service, и переходить из OPEN обратно в CLOSED.

Такой дизайн неверен. При масштабировании он приводит к утечке ресурсов, усложняет shutdown и создает race conditions, которые действительно трудно тестировать. Хуже того, фоновая работа совершенно излишняя. Можно построить circuit breaker, который никогда не просыпается сам по себе, никогда не выделяет таймер и при этом корректно обнаруживает восстановление.

Скрытая стоимость health-check goroutine

Circuit breaker отслеживает failures. После достаточного количества consecutive errors он trips OPEN и начинает немедленно отклонять requests. Цель — дать упавшему сервису передышку вместо того, чтобы топить его в retry traffic.

Сложная часть — решить, когда попробовать снова. Большинство библиотек решают это через setTimeout или time.AfterFunc. В Go типичная реализация выглядит так:

func (cb *CircuitBreaker) Trip() {
    cb.state.Store(StateOpen)
    time.AfterFunc(cb.timeout, func() {
        cb.state.Store(StateHalfOpen)
    })
}

Это работает для одного breaker. Для десяти тысяч — нет.

Если создать один circuit breaker на downstream host (распространенный паттерн в microservices), получаем десять тысяч goroutines, спящих в фоне. Каждая goroutine стоит ~2 KB stack space и добавляет scheduling overhead. При перезапуске контейнеров эти goroutines участвуют в гонке с shutdown deadlines. При timeouts они срабатывают в самый неподходящий момент и создают flapping.

Фоновый поток решает проблему, которой не существует. Recovery не нужно обнаруживать proactively. Его можно обнаруживать lazily, на request path.

Как работает lazy recovery

Вместо таймера, который переводит breaker, храним один timestamp: момент, когда breaker tripped OPEN. При каждом входящем request сравниваем now с этим timestamp плюс configured timeout. Если прошло достаточно времени, пропускаем один probe. Если probe succeeds, закрываем breaker. Если fails, обновляем timestamp и остаемся в OPEN.

State machine остается идентичной. Меняется только transition trigger.

  • CLOSED: requests проходят. Failures инкрементируют counter. Когда counter достигает threshold, атомарно swap в OPEN и записываем trippedAt.
  • OPEN: каждый входящий request проверяет time.Now() > trippedAt + timeout. Если false, fail fast. Если true, атомарно swap в HALF-OPEN и пропускаем этот request.
  • HALF-OPEN: ровно один request in flight. Если succeeds, swap в CLOSED и сбрасываем failure counter. Если fails, swap обратно в OPEN и обновляем trippedAt.

Ни одна goroutine не просыпается. Ни один таймер не выделяется. Breaker полностью пассивен, пока не придет request.

Рабочая реализация на Go

Вот полный, zero-background circuit breaker. Он использует только sync/atomic для state transitions и хранит tripped timestamp как nanosecond counter.

package breaker

import (
	"errors"
	"sync/atomic"
	"time"
)

type State int32

const (
	StateClosed State = iota
	StateOpen
	StateHalfOpen
)

type CircuitBreaker struct {
	// state is accessed with atomic operations.
	state      int32
	failures   int32
	threshold  int32
	timeout    time.Duration
	trippedAt  int64 // nanoseconds since Unix epoch
}

func New(threshold int, timeout time.Duration) *CircuitBreaker {
	return &CircuitBreaker{
		threshold: int32(threshold),
		timeout:   timeout,
	}
}

func (cb *CircuitBreaker) State() State {
	return State(atomic.LoadInt32(&cb.state))
}

// Allow reports whether the current request may proceed.
// It returns a done function that must be called with the outcome.
func (cb *CircuitBreaker) Allow() (done func(success bool), err error) {
	switch State(atomic.LoadInt32(&cb.state)) {
	case StateClosed:
		return cb.trackClosed, nil

	case StateOpen:
		// Lazy recovery check: has the timeout elapsed?
		if time.Now().UnixNano()-atomic.LoadInt64(&cb.trippedAt) < int64(cb.timeout) {
			return nil, errors.New("circuit breaker is open")
		}
		// Race: multiple goroutines may see this simultaneously.
		// Only one wins the CAS to HALF-OPEN.
		if atomic.CompareAndSwapInt32(&cb.state, int32(StateOpen), int32(StateHalfOpen)) {
			return cb.trackHalfOpen, nil
		}
		// Another goroutine won the race; fail fast this request.
		return nil, errors.New("circuit breaker is open")

	case StateHalfOpen:
		// Only one probe at a time. Every other request fails fast.
		return nil, errors.New("circuit breaker is half-open")
	}

	return nil, errors.New("unknown circuit breaker state")
}

func (cb *CircuitBreaker) trackClosed(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		return
	}

	// Increment failures and trip if threshold reached.
	if atomic.AddInt32(&cb.failures, 1) >= cb.threshold {
		// Record the trip time before switching state so readers
		// never see OPEN with a stale trippedAt.
		atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
		atomic.StoreInt32(&cb.state, int32(StateOpen))
	}
}

func (cb *CircuitBreaker) trackHalfOpen(success bool) {
	if success {
		atomic.StoreInt32(&cb.failures, 0)
		atomic.StoreInt32(&cb.state, int32(StateClosed))
		return
	}

	atomic.StoreInt64(&cb.trippedAt, time.Now().UnixNano())
	atomic.StoreInt32(&cb.state, int32(StateOpen))
}

Ключевой invariant: trippedAt всегда записывается до того, как state transition в OPEN. Readers в Allow() могут безопасно читать trippedAt после обнаружения OPEN, зная, что он свежий. На обратном пути из HALF-OPEN мы обновляем trippedAt до возврата в OPEN, чтобы cooldown начался заново.

Почему большинство библиотек так не делают

У lazy дизайна есть один очевидный downside: recovery обнаруживается только при arrival request. Если сервис не получает traffic в течение часа, breaker остается в OPEN час.

Это звучит плохо. На самом деле нет.

Если нет requests, нечего защищать. Breaker существует для предотвращения cascading failures во время traffic, а не для поддержания real-time health dashboard. Когда приходит следующий request, timeout check выполняется за nanoseconds и probe запускается немедленно. Effective recovery latency ограничена max(timeout, time-between-requests).

Для high-traffic services промежуток между requests незначителен. Для low-traffic services timeout все равно доминирует. Фоновый таймер на практике почти никогда не улучшает real recovery time.

Другая причина использования таймеров в библиотеках — историческая. Паттерн circuit breaker был популяризирован в средах (Java с Hystrix, .NET с Polly), где один breaker instance защищал целую service dependency, а не per-host connection. Один фоновый поток был приемлем. В современных distributed systems, где может быть breaker per upstream endpoint, это предположение разваливается.

Тестирование race conditions

CAS loop на переходе OPEN в HALF-OPEN — единственное место, где goroutines contend. Если два request приходят одновременно после timeout, только один proceed как probe. Другой fail fast и retry на следующем request. Это корректное поведение. Никогда не нужно multiple probes in flight во время recovery, потому что один failure среди нескольких successes все еще может вернуть в OPEN.

Тестирование простое, потому что нет asynchronous timers. Можно написать unit test, который напрямую манипулирует trippedAt (или использует time wrapper) без sleeping:

func TestLazyRecovery(t *testing.T) {
	cb := New(1, time.Minute)

	// Trip the breaker.
	done, _ := cb.Allow()
	done(false)

	if cb.State() != StateOpen {
		t.Fatal("expected OPEN")
	}

	// Simulate timeout by winding back trippedAt.
	atomic.StoreInt64(&cb.trippedAt, time.Now().Add(-2*time.Minute).UnixNano())

	done, err := cb.Allow()
	if err != nil {
		t.Fatalf("expected probe to be allowed: %v", err)
	}

	// Success closes the breaker.
	done(true)
	if cb.State() != StateClosed {
		t.Fatal("expected CLOSED after successful probe")
	}
}

Никакого time.Sleep. Никакого sync.WaitGroup для goroutines. Тест детерминированный, потому что реализация синхронная.

Чем мы пожертвовали

Есть одна реальная потеря: нельзя eagerly pre-warm breaker перед отправкой traffic. Если нужно probe dependency по фиксированному расписанию (скажем, каждые 5 секунд), чтобы держать connection pool warm, все еще нужен таймер. Но этот таймер принадлежит connection pool или health checker, а не circuit breaker. Breaker должен защищать pool. Он не должен им управлять.

Разделяйте concerns. Health checking разогревает connections. Circuit breaking предотвращает cascading overload. Когда вы их объединяете, получаете сложность в обоих местах.

Паттерн для других языков

Та же структура работает везде, где есть atomic compare-and-swap и monotonic clock. В Rust с std::sync::atomic, в Java с AtomicIntegerFieldUpdater и System.nanoTime(), в C++ с std::atomic и custom enum. Реализация занимает меньше сотни строк в каждом случае.

Если ваш язык не экспонирует CAS, sync.Mutex (или эквивалент) все еще дешевле фонового потока. Mutex удерживается только nanoseconds per request и только во время state transitions. Он никогда не блокируется на I/O и не спит.

Попробуйте

Полная реализация выше — production-ready как отправная точка. Добавьте сверху metrics, logging и adaptive thresholds. Но оставьте goroutine за бортом. Ваш runtime scheduler скажет вам спасибо, ваши тесты будут работать быстрее, и вы перестанете удивляться, почему тот контейнер отказывается shutdown cleanly.