Перейти к содержимому

Concurrent API Design: Principles, Patterns, Testing

Зачем знать на Middle 3: дизайн concurrent API — это то, что отличает middle от senior. Можно написать корректный код, но если API неправильный (race-prone, badly composable, leaky abstractions), то любой junior, использующий ваш пакет, создаст data race в production. Middle 3 / Senior должен: (1) знать, когда channels vs mutex vs atomic; (2) дизайнить APIs так, чтобы concurrency была hidden или explicit (never accidental); (3) понимать context propagation, memory ordering documentation, lock granularity; (4) уметь тестировать concurrent code (race detector, stress test, synctest). Без этого вы будете автором тех самых пакетов, которые “иногда падают на проде”.

  1. Краткое введение
  2. Глубокое погружение
    • 2.1 Принципы concurrent API
    • 2.2 Когда channels, когда mutex (Rob Pike’s rules)
    • 2.3 API patterns: functional options, builder, iterator
    • 2.4 Context propagation
    • 2.5 Memory ordering и happens-before
    • 2.6 Lock granularity
    • 2.7 Actor model и reactor pattern
    • 2.8 Worker pool
    • 2.9 Backpressure
    • 2.10 Error handling
    • 2.11 Testing concurrent code
  3. Gotchas
  4. Real cases
  5. Вопросы
  6. Practice
  7. Источники

Concurrent API — это публичный интерфейс, который безопасно используется из нескольких горутин одновременно (или явно одно-горутинный с документацией). Хороший concurrent API:

  1. Скрывает синхронизацию внутри — пользователь не должен брать lock, чтобы вызвать вашу функцию.
  2. Не возвращает ссылки на internal state без копирования.
  3. Документирует thread safety в comment’е к типу/функции.
  4. Composable — можно безопасно использовать с другими concurrent APIs.
  5. Поддерживает context для cancellation.
  6. Имеет понятную error semantics (как ошибки распространяются между горутинами).

“Don’t communicate by sharing memory; share memory by communicating.” — Rob Pike

Это не значит “всегда channel’ы”. Это значит — структурируйте программу так, чтобы данные перемещались между горутинами, а не разделялись.

“Locks внутри API, не наружу.”

Никогда не делайте API, где пользователь должен взять lock перед вызовом ваших функций. Это leaky abstraction.

“Make zero value useful.”

sync.Mutex{} готов к работе без init. То же должно быть с вашими типами.


Bad:

type Cache struct {
Mu sync.RWMutex // ⚠️ exported field
Data map[string]int
}
// User must do:
cache.Mu.RLock()
v := cache.Data["key"]
cache.Mu.RUnlock()

Good:

type Cache struct {
mu sync.RWMutex // unexported
data map[string]int
}
func (c *Cache) Get(key string) (int, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}

Bad:

func (c *Cache) Snapshot() map[string]int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data // ⚠️ caller has reference to internal map!
}
// User does:
m := cache.Snapshot()
m["evil"] = 666 // BOOM — race on cache.data!

Good:

func (c *Cache) Snapshot() map[string]int {
c.mu.RLock()
defer c.mu.RUnlock()
snap := make(map[string]int, len(c.data))
for k, v := range c.data {
snap[k] = v
}
return snap
}

Alternative (immutable snapshot):

type Cache struct {
data atomic.Pointer[map[string]int]
}
func (c *Cache) Snapshot() map[string]int {
return *c.data.Load() // read-only by convention
}

⚠️ Но нужно документировать, что snapshot read-only. Можно использовать generic interface для гарантии.

Стандартная нотация в Go-коде:

// Cache is safe for concurrent use by multiple goroutines.
type Cache struct { /* ... */ }
// NewIterator returns an iterator that is NOT safe for concurrent use.
// Each goroutine should call NewIterator separately.
func (c *Cache) NewIterator() *Iterator { /* ... */ }

Don’t mix channel-based и mutex-based в одном пакете без причины. Это путает users. Выберите парадигму и следуйте.

“Use whatever is most expressive and/or most simple.” — Rob Pike

Но если нужны правила:

СценарийPrimitive
Pipeline / fan-in / fan-out / event passingchannels
Mutable shared state (cache, map, counter)mutex
Atomic counteratomic
One-time initsync.Once
Wait for N goroutinesWaitGroup
Broadcast (signal multiple)sync.Cond или closed channel
Rate limitingtime.Tick или golang.org/x/time/rate
func stage1(out chan<- int) {
defer close(out)
for i := 0; i < 100; i++ {
out <- i
}
}
func stage2(in <-chan int, out chan<- int) {
defer close(out)
for v := range in {
out <- v * 2
}
}
func main() {
c1 := make(chan int)
c2 := make(chan int)
go stage1(c1)
go stage2(c1, c2)
for v := range c2 {
fmt.Println(v)
}
}
type Counter struct {
mu sync.Mutex
v int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.v++
c.mu.Unlock()
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.v
}
type Counter struct {
v atomic.Int64
}
func (c *Counter) Inc() { c.v.Add(1) }
func (c *Counter) Value() int64 { return c.v.Load() }
type Server struct {
addr string
timeout time.Duration
maxConn int
// ... unexported fields, mutex, etc.
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func WithMaxConn(n int) Option {
return func(s *Server) { s.maxConn = n }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second,
maxConn: 100,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Usage:
srv := NewServer(":8080", WithTimeout(10*time.Second), WithMaxConn(1000))

Configuration applied до того, как server start. Никаких race conditions — options применяются на single-goroutine construction phase.

type Pipeline struct {
mu sync.Mutex
stages []stage
}
func (p *Pipeline) AddStage(s stage) *Pipeline {
p.mu.Lock()
p.stages = append(p.stages, s)
p.mu.Unlock()
return p
}
func (p *Pipeline) Build() *RunningPipeline {
p.mu.Lock()
defer p.mu.Unlock()
return &RunningPipeline{stages: append([]stage(nil), p.stages...)}
}

Builder thread-safe (locks внутри). После Build — immutable snapshot.

type Cache struct {
mu sync.RWMutex
data map[string]int
}
func (c *Cache) Iterate(ctx context.Context) <-chan KV {
out := make(chan KV)
go func() {
defer close(out)
c.mu.RLock()
defer c.mu.RUnlock()
for k, v := range c.data {
select {
case out <- KV{k, v}:
case <-ctx.Done():
return
}
}
}()
return out
}
// Usage:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for kv := range cache.Iterate(ctx) {
if kv.K == "stop" {
cancel() // graceful stop
break
}
}

⚠️ Internal lock держится всё время итерации. Если consumer медленный — writers заблокированы. Альтернатива — snapshot первый, потом итерация без lock.

func (c *Cache) Iterate(ctx context.Context) <-chan KV {
c.mu.RLock()
snap := make([]KV, 0, len(c.data))
for k, v := range c.data {
snap = append(snap, KV{k, v})
}
c.mu.RUnlock() // release ASAP
out := make(chan KV)
go func() {
defer close(out)
for _, kv := range snap {
select {
case out <- kv:
case <-ctx.Done():
return
}
}
}()
return out
}

Trade-off: больше памяти, но не держим lock.

// GOOD:
func (c *Client) Get(ctx context.Context, key string) ([]byte, error)
// BAD:
func (c *Client) Get(key string, ctx context.Context) ([]byte, error)
// BAD:
type Client struct {
ctx context.Context
// ...
}
// GOOD:
type Client struct {
// ... no ctx
}
func (c *Client) Do(ctx context.Context, req Request) { /* ... */ }

Exception: long-running services могут иметь lifecycleCtx для shutdown signal. Но это не request context.

ctx := /* request context with cancellation */
logCtx := context.WithoutCancel(ctx) // detached from cancel
go func() {
// logging continues even after request done
log.WithContext(logCtx).Info("processed")
}()

Полезно для:

  • Logging continues after request done.
  • Cleanup operations.
  • Background work, которая не должна быть cancel’нута вместе с request.
ctx, cancel := context.WithCancel(parent)
defer cancel()
stop := context.AfterFunc(ctx, func() {
// called when ctx is canceled
conn.Close()
})
defer stop() // optional cleanup
// ... do work ...

Replaces:

go func() {
<-ctx.Done()
conn.Close()
}()

Преимущество: не нужна горутина (callback run on cancel’s goroutine).

Happens-before relation:

Event A happens-before B if:
1. A and B are in the same goroutine, and A is before B in program order.
2. A is goroutine start (go f()), B is anything in f.
3. A is channel send, B is corresponding receive.
4. A is unlock, B is corresponding lock.
5. ... (other rules)
Transitive: if A → B and B → C, then A → C.

Перед Go 1.19 memory model для atomic was vague. Сейчас:

  • Load — acquire semantics.
  • Store — release semantics.
  • Swap, Add, CAS — sequentially consistent.
var x int
var ready atomic.Bool
// Goroutine A:
x = 42
ready.Store(true) // release: x=42 publishes
// Goroutine B:
if ready.Load() { // acquire: sees published x
fmt.Println(x) // guaranteed to see 42
}
// Set updates the value.
//
// Set is safe for concurrent use.
// Set has release semantics: any writes before Set in the calling goroutine
// are visible to goroutines that observe the new value via Get.
func (c *Cache) Set(v int) { c.v.Store(v) }
type Inventory struct {
mu sync.Mutex
items map[string]int
orders []Order
stats Stats
}
func (i *Inventory) AddItem(...) {
i.mu.Lock()
defer i.mu.Unlock()
// ...
}

Плюсы: простота, легко reason about. Минусы: всё сериализуется через один mutex → contention.

type Inventory struct {
itemsMu sync.RWMutex
items map[string]int
ordersMu sync.Mutex
orders []Order
statsMu sync.Mutex
stats Stats
}

Плюсы: разные операции независимы. Минусы: deadlock risk (нужен strict ordering), сложнее reason about.

const numShards = 16
type ConcurrentMap struct {
shards [numShards]struct {
mu sync.Mutex
m map[string]int
}
}
func (c *ConcurrentMap) shard(k string) int {
h := fnv.New32a()
h.Write([]byte(k))
return int(h.Sum32()) % numShards
}
func (c *ConcurrentMap) Get(k string) (int, bool) {
s := &c.shards[c.shard(k)]
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[k]
return v, ok
}

Плюсы: scale до N concurrent access (если keys хорошо распределены). Минусы: range/iterate операции дороже (нужно lock все shards).

sync.Map использует более сложную structure (read-only + dirty map), но идея похожа.

Actor = goroutine + mailbox (channel). Все communication через message passing.

type Actor struct {
inbox chan Message
}
func NewActor() *Actor {
a := &Actor{inbox: make(chan Message, 16)}
go a.run()
return a
}
func (a *Actor) run() {
state := initialState()
for msg := range a.inbox {
state = handle(state, msg)
}
}
func (a *Actor) Send(m Message) {
a.inbox <- m
}
func (a *Actor) Stop() {
close(a.inbox)
}

Преимущества:

  • No shared state — нет race conditions by design.
  • Single goroutine processes state — простая модель.
  • Composable (actors send to actors).

Недостатки:

  • Overhead на channel ops.
  • Голова mailbox = bottleneck.
  • Sequential processing внутри actor (если нужен parallelism — sub-actors).

Single goroutine processes events from multiple sources via select.

type Server struct {
requests chan Request
events chan Event
quit chan struct{}
}
func (s *Server) run() {
for {
select {
case req := <-s.requests:
s.handleRequest(req)
case ev := <-s.events:
s.handleEvent(ev)
case <-s.quit:
return
}
}
}

Используется в HTTP/2 connection processing (один loop читает frames из socket’а).

func WorkerPool(n int, jobs <-chan Job, results chan<- Result) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- process(j)
}
}()
}
go func() {
wg.Wait()
close(results)
}()
}
type Pool struct {
minWorkers int
maxWorkers int
mu sync.Mutex
workers int
jobs chan Job
}
func (p *Pool) Submit(j Job) {
p.mu.Lock()
if p.workers < p.maxWorkers && len(p.jobs) > 0 {
p.workers++
go p.worker()
}
p.mu.Unlock()
p.jobs <- j
}

Сложнее, но adaptive под load.

import "golang.org/x/sync/errgroup"
func ProcessAll(ctx context.Context, items []Item) error {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(10) // max concurrent
for _, item := range items {
item := item // capture
g.Go(func() error {
return process(gctx, item)
})
}
return g.Wait() // returns first error, cancels others
}

errgroup — must-have для concurrent code в Go.

ch := make(chan Job, 100) // bounded buffer
// Producer:
select {
case ch <- job:
case <-ctx.Done():
return ctx.Err()
}
// Consumer:
for j := range ch {
process(j)
}

Если consumer медленный — producer блокируется. Это и есть backpressure.

import "golang.org/x/time/rate"
limiter := rate.NewLimiter(rate.Limit(100), 10) // 100/sec, burst 10
for _, req := range requests {
if err := limiter.Wait(ctx); err != nil {
return err // ctx canceled
}
process(req)
}
type Controller struct {
mu sync.Mutex
inFlight int
maxConcurrent int
}
func (c *Controller) OnSuccess() {
c.mu.Lock()
c.maxConcurrent++ // increase on success
c.mu.Unlock()
}
func (c *Controller) OnError() {
c.mu.Lock()
c.maxConcurrent /= 2 // halve on error (AIMD)
if c.maxConcurrent < 1 {
c.maxConcurrent = 1
}
c.mu.Unlock()
}

Используется в gRPC (semantic retries with backoff).

Уже показано. Стандартный паттерн.

errs := make(chan error, len(items))
for _, item := range items {
go func(it Item) {
if err := process(it); err != nil {
errs <- err
}
}(item)
}
// Collect errors:
var allErrs []error
for range items {
if err := <-errs; err != nil {
allErrs = append(allErrs, err)
}
}

⚠️ Если горутина не отправляет error (success path) — deadlock. Лучше использовать errgroup или buffered channel size = N.

func worker() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v", r)
}
}()
// ... work
}

⚠️ Panic в goroutine не ловится в parent goroutine. Каждая горутина должна сама defer recover. Иначе вся программа crash’нется.

import "errors"
var errs []error
// ... gather errors ...
return errors.Join(errs...) // Go 1.20+

errors.Join создаёт error, который wrap’ит несколько. Полезно при concurrent operations.

Окно терминала
go test -race ./...
go build -race ./...
go run -race main.go

Включает ThreadSanitizer-подобный runtime check. Замедляет в 2-20x, но ловит data races.

⚠️ Race detector ловит только races, которые реально произошли. Не triggered race может остаться невидимым.

⚠️ Должен быть включён в CI.

Новый пакет для тестирования concurrent code с virtual time.

import "testing/synctest"
func TestTimeout(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan struct{})
go func() {
<-ctx.Done()
close(done)
}()
// virtual time advances when all goroutines are blocked
synctest.Wait()
select {
case <-done:
// OK
default:
t.Fatal("ctx should be done")
}
})
}

Идея: synctest определяет, когда все горутины блокированы на time-related ops, и продвигает virtual time. Тесты с timeouts проходят моментально.

func TestStress(t *testing.T) {
if testing.Short() {
t.Skip()
}
c := NewCache()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 1000; j++ {
c.Set(fmt.Sprintf("k%d", j%10), j)
_, _ = c.Get(fmt.Sprintf("k%d", j%10))
}
}(i)
}
wg.Wait()
}

Запускайте с -race. Должен пройти 100+ раз подряд без падений.

pgregory.net/rapid или gopter — generate random sequences of operations, проверяют invariants.

import "pgregory.net/rapid"
func TestCacheProperty(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
c := NewCache()
ops := rapid.SliceOf(rapid.Int()).Draw(t, "ops")
for _, op := range ops {
c.Set(strconv.Itoa(op), op)
}
// verify invariants
})
}

Go runtime детектит deadlock, если все горутины sleep’ят. Не ловит deadlock между подгруппой.

go-deadlock (sasha-s) — drop-in replacement для sync.Mutex с deadlock detection.

import deadlock "github.com/sasha-s/go-deadlock"
var mu deadlock.Mutex
// если lock держится > 30s → printf stack trace

  1. ⚠️ Exported mutex fields. Public mutex = leaky abstraction. Locks должны быть внутри API.

  2. ⚠️ Возвращение reference на internal map/slice. Caller может mutate под вашими ногами. ВСЕГДА копируйте или возвращайте read-only view.

  3. ⚠️ Context stored в struct. Это anti-pattern (документировано в Go). Context — per-call, не per-object.

  4. ⚠️ Panic в горутине = crash программы. Каждая горутина должна сама defer recover, иначе process die.

  5. ⚠️ Channel close дважды = panic. Чёткие правила ownership: только sender close’ит. Никогда не close из reader.

  6. ⚠️ Closing channel в for-loop без guard. Если несколько senders — кто close’ит? Используйте sync.Once или producer-coordinator pattern.

  7. ⚠️ sync.WaitGroup.Add после Wait = panic. Все Add должны быть до Wait.

  8. ⚠️ errgroup без SetLimit. Если items = 1M, создаст 1M горутин. Используйте SetLimit (Go 1.20+) для ограничения.

  9. ⚠️ context.WithCancel без defer cancel = leak. Парент context’а удерживает child. Резкое падение memory если есть много активных contexts.

  10. ⚠️ sync.Map для не-read-heavy workload. Документация чётко говорит: sync.Map оптимизирован для (1) read-mostly или (2) disjoint key sets. Иначе хуже sync.RWMutex + map.

  11. ⚠️ Lock granularity vs deadlock. Fine-grained → больше deadlock risk. Strict lock ordering (по уровням) необходим.

  12. ⚠️ Race detector в CI без stress test. Race detector ловит only triggered races. Без стресса rare races не появятся.

  13. ⚠️ Variable capture в goroutine закрытии. Classic bug:

for _, x := range items {
go func() { use(x) }() // ⚠️ captures loop var
}

В Go 1.22+ поведение исправлено (каждая iteration — новый scope), но в legacy коде проблема.

  1. ⚠️ time.After в hot loop = memory leak. Каждый time.After создаёт timer, который не освобождается до срабатывания. Используйте time.NewTimer + Stop.

  2. ⚠️ Не закрытый channel + range = leak. for v := range ch blocks вечно, если ch не closed и не получает новых values.

  3. ⚠️ context.WithCancel parent context передан как nil → panic (Go 1.21+).

  4. ⚠️ atomic.Pointer[T] Store(nil) — atomic.Pointer не принимает untyped nil. Нужно var p *T; atomic.Pointer.Store(p).

  5. ⚠️ Возврат channel из API — кто его close’ит? Документируйте чётко. Best practice: каждая function, которая возвращает channel, обещает его close. Read-only channels (<-chan) clear выражают это.

  6. ⚠️ select без default может block вечно. Если все case’ы NIL channel’ы — deadlock. Иногда intentional, но всегда документируйте.

  7. ⚠️ Mutex в struct, передаваемый по значению. Mutex копируется (go vet ловит). Передавайте *T.


http.Client использует http.Transport, который имеет connection pool. Внутри — pool с mutex:

type Transport struct {
idleMu sync.Mutex
idleConn map[connectMethodKey][]*persistConn
// ...
}

Pool safe for concurrent use. Одна *Client обычно shared между горутинами. Пользователь не берёт lock — всё внутри Transport.

grpc.ClientConn — managed pool subconnections. Внутри:

  • sync.RWMutex для общей структуры.
  • atomic.Pointer для current load balancer state.
  • Channels для control plane events.

Mix of patterns: read-heavy snapshot через atomic, modify через mutex, events через channels.

Producer накапливает messages per-partition в batches. Каждый batch — sync.Mutex-protected slice. При flush — batch swap’ится, mutex отпускается, новый batch начинает накапливаться.

Backpressure: если broker медленный — batches растут, producer in-flight bytes counter гейтит дальнейшие sends.

etcd использует custom storage interface, защищённый RWMutex. Reads (snapshot, replay) идут под RLock. Writes (commit, advance) — под Lock.

При high commit rate — writer starvation возможна, но в практике writes batchatся, и читателей много.

Caddy v2 — modular HTTP server. Каждый module реализует caddy.Module. Module instances могут быть shared между горутинами (handlers). Module API документирует thread safety per-method.

Конфигурация: caddy hot-reload генерирует new module instances в parallel, swap pointer атомарно. Old instances drain’ятся (текущие requests доживают).

sync.Once используется в Std для lazy init:

  • time.Local — lazy load timezone.
  • unicode.RangeTable builds.
  • HTTP Server’s default TLS config.

Каждый раз — Once.Do(init). Init runs once, последующие caller’ы get cached result.

k8s.io/client-go/informers — watch Kubernetes resources с local cache. Cache — sync.RWMutex-protected map + indexer.

Watch events идут через single goroutine (event loop). Listeners получают callbacks. Callback running на shared dispatcher goroutine — длинные операции в callback заблокируют informer.


  1. Почему mutex должен быть внутри API, не наружу?
    Leaky abstraction: user должен знать про synchronization. Один пропущенный Lock → race. API должно скрывать synchronization детали.

  2. Когда использовать channels vs mutex?
    Channels: pipeline, event passing, fan-in/out, ownership transfer. Mutex: mutable shared state, counter (atomic лучше), one-time init (Once).

  3. Можно ли хранить context в struct?
    Не рекомендуется. Context — per-call lifecycle. Исключение: long-running service может иметь lifecycle context для shutdown.

  4. Что такое context.WithoutCancel (Go 1.21+)?
    Detach context от cancellation, но сохраняет values. Для logging, audit, cleanup, которые не должны быть cancel’нуты вместе с request.

  5. Что такое context.AfterFunc?
    Регистрирует callback, который вызовется при cancel context’а. Не требует отдельной горутины.

  6. Что такое happens-before в Go memory model?
    Если A happens-before B, то эффекты A видны в B. Устанавливается через channel ops, mutex, atomic, goroutine start.

  7. Какие memory ordering гарантии у atomic.Load?
    Acquire semantics: после Load все последующие операции видят, что было до соответствующего Store (с release semantics).

  8. Что такое coarse vs fine-grained lock?
    Coarse: один большой mutex (простой, но contention). Fine-grained: per-resource mutex (concurrent, но deadlock risk).

  9. Что такое striped lock?
    Sharded lock: hash(key) % N → выбор одного из N mutex’ов. Scale до N concurrent, key distribution критична.

  10. Зачем нужен sync.Map?
    Optimized для (1) read-mostly map, (2) disjoint key sets per-goroutine. Иначе хуже sync.RWMutex + map.

  11. Чем actor model отличается от mutex-based?
    Actor: state локально в горутине, communication через message passing. Mutex: shared state, manual sync. Actor — no races by design.

  12. Что такое reactor pattern?
    Single goroutine, select по нескольким event sources. Обрабатывает события serially. Используется в HTTP/2, gRPC, network frameworks.

  13. Когда worker pool правильнее, чем создавать goroutine per task?
    При большом числе tasks (миллионы) — pool ограничивает memory/CPU. Также для rate limiting и backpressure.

  14. Что такое backpressure?
    Механизм, через который consumer сигнализирует producer’у, что не успевает. В Go — buffered channel (producer blocks when full).

  15. Что такое AIMD?
    Additive Increase Multiplicative Decrease — алгоритм adaptive rate control (TCP congestion control, gRPC). Increase by 1 on success, halve on error.

  16. Как обрабатывать panic в горутине?
    Каждая горутина должна сама defer recover. Иначе panic crashes всю программу.

  17. Что такое errgroup и зачем?
    golang.org/x/sync/errgroup — пакет для concurrent ops с error propagation. First error cancels context, который другие горутины могут observe.

  18. Что такое errgroup.SetLimit?
    Ограничение числа одновременно работающих горутин. Введено в Go 1.20.

  19. Что делает errors.Join (Go 1.20+)?
    Возвращает один error, который wrap’ит несколько. Полезно для aggregating errors из concurrent ops.

  20. Что показывает race detector?
    Reported данные, к которым обращались из нескольких горутин без synchronization. Использует ThreadSanitizer внутри.

  21. Race detector overhead?
    2-20x slowdown, 5-10x memory. Не для production, но обязательно в CI.

  22. Что такое testing/synctest (Go 1.24+)?
    Тестирование concurrent code с virtual time. Time advances, когда все горутины блокированы. Timeouts тесты — мгновенные.

  23. Чем sync.Once лучше bool + mutex?
    Optimized для one-time init: после первого вызова — atomic load (lock-free). Sync.Once используется в std для lazy init.

  24. Что такое singleflight?
    golang.org/x/sync/singleflight — deduplicates concurrent calls. Если 100 горутин вызывают cache.GetOrFetch(key), fetch выполняется один раз.

  25. Что значит “make zero value useful”?
    Struct полезен без инициализации. var mu sync.Mutex; mu.Lock() works. Аналогично делайте свои types.


Реализуйте Cache[K, V] с операциями Get, Set, Delete, Snapshot, Iterate. Документируйте thread safety каждой операции. Race-test.

Реализуйте 3-stage pipeline: read → transform → write. Используйте errgroup для error handling. При ошибке на любой stage — все cancel.

Реализуйте Actor system: supervisor запускает worker actor’ов. Если worker panic’нул — supervisor restart’ит. Используйте channels для inter-actor communication.

Реализуйте dynamic worker pool, который scale’ит от 1 до N workers в зависимости от queue depth. Если queue растёт — добавьте worker. Если пуста — удалите.

Реализуйте token bucket rate limiter. Поддержите Allow(), Wait(ctx), Reserve(). Бенчмарк vs стандартный.

Расширьте errgroup: вместо Wait()WaitDeadline(d time.Duration). Возвращает либо first error, либо timeout.

Реализуйте StripedMap[K, V] с N shards. Стресс-тест: 100 горутин, 10k ops each. Сравните throughput с sync.Map и sync.RWMutex+map.

Возьмите свой timer-based код. Перепишите тест с testing/synctest (Go 1.24+). Должен пройти моментально (без реальных sleep’ов).


  1. Rob Pike, Go Concurrency Patterns — фундаментальный talk.
  2. Sameer Ajmani, Advanced Go Concurrency Patterns — продолжение.
  3. Bryan Mills, Rethinking Classical Concurrency Patterns — современный взгляд (GopherCon 2018).
  4. Russ Cox, Go Memory Model — обновлён в Go 1.19.
  5. Effective Go Concurrency section.
  6. Dave Cheney, Never start a goroutine without knowing how it will stop.
  7. golang/sync source — errgroup, semaphore, singleflight.
  8. Katherine Cox-Buday, “Concurrency in Go” — книга, особенно главы про pipelines.
  9. Bryan Cantrill, “Falling in Love with Rust” — relevant даже для Go (about ownership).
  10. Akka documentation — actor model patterns.
  11. Reactive Manifesto reactivemanifesto.org — про responsive, resilient, elastic systems.
  12. golang.org/x/time/rate — token bucket implementation.
  13. net/http sourceTransport, connection pool как пример хорошего design.
  14. grpc-go sourceClientConn, mixed channels/mutex patterns.
  15. testing/synctest proposal — Go 1.24+ feature.