MidТеория5 min

Горутины

Горутины, планировщик G-M-P, жизненный цикл, утечки и синхронизация

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

Горутина (goroutine) -- это легковесная единица конкурентного выполнения в Go. Горутины часто называют "зелёными потоками", но это не совсем точно: они реализованы через модель M:N, где M горутин мультиплексируются на N потоков ОС. Горутина -- это не поток ОС. Она значительно дешевле по памяти и времени создания.

Потоки ОС (Thread):    ~1-8 MB стека, ~1ms создание, kernel context switch ~1-10μs
Горутины (Goroutine):  ~2 KB стека, ~0.3μs создание, runtime context switch ~0.2μs

Горутина -- это функция, запущенная конкурентно с помощью ключевого слова go. Каждая программа на Go начинается с одной горутины -- main goroutine, которая выполняет функцию main().

Запуск горутин: ключевое слово go

Для запуска функции как горутины достаточно добавить ключевое слово go перед вызовом:

package main

import (
    "fmt"
    "time"
)

// greet prints a greeting message
func greet(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    // Launch goroutine — runs concurrently
    go greet("World")

    // Launch anonymous goroutine
    go func() {
        fmt.Println("From anonymous goroutine")
    }()

    // Without this sleep, main() may exit before goroutines complete
    time.Sleep(100 * time.Millisecond)
}

Важно: Использование time.Sleep для ожидания горутин -- это антипаттерн. В продакшен-коде используйте sync.WaitGroup, каналы или контекст.

Жизненный цикл горутины

Горутина проходит через несколько состояний:

                    ┌─────────────┐
    go func() ───► │   Runnable   │ ◄──── I/O complete, chan ready
                    │  (в очереди) │
                    └──────┬──────┘
                           │
                    scheduler picks
                           │
                    ┌──────▼──────┐
                    │   Running   │ ──── выполняется на потоке ОС (M)
                    │ (на потоке) │
                    └──────┬──────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
       ┌──────▼──────┐  ┌──▼───┐  ┌────▼─────┐
       │   Waiting    │  │ Dead │  │ Preempted│
       │(I/O, chan,   │  │      │  │(async)   │
       │ mutex, sleep)│  └──────┘  └────┬─────┘
       └──────┬──────┘                  │
              │                         │
              └─────── back to ─────────┘
                       Runnable

Состояния:

  • Runnable -- горутина готова к выполнению, ждёт своей очереди
  • Running -- горутина выполняется на потоке ОС
  • Waiting -- горутина заблокирована (I/O, канал, мьютекс, sleep)
  • Dead -- горутина завершилась (return или паника)
  • Preempted -- горутина принудительно снята с потока (с Go 1.14 -- асинхронная вытеснённость)

Планировщик G-M-P

Планировщик Go реализует модель G-M-P (Goroutine - Machine - Processor):

                    ┌──────────────────────────────────────┐
                    │          Go Runtime Scheduler         │
                    └──────────────────────────────────────┘

     ┌─────────┐     ┌─────────┐     ┌─────────┐
     │   P0    │     │   P1    │     │   P2    │    P = Processor (logical)
     │ Local Q │     │ Local Q │     │ Local Q │    GOMAXPROCS = количество P
     │ [G][G]  │     │ [G][G]  │     │ [G]     │
     └────┬────┘     └────┬────┘     └────┬────┘
          │               │               │
     ┌────▼────┐     ┌────▼────┐     ┌────▼────┐
     │   M0    │     │   M1    │     │   M2    │    M = Machine (OS thread)
     │ (thread)│     │ (thread)│     │ (thread)│
     └─────────┘     └─────────┘     └─────────┘

     ┌───────────────────────────────────────────┐
     │            Global Run Queue               │    Overflow горутин
     │            [G] [G] [G] [G]                │    (когда локальная полна)
     └───────────────────────────────────────────┘

     G = Goroutine     M = OS Thread     P = Processor (logical CPU)

Компоненты модели

G (Goroutine) -- горутина. Содержит стек, указатель на инструкцию (PC), ссылку на функцию и другие метаданные.

M (Machine) -- поток ОС. Привязывается к P для выполнения горутин. Может быть заблокирован на системном вызове.

P (Processor) -- логический процессор. Содержит локальную очередь горутин (до 256 штук). Количество P определяется GOMAXPROCS.

Как работает планирование

  1. Новая горутина помещается в локальную очередь текущего P
  2. Если локальная очередь полна (256), половина горутин переносится в глобальную очередь
  3. M берёт горутину из локальной очереди своего P
  4. Если очередь пуста -- work stealing: M крадёт половину горутин у другого P
  5. Если красть нечего -- берёт из глобальной очереди

Вытеснённость (Preemption)

До Go 1.14 горутина могла быть вытеснена только в определённых точках (вызов функции, операции с каналами). Начиная с Go 1.14, планировщик поддерживает асинхронную вытеснённость через сигналы ОС, что предотвращает блокировку P тяжёлыми вычислительными горутинами.

package main

import (
    "fmt"
    "runtime"
)

func main() {
    // Number of logical processors (P)
    fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
    fmt.Println("NumCPU:", runtime.NumCPU())
    fmt.Println("NumGoroutine:", runtime.NumGoroutine())

    // Set GOMAXPROCS explicitly (rarely needed)
    // runtime.GOMAXPROCS(4)

    // Default: GOMAXPROCS = runtime.NumCPU() (since Go 1.5)
}

GOMAXPROCS и runtime.NumCPU()

GOMAXPROCS определяет максимальное число потоков ОС, которые могут одновременно выполнять пользовательский Go-код. Начиная с Go 1.5, значение по умолчанию равно runtime.NumCPU().

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    // Query current GOMAXPROCS (pass 0 to read without changing)
    current := runtime.GOMAXPROCS(0)
    fmt.Printf("GOMAXPROCS=%d, NumCPU=%d\n", current, runtime.NumCPU())

    var wg sync.WaitGroup

    // Launch goroutines on all available processors
    for i := range runtime.NumCPU() {
        wg.Add(1)
        go func() {
            defer wg.Done()
            // Each goroutine can run on a separate OS thread
            fmt.Printf("Goroutine %d running\n", i)
        }()
    }

    wg.Wait()
}

Заметка: Начиная с Go 1.22, цикл for i := range N создаёт итерацию от 0 до N-1. Переменная i захватывается корректно -- каждая итерация получает свою копию.

Стек горутины

Стек горутины начинается с 2 KB (с Go 1.4) и динамически растёт до максимума в 1 GB (по умолчанию). Рост стека реализован через copyable stacks -- при нехватке места выделяется новый стек в 2 раза больше, и все данные копируются.

Начальный размер:   2 KB
Рост:               удвоение (2 KB → 4 KB → 8 KB → 16 KB → ...)
Максимум:           1 GB (настраивается через runtime/debug.SetMaxStack)
Сжатие:             да, при GC (если используется < 1/4 стека)

Для сравнения:

Характеристика Горутина Поток ОС
Начальный стек 2 KB 1-8 MB
Максимальный стек 1 GB Фиксирован
Время создания ~0.3 мкс ~1 мс
Context switch ~0.2 мкс (runtime) ~1-10 мкс (kernel)
Количество Миллионы Тысячи
Управление Go runtime OS kernel

Утечки горутин (Goroutine Leaks)

Утечка горутин -- одна из самых распространённых ошибок в Go. Горутина, которая никогда не завершается, навсегда занимает память.

Паттерны утечек

1. Заблокированная отправка в канал (никто не читает):

package main

func main() {
    // LEAK: goroutine blocks forever sending to unbuffered channel
    ch := make(chan int)
    go func() {
        ch <- 42 // blocks forever — no receiver
    }()
    // main returns, goroutine is leaked
}

2. Заблокированное чтение из канала (никто не пишет):

package main

func main() {
    ch := make(chan int)
    go func() {
        val := <-ch // blocks forever — no sender
        _ = val
    }()
    // main returns, goroutine is leaked
}

3. Бесконечный цикл без выхода:

package main

import "time"

func main() {
    // LEAK: no way to stop this goroutine
    go func() {
        for {
            time.Sleep(time.Second)
            // do something...
        }
    }()
}

Предотвращение утечек

Используйте context.Context или done-канал для сигнализации о завершении:

package main

import (
    "context"
    "fmt"
    "time"
)

// worker processes tasks until context is cancelled
func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Worker %d: shutting down\n", id)
            return
        default:
            fmt.Printf("Worker %d: working...\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    for i := range 3 {
        go worker(ctx, i)
    }

    // Wait for context to expire
    <-ctx.Done()
    // Give workers a moment to print shutdown message
    time.Sleep(100 * time.Millisecond)
    fmt.Println("All workers stopped")
}

sync.WaitGroup

sync.WaitGroup -- основной механизм ожидания завершения группы горутин:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup

    urls := []string{
        "https://golang.org",
        "https://pkg.go.dev",
        "https://go.dev",
    }

    for _, url := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Printf("Fetching %s\n", url)
            // simulate HTTP request
        }()
    }

    // Block until all goroutines call Done()
    wg.Wait()
    fmt.Println("All fetches complete")
}

Правила WaitGroup:

  • Add(n) вызывается до запуска горутины (обычно в main горутине)
  • Done() вызывается внутри горутины (лучше через defer)
  • Wait() блокирует до тех пор, пока счётчик не станет 0
  • Никогда не копируйте WaitGroup после первого использования

Главная горутина: завершение программы

Программа Go завершается, когда возвращается функция main(). Все запущенные горутины принудительно завершаются без выполнения defer.

package main

import "fmt"

func main() {
    go func() {
        // This may NEVER print — main() can return first
        fmt.Println("goroutine")
    }()

    // Program exits here — goroutine is killed
    fmt.Println("main done")
}

Выход из main:

main done

Горутина может не успеть выполниться, потому что main завершается первым. Это не ошибка -- это поведение по дизайну.

runtime.Gosched() и runtime.Goexit()

runtime.Gosched()

Gosched() уступает процессор другим горутинам. Текущая горутина помещается обратно в очередь и позже продолжит выполнение.

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        for i := range 5 {
            fmt.Printf("goroutine: %d\n", i)
            runtime.Gosched() // yield to main goroutine
        }
    }()

    for i := range 5 {
        fmt.Printf("main: %d\n", i)
        runtime.Gosched() // yield to other goroutines
    }
}

На практике: runtime.Gosched() редко используется в современном Go, так как планировщик с асинхронной вытеснённостью (Go 1.14+) хорошо распределяет время.

runtime.Goexit()

Goexit() завершает текущую горутину. Все defer-функции выполняются. Если Goexit() вызвана из main горутины, программа завершится после выполнения всех defer, но без вызова os.Exit.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    go func() {
        defer wg.Done()
        defer fmt.Println("deferred in goroutine") // WILL execute

        fmt.Println("goroutine started")
        runtime.Goexit() // stop goroutine, run defers
        fmt.Println("NEVER printed")
    }()

    wg.Wait()
    fmt.Println("main done")
}
// Output:
// goroutine started
// deferred in goroutine
// main done

Практический пример: параллельная обработка файлов

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

// processFile simulates processing a file
func processFile(name string) (int, error) {
    // Simulate varying processing time
    duration := time.Duration(rand.Intn(500)) * time.Millisecond
    time.Sleep(duration)
    return rand.Intn(1000), nil
}

func main() {
    files := []string{"a.txt", "b.txt", "c.txt", "d.txt", "e.txt"}

    type result struct {
        file  string
        size  int
        err   error
    }

    var (
        wg      sync.WaitGroup
        mu      sync.Mutex
        results []result
    )

    for _, file := range files {
        wg.Add(1)
        go func() {
            defer wg.Done()
            size, err := processFile(file)

            mu.Lock()
            results = append(results, result{file: file, size: size, err: err})
            mu.Unlock()
        }()
    }

    wg.Wait()

    for _, r := range results {
        if r.err != nil {
            fmt.Printf("  %s: ERROR %v\n", r.file, r.err)
        } else {
            fmt.Printf("  %s: %d bytes\n", r.file, r.size)
        }
    }
}

Пример: мониторинг количества горутин

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func main() {
    fmt.Printf("Goroutines at start: %d\n", runtime.NumGoroutine())

    var wg sync.WaitGroup
    for i := range 100 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(time.Second)
            _ = i
        }()
    }

    fmt.Printf("Goroutines after launch: %d\n", runtime.NumGoroutine())

    wg.Wait()
    // Give GC a moment to clean up
    runtime.Gosched()
    fmt.Printf("Goroutines after wait: %d\n", runtime.NumGoroutine())
}
// Output (approximate):
// Goroutines at start: 1
// Goroutines after launch: 101
// Goroutines after wait: 1

Проверь себя

Когда в Go появилась асинхронная вытеснённость горутин (preemption)?

Что произойдёт, если функция main() завершится, а горутины ещё работают?

Что делает runtime.Goexit()?

Что обозначает P в модели планировщика G-M-P?

Каков начальный размер стека горутины в Go?

Code Challenges

Конкурентная сумма

GO

Напишите функцию, которая суммирует элементы массива, используя горутины. Разделите массив на 2 части и суммируйте параллельно.

Test Cases

1. Input: [1,2,3,4,5,6,7,8,9,10]→ Expected: 55
2. Input: [100]→ Expected: 100
3. Input: []→ Expected: 0