Перейти до змісту

Горутини

Горутина (goroutine) — це функція, що виконується паралельно з усім іншим у програмі. Запускають її, поставивши go перед викликом. Це весь синтаксис — функція виконується незалежно, а викликач продовжує роботу, не чекаючи.

go doWork()        // запускає doWork паралельно; одразу повертає керування

Горутини — це не потоки ОС. Середовище виконання Go мультиплексує багато горутин на невеликий пул потоків (планувальник M:N), тож вони надзвичайно дешеві — кілька кілобайтів стеку кожна, і їх можуть бути сотні тисяч. Створення горутини коштовно ближче до виклику функції, ніж до породження потоку.

З погляду Python: горутина схожа на задачу, заплановану в циклі подій, але без «забарвлення» async/awaitбудь-яка функція може виконуватися в горутині — і середовище використовує справжні потоки ОС під капотом, тож горутини виконуються паралельно на кількох ядрах, а не лише конкурентно.

Головна горутина завершується — і забирає все з собою

Сам main виконується в горутині. Коли main повертає керування, програма завершується негайно, не чекаючи на інші горутини. Тому це часто не друкує нічого:

func main() {
    go fmt.Println("hello from a goroutine")
    // main повертає тут; програма може завершитися до запуску горутини
}

Горутина може не встигнути виконатися. Потрібна синхронізація — щоб main дочекався завершення роботи. Сон — це не синхронізація (це здогад); правильний інструмент — sync.WaitGroup.

Очікування через sync.WaitGroup

WaitGroup рахує незавершені горутини. Add(n) піднімає лічильник, кожна горутина викликає Done() після завершення (через defer), а Wait() блокує, доки лічильник не сягне нуля.

// checkHealth пінгує один сервіс. Sleep імітує реальну мережеву затримку.
func checkHealth(name string) string {
    time.Sleep(100 * time.Millisecond)
    return name + ": ok"
}

func main() {
    services := []string{"api", "db", "cache"}
    statuses := make([]string, len(services))

    var wg sync.WaitGroup
    for i, name := range services {
        wg.Add(1)
        go func() {
            defer wg.Done()
            statuses[i] = checkHealth(name)   // кожна горутина пише у свій слот
        }()
    }
    wg.Wait()                                 // блокувати, доки всі три перевірки не завершаться

    for _, s := range statuses {
        fmt.Println(s)
    }
}
// output:
// api: ok
// db: ok
// cache: ok

Ось навіщо потрібні горутини: перекривати незалежну повільну роботу. Кожен checkHealth триває 100 мс. Один за одним — це ~300 мс; запущені як горутини, вони всі чекають одночасно, тож увесь пакет завершується за ~100 мс. Замініть sleep на справжній HTTP-запит чи запит до БД — і це справжній конкурентний перевіряч стану.

Дві речі тримають це коректним:

  • Wait() гарантує, що кожна перевірка завершилася, перш ніж ми читаємо statuses.
  • Кожна горутина пише в різний слот зрізу, тож немає конкурентного запису в ту саму пам'ять (немає гонитви — перевірте go run -race).

Змінна циклу — на кожну ітерацію своя

Цикл вище захопив і i, і name у кожну горутину, і кожна ітерація має власну копію — тож горутина для "db" справді бачить name == "db", а не те значення, на якому цикл завершився. Це сучасна поведінка Go: кожна ітерація циклу for має свіжу змінну циклу.

У старішому Go це було сумнозвісним багом — єдина спільна змінна на момент запуску горутин зазвичай мала останнє значення циклу, тож кожна горутина використала б "cache". Це обходили, передаючи значення як аргумент:

for i, name := range services {
    wg.Add(1)
    go func(i int, name string) {   // старий обхід: передати копії як аргументи
        defer wg.Done()
        statuses[i] = checkHealth(name)
    }(i, name)
}

Це все ще працює, і ви побачите його в старому коді, але потреби в ньому більше немає.

Горутини виконуються паралельно

За наявності кількох CPU горутини справді виконуються одночасно. GOMAXPROCS керує тим, скільки їх може виконуватися водночас (за замовчуванням — кількість CPU). Через справжню паралельність будь-який спільний змінний стан потребує захисту — саме для цього є канали та пакет sync, розглянуті далі.

fmt.Println(runtime.NumGoroutine())   // скільки горутин існує просто зараз

Паніка горутини валить програму

Паніка в горутині, яку не відновлено всередині тієї самої горутини, завалює весь процес — ви не можете відновити її з батьківської. Кожна горутина відповідає за власний recover (див. panic та recover).

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        panic("boom")     // тут не відновлено
    }()
    wg.Wait()
}
// panic: boom
// (уся програма падає — відкладений wg.Done виконується під час розкручування,
//  але ніщо в main не може це перехопити)

Виправлення — робити recover у самій горутині:

go func() {
    defer wg.Done()
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)   // output: recovered: boom
        }
    }()
    panic("boom")
}()
// main продовжує працювати

Швидка довідка

Конструкція Значення
go f(args) виконати f паралельно; одразу повертає керування
func main() повертає програма завершується, покидаючи інші горутини
var wg sync.WaitGroup рахувати незавершені горутини
wg.Add(n) / wg.Done() / wg.Wait() підняти / зменшити / блокувати-до-нуля
змінна циклу на ітерацію кожна ітерація захоплює власну копію
runtime.GOMAXPROCS макс. горутин, що виконуються паралельно
runtime.NumGoroutine() поточна кількість горутин

Джерела