Идемпотентный 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()
корректно работают и в многозадачной среде.
Вот и все!
★ Подписывайтесь на новые заметки.