Конкуренция в Go

Теория: Goroutines

Горутины дополняют модель конкуренции Go и дают программе реальный инструмент для разветвления исполнения. Каждая горутина представляет собой легкую линию выполнения внутри процесса. У нее есть собственный стек и счетчик инструкций, но память процесса остается общей. Такое устройство делает запуск конкурентных задач почти бесплатным и позволяет опираться на конкуренцию в архитектуре сервиса, а не относиться к ней как к исключению.

Создание и запуск goroutine

Создание горутины в Go начинается с ключевого слова go. После него указывается вызов функции, и этот вызов превращается в отдельную линию исполнения. Функция main при этом продолжает работу как основная горутина, а новая задача добавляется в очередь планировщика.

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("вторая горутина") // запуск отдельной горутины
	fmt.Println("первая горутина")    // код основной горутины

	time.Sleep(100 * time.Millisecond) // пауза, чтобы успел отработать вывод
}

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

Если убрать синхронизацию, вторая горутина может не успеть выполниться вообще.

func main() {
	go fmt.Println("вторая горутина") // горутина добавляется в очередь, но программа сразу завершается
}

Такая форма кода иллюстрирует типичную проблему: запуск фоновой задачи без явного ожидания ее завершения.

При работе с группой задач синхронизация обычно строится через sync.WaitGroup. Эта структура хранит счетчик активных горутин и позволяет дождаться их завершения без искусственных задержек.

package main

import (
	"fmt"
	"net/http"
	"sync"
)

func fetch(url string, wg *sync.WaitGroup) {
	defer wg.Done() // уменьшение счетчика при завершении горутины

	resp, err := http.Get(url) // HTTP-запрос
	if err != nil {
		fmt.Println("ошибка:", err)
		return
	}
	defer resp.Body.Close() // освобождение ресурсов соединения

	fmt.Println(url, resp.Status) // вывод статуса ответа
}

func main() {
	var wg sync.WaitGroup

	sites := []string{
		"https://golang.org",
		"https://go.dev",
		"https://example.com",
	}

	for _, url := range sites {
		wg.Add(1)          // увеличивается счетчик ожидаемых задач
		go fetch(url, &wg) // каждая страница обрабатывается в своей горутине
	}

	wg.Wait() // ожидание завершения всех fetch
	fmt.Println("все запросы выполнены")
}

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

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

names := []string{"A", "B", "C"}

for _, name := range names {
	go func(n string) {
		fmt.Println("Hello,", n) // использование локальной копии n
	}(name) // передача значения как аргумента
}

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

Планировщик и M

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

Внутри рантайма участвуют три сущности. Горутина (G) рассматривается как отдельная единица работы с собственным стеком и контекстом. Системный поток (M) выполняет код горутины на ядре процессора. Логический процессор (объект P в терминологии рантайма Go) хранит очередь горутин и передает их на исполнение потокам. Количество логических процессоров по умолчанию равно числу ядер и может управляться через runtime.GOMAXPROCS.

package main

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

func main() {
	runtime.GOMAXPROCS(2) // использование двух логических процессоров

	for i := 0; i < 5; i++ {
		go func(n int) {
			for step := 0; step < 3; step++ {
				fmt.Println("goroutine", n, "step", step)
				time.Sleep(50 * time.Millisecond) // добровольная точка переключения
			}
		}(i)
	}

	time.Sleep(500 * time.Millisecond) // время на выполнение всех шагов
}

Этот пример показывает, как несколько горутин выполняются вперемешку. Планировщик переключает их в моменты блокировки или специальных точек, таких как вызовы time.Sleep. Переключение происходит в пользовательском пространстве, без участия ядра ОС, что делает операции дешевле, чем переключение системных потоков.

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

Стоимость и производительность

Горутина спроектирована как очень легкий исполнитель. При создании ей выделяется небольшой начальный стек, обычно несколько килобайт Минимум - 2KiB, который затем может автоматически расти и сжиматься. Рантайм контролирует этот процесс, выделяет дополнительные сегменты памяти при необходимости и копирует стек прозрачным для кода образом.

Переход между горутинами также малозатратен. Планировщик сохраняет минимальный набор данных о текущей горутине и восстанавливает контекст следующей. Так как переключение происходит внутри рантайма, без системного вызова, стоимость оказывается намного ниже, чем у потоков ОС. Это позволяет держать десятки и сотни тысяч активных горутин без заметной нагрузки.

Поведение можно оценить с помощью простого эксперимента с большим числом конкурентных задач.

package main

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

func work() {
	time.Sleep(10 * time.Millisecond)
}

func main() {
	const N = 10000

	start := time.Now()

	var wg sync.WaitGroup
	wg.Add(N)

	for i := 0; i < N; i++ {
		go func() {
			defer wg.Done()
			work()
		}()
	}

	wg.Wait()
	fmt.Println("время выполнения:", time.Since(start))
}

Даже при десятках тысяч горутин суммарное время определяется в основном задержкой внутри work и количеством доступных ядер. Переключения и создание горутин занимают существенно меньшую долю. Однако при дальнейшем росте числа горутин появляются другие факторы: возрастает нагрузка на сборщик мусора, увеличиваются очереди у планировщика, а структура программы может упираться в блокировки на общих ресурсах.

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

Жизненный цикл goroutine

Жизненный цикл горутины начинается в момент, когда рантайм встречает конструкцию go и создает внутреннюю структуру, описывающую новую задачу. Ей выделяется начальный стек, задается функция входа и аргументы, после чего горутина помещается в очередь одного из логических процессоров.

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

После снятия условия блокировки горутина возвращается в очередь готовых задач. Планировщик снова выдает ей ресурс потока, и выполнение продолжается с той же строчки кода. Такой цикл может повторяться много раз за время жизни горутины.

Завершение происходит, когда функция горутины доходит до конца или явно выполняет return. Стек помечается как свободный, структура горутины становится кандидатом на удаление сборщиком мусора. Если синхронизация организована через WaitGroup() или каналы, соответствующие сигналы тоже срабатывают в этот момент.

Простая демонстрация жизненного цикла выглядит так.

package main

import (
	"fmt"
	"time"
)

func worker(id int) {
	fmt.Println("start", id)           // начало работы горутины
	time.Sleep(100 * time.Millisecond) // имитация полезной работы
	fmt.Println("done", id)            // завершение работы
}

func main() {
	for i := 1; i <= 3; i++ {
		go worker(i) // создание трех горутин
	}

	time.Sleep(300 * time.Millisecond) // ожидание, чтобы все успели завершиться
	fmt.Println("main done")
}

Горутины worker создаются и помещаются в ��чередь. Планировщик по очереди запускает их, каждая печатает сообщение о старте, затем блокируется на Sleep, после чего возобновляется и печатает сообщение о завершении. Функция main ждет достаточно времени, чтобы весь цикл каждой горутины был пройден.

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

Есть еще одна важная особенность: завершение функции main останавливает всю программу. Незавершенные горутины не продолжают жить после этого момента. Поэтому ожидание их завершения должно быть организовано явно — через WaitGroup(), каналы или контекст. Такой подход делает жизненный цикл каждой горутины прозрачным и контролируемым, а поведение программы предсказуемым при любой нагрузке.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845