Измерения эффективности типа Go's Once - PullRequest
0 голосов
/ 19 ноября 2018

У меня есть фрагмент кода, который я хочу запустить только один раз для инициализации. До сих пор я использовал sync.Mutex в сочетании с условием if, чтобы проверить, был ли он уже запущен. Позже я столкнулся с типом Once и его функцией DO () в том же пакете синхронизации.

Реализация следующая https://golang.org/src/sync/once.go:

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return
    }
    // Slow-path.
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

Глядя на код, это то же самое, что я использовал раньше. Мьютекс в сочетании с условием if. Тем не менее, добавленные вызовы функций делают это довольно неэффективным для меня. Я провел некоторое тестирование и попробовал различные версии:

func test1() {
    o.Do(func() {
        // Do smth
    })
    wg.Done()
}

func test2() {
    m.Lock()
    if !b {
        func() {
            // Do smth
        }()
    }
    b = true
    m.Unlock()
    wg.Done()
}

func test3() {
    if !b {
        m.Lock()
        if !b {
            func() {
                // Do smth
            }()
            b = true
        }
        m.Unlock()
    }
    wg.Done()
}

Я протестировал все версии, выполнив следующий код:

    wg.Add(10000)
    start = time.Now()
    for i := 0; i < 10000; i++ {
        go testX()
    }
    wg.Wait()
    end = time.Now()

    fmt.Printf("elapsed: %v\n", end.Sub(start).Nanoseconds())

со следующими результатами:

elapsed: 8002700 //test1
elapsed: 5961600 //test2
elapsed: 5646700 //test3

Стоит ли даже использовать тип Once? Это удобно, но производительность даже хуже, чем у test2, который всегда сериализует все подпрограммы.

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

Редактировать: Перейти на игровую площадку: https://play.golang.org/p/qlMxPYop7kS ВНИМАНИЕ: результаты не отображаются, поскольку на игровой площадке установлено время.

1 Ответ

0 голосов
/ 19 ноября 2018

Это не то, как вы должны тестировать производительность кода.Вы должны использовать встроенную среду тестирования Go (пакет testing и команду go test).См. Порядок кода и производительность для получения подробной информации.

Давайте создадим тестируемый код:

func f() {
    // Code that must only be run once
}

var testOnce = &sync.Once{}

func DoWithOnce() {
    testOnce.Do(f)
}

var (
    mu = &sync.Mutex{}
    b  bool
)

func DoWithMutex() {
    mu.Lock()
    if !b {
        f()
        b = true
    }
    mu.Unlock()
}

Давайте напишем надлежащий код тестирования / тестирования с использованием пакета testing:

func BenchmarkOnce(b *testing.B) {
    for i := 0; i < b.N; i++ {
        DoWithOnce()
    }
}

func BenchmarkMutex(b *testing.B) {
    for i := 0; i < b.N; i++ {
        DoWithMutex()
    }
}

Мы можем запустить бенчмарк со следующим кодом:

go test -bench .

И вот результаты бенчмаркинга:

BenchmarkOnce-4         200000000                6.30 ns/op
BenchmarkMutex-4        100000000               20.0 ns/op
PASS

Как видите,использование sync.Once() было почти в 4 раза быстрее, чем использование sync.Mutex.Зачем?Поскольку sync.Once() имеет «оптимизированный» короткий путь, который использует только атомарную загрузку, чтобы проверить, была ли задача вызвана ранее, и если это так, мьютекс не используется.«Медленный» путь, вероятно, используется только один раз при первом вызове Once.Do().Хотя, если у вас будет много одновременных попыток вызова DoWithOnce(), медленный путь может быть достигнут несколько раз, но в долгосрочной перспективе once.Do() потребуется использовать только атомную загрузку.

Параллельное тестирование(из нескольких программ)

Да, в приведенном выше коде сравнительного анализа для тестирования используется только одна программа.Но использование нескольких одновременных программ только ухудшит ситуацию с мьютексом, поскольку ему всегда нужно получить мьютекс, чтобы даже проверить, должна ли быть вызвана задача, в то время как sync.Once просто использует атомарную загрузку.

Тем не менее, давайтесравните его.

Вот код тестирования, использующий параллельное тестирование:

func BenchmarkOnceParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            DoWithOnce()
        }
    })
}

func BenchmarkMutexParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            DoWithMutex()
        }
    })
}

У меня 4 ядра на моей машине, поэтому я собираюсь использовать эти 4 ядра:

go test -bench Parallel -cpu=4

(Вы можете опустить флаг -cpu, в этом случае по умолчанию он равен GOMAXPROCS - количество доступных ядер.)

И вот результаты:

BenchmarkOnceParallel-4         500000000                3.04 ns/op
BenchmarkMutexParallel-4        20000000                93.7 ns/op

Когда «увеличивается параллелизм», результаты начинают становиться несопоставимыми в пользу sync.Once (в приведенном выше тесте это в 30 раз быстрее).

Мы можем еще больше увеличить числоколичество процедур, созданных с использованием testing.B.SetPralleism(), но я получил аналогичный результат, когда установил его на 100 (это означает, что 400 вызовов были использованы для вызова кода тестирования).

...