Рассмотрим простой и вообщем-то спорный шаблон Singleton. Его “потокобезопасную” версию и общие подходы к решению таких задач.

Классическое назначение: гарантирует, что у типа есть только один экземпляр, и предоставляет глобальную точку доступа к нему.

В объектно-ориентированных языках синглетон эмулирует глобальные переменные.

В Go есть глобальные переменные, которые к тому же могут быть инициализированы в init(). Но синглетон может дать возможность для отложенной(ленивой) инициализация(lazy initialization).

Надо помнить, что синглетон обладает теми же проблема, что и глобальные переменные.

Начнем с простой реализации

Реализуем глобальный счетчик:

package base

type Singleton interface {
   AddOne() int
}

type singleton struct {
   count int
}

var instance *singleton

func GetInstance() Singleton {
   if instance==nil{
      instance = new(singleton)
   }
   return instance
}
func (s *singleton) AddOne() int {
   s.count++
   return s.count
}

Напишем тесты и убедимся что он работает:

package base

import "testing"

func TestGetInstance(t *testing.T) {
   var counter1 Singleton
   counter1 = GetInstance()
   if counter1 == nil {
      t.Fatalf("expected pointer to Singleton after calling GetInstance(), not nil")
   }

   currentCount := counter1.AddOne()
   if currentCount != 1 {
      t.Errorf("After calling for the first time to count, the count must be 1 but it is %d\n", currentCount)
   }

   var counter2, expectedCounter Singleton
   expectedCounter = counter1
   counter2 = GetInstance()
   if counter2 != expectedCounter {
      t.Error("Expected same instance in counter2 but it got a different instance")
   }

   currentCount = counter2.AddOne()
   if currentCount != 2 {
      t.Errorf("After calling 'AddOne' using the second counter, the current count must be 2 but was %d\n", currentCount)
   }
}

Проблема этой реализации становится сразу очевидна, функция GetInstance и метод AddOne подвержены ошибкам конкурентного доступа.

GetInstance

Первое, что приходит в голову это просто добавить блокировок:

var instance *singleton
var mu sync.Mutex

func GetInstance() Singleton {
   mu.Lock()   
   defer mu.Unlock()
   
   if instance==nil{
      instance = new(singleton)
   }
   return instance
}

И это неплохо работает. Но создаем мы instance всего один раз, а блокировки срабатывают каждый раз.

Так же можно попробовать некоторые варианты “блокировка с двойной проверкой”, но к счастью в стандартной библиотеке Golang есть sync.Once. Ему можно передать функцию, которая будет выполнена не более одного раза:

var instance *singleton
var once sync.Once

func GetInstance() Singleton {
   once.Do(func() {
      instance = new(singleton)
   })

   return instance
}

С GetInstance разобрались, осталось самое интересное.

AddOne и другие методы доступа

Начнем с теста который сможет продемонстрировать проблему:

func TestParallel(t *testing.T) {
   singleton := GetInstance()
   singleton2 := GetInstance()

   n := 5000

   var wg sync.WaitGroup

   for i := 0; i < n; i++ {
      wg.Add(1)
      go func() {
         singleton.AddOne()
         wg.Done()
      }()
      wg.Add(1)
      go func() {
         singleton2.AddOne()
         wg.Done()
      }()
   }

   fmt.Printf("Before loop, current count is %d\n", singleton.GetCount())

   wg.Wait()

   fmt.Printf("Current count is %d\n", singleton.GetCount())

   currentCount1 := singleton.GetCount()
   currentCount2 := singleton2.GetCount()
   if currentCount1 != currentCount2 {
      t.Errorf("Counts not match\nCurrentCount1=%d\nCurrentCount2=%d", currentCount1, currentCount2)
   }

   if currentCount1 != n*2 {
      t.Errorf("Counts not match\nCurrentCount1=%d\nN*2=%d", currentCount1, n*2)
   }
}

Запускаем цикл на 5000 итераций в которых создается по две горутины, в горутинах вызывается метод AddOne. По завершению всех горутин мы ожидаем, что наш счетчик будет иметь значение 5000*2=10к

=== RUN   TestParallel
Before loop, current count is 9499
Current count is 9524
--- FAIL: TestParallel (0.00s)
        main_test.go:68: Counts not match
                CurrentCount1=9524
                N*2=10000

Сразу по окончанию цикла у нас получилось 9499, это неплохо, к этому моменту еще не все горутины закончили свою работу.

А уже по завершению мы получаем счетчик со значением 9524, а это плохо, куда то потерялось целых 476.

Добавляем -race и запускаем:

WARNING: DATA RACE
Read at 0x00c04205e130 by goroutine 9:
  github.com/germangorelkin/go-patterns/creational/singleton/once.(*singleton).AddOne()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main.go:26 +0x3f
  github.com/germangorelkin/go-patterns/creational/singleton/once.TestParallel.func2()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main_test.go:50 +0x45
Previous write at 0x00c04205e130 by goroutine 8:
  github.com/germangorelkin/go-patterns/creational/singleton/once.(*singleton).AddOne()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main.go:26 +0x55
  github.com/germangorelkin/go-patterns/creational/singleton/once.TestParallel.func1()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main_test.go:45 +0x45
Goroutine 9 (running) created at:
  github.com/germangorelkin/go-patterns/creational/singleton/once.TestParallel()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main_test.go:49 +0x178
  testing.tRunner()
      ./testing/testing.go:777 +0x174
Goroutine 8 (finished) created at:
  github.com/germangorelkin/go-patterns/creational/singleton/once.TestParallel()
      ./github.com/germangorelkin/go-patterns/creational/singleton/once/main_test.go:44 +0x11f
  testing.tRunner()
      ./testing/testing.go:777 +0x174
==================
Before loop, current count is 9988
Current count is 9989
--- FAIL: TestParallel (0.60s)
        main_test.go:68: Counts not match
                CurrentCount1=9989
                N*2=10000
        testing.go:730: race detected during execution of test
FAIL

Получаем DATA RACE.

Data race(“гонка данных”) осуществляется, когда две горутины одновременно обращаются к одной и той же переменной и по крайней мере одно из обращений представляет собой запись. Подробней можно посмотреть в статье Race condition и Data Race.

Существует минимум три способа избежать этой проблемы:

Первый способ — не изменять переменную. Как не странно, но иногда есть возможность отказаться от записи и использовать уже предустановленный набор данных.

Второй способ — использовать взаимное исключение. Для этой цели служит бинарный семафор, он же мьютекс. В Go его можно реализовать через буферизированный канал. Но, к счастью, в стандартной библиотеки уже есть примитивы синхронизации: sync.Mutex и sync.RWMutex.

Внесем корректировки:

import "sync"

type Singleton interface {
   AddOne()
   GetCount() int
}

type singleton struct {
   count int
   sync.RWMutex
}

var instance *singleton
var once sync.Once

func GetInstance() Singleton {
   once.Do(func() {
      instance = new(singleton)
   })

   return instance
}

func (s *singleton) AddOne() {
   s.Lock()
   defer s.Unlock()
   s.count++
}

func (s *singleton) GetCount()int {
   s.RLock()
   defer s.RUnlock()
   return s.count
}

go test -race:

Before loop, current count is 9864
Current count is 10000
PASS

Всё в порядке, всё отлично.

Третий способ — избегать обращения к переменной из нескольких горутин.

Ограничиваем переменную одной горутиной. Другие горутины не могут получить прямой доступ к переменным, они должны использовать каналы для запроса у ограничивающей горутины(“monitor”) получения значения или обновления переменной.

Следуя этому принципам, пробуем реализовать синглетон.

Потребуются три канала:

var addCh chan bool = make(chan bool)
var getCountCh chan chan int = make(chan chan int)
var quitCh chan bool = make(chan bool)

Через канал addCh будем запрашивать обновление переменной. Канал quitCh сигнализирует ограничивающей горутине, что нужно завершить работу и освободить ресурсы.

getCountCh это канал каналов int. Через него можно будет запрашивать значение переменной, значение будет передано обратно через полученный канал. Возможно, это звучит запутано, но на деле это достаточно удобно и требует небольшого усилия, что бы разобраться.

Функция которая будет выступать ограничителем:

go func() {
   for {
      select {
      case <-addCh:
         instance.count++
      case ch := <-getCountCh:
         ch <- instance.count
      case <-quitCh:
         return
      }
   }
}()

Полный код может выглядит таким образом:

import "sync"

var addCh chan bool = make(chan bool)
var getCountCh chan chan int = make(chan chan int)
var quitCh chan bool = make(chan bool)

type Singleton interface {
   AddOne()
   GetCount() int
   Stop()
}

type singleton struct {
   count int
}

var instance *singleton
var once sync.Once

func GetInstance() Singleton {
   once.Do(func() {
      instance = new(singleton)

      go func() {
         for {
            select {
            case <-addCh:
               instance.count++
            case ch := <-getCountCh:
               ch <- instance.count
            case <-quitCh:
               return
            }
         }
      }()
   })

   return instance
}

func (s *singleton) AddOne() {
   addCh <- true
}

func (s *singleton) GetCount() int {
   resCh := make(chan int)
   defer close(resCh)
   getCountCh <- resCh
   return <-resCh
}

func (s *singleton) Stop() {
   quitCh <- true
   close(addCh)
   close(getCountCh)
   close(quitCh)
   instance = nil
}

go test -race:

Before loop, current count is 9824
Current count is 10000
PASS

Benchmark

func BenchmarkChannelSingletonParallel(b *testing.B) {
 singleton := GetInstance()
 singleton2 := GetInstance()
 b.ResetTimer()
 b.RunParallel(func(pb *testing.PB) {
  for pb.Next() {
   singleton.AddOne()
   singleton2.GetCount()
   singleton2.AddOne()
   singleton.GetCount()
  }
 })
}
func BenchmarkChannelSingleton(b *testing.B) {
 singleton := GetInstance()
 singleton2 := GetInstance()
 b.ResetTimer()
 for i := 0; i < b.N; i++ {
  singleton.AddOne()
  singleton2.GetCount()
  singleton2.AddOne()
  singleton.GetCount()
 }
}

Дают такой результат на каналы:

BenchmarkChannelSingletonParallel-4       500000              3441 ns/op             192 B/op          2 allocs/op
BenchmarkChannelSingleton-4               300000              4590 ns/op             192 B/op          2 allocs/op
flat  flat%   sum%        cum   cum%
650ms  9.37%  9.37%      650ms  9.37%  runtime.osyield
470ms  6.77% 16.14%      470ms  6.77%  runtime.stdcall1
400ms  5.76% 21.90%      410ms  5.91%  runtime.addspecial
400ms  5.76% 27.67%      850ms 12.25%  runtime.pcvalue
400ms  5.76% 33.43%      400ms  5.76%  runtime.stdcall2
390ms  5.62% 39.05%      390ms  5.62%  runtime.procyield
360ms  5.19% 44.24%      450ms  6.48%  runtime.step
340ms  4.90% 49.14%     1500ms 21.61%  runtime.gentraceback
290ms  4.18% 53.31%     1150ms 16.57%  runtime.lock
280ms  4.03% 57.35%      880ms 12.68%  runtime.selectgo

И такие на мьютексы:

BenchmarkMutexSingletonParallel-4        3000000               419 ns/op               0 B/op          0 allocs/op
BenchmarkMutexSingleton-4               10000000               207 ns/op               0 B/op          0 allocs/op
flat  flat%   sum%        cum   cum%
690ms 13.12% 13.12%      690ms 13.12%  runtime.procyield
470ms  8.94% 22.05%      950ms 18.06%  runtime.deferreturn
470ms  8.94% 30.99%      470ms  8.94%  runtime.freedefer
460ms  8.75% 39.73%      460ms  8.75%  runtime.newdefer
330ms  6.27% 46.01%      990ms 18.82%  sync.(*Mutex).Lock
310ms  5.89% 51.90%      390ms  7.41%  sync.(*RWMutex).RUnlock
260ms  4.94% 56.84%     1260ms 23.95%  sync.(*RWMutex).Lock
250ms  4.75% 61.60%      320ms  6.08%  sync.(*Mutex).Unlock
230ms  4.37% 65.97%      230ms  4.37%  sync.(*RWMutex).RLock
220ms  4.18% 70.15%      720ms 13.69%  runtime.deferproc

https://github.com/GermanGorelkin/go-patterns/tree/master/creational/singleton


Комментарии в Telegram-группе!