Горутины - легковесная конкурентность

Горутины - легковесная конкурентность

Горутины - это killer feature Go. Это легковесные потоки выполнения, управляемые runtime Go. Можно запустить тысячи горутин без проблем с производительностью.

Основы горутин

Запуск горутины

func sayHello(name string) {
    fmt.Printf("Привет, %s!\n", name)
}

func main() {
    // Обычный вызов
    sayHello("Мир")

    // Запуск в горутине
    go sayHello("Горутина")

    // Без этого main завершится раньше горутины
    time.Sleep(time.Second)
}
Когда main завершается, все горутины тоже завершаются, даже если не закончили работу.

Почему горутин могут быть тысячи

Каждая горутина стартует с маленьким стеком - всего 2 КБ. Это в сотни раз меньше OS-потока. Когда стек переполняется, runtime копирует его в новый блок большего размера и поправляет указатели.

Стек горутины: 2 КБ старт, runtime копирует в больший блок при переполнении

Поэтому 1000 горутин обходится примерно в 2 МБ - на два-три порядка дешевле такого же числа потоков ОС.

Отсюда не следует, что горутины бесплатны. По той же арифметике миллион горутин - это уже около 2 ГБ только под стеки, и это при минимальном стеке: он растёт по мере вложенности вызовов. Добавьте к этому работу планировщика и давление на GC, которому все эти стеки надо просматривать. Ниже в уроке есть таблица, где «миллионы горутин» стоят в колонке «плохо», и это не противоречие - дешёвая горутина и неограниченное их количество разные вещи. Когда задач много, число одновременных горутин ограничивают: worker pool или семафор.

Анонимные функции

Подробнее про анонимные функции и замыкания - в уроке про функции.

func main() {
    message := "Hello"

    go func() {
        fmt.Println(message) // захват переменной
    }()

    go func(msg string) {
        fmt.Println(msg) // передача параметром (безопаснее)
    }(message)

    time.Sleep(time.Second)
}

Синхронизация с WaitGroup

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // уменьшаем счетчик при завершении

    fmt.Printf("Worker %d начал работу\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d закончил работу\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // увеличиваем счетчик
        go worker(i, &wg)
    }

    wg.Wait() // ждем пока счетчик не станет 0
    fmt.Println("Все workers завершены")
}

Race conditions и синхронизация

Проблема race condition

// ПЛОХО: race condition
var counter int

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

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }

    wg.Wait()
    fmt.Println(counter) // не всегда 1000!
}

Решение с Mutex

var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()
    defer mu.Unlock()
    counter++
}

Атомарные операции

var counter int64

func increment() {
    atomic.AddInt64(&counter, 1)
}

func getCount() int64 {
    return atomic.LoadInt64(&counter)
}

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

Когда базовых горутин и WaitGroup уже недостаточно, в дело вступают комбинации горутин с каналами: worker pool, fan-in / fan-out, pipeline, rate limiting, errgroup. Это уже не про синтаксис, а про композицию - поэтому их разбор вынесен в отдельный трек:

В этом уроке наша цель - понять, как горутины устроены и синхронизируются. Применение к реальным задачам - следующий шаг.

Контекст отмены

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d остановлен\n", id)
            return
        default:
            // выполняем работу
            fmt.Printf("Worker %d работает\n", id)
            time.Sleep(time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i := 1; i <= 3; i++ {
        go worker(ctx, i)
    }

    time.Sleep(3 * time.Second)
    cancel() // останавливаем всех

    time.Sleep(time.Second)
}

Типичные ошибки

Захват переменной цикла

// ОШИБКА - но только до Go 1.22.
// На современной версии этот код печатает 0..4: см. врезку ниже.
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // до 1.22: все горутины видели i = 5
    }()
}

// Работает на любой версии: значение передаётся аргументом
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n)
    }(i) // передаём копию
}

// Идиома ДО Go 1.22: локальная копия внутри тела цикла.
// С 1.22 эта строка ничего не меняет - переменная и так новая.
for i := 0; i < 5; i++ {
    i := i
    go func() {
        fmt.Println(i)
    }()
}
Это одно из немногих изменений языка, которое **меняет поведение уже написанного кода**, поэтому важно не запомнить правило задом наперёд.
Версияfor i := ... + go func(){ print(i) }()
до 1.22одна переменная на весь цикл - горутины видят последнее значение
1.22 и новеесвоя переменная на итерацию - горутины видят 0..4

Практические следствия:

  • если вы запустите «ошибочный» пример на актуальной версии Go, он напечатает правильные числа. Это не значит, что примера не было - значит, язык починили;
  • i := i внутри цикла на 1.22+ - no-op. Встретив его в чужом коде, вы смотрите на код, написанный до 1.22, а не на необходимую строку;
  • поведение зависит от версии в go.mod, а не от установленного компилятора. Модуль с go 1.21 собирается новым Go по старым правилам - именно чтобы старый код не менял поведение молча.

Передача аргументом (go func(n int){...}(i)) работает одинаково на всех версиях, поэтому в примерах курса используется она.

Горутины-утечки

// ПЛОХО: горутина никогда не завершится
func leak() {
    ch := make(chan int)

    go func() {
        val := <-ch // блокируется навсегда
        fmt.Println(val)
    }()

    // канал не закрыт, никто не пишет
}

// ХОРОШО: используем контекст
func noLeak(ctx context.Context) {
    ch := make(chan int)

    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-ctx.Done():
            return
        }
    }()
}

Мониторинг горутин

Как runtime убирает мусор

Объекты в куче (значения, которые «уехали» с фрейма функции - возврат указателя, замыкание, отправка в канал) собирает GC. Он стартует от корней (глобалы и стеки горутин), отмечает достижимые объекты, потом подметает остальные в free list.

GC проходит mark от корней, потом sweep освобождает непомеченные

GC работает конкурентно: программа не останавливается надолго - паузы порядка миллисекунд.

// Количество горутин
fmt.Println("Горутин:", runtime.NumGoroutine())

// Профилирование
import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // основная логика
}

// Затем: go tool pprof http://localhost:6060/debug/pprof/goroutine

Best practices

<ComparisonTable data={{ headers: ["Практика", "Плохо", "Хорошо"], rows: [ ["Завершение", "go func() { for { } }()", "Используйте context для отмены"], ["Синхронизация", "time.Sleep()", "sync.WaitGroup или каналы"], ["Обработка паник", "// паника убьет программу", "defer recover() в горутине"], ["Количество", "Миллионы горутин", "Пул воркеров"], ["Передача данных", "Через общую память", "Через каналы"] ] }} />

Практический пример: веб-краулер

type Result struct {
    URL   string
    Links []string
    Err   error
}

func crawl(url string) Result {
    // имитация работы
    time.Sleep(100 * time.Millisecond)

    if rand.Float32() < 0.1 {
        return Result{URL: url, Err: errors.New("failed to fetch")}
    }

    // генерируем случайные ссылки
    numLinks := rand.Intn(5)
    links := make([]string, numLinks)
    for i := range links {
        links[i] = fmt.Sprintf("%s/%d", url, i)
    }

    return Result{URL: url, Links: links}
}

func crawler(ctx context.Context, startURL string, maxDepth int) {
    visited := make(map[string]bool)
    var mu sync.Mutex

    var wg sync.WaitGroup
    results := make(chan Result)

    // Запускаем сборщик результатов
    go func() {
        for result := range results {
            if result.Err != nil {
                fmt.Printf("Ошибка %s: %v\n", result.URL, result.Err)
                continue
            }
            fmt.Printf("Обработано %s: %d ссылок\n", result.URL, len(result.Links))
        }
    }()

    var crawlURL func(string, int)
    crawlURL = func(url string, depth int) {
        if depth > maxDepth {
            return
        }

        mu.Lock()
        if visited[url] {
            mu.Unlock()
            return
        }
        visited[url] = true
        mu.Unlock()

        wg.Add(1)
        go func() {
            defer wg.Done()

            select {
            case <-ctx.Done():
                return
            default:
                result := crawl(url)
                results <- result

                for _, link := range result.Links {
                    crawlURL(link, depth+1)
                }
            }
        }()
    }

    crawlURL(startURL, 0)
    wg.Wait()
    close(results)
}
Пример показывает связку «горутина + WaitGroup + мьютекс на общей карте», и для этого он годится. Но копировать его как основу настоящего краулера нельзя, и вот почему - ровно те три вещи, о которых говорит таблица выше.

Число горутин не ограничено. На каждую найденную ссылку запускается своя горутина, и ветвление идёт по глубине: пять ссылок на страницу и глубина четыре - это уже сотни горутин, при большем ветвлении счёт идёт на тысячи. Как надо: worker pool или семафор с фиксированным лимитом - см. Fan-in, fan-out.

Отмена проверяется один раз, до начала работы. Уже запущенное поддерево обхода cancel() не остановит: каждая горутина проверила ctx на входе и дальше работает до конца. Как надо: проверять отмену перед каждым сетевым вызовом и при отправке в канал.

Последние результаты теряются. Горутина-сборщик читает results, но её никто не дожидается: crawler закрывает канал и возвращается, main завершается - и то, что сборщик не успел напечатать, исчезает. Как надо: отдельный sync.WaitGroup (или done-канал) на сборщика и ожидание его завершения после close(results).

Держите это в голове как список того, что добавляется при переходе от примера к рабочему коду.

Мини-задание

  • Запусти горутину и сразу заверши main без ожидания. Горутина не успеет отработать: программа не ждёт никого
  • Напечатай runtime.NumGoroutine() до, во время и после работы пула горутин
  • Запусти 100000 горутин, которые спят секунду, и посмотри потребление памяти. Столько же потоков ОС создать нельзя
  • Сравни время работы при GOMAXPROCS=1 и без ограничения: GOMAXPROCS=1 go run .
  • Добавь GODEBUG=schedtrace=1000 go run . и посмотри, что печатает планировщик

Итоги

  • Горутины - легковесные потоки
  • Запуск через go
  • Синхронизация через WaitGroup
  • Защита данных через Mutex
  • Всегда думайте о завершении горутин

В следующем уроке изучим каналы - основной способ коммуникации!

Типичная ошибка

Делать горутины и менять общую переменную без защиты. Детектор гонок (-race) потом будет плакать.

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