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

Теория: Контекст выполнения (context)

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

Сигналы отмены и дедлайны

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

Основой служит канал Done(), который принадлежит каждому контексту. Пока контекст активен, чтение из этого канала блокируется. Когда вызывается отмена или наступает дедлайн, канал закрывается. Все горутины, которые слушают <-ctx.Done(), получают сигнал и выходят из своих циклов.

package main

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

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done(): // сигнал отмены или дедлайна
			fmt.Println("worker", id, "остановлен:", ctx.Err())
			return // корректное завершение горутины
		default:
			fmt.Println("worker", id, "работает")
			time.Sleep(100 * time.Millisecond) // имитация полезной работы
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background()) // контекст с ручной отменой
	for id := 1; id <= 3; id++ {
		go worker(ctx, id) // все воркеры завязаны на один контекст
	}

	time.Sleep(250 * time.Millisecond) // даем им поработать
	cancel()                           // один сигнал для всех
	time.Sleep(200 * time.Millisecond) // время на завершение вывода
}

Здесь одна точка отмены управляет сразу тремя воркерами. Как только вызывается cancel(), канал Done() для контекста закрывается, и каждая горутина завершает работу при следующей итерации select. Ошибка ctx.Err() показывает причину: при ручной отмене это context.Canceled, при дедлайне — context.DeadlineExceeded.

То же поведение можно привязать ко времени. В этом случае WithTimeout создает контекст, который автоматически отменяется после заданной длительности, а WithDeadline — в конкретный момент времени.

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) // таймаут 500 мс
defer cancel()                                                                 // освобождение ресурсов

select {
case <-time.After(2 * time.Second):                                            // работа заняла слишком много
	fmt.Println("операция закончилась, но слишком поздно")
case <-ctx.Done():                                                             // таймаут сработал раньше
	fmt.Println("таймаут:", ctx.Err())
}

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

context.Background() и context.TODO()

Любое дерево контекстов начинается с корневого узла. В Go для этого есть два исходных варианта: context.Background() и context.TODO(). Оба создают пустой контекст без дедлайна, таймаута и значений. Разница между ними в том, какое намерение показывает код.

Background выступает как корень системы. Его обычно используют в main(), при инициализации сервера, запуске фоновых задач, от которых зависят остальные части программы. Такой контекст живет столько же, сколько живет приложение, и из него строятся все дочерние контексты.

package main

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

func main() {
	root := context.Background() // корневой контекст приложения
	ctx, cancel := context.WithTimeout(root, 500*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(2 * time.Second):
		fmt.Println("операция выполнена")
	case <-ctx.Done():
		fmt.Println("контекст завершен:", ctx.Err())
	}
}

TODO используют как заглушку. Это явный сигнал, что контекст здесь нужен, но источник еще не определен. Функция уже принимает context.Context, но вызывающая сторона пока не может передать реальный контекст запроса, соединения или задачи.

package main

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

func fetch(ctx context.Context) error {
	select {
	case <-time.After(100 * time.Millisecond): // имитация работы
		return nil
	case <-ctx.Done(): // реакция на отмену
		return ctx.Err()
	}
}

func main() {
	// место, где позже появится настоящий контекст (например, из HTTP-запроса)
	err := fetch(context.TODO()) // временная заглушка
	fmt.Println("результат:", err)
}

Пока TODO и Background ведут себя одинаково, но по коду видно, где начинается реальное дерево контекстов, а где его еще предстоит протянуть от внешнего источника.

WithCancel, WithDeadline, WithTimeout

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

WithCancel добавляет ручную отмену.

package main

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

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done(): // получение сигнала отмены
			fmt.Println("работа остановлена:", ctx.Err())
			return
		default:
			fmt.Println("работаю...")
			time.Sleep(100 * time.Millisecond)
		}
	}
}

func main() {
	parent := context.Background()            // базовый контекст
	ctx, cancel := context.WithCancel(parent) // дочерний с возможностью отмены

	go worker(ctx) // горутина привязана к ctx
	time.Sleep(300 * time.Millisecond)
	cancel() // явный сигнал остановки
	time.Sleep(100 * time.Millisecond)
}

WithDeadline задает жесткий момент во времени, после которого контекст считается просроченным.

deadline := time.Now().Add(500 * time.Millisecond)    // фиксированный момент
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()                                        // освобождение таймера и ресурсов

select {
case <-time.After(2 * time.Second):
	fmt.Println("операция завершена")
case <-ctx.Done():
	fmt.Println("дедлайн:", ctx.Err())
}

WithTimeout упрощает задачу, когда нужен не конкретный момент, а длительность. Внутри он просто вызывает WithDeadline с time.Now().Add(d).

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

select {
case <-time.After(2 * time.Second):
	fmt.Println("ответ получен слишком поздно")
case <-ctx.Done():
	fmt.Println("таймаут:", ctx.Err())
}

Все дочерние контексты наследуют состояние родителя. Если родитель отменен, все потомки получают сигнал Done() и завершают работу. Именно поэтому важно вызывать cancel() всегда, когда контекст создается через WithCancel, WithDeadline или WithTimeout, даже если операция уже завершилась успешно. Это освобождает внутренние таймеры и предотвращает утечки ресурсов.

Завершение горутин по сигналу

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

package main

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

func worker(ctx context.Context, id int) {
	for {
		select {
		case <-ctx.Done(): // команда остановиться
			fmt.Println("воркер", id, "остановлен:", ctx.Err())
			return
		default:
			fmt.Println("воркер", id, "работает")
			time.Sleep(100 * time.Millisecond) // регулярная работа
		}
	}
}

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

	for id := 1; id <= 3; id++ {
		go worker(ctx, id) // все воркеры слушают один контекст
	}

	time.Sleep(300 * time.Millisecond) // имитация работы системы
	cancel()                           // один вызов останавливает всех
	time.Sleep(100 * time.Millisecond)
}

В этом примере каждая горутина регулярно проверяет ctx.Done() внутри своего цикла. Как только контекст отменяется, worker завершает работу сам, не оставляя «висящих» операций.

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

package main

import (
	"fmt"
	"time"
)

func worker(stop <-chan struct{}) {
	for {
		select {
		case <-stop: // внешний сигнал остановки
			fmt.Println("остановка по сигналу")
			return
		default:
			fmt.Println("работаю...")
			time.Sleep(100 * time.Millisecond)
		}
	}
}

func main() {
	stop := make(chan struct{})

	go worker(stop)
	time.Sleep(300 * time.Millisecond)
	close(stop) // закрытие канала как команда завершиться
	time.Sleep(100 * time.Millisecond)
}

Для взаимодействия с операционной системой используется связка контекста и os/signal. В этом случае завершение приложения по Ctrl+C превращается в тот же самый сигнал Done(), который получают все связанные горутины.

package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done(): // сигнал от системы или отмены
			fmt.Println("завершение воркера:", ctx.Err())
			return
		default:
			fmt.Println("фонова работа")
			time.Sleep(150 * time.Millisecond)
		}
	}
}

func main() {
	ctx, stop := signal.NotifyContext( // контекст, реагирующий на SIGINT и SIGTERM
		context.Background(),
		os.Interrupt,
		syscall.SIGTERM,
	)
	defer stop() // освобождение ресурсов

	go worker(ctx)
	<-ctx.Done() // ожидание системного сигнала
	fmt.Println("завершение по внешнему сигналу")
}

Здесь нажатие Ctrl+C приводит к отмене контекста, закрытию Done() и последовательному завершению всех связанных горутин.

Контекст выстраивает управление конкурентным кодом в понятную схему. Сигналы отмены и дедлайны передаются по дереву, горутины слушают Done() и корректно завершаются. Вместо разрозненных флагов, глобальных переменных и самодельных каналов появляется единый механизм, который описывает: когда задача начинается, когда должна закончиться и что происходит, если время истекло раньше результата.

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

+7 800 100 22 47

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

+7 495 085 21 62

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

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