← Назад к вопросам

Как происходит исполнение горутин?

1.7 Middle🔥 271 комментариев
#Конкурентность и горутины

Комментарии (1)

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Принципы исполнения горутин в Go

Горутины (goroutines) — это легковесные потоки выполнения, реализованные на уровне среды выполнения Go (runtime). Их исполнение кардинально отличается от классических потоков ОС и строится на трех ключевых компонентах: модели многопоточности M:N, планировщике (scheduler) и концепции разбиения на сегменты (work-stealing).

Модель M:N и компоненты рантайма

Go использует гибридную модель M:N, где:

  • M — потоки ОС (machine threads), управляемые рантаймом
  • N — горутины (goroutines), логические потоки приложения
  • G — структура данных, представляющая горутину (хранит стек, статус, контекст)
  • P — процессоры (processors), виртуальные контексты выполнения (максимум GOMAXPROCS)
  • Scheduler — планировщик, распределяющий G по M и P

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

// Пример создания горутин
package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Горутина %d начала работу\n", id)
    time.Sleep(500 * time.Millisecond)
    fmt.Printf("Горутина %d завершила работу\n", id)
}

func main() {
    // Запускаем 5 горутин
    for i := 1; i <= 5; i++ {
        go worker(i)
    }
    
    // Ждем завершения горутин
    time.Sleep(2 * time.Second)
}

Работа планировщика (scheduler)

Планировщик Go — это кооперативный вытесняющий планировщик (cooperative preemptive scheduler), который управляет выполнением горутин следующим образом:

Триггеры перепланирования:

  1. Системные вызовы (I/O операции, системные запросы)
  2. Операции с каналами (чтение/запись, если операция блокируется)
  3. Вызов runtime.Gosched() — явная уступка процессорного времени
  4. Сетевые операции через netpoller (асинхронный ввод-вывод)
  5. Сборка мусора (stop-the-world фазы)
  6. Истечение кванта времени (после 10ms непрерывного выполнения)

Алгоритм work-stealing:

Каждый P (процессор) поддерживает локальную очередь горутин (runqueue). Когда очередь пуста, процессор "ворует" работу из:

  • Глобальной очереди планировщика
  • Очередей других процессоров
  • Сетевого планировщика (netpoller)
// Демонстрация перепланирования
package main

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

func cpuIntensive() {
    sum := 0
    for i := 0; i < 1000000000; i++ {
        sum += i
    }
    fmt.Println("Завершена CPU-intensive горутина")
}

func ioIntensive() {
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Завершена I/O-intensive горутина")
}

func main() {
    // Устанавливаем 2 процессора
    runtime.GOMAXPROCS(2)
    
    go cpuIntensive()
    go ioIntensive()
    
    time.Sleep(1 * time.Second)
}

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

  1. Создание — аллокация начального стека (2KB, растет динамически)
  2. Постановка в очередь — попадает в локальную очередь P
  3. Исполнение — выполняется на потоке M, привязанном к P
  4. Блокировка — при системных вызовах, операциях с каналами
  5. Пробуждение — перемещается в очередь исполнения
  6. Завершение — освобождает ресурсы, может вызвать панику

Стек горутин и управление памятью

Каждая горутина начинается с маленького стека (обычно 2KB), который динамически растет и сокращается:

  • Segmented stacks (ранние версии) — прерывание при переполнении
  • Stack copying (современные версии) — копирование в больший сегмент

Отличия от потоков ОС

АспектГорутиныПотоки ОС
Создание~2-4 µs, несколько KB~100 µs, 1-8 MB
Переключение~100 ns (в пространстве пользователя)~1-10 µs (системный вызов)
ПланированиеКооперативное + вытеснение по времениВытесняющее (ОС)
ПараллелизмM:N модель, тысячи легко1:1 модель, сотни сложно

Практические рекомендации

  1. Не блокируйте планировщик — избегайте долгих вычислений без точек вытеснения
  2. Используйте runtime.Gosched() для CPU-bound операций
  3. Контролируйте утечки горутин — всегда предусматривайте условия выхода
  4. Используйте пулы воркеров для задач с известным количеством параллелизма
  5. Мониторьте использование через runtime.NumGoroutine()
// Пример с пулом воркеров
package main

import (
    "fmt"
    "sync"
)

func workerPool(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        results <- job * 2 // Обработка задачи
    }
}

func main() {
    const numWorkers = 5
    const numJobs = 20
    
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup
    
    // Запускаем пул воркеров
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go workerPool(i, jobs, results, &wg)
    }
    
    // Отправляем задания
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)
    
    // Собираем результаты
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // Выводим результаты
    for result := range results {
        fmt.Println("Результат:", result)
    }
}

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