Deep Engineering

ЗАМЕР

bench/gosync/internals.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/sync-atomic

Запись прогона

У этого замера записи прогона нет — только скрипт.

Скрипт

262 строк
//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()
}