Goroutines в Go: как работают и что спрашивают на интервью

Глубокий разбор горутин в Go: планировщик M:N, стек, каналы, sync-примитивы, паттерны конкурентности, утечки горутин и context — с примерами кода и реальными вопросами с интервью.

01 марта 2026 г.20 минLexicon Team

Горутины — это главная суперсила Go. Именно они позволяют запускать сотни тысяч конкурентных задач на обычном сервере, не платя при этом цену OS-потоков. За простым синтаксисом go f() скрывается сложная машина: планировщик с work-stealing, динамический стек, кооперативное и преемптивное переключение. Понять это — значит не просто использовать горутины, а правильно их проектировать.

Если ты готовишься к Go-собеседованию в целом — загляни в 50 вопросов для junior и middle Go-разработчика: там разбираются интерфейсы, ошибки, каналы и многое другое. В этой статье мы уходим глубже — разбираем конкурентность как отдельную тему, потому что на середнячковых и сеньорских интервью именно она проверяется особенно тщательно.

Читатель познакомится с моделью GMP, поймёт чем горутина отличается от потока на уровне реализации, научится правильно применять каналы, sync-примитивы и context, распознавать утечки горутин и data race. И, конечно, получит развёрнутые ответы на вопросы, которые реально задают на интервью.

Go для собеседований: 50 реальных вопросов

Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.

Начать тренировку

1. Конкурентность vs параллелизм

Роб Пайк сформулировал разницу точно: «конкурентность — о структуре программы, параллелизм — об исполнении». Конкурентная программа составлена из независимых частей, которые могут выполняться в перекрывающиеся промежутки времени. Параллельная программа физически исполняет несколько вычислений одновременно на разных CPU.

Представь кухню ресторана. Один повар может быть конкурентным: поставил воду кипятиться, пока ждёт — нарезает овощи, потом возвращается к плите. Два повара на двух плитах — это параллелизм. Go позволяет и то, и другое: писать конкурентную структуру программы через горутины, а параллелизм получать автоматически через GOMAXPROCS.

Важный вывод: конкурентность не требует нескольких ядер. Программа с тысячей горутин прекрасно работает на одном ядре — планировщик будет переключать их по очереди. Параллелизм — это уже про физическое одновременное исполнение на нескольких ядрах. Go даёт оба инструмента, и понимание разницы помогает принимать правильные архитектурные решения.

2. Планировщик Go: модель GMP

Планировщик Go реализует схему M:N — N горутин мультиплексируются на M OS-потоков. В основе лежит модель из трёх сущностей: G, M и P.

G (Goroutine) — горутина. Структура в памяти рантайма, содержащая стек, регистры, статус и указатель на текущую функцию. Создаётся дёшево, может их быть миллионы.

M (Machine) — OS-поток. Именно M выполняет код. M нужен P, чтобы исполнять горутины. Количество M не ограничено фиксированно, но рантайм создаёт новые потоки осторожно — обычно их около GOMAXPROCS.

P (Processor) — логический процессор, контекст выполнения. P держит локальную очередь горутин (до 256 штук) и предоставляет M ресурсы для исполнения. Количество P задаётся GOMAXPROCS и по умолчанию равно числу логических CPU.

import (
    "fmt"
    "runtime"
)

func main() {
    // По умолчанию = runtime.NumCPU()
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
    fmt.Println("Goroutines:", runtime.NumGoroutine())

    // Явное ограничение (полезно для тестов и бенчмарков)
    runtime.GOMAXPROCS(1)
}

Глобальная очередь и work-stealing. Помимо локальных очередей P, есть глобальная очередь горутин. Когда локальная очередь P пуста, он сначала смотрит в глобальную, а если и там пусто — крадёт половину горутин из очереди другого P. Это work-stealing: балансировка нагрузки без централизованного координатора.

Преемптивное планирование с Go 1.14. До версии 1.14 Go использовал только кооперативное планирование — горутина уступала процессор добровольно при вызовах функций, операциях с каналами или I/O. Это создавало проблему: горутина в плотном CPU-цикле могла заблокировать поток надолго. С Go 1.14 появился sysmon — служебный поток, который периодически посылает сигнал SIGURG работающим горутинам, принудительно прерывая их.

3. Горутина vs OS-поток: детали реализации

Разница между горутиной и OS-потоком — не просто «один легче другого». Это принципиально разные абстракции.

Стек. OS-поток получает фиксированный стек при создании — обычно 1–8 МБ. Горутина начинается с 2–8 КБ и растёт динамически по мере необходимости, вплоть до 1 ГБ (настраивается через runtime/debug.SetMaxStack). До Go 1.3 использовались segmented stacks (цепочка сегментов), потом перешли на contiguous stacks — при переполнении рантайм аллоцирует новый непрерывный стек большего размера и копирует туда содержимое старого.

Стоимость создания. Горутина создаётся примерно за 2–4 мкс и занимает ~4 КБ. OS-поток создаётся за ~1 мс и занимает ~1–8 МБ стека. Разница в 100–500 раз по времени и в 250–2000 раз по памяти. Именно поэтому в Go легко запустить миллион горутин там, где несколько тысяч потоков уже убьют систему.

Переключение контекста. Переключение между OS-потоками — это системный вызов, требующий сохранения и восстановления всех регистров процессора, TLB-промахи, обновление таблицы страниц. Переключение между горутинами происходит внутри рантайма Go, без syscall ОС, с сохранением только небольшого набора регистров Go. Это на порядок быстрее.

Масштаб. На практике Go-приложения без проблем держат 100 000 горутин. Веб-сервер с горутиной на каждый запрос при нагрузке 10 000 RPS будет иметь десятки тысяч активных горутин одновременно — это штатный режим работы.

4. Базовое использование горутин

Запустить горутину проще некуда:

go sayHello("world") // горутина из именованной функции

go func() {          // анонимная горутина
    fmt.Println("anonymous")
}()

Классическая ловушка: замыкание в цикле. Это один из самых частых источников гонок данных у начинающих:

// Неправильно: все горутины захватывают одну переменную i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // i — это переменная цикла, не значение
    }()
}
// Выведет что-то вроде: 5 5 5 5 5

// Правильно: передаём значение как параметр
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)
    }(i)
}

В Go 1.22 поведение переменной цикла изменилось — теперь каждая итерация создаёт новую переменную, и старый баг ушёл. Но знать оба варианта важно, потому что большинство продакшн-кодов написано на более ранних версиях.

sync.WaitGroup: ждём завершения горутин. Без синхронизации main завершится, не дожидаясь горутин:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1) // Add вызываем ДО запуска горутины
    go func(n int) {
        defer wg.Done()
        fmt.Printf("worker %d done\n", n)
    }(i)
}

wg.Wait() // блокируемся до Done() * 5

Распространённая ошибка — вызов wg.Add(1) внутри горутины. Если wg.Wait() выполнится раньше, чем горутина успеет сделать Add, программа завершится преждевременно. Правило: Add всегда вызывается в том же потоке управления, что и Wait.

5. Каналы: механизм общения горутин

Каналы — основной инструмент координации горутин в Go. Философия языка: «не общайтесь через разделяемую память — разделяйте память через общение».

Небуферизированный канал синхронный: отправитель блокируется до тех пор, пока получатель не готов принять. Это гарантирует, что передача данных произошла.

ch := make(chan int) // небуферизированный

go func() {
    ch <- 42 // блокируется, пока main не прочитает
}()

val := <-ch // блокируется, пока горутина не отправит
fmt.Println(val)

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

ch := make(chan int, 3) // буфер на 3 элемента

ch <- 1 // не блокирует, буфер не полон
ch <- 2
ch <- 3
// ch <- 4 // заблокировало бы отправителя

fmt.Println(<-ch) // 1

Направленность каналов — важный инструмент выразительности и безопасности. В сигнатурах функций принято указывать направление:

func producer(out chan<- int) { // только запись
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) { // только чтение
    for v := range in {
        fmt.Println(v)
    }
}

for range по каналу читает значения до тех пор, пока канал не закрыт и не пуст. Это самый удобный способ потребления потока данных.

select позволяет ждать на нескольких каналах одновременно:

select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    // выполняется немедленно, если ни один канал не готов
    fmt.Println("nothing ready")
}

Если несколько каналов готовы одновременно, select выбирает один из них псевдослучайно. Это важный факт для интервью.

Закрытие канала. Закрытый канал сигнализирует получателю, что данных больше не будет. Запись в закрытый канал вызывает panic. Чтение из закрытого канала немедленно возвращает нулевое значение:

ch := make(chan int, 1)
ch <- 42
close(ch)

v, ok := <-ch
fmt.Println(v, ok) // 42 true (данные из буфера)

v, ok = <-ch
fmt.Println(v, ok) // 0 false (канал закрыт и пуст)

Правило: закрывает канал только отправитель, и только один раз. Никогда не закрывай канал на стороне получателя.

Deadlock. Deadlock возникает когда все горутины заблокированы и нет возможности продолжить выполнение. Go-рантайм обнаруживает это и вызывает панику с сообщением all goroutines are asleep - deadlock!. Классический пример — попытка отправить в небуферизированный канал без получателя:

ch := make(chan int)
ch <- 42 // deadlock: некому читать

6. Синхронизация: sync-примитивы

Каналы хороши для передачи данных и сигналов, но для защиты разделяемого состояния sync-примитивы часто удобнее и производительнее.

sync.Mutex защищает критическую секцию от одновременного доступа:

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock() // defer гарантирует разблокировку даже при панике
    c.value++
}

func (c *SafeCounter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

sync.RWMutex оптимален для read-heavy нагрузок: множество читателей могут работать одновременно, но запись — эксклюзивна:

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()         // несколько читателей одновременно
    defer c.mu.RUnlock()
    v, ok := c.data[key]
    return v, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()          // эксклюзивная запись
    defer c.mu.Unlock()
    c.data[key] = value
}

sync.WaitGroup — см. раздел 4. Ключевое правило: не копируй WaitGroup после первого использования.

sync.Once гарантирует, что функция выполнится ровно один раз, независимо от числа вызывающих горутин — классический lazy singleton:

var (
    instance *Database
    once     sync.Once
)

func GetDB() *Database {
    once.Do(func() {
        instance = &Database{} // выполнится ровно один раз
        instance.Connect()
    })
    return instance
}

sync.Map — конкурентно-безопасная map без ручных блокировок. Оптимальна когда записи редки, а чтения часты, или когда разные горутины работают с непересекающимися ключами:

var m sync.Map

m.Store("key", "value")

if v, ok := m.Load("key"); ok {
    fmt.Println(v)
}

m.Range(func(k, v interface{}) bool {
    fmt.Println(k, v)
    return true // false остановит обход
})

Когда канал, когда mutex? Канал — когда нужно передать ownership данных или отправить сигнал между горутинами. Mutex — когда несколько горутин должны обращаться к одному разделяемому состоянию. Простой критерий: если горутины общаются («пришли новые данные, обрабатывай»), используй канал. Если несколько горутин просто читают/пишут одну структуру — используй mutex.

7. Паттерны конкурентности

Go-экосистема выработала несколько устойчивых паттернов. Знание их — это то, что отличает человека, который «писал горутины», от того, кто умеет проектировать конкурентные системы.

Done-канал: контролируемая отмена

Самый простой способ остановить горутину — сигнальный канал. Закрытие канала (close) будет немедленно разблокировать все горутины, которые его слушают:

func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            fmt.Println("worker stopped")
            return
        default:
            // выполняем работу
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    done := make(chan struct{})
    go worker(done)

    time.Sleep(500 * time.Millisecond)
    close(done) // останавливаем все воркеры разом
}

Pipeline: цепочка обработки

Паттерн pipeline строит цепочку горутин, где каждая читает из входного канала, обрабатывает данные и пишет в выходной:

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func main() {
    nums := generate(2, 3, 4, 5)
    squares := square(nums)

    for v := range squares {
        fmt.Println(v) // 4 9 16 25
    }
}

Fan-out / Fan-in: распределение и сбор

Fan-out: один источник → несколько воркеров. Fan-in: несколько источников → один потребитель:

func fanOut(in <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        channels[i] = square(in) // несколько воркеров на одном входном канале
    }
    return channels
}

func fanIn(channels ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    merged := make(chan int)

    output := func(c <-chan int) {
        defer wg.Done()
        for v := range c {
            merged <- v
        }
    }

    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

Worker pool: ограничение параллелизма

Worker pool ограничивает количество одновременно работающих горутин через буферизированный канал задач:

func workerPool(jobs <-chan int, results chan<- int, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- job * job // тяжёлая работа
            }
        }()
    }
    go func() {
        wg.Wait()
        close(results)
    }()
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    workerPool(jobs, results, 5) // 5 воркеров

    for i := 1; i <= 20; i++ {
        jobs <- i
    }
    close(jobs)

    for r := range results {
        fmt.Println(r)
    }
}

Go для собеседований: 50 реальных вопросов

Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.

Начать тренировку

8. context.Context: отмена и таймауты

context.Context — стандартный способ распространять отмену, дедлайны и метаданные запроса через цепочку горутин. Это не просто удобство — это идиома, которую используют все стандартные библиотеки Go.

context.WithCancel даёт ручной контроль над отменой:

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // важно: всегда вызывать cancel, чтобы освободить ресурсы

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("goroutine stopped:", ctx.Err())
            return
        default:
            doWork()
        }
    }
}(ctx)

time.Sleep(time.Second)
cancel() // останавливаем горутину

context.WithTimeout и context.WithDeadline добавляют автоматическую отмену по времени:

// Отмена через 2 секунды
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// Отмена в конкретный момент времени
deadline := time.Now().Add(5 * time.Second)
ctx, cancel = context.WithDeadline(context.Background(), deadline)
defer cancel()

Реальный пример: HTTP-обработчик с отменяемой горутиной. Когда клиент закрывает соединение, контекст запроса отменяется — и все горутины, работающие в его рамках, могут это обнаружить:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // контекст запроса отменяется при закрытии соединения

    resultCh := make(chan string, 1)
    go func() {
        result, err := fetchFromDB(ctx) // передаём ctx в запрос к БД
        if err != nil {
            return // ctx.Done() уже сработал, не пишем в resultCh
        }
        resultCh <- result
    }()

    select {
    case result := <-resultCh:
        fmt.Fprintln(w, result)
    case <-ctx.Done():
        http.Error(w, "request cancelled", http.StatusRequestTimeout)
    }
}

Правило про хранение Context. Никогда не храни context.Context в структуре — передавай явно первым параметром функции. Это сделано намеренно: контекст привязан к запросу/операции, а не к объекту. Хранение в структуре маскирует время жизни контекста и усложняет отладку.

9. Утечки горутин

Утечка горутины — горутина запустилась и никогда не завершится: она заблокирована на приёме из канала, в который никто не пишет, или на отправке в канал, из которого никто не читает. Горутина занимает память и удерживает ссылки на объекты — GC не может их собрать.

Классический пример утечки:

func leaky(ch <-chan int) {
    // Если никто не отправит в ch — горутина висит вечно
    val := <-ch
    fmt.Println(val)
}

func main() {
    ch := make(chan int)
    go leaky(ch)
    // ch никогда не получает данных
    // горутина leaky утекла
}

Исправленная версия с context:

func safe(ctx context.Context, ch <-chan int) {
    select {
    case val := <-ch:
        fmt.Println(val)
    case <-ctx.Done():
        fmt.Println("context cancelled, stopping")
        return
    }
}

Диагностика. Самый быстрый способ — runtime.NumGoroutine() в начале и конце теста. Если число растёт — есть утечка:

func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()

    // запускаем тестируемый код
    doWork()

    time.Sleep(100 * time.Millisecond) // ждём завершения горутин

    after := runtime.NumGoroutine()
    if after > before {
        t.Errorf("goroutine leak: before=%d after=%d", before, after)
    }
}

Для детальной диагностики — pprof. Добавь в HTTP-сервер:

import _ "net/http/pprof"
// Затем: curl http://localhost:6060/debug/pprof/goroutine?debug=1

Для автоматической проверки в тестах используй go.uber.org/goleak:

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m) // провалит тест при любой утечке горутин
}

10. Data race и race detector

Data race возникает, когда две или более горутин одновременно обращаются к одной переменной, и хотя бы одно из обращений — запись. Результат неопределён: можно получить некорректные данные, краш, или всё может работать «нормально» до того дня, когда нагрузка вырастет.

// Data race: counter читается и пишется без синхронизации
var counter int

func increment() {
    counter++ // не атомарная операция: read → increment → write
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    time.Sleep(time.Second)
    fmt.Println(counter) // результат непредсказуем
}

Race detector встроен в Go-тулчейн. Включается флагом -race:

go test -race ./...
go run -race main.go
go build -race -o app .

При обнаружении гонки программа выведет полный стек трейс — какие горутины обращались к переменной и откуда. В продакшне -race не включают (накладные расходы ~2–20x по памяти и CPU), но в тестах — обязательно.

sync/atomic позволяет выполнять атомарные операции без мьютекса. Атомарные операции неделимы — гонки невозможны:

import "sync/atomic"

var counter int64

func increment() {
    atomic.AddInt64(&counter, 1) // атомарно: нет data race
}

func get() int64 {
    return atomic.LoadInt64(&counter) // атомарное чтение
}

Atomic достаточен для простых счётчиков и флагов. Когда нужно атомарно обновить несколько полей структуры или выполнить несколько зависимых операций — используй mutex.

11. Что спрашивают на интервью

Этот раздел — концентрат реальных вопросов, которые задают на Go-собеседованиях от middle до senior.


Объясни модель GMP.

G — горутина (виртуальный поток), M — OS-поток (Machine), P — логический процессор (Processor). P хранит локальную очередь горутин и предоставляет контекст выполнения. M для работы должен захватить P. Количество P задаётся GOMAXPROCS. Если у P заканчиваются горутины, он крадёт из очереди другого P (work-stealing). Когда горутина блокируется на syscall, M отпускает P, P подхватывает другой M — это обеспечивает непрерывную работу CPU.


Сколько горутин можно создать?

Теоретически — ограничено памятью. На практике Go легко держит сотни тысяч горутин. Первоначальный стек ~4 КБ, при 100 000 горутин — около 400 МБ только на стеки. Плюс накладные расходы планировщика. Разумный ориентир: если нужны миллионы горутин — стоит задуматься о worker pool.


Что такое deadlock? Как возникает?

Deadlock — все горутины заблокированы и ни одна не может продолжить работу. Типичные причины: горутина ждёт приёма из канала, в который никто не отправляет; горутина ждёт отправки в канал, из которого никто не читает; циклическое ожидание мьютексов. Рантайм Go обнаруживает полный deadlock и завершает программу паникой: all goroutines are asleep - deadlock!.


Разница между буферизированным и небуферизированным каналом?

Небуферизированный канал (make(chan T)) синхронен: отправитель блокируется до тех пор, пока получатель не готов принять. Буферизированный канал (make(chan T, N)) асинхронен: отправитель блокируется только когда буфер заполнен, получатель — только когда буфер пуст. Небуферизированный канал гарантирует рандеву — отправитель знает, что получатель взял данные.


Как правильно остановить горутину?

Три идиоматичных способа. Первый — done-канал: передать done <-chan struct{}, горутина проверяет его в select. Второй — context.Context: передать ctx, горутина проверяет ctx.Done(). Третий — явный сигнал через канал. Прямого способа убить горутину снаружи нет — это намеренное решение языка для безопасности.


Что будет при записи в закрытый канал?

Запись в закрытый канал вызывает panic: send on closed channel. Чтение из закрытого канала немедленно возвращает нулевое значение и false вторым аргументом. Чтобы проверить, закрыт ли канал: v, ok := <-ch — если ok == false, канал закрыт. Закрывает канал только отправитель, один раз. Получатель никогда не закрывает канал.


Когда использовать sync.Mutex, а когда канал?

Канал — для коммуникации: передачи данных и ownership между горутинами, для сигналов и событий. Mutex — для защиты разделяемого состояния, к которому несколько горутин обращаются одновременно. Простое правило: если хочешь «сообщить о событии» — канал; если хочешь «защитить доступ» — mutex. В read-heavy сценариях sync.RWMutex эффективнее обычного mutex.


Что такое data race? Как обнаружить?

Data race — одновременный доступ нескольких горутин к одной переменной, где хотя бы одно обращение — запись, без синхронизации. Результат непредсказуем. Обнаружить: go test -race ./... или go run -race main.go. Race detector использует LLVM ThreadSanitizer и при обнаружении гонки выводит полный стектрейс. В продакшне не включают из-за накладных расходов.


Как работает select с несколькими готовыми каналами?

Если несколько каналов в select готовы одновременно, Go выбирает один из них псевдослучайно. Это намеренно — предотвращает голодание (starvation), при котором один канал всегда обрабатывается раньше других.


Что такое утечка горутин?

Горутина, которая запустилась и никогда не завершится — заблокирована на канале или синхронизации без возможности выхода. Утечка горутин постепенно исчерпывает память. Диагностика: runtime.NumGoroutine() в динамике, pprof /debug/pprof/goroutine, goleak в тестах.


Итоги

Горутины — это не просто «лёгкие потоки». Это целая система: планировщик GMP с work-stealing, динамический стек, преемптивное переключение, каналы как механизм координации, богатый набор sync-примитивов. Понимание этого стека позволяет писать не просто рабочий, а корректный и предсказуемый конкурентный код.

Ключевые принципы, которые стоит помнить: всегда давай горутинам способ завершиться, передавай context.Context для управления временем жизни, не копируй sync-примитивы, ищи гонки через -race, следи за утечками в тестах через goleak.

Если хочешь углубиться в другие аспекты Go-собеседований — читай 50 вопросов для junior и middle Go-разработчика: там разбираются интерфейсы, обработка ошибок, работа с памятью и другие темы, которые часто встречаются рядом с вопросами о конкурентности.

Тренируй Go с AI-интервьюером

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

Попробовать

Go-вопросы в Telegram

Ежедневные разборы горутин, каналов и паттернов.

Подписаться

Автор

Lexicon Team

Читайте также