MEASUREMENT
bench/gosync/internals.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/golang/sync-atomic
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
This measurement has no recorded run — only the script.
Script
262 lines//go:build ignore
// sync и atomic изнутри: конкуренция, RWMutex и цена Once.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Три совета, которые дают чаще всего, — «бери atomic,
// он быстрее», «на чтениях бери RWMutex» и «Once дорогой, кешируй сам» —
// проверяются только замером, и два из трёх при проверке разваливаются.
//
// Главное здесь — не отдельные числа, а то, что они меняются с числом
// горутин. Замер на одной горутине отвечает на вопрос «сколько стоит
// операция», а спрашивают почти всегда другое: «что будет, когда за неё
// начнут драться».
//
// ЗАПУСК:
//
// go run bench/gosync/internals.go
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"testing"
)
// ------------------------------------------------- блок 1: конкуренция
var (
mu sync.Mutex
plain int64
atomCnt atomic.Int64
sink int64
)
// parallelMutex и parallelAtomic крутят один и тот же счётчик из p горутин.
// testing.B с SetParallelism даёт ровно то, что нужно: одна и та же работа,
// разное число претендентов.
func parallelMutex(p int) func(*testing.B) {
return func(b *testing.B) {
b.SetParallelism(p)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
plain++
mu.Unlock()
}
})
}
}
func parallelAtomic(p int) func(*testing.B) {
return func(b *testing.B) {
b.SetParallelism(p)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomCnt.Add(1)
}
})
}
}
func contention() {
fmt.Println("ОДИН СЧЁТЧИК, РАЗНОЕ ЧИСЛО ПРЕТЕНДЕНТОВ")
fmt.Println("───────────────────────────────────────")
fmt.Printf(" %-14s %12s %12s %10s\n", "горутин на P", "мьютекс", "atomic", "кратность")
var firstM, firstA, lastM, lastA float64
for i, p := range []int{1, 2, 4, 8} {
m := best(parallelMutex(p))
a := best(parallelAtomic(p))
if i == 0 {
firstM, firstA = m, a
}
lastM, lastA = m, a
fmt.Printf(" %-14d %9.2f нс %9.2f нс %10.2f\n", p*runtime.GOMAXPROCS(0), m, a, m/a)
}
fmt.Println()
// ПОЧЕМУ ПЕЧАТАЮТСЯ ЕЩЁ И ЭТИ ДВА ЧИСЛА. Абсолютные значения на
// двухъядерной виртуальной машине шумят от прогона к прогону, и строить
// на них вывод нельзя. Устойчиво другое — во сколько раз каждый столбец
// вырастает от наименьшей нагрузки к наибольшей. Эти два числа и есть
// результат замера; отдельные ячейки — иллюстрация к ним.
fmt.Printf(" рост мьютекса от 2 к 16 горутинам ×%.1f\n", lastM/firstM)
fmt.Printf(" рост atomic от 2 к 16 горутинам ×%.1f\n", lastA/firstA)
fmt.Println()
fmt.Println(" Читать надо не отдельные ячейки — они шумят, — а две строки")
fmt.Println(" выше. Мьютекс с ростом числа претендентов дорожает заметно:")
fmt.Println(" проигравший паркуется, и цена ожидания входит в замер.")
fmt.Println(" Атомарная операция дорожает существенно меньше — она никого")
fmt.Println(" не паркует, а повторяет попытку.")
fmt.Println()
fmt.Println(" ЧТО ЭТО НЕ ЗНАЧИТ. Не значит, что atomic бесплатен: одна")
fmt.Println(" ячейка на все горутины остаётся одной ячейкой, и кеш-линия")
fmt.Println(" под ней ходит между ядрами. Просто цена здесь платится")
fmt.Println(" ожиданием шины, а не парковкой горутины.")
fmt.Println()
fmt.Println(" Отсюда ответ на «что брать»: если за одну ячейку дерутся")
fmt.Println(" восемь горутин, дело уже не в примитиве. Дешевле всего")
fmt.Println(" разделить счётчик по горутинам и сложить в конце — тогда")
fmt.Println(" конкуренции не будет вовсе.")
fmt.Println()
}
// ------------------------------------------------- блок 2: RWMutex
// ПОЧЕМУ ЭТОТ ЗАМЕР ВАЖЕН. Совет «на чтениях бери RWMutex» звучит очевидно и
// неверен на КОРОТКИХ критических секциях: RLock дороже Lock, потому что
// делает больше работы (счётчик читателей плюс проверка писателя). Выигрыш
// появляется только тогда, когда под замком проводят достаточно времени.
var (
rw sync.RWMutex
m2 sync.Mutex
value int64
)
// spin имитирует работу под замком: n итераций счёта.
func spin(n int) int64 {
var x int64
for i := 0; i < n; i++ {
x = x*1664525 + 1013904223
}
return x
}
func readRW(work int) func(*testing.B) {
return func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
rw.RLock()
sink += value + spin(work)
rw.RUnlock()
}
})
}
}
func readMutex(work int) func(*testing.B) {
return func(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m2.Lock()
sink += value + spin(work)
m2.Unlock()
}
})
}
}
func rwmutex() {
fmt.Println("RWMUTEX ПРОТИВ MUTEX НА ЧТЕНИЯХ, ПО ДЛИНЕ СЕКЦИИ")
fmt.Println("────────────────────────────────────────────────")
fmt.Printf(" %-16s %12s %12s %12s\n", "работа под", "RWMutex", "Mutex", "кто быстрее")
crossover := -1
for _, w := range []int{0, 10, 100, 1000} {
r := best(readRW(w))
m := best(readMutex(w))
winner := "Mutex"
if r < m {
winner = "RWMutex"
if crossover < 0 {
crossover = w
}
}
fmt.Printf(" %-16d %9.2f нс %9.2f нс %12s\n", w, r, m, winner)
}
fmt.Println()
fmt.Printf(" RWMutex начинает выигрывать с работы %d\n", crossover)
fmt.Println()
fmt.Println(" Совет «на чтениях бери RWMutex» верен не всегда. На пустой")
fmt.Println(" секции RLock ДОРОЖЕ Lock: он делает больше работы — считает")
fmt.Println(" читателей и проверяет писателя. Выигрыш появляется только")
fmt.Println(" когда под замком проводят достаточно времени, чтобы")
fmt.Println(" параллельное чтение окупило эту разницу.")
fmt.Println()
fmt.Println(" Устойчив здесь сам ПЕРЕЛОМ, а не то, где именно он проходит:")
fmt.Println(" граница зависит от машины и числа ядер и от прогона к")
fmt.Println(" прогону смещается. Практический вывод из этого один —")
fmt.Println(" RWMutex не бесплатная замена Mutex, и на коротких секциях")
fmt.Println(" он проигрывает.")
fmt.Println()
}
// ------------------------------------------------- блок 3: цена Once
var (
once sync.Once
ready atomic.Bool
inited int64
)
func viaOnce(b *testing.B) {
for i := 0; i < b.N; i++ {
once.Do(func() { inited++ })
sink += inited
}
}
func viaAtomicFlag(b *testing.B) {
for i := 0; i < b.N; i++ {
if !ready.Load() {
ready.Store(true)
}
sink += inited
}
}
func viaNothing(b *testing.B) {
for i := 0; i < b.N; i++ {
sink += inited
}
}
func onceCost() {
fmt.Println("ЦЕНА sync.Once ПОСЛЕ ПЕРВОГО ВЫЗОВА")
fmt.Println("───────────────────────────────────")
once.Do(func() { inited = 1 }) // прогрев: интересен быстрый путь
o := best(viaOnce)
f := best(viaAtomicFlag)
n := best(viaNothing)
fmt.Printf(" once.Do(...) %9.2f нс\n", o)
fmt.Printf(" своя atomic-проверка %9.2f нс\n", f)
fmt.Printf(" без проверки вовсе %9.2f нс\n", n)
fmt.Println()
fmt.Println(" Все три строки лежат в пределах шума друг от друга — и это")
fmt.Println(" и есть результат. Быстрый путь Once — одно атомарное чтение")
fmt.Println(" флага, и на фоне остального кода он не виден вовсе.")
fmt.Println()
fmt.Println(" Значит, заменять Once ручным флагом ради скорости нечем:")
fmt.Println(" выигрыша нет, а ошибиться в ручном варианте легко — между")
fmt.Println(" проверкой и установкой флага успевает вклиниться вторая")
fmt.Println(" горутина, и инициализация выполнится дважды.")
fmt.Println()
}
// ------------------------------------------------------------ утилиты
const rounds = 5
func nsPerOp(r testing.BenchmarkResult) float64 {
return float64(r.T.Nanoseconds()) / float64(r.N)
}
func best(f func(*testing.B)) float64 {
out := 0.0
for r := 0; r < rounds; r++ {
v := nsPerOp(testing.Benchmark(f))
if out == 0 || v < out {
out = v
}
}
return out
}
func main() {
fmt.Printf("%s %s/%s, GOMAXPROCS=%d | sync и atomic изнутри\n\n",
runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
contention()
rwmutex()
onceCost()
}