Deep Engineering

MEASUREMENT

bench/gosched/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/scheduler

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

235 lines
//go:build ignore

// Планировщик изнутри: GOMAXPROCS, вытеснение и что видно из программы.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Про GMP обычно рассказывают схемой из трёх букв, и из
// неё не следует ни одного проверяемого утверждения. Проверяемых здесь три, и
// каждое меряется отдельно:
//
//  1. GOMAXPROCS ограничивает число горутин, выполняющихся ОДНОВРЕМЕННО, а не
//     число существующих. Видно по масштабированию счётной задачи.
//  2. Планировщик вытесняющий с Go 1.14: горутина, не делающая вызовов, всё
//     равно снимается с процессора. До этого такой цикл вешал сборку мусора.
//  3. Цена переключения — это цена ПАРКОВКИ, и она видна на runtime.Gosched.
//
// ЧЕГО ЗДЕСЬ НЕТ. Внутренних очередей P, кражи работы и hand-off тут не видно:
// они не наблюдаемы из программы, и подпирать рассказ о них числом было бы
// подделкой. Про них в уроке сказано отдельно и без чисел.
//
// ЗАПУСК:
//
//	go run bench/gosched/internals.go
package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

// ------------------------------------------------------- блок 1: GOMAXPROCS

// spin — чистый счёт без единого вызова: ничего не выделяет, никуда не ходит.
func spin(n int) uint64 {
	var x uint64
	for i := 0; i < n; i++ {
		x = x*1664525 + 1013904223
	}
	return x
}

var sink uint64

// parallelWork запускает workers горутин, каждая крутит spin, и возвращает
// время выполнения всей пачки.
func parallelWork(workers, iters int) time.Duration {
	var wg sync.WaitGroup
	wg.Add(workers)
	start := time.Now()
	for w := 0; w < workers; w++ {
		go func() {
			defer wg.Done()
			atomic.AddUint64(&sink, spin(iters))
		}()
	}
	wg.Wait()
	return time.Since(start)
}

func maxprocs() {
	fmt.Println("GOMAXPROCS ОГРАНИЧИВАЕТ ОДНОВРЕМЕННЫХ, А НЕ СУЩЕСТВУЮЩИХ")
	fmt.Println("─────────────────────────────────────────────────────────")

	cpus := runtime.NumCPU()
	fmt.Printf("  ядер у машины                 %d\n", cpus)
	fmt.Printf("  GOMAXPROCS по умолчанию       %d\n", runtime.GOMAXPROCS(0))
	fmt.Println()

	const iters = 20_000_000
	base := runtime.GOMAXPROCS(0)
	defer runtime.GOMAXPROCS(base)

	fmt.Printf("  %-14s %12s %12s\n", "GOMAXPROCS", "1 горутина", "4 горутины")
	for _, p := range []int{1, 2} {
		if p > cpus {
			continue
		}
		runtime.GOMAXPROCS(p)
		// Прогрев: первый прогон после смены GOMAXPROCS ловит перестройку.
		parallelWork(1, iters/10)
		one := parallelWork(1, iters)
		four := parallelWork(4, iters)
		fmt.Printf("  %-14d %10.1f мс %10.1f мс\n", p,
			float64(one.Microseconds())/1000, float64(four.Microseconds())/1000)
	}
	runtime.GOMAXPROCS(base)
	fmt.Println()
	fmt.Println("  Читать надо по строкам. При GOMAXPROCS=1 четыре горутины")
	fmt.Println("  занимают вчетверо больше времени, чем одна: они выполняются")
	fmt.Println("  по очереди на одном процессоре. При GOMAXPROCS=2 те же")
	fmt.Println("  четыре укладываются примерно вдвое — работают по две сразу.")
	fmt.Println()
	fmt.Println("  Горутин при этом всё время четыре. Ограничивается не их")
	fmt.Println("  число, а число тех, что выполняются ОДНОВРЕМЕННО.")
	fmt.Println()
}

// ------------------------------------------------- блок 2: вытеснение

// preemption проверяет, снимет ли планировщик с процессора горутину, которая
// не делает ни вызовов, ни выделений, ни обращений к каналам.
//
// До Go 1.14 такая горутина не снималась вовсе: планировщик был кооперативным
// и переключался только в точках вызова. Цикл вроде этого при GOMAXPROCS=1
// вешал программу насмерть — в том числе сборку мусора, которой нужно
// остановить все горутины.
func preemption() {
	fmt.Println("ВЫТЕСНЕНИЕ: ЦИКЛ БЕЗ ВЫЗОВОВ ВСЁ РАВНО СНИМАЕТСЯ")
	fmt.Println("────────────────────────────────────────────────")

	base := runtime.GOMAXPROCS(1)
	defer runtime.GOMAXPROCS(base)

	var progressed atomic.Bool
	done := make(chan struct{})

	// Горутина-счётчик: ни одного вызова внутри цикла.
	go func() {
		x := uint64(0)
		for i := 0; i < 400_000_000; i++ {
			x = x*1664525 + 1013904223
		}
		atomic.AddUint64(&sink, x)
		close(done)
	}()

	// Вторая горутина: если её ни разу не пустят на процессор, флаг
	// останется снятым.
	go func() {
		for {
			select {
			case <-done:
				return
			default:
				progressed.Store(true)
				time.Sleep(time.Millisecond)
			}
		}
	}()

	select {
	case <-done:
	case <-time.After(10 * time.Second):
	}

	fmt.Printf("  GOMAXPROCS на время опыта      1\n")
	fmt.Printf("  соседняя горутина работала     %v\n", progressed.Load())
	fmt.Println()
	fmt.Println("  Счётный цикл не делает ни вызовов, ни выделений — то есть")
	fmt.Println("  не даёт планировщику ни одной точки, где тот мог бы")
	fmt.Println("  переключиться по-старому. Соседняя горутина всё равно")
	fmt.Println("  получила процессор: с Go 1.14 планировщик вытесняющий и")
	fmt.Println("  снимает горутину сигналом, а не по её доброй воле.")
	fmt.Println()
	fmt.Println("  До Go 1.14 такой цикл при GOMAXPROCS=1 вешал программу")
	fmt.Println("  целиком, включая сборку мусора: ей нужно остановить все")
	fmt.Println("  горутины, а эту остановить было нечем.")
	fmt.Println()
}

// ------------------------------------------------- блок 3: цена парковки

var counter atomic.Int64

func noYield(b *testing.B) {
	for i := 0; i < b.N; i++ {
		counter.Add(1)
	}
}

func withYield(b *testing.B) {
	for i := 0; i < b.N; i++ {
		counter.Add(1)
		runtime.Gosched()
	}
}

func withSleep(b *testing.B) {
	for i := 0; i < b.N; i++ {
		counter.Add(1)
		time.Sleep(0)
	}
}

func switchCost() {
	fmt.Println("ЦЕНА ПЕРЕКЛЮЧЕНИЯ НА ОДИНАКОВОЙ РАБОТЕ")
	fmt.Println("──────────────────────────────────────")
	plain := best(noYield)
	yield := best(withYield)
	sleep := best(withSleep)
	fmt.Printf("  атомарный инкремент           %9.2f нс\n", plain)
	fmt.Printf("  он же + runtime.Gosched()     %9.2f нс\n", yield)
	fmt.Printf("  он же + time.Sleep(0)         %9.2f нс\n", sleep)
	fmt.Printf("  цена уступки процессора       %9.2f нс\n", yield-plain)
	fmt.Println()
	fmt.Println("  Работа одинаковая во всех трёх строках — один атомарный")
	fmt.Println("  инкремент. Разница целиком в том, что горутина отдаёт")
	fmt.Println("  процессор и потом получает его обратно.")
	fmt.Println()
	fmt.Println("  Отсюда практический вывод про каналы и мьютексы: дорожает")
	fmt.Println("  не операция, а ПАРКОВКА, когда операция заставляет ждать.")
	fmt.Println("  Пока горутина не блокируется, планировщик в её цене не")
	fmt.Println("  участвует вовсе.")
	fmt.Println()
}

// ------------------------------------------------------------ утилиты

const rounds = 7

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 | планировщик изнутри\n\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)
	maxprocs()
	preemption()
	switchCost()
}