Раздел 22 · Go

Concurrency глубоко: GMP, sync, atomics, race

middle-senior~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Concurrency глубоко: GMP, sync, atomics, race

Урок-конспект по второй большой теме собеседований: как устроен Go-scheduler, что такое горутина, какие есть примитивы синхронизации и где их применять. Считаем, что ты уже прошёл Tour of Go и идиоматику и внутреннее устройство типов.

1. Goroutine, не поток

Горутина, это легковесная задача рантайма, не OS-thread. Сравнение в одном кадре:

ХарактеристикаGoroutineOS Thread
Стартовый стек2 KB1, 2 MB
Создание~300 нс~1 мс
Context switch~100 нс~1 мкс
УправлениеGo runtimeOS kernel
Stack growthрастёт сегментами по необходимостификсированный, переполнение, segfault

В одном Go-процессе нормально иметь сотни тысяч горутин. Поток ОС столько никогда не запустит.

Где живёт горутина

Стек горутины растёт сегментами: рантайм проверяет в начале каждой функции, не пора ли расти, и если да, аллоцирует новый сегмент с копированием старого. Поэтому стек начинается с 2 KB, но может вырасти до сотен мегабайт (лимит, GOTRACEBACK и runtime/debug.SetMaxStack).

States горутины

  1. _Gidle, не инициализирована.
  2. _Grunnable, готова к запуску, в очереди.
  3. _Grunning, исполняется на M (OS-thread).
  4. _Gwaiting, заблокирована (channel, syscall, mutex, sleep).
  5. _Gdead, завершилась.

2. GMP-модель планировщика

Три буквы, которые ждут на собеседовании:

  • G, goroutine.
  • M, machine, OS-thread.
  • P, processor, логический процессор (контекст исполнения).

Схема:

        Global Run Queue
              |
    +---------+---------+
    |         |         |
   P0        P1        P2     (GOMAXPROCS штук)
  /|\       /|\       /|\
Local     Local     Local
RunQ      RunQ      RunQ
   \         |         /
    \        |        /
     M0     M1      M2     (OS threads)

GOMAXPROCS по умолчанию равен числу CPU-ядер видимых процессу. Управляет числом P, а не M. M создаётся под надобность (например, при syscall-блокировке P отвязывается от текущего M и привязывается к свободному).

Алгоритм диспетчеризации (упрощённо)

  1. M ищет горутины в локальной очереди своего P.
  2. Если пусто, проверяет глобальную очередь.
  3. Если пусто, steals половину из локальной очереди другого P (work stealing).
  4. Если совсем нечего, ждёт на park.

Каждые ~10 мс рантайм делает sysmon-cycle: вытесняет горутины, занимающие P слишком долго (preemption), забирает работу из netpoller-а.

Netpoller

Для non-blocking I/O рантайм держит отдельный poller (epoll на Linux, kqueue на macOS, IOCP на Windows). Когда горутина делает read на TCP-сокете, который не готов:

  • горутина паркуется (state, _Gwaiting),
  • M освобождается и подхватывает другую горутину,
  • когда сокет готов, netpoller “будит” горутину обратно в _Grunnable.

Это позволяет одному M обслуживать тысячи параллельных connection-ов. Это и есть main-фича Go для backend-сервисов.

Preemption (Go 1.14+)

До Go 1.14 рантайм мог вытеснить горутину только в “safe-points” (вызов функции, аллокация). Тяжёлая горутина без вызовов могла монополизировать P. С Go 1.14 включён asynchronous preemption через сигналы (SIGURG на Linux), и любая горутина может быть вытеснена в любой момент. Для собеседования: знать что preemption async, что появилось в 1.14.

3. context, обязательный первый аргумент

Любая функция, которая делает I/O или может работать долго, первым параметром принимает ctx context.Context. Это уже не стиль, это контракт экосистемы.

func DoSomething(ctx context.Context, args ...) (result, error) { ... }

Дерево контекстов

ctx := context.Background()
ctx1, cancel1 := context.WithCancel(ctx)
ctx2, cancel2 := context.WithTimeout(ctx1, 5*time.Second)
defer cancel1()
defer cancel2()
  • Отмена родителя, отменяются все потомки.
  • Отмена потомка, родитель не затрагивается.
  • cancel обязательно вызывать (через defer), иначе утечка ресурсов рантайма.

Шаблон проверки

select {
case <-ctx.Done():
    return ctx.Err() // context.Canceled или context.DeadlineExceeded
case v := <-ch:
    use(v)
}

Antipattern, context.Value для бизнес-данных

ctx.Value нужен только для request-scoped инфраструктуры (trace-id, request-id, user из middleware). Запихивать туда бизнес-объекты, как в Java ThreadLocal, плохо: теряется типизация, неявные зависимости.

4. Sync-примитивы, когда что брать

Mutex и RWMutex

var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// критическая секция

var rw sync.RWMutex
rw.RLock()    // много читателей одновременно
v := state
rw.RUnlock()
rw.Lock()     // эксклюзивно для записи
state = newV
rw.Unlock()

RWMutex выгоден когда readers сильно преобладают над writers (раз в 10+). Для смешанной нагрузки обычный Mutex может быть быстрее из-за более простой реализации.

Не копировать структуры с mutex. Если у тебя type Server struct { mu sync.Mutex; ... }, методы должны быть только pointer-receiver-ами. go vet это ловит как warning.

Atomic

sync/atomic, lock-free операции над числами и указателями.

var n int64
atomic.AddInt64(&n, 1)
atomic.LoadInt64(&n)
atomic.StoreInt64(&n, 0)
atomic.CompareAndSwapInt64(&n, old, new)

С Go 1.19 удобнее через atomic.Int64, atomic.Pointer[T]:

var counter atomic.Int64
counter.Add(1)
v := counter.Load()

Когда atomic, а когда mutex:

  • atomic, простые счётчики, флаги, single-pointer swaps.
  • mutex, защита сложных структур или нескольких полей “вместе”.

WaitGroup

var wg sync.WaitGroup
for i := 0; i < n; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        work(i)
    }(i)
}
wg.Wait()

Главные грабли:

  • wg.Add(n) строго до go func(), не внутри. Иначе race.
  • Передавай WaitGroup по указателю, не по значению (это структура с состоянием).
  • В Go 1.25 ожидается метод Go на WaitGroup (wg.Go(func(){...})), упрощающий запуск горутин.

Once и init

var (
    once sync.Once
    inst *DB
)

func GetDB() *DB {
    once.Do(func() {
        inst = newDB()
    })
    return inst
}

Once.Do гарантирует, что функция выполнится ровно один раз на все горутины. Это отличается от init():

Критерийinit()sync.Once
Когдапри загрузке пакетапри первом вызове
Параметрынетзамыкание со всем нужным
Тестируемостьсложно (нет контроля)легко (можно сбросить)

Pool

var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)

sync.Pool, переиспользование объектов, чтобы снизить нагрузку на GC. Подходит для buffers, parsers, encoders, всего что переаллоцируется в горячих путях.

Важно: рантайм может очистить pool при GC. Не клади туда состояние, которое обязательно должно сохраниться.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.