Идемпотентный Close в Go

Идемпотентность — это когда повторный вызов операции над объектом не приводит к изменениям или ошибкам. Очень полезная в разработке штука.

Давайте посмотрим, как применить идемпотентность, чтобы безопасно освободить занятые ресурсы.

Идемпотентный Close

Например, есть у нас ворота:

type Gate struct{
	// внутреннее состояние
}

Конструктор NewGate() открывает ворота, занимая какие-то системные ресурсы, и возвращает экземпляр Gate.

g := NewGate()

Понятно, что в итоге занятые ресурсы надо освободить:

func (g *Gate) Close() {
	// освободить ресурсы
}

g := NewGate()
defer g.Close()
// ...

Проблемы начнутся, если в какой-то ветке кода мы захотим явно закрыть ворота:

g := NewGate()
defer g.Close()

err := checkSomething()
if err != nil {
	g.Close()
	// ...
	return
}

// ...

Первый Close() сработает, а вот второй (через defer) сломается, потому что ресурсы уже освобождены.

Решение — сделать Close() идемпотентным, чтобы повторные вызовы ничего не делали, если ворота уже закрыты:

type Gate struct{
	closed bool
	// внутреннее состояние
}

func (g *Gate) Close() {
	if g.closed {
		// игнорируем повторное закрытие
		return
	}
	g.closed = true
	// освободить ресурсы
}

песочница

Теперь Close() можно вызывать сколько угодно без каких-либо проблем. До тех пор, пока мы не попытаемся закрыть ворота из разных горутин — тут-то все и развалится.

Идемпотентность в многозадачной среде

Мы сделали закрытие ворот идемпотентным — безопасным для повторных вызовов:

func (g *Gate) Close() {
	if g.closed {
		// игнорируем повторное закрытие
		return
	}
	g.closed = true
	// освободить ресурсы
}

Но если несколько горутин используют общий экземпляр Gate, одновременный вызов Close() приведет к гонкам — конкурентной модификации поля closed. Такого счастья нам не надо.

Есть несколько способов обезопасить Close:

sync.Mutex

Дубовый, но надежный способ. Защищаем изменение логического поля closed мьютексом:

type Gates struct {
	// признак закрытия ворот
	closed bool
	// мьютекс для защиты closed
	mu sync.Mutex
}

func (g *Gates) Close() {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.closed {
		// игнорируем повторное закрытие
		return
	}
	// закрыть ворота
	g.closed = true
	// освободить ресурсы
}

Мьютекс гарантирует, что код между Lock() и Unlock() выполняется только одной горутиной в любой момент времени.

atomic.Bool

Compare-and-set на атомарном bool гарантирует, что только одна горутина сможет поменять значение с false на true:

type Gates struct {
	// признак закрытия ворот
	closed atomic.Bool
}

func (g *Gates) Close() {
	if !g.closed.CompareAndSwap(false, true) {
		// игнорируем повторное закрытие
		return
	}
	// закрыли ворота,
	// можно освободить ресурсы
}

sync.Once

Once.Do гарантирует однократное выполнение в конкурентной среде, поэтому не приходится даже явно хранить состояние:

type Gates struct {
	// гарантирует однократное выполнение
	once sync.Once
}

func (g *Gates) Close() {
	g.once.Do(func() {
		// освободить ресурсы
	})
}

Правда, такие ворота уже не получится открыть обратно, в отличие от предыдущих вариантов.

⛔️ select

Может показаться, что select с каналом closed тоже подойдет:

type Gates struct {
	// признак закрытия ворот
	closed chan struct{}
}

func (g *Gates) Close() {
	select {
	case <-g.closed:
		// игнорируем повторное закрытие
		return
	default:
		// закрыть ворота
		close(g.closed)
		// освободить ресурсы
	}
}

Но такой метод Close — небезопасный. Если две горутины одновременно вызовут Close, обе могут провалиться в default-ветку селекта, обе попытаются закрыть канал, и второе закрытие приведет к панике.

Другими словами, здесь гонки на закрытии канала. Селект сам по себе не защищает от гонок. Печально, но факт.

Итого

Теперь многократные вызовы Close() корректно работают и в многозадачной среде.

Вот и все!

★ Подписывайтесь на новые заметки.