Concurrency глубоко: GMP, sync, atomics, race
Урок-конспект по второй большой теме собеседований: как устроен Go-scheduler, что такое горутина, какие есть примитивы синхронизации и где их применять. Считаем, что ты уже прошёл Tour of Go и идиоматику и внутреннее устройство типов.
1. Goroutine, не поток
Горутина, это легковесная задача рантайма, не OS-thread. Сравнение в одном кадре:
| Характеристика | Goroutine | OS Thread |
|---|---|---|
| Стартовый стек | 2 KB | 1, 2 MB |
| Создание | ~300 нс | ~1 мс |
| Context switch | ~100 нс | ~1 мкс |
| Управление | Go runtime | OS kernel |
| Stack growth | растёт сегментами по необходимости | фиксированный, переполнение, segfault |
В одном Go-процессе нормально иметь сотни тысяч горутин. Поток ОС столько никогда не запустит.
Где живёт горутина
Стек горутины растёт сегментами: рантайм проверяет в начале каждой функции, не пора ли расти, и если да, аллоцирует новый сегмент с копированием старого. Поэтому стек начинается с 2 KB, но может вырасти до сотен мегабайт (лимит, GOTRACEBACK и runtime/debug.SetMaxStack).
States горутины
_Gidle, не инициализирована._Grunnable, готова к запуску, в очереди._Grunning, исполняется на M (OS-thread)._Gwaiting, заблокирована (channel, syscall, mutex, sleep)._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 и привязывается к свободному).
Алгоритм диспетчеризации (упрощённо)
- M ищет горутины в локальной очереди своего P.
- Если пусто, проверяет глобальную очередь.
- Если пусто, steals половину из локальной очереди другого P (work stealing).
- Если совсем нечего, ждёт на 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.