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

Отмена как явное

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		if err := worker(ctx); err != nil {
			log.Printf("воркер выполнился: %v", err)
		}
	}()

	time.Sleep(time.Second)
	
	cancel()
	
	time.Sleep(2 * time.Second)
}

func worker(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		return nil
	case <-ctx.Done():
		log.Printf("воркер отменен, err: %v", ctx.Err())
		
		return ctx.Err()
	}
}

Для наглядности я подготовил простейший пример, который демонстрирует, как можно отменить контекст. Что же выведет этот кусок кода?

воркер отменен, err: context canceled
воркер выполнился: context canceled

Из прошлой статьи, что рекомендую почитать, мы вспомним, что WithCancel возвращает CancelFunc, которую мы благополучно можем вызвать какой-либо части кода. Сам CancelFunс является функцией.

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

"Особой" внутренней реализации она не содержит, она просто проверяет является ли контекст parent и вызывает propagateCancel.

Что происходит в этом куске кода пошагово:

1) создаем контекст с отменой
2) вызываем в фоне горутину, которая в себе выполняет "некого" воркера
3) ждем время поработать воркеру
4) отменяем контекст

Мы написали простейший пример работы отмены контекста.

Как контекст узнал, что надо закрыть Done?

Копнем внутрь самого метода propagateCancel.

Сама сигнатура этого метода принимает в себя 2 параметра.


1) сам интерфейс Context
2) интерфейс canceler

Напомню как выглядит сам Context интерфейс:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

Сам canceler:

type canceler interface {
	cancel(removeFromParent bool, err, cause error)
	Done() <-chan struct{}
}

Не забываем, что в самом контексте есть внутренние структуры, контекст отмены же идет от cancelCtx. Сам propagate:

func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
    // назначаем родительский контекст для текущего контекста отмены
	c.Context = parent

    // если родительский контекст не поддерживает отмену (его метод Done возвращает nil), метод завершается
	done := parent.Done()
	if done == nil {
		return // родитель никогда не отменяется
	}

	select {
	case <-done:
		// родитель уже отменен
		child.cancel(false, parent.Err(), Cause(parent))
		return
	default:
	}

    // добавляем дочерний контекст в список children родительского контекста, чтобы он мог быть отменен вместе с родительским
	if p, ok := parentCancelCtx(parent); ok {
		// родитель это *cancelCtx, или является производным от него
		p.mu.Lock()
		if p.err != nil {
			// родитель уже отменен
			child.cancel(false, p.err, p.cause)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
		return
	}

	if a, ok := parent.(afterFuncer); ok {
		// родитель имплементирует AfterFunc метод
		c.mu.Lock()
		stop := a.AfterFunc(func() {
			child.cancel(false, parent.Err(), Cause(parent))
		})
		c.Context = stopCtx{
			Context: parent,
			stop:    stop,
		}
		c.mu.Unlock()
		return
	}

    // если ни один из предыдущих условий не выполнен, 
    // метод запускает новую горутину, которая ожидает сигнала об отмене 
    // родительского контекста и, в случае его получения, отменяет дочерний контекст
	goroutines.Add(1)
	go func() {
		select {
		case <-parent.Done():
			child.cancel(false, parent.Err(), Cause(parent))
		case <-child.Done():
		}
	}()
}

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

Как видно из исходного кода создания cancelCtx, внутренняя сигнализация cancelCtx зависит от канала Done. Если вы хотите отменить этот контекст, вам нужно заблокировать все <-c.Done ()Самый простой способ — закрыть этот канал или заменить его уже закрытым каналом.

Важно запомнить: у каждогоcancelCtx есть map children, при отмене контекста она итерируется по ней, как можно заметить из исходного кода. То есть схема примерного взаимодействия может выглядить так:

Parent (cancelCtx) -> Child(timerCtx) -> GrandChild(cancelCtx).

Но вы спросите, так не видно же самой мапы, какая структура внутренняя контекста?

type cancelCtx struct {
	Context

	mu       sync.Mutex 
	done     atomic.Value
	children map[canceler]struct{} 
	err      atomic.Value
	cause    error
}

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

WithCancelCause

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

func main() {
	base := context.Background()

	ctx1, cancel1 := context.WithCancel(base)
    cancel1()

	fmt.Println("С отменой:")
	fmt.Printf("Err()  = %v\n", ctx1.Err())
	fmt.Printf("Cause() = %v\n\n", context.Cause(ctx1))

	ctx2, cancel2 := context.WithCancelCause(base)
	cancel2(errors.New("какая-то ошибка"))

	fmt.Println("С отменой по причине:")
	fmt.Printf("Ошибка  = %v\n", ctx2.Err())
	fmt.Printf("Причина = %v\n\n", context.Cause(ctx2))

	child, _ := context.WithTimeout(ctx2, 0)
	fmt.Println("Дочерний контекст:")
	fmt.Printf("Ошибка  = %v\n", child.Err())
	fmt.Printf("Причина = %v\n", context.Cause(child))
}

Вывод:

С отменой:
Err()  = context canceled
Cause() = context canceled

С отменой по причине:
Ошибка  = context canceled
Причина = какая-то ошибка

Дочерний контекст:
Ошибка  = context canceled
Причина = какая-то ошибка

Cause() распространяется вниз по дереву контекстов - дочерние контексты грубо говоря "наследуют" причину.

А что если попробуем вызвать cancel(nil)?

То все будет хорошо в плане обработки, так как контекст сохранит context canceled.

Что можно подчеркнуть?

1) У нас есть сам факт отмены Err(), который, который совместим с версиями, когда cause еще не было
2) Для детальной диагностики может ипользоваться Cause

Доступ из нескольких горутин

Сам по себе ctx.Done() безопасно вызывать из нескольких горутин. Потому что:

1) Канал, который возращает ctx.Done, закрывается один раз в момент первой успешной отмены контекста.

2) Для предотвращения гонки, как я и описывал выше, контекст использует мьютексы и атомики.

Важные моменты:

1) Ждать отмену можно из скольки угодно горутин
2) Вызывать Err можно тоже из нескольких горутин, так как используется atomic
3) Отменять контекст можно везде, но сработает только один раз)

AfterFunc

Удобная вещь для выполнения какого-либо кода при отмене контекста.

stopRollback := context.AfterFunc(ctx, func() {
    log.Printf("ctx отменен")
    _ = tx.Rollback()
})

// что-то типо бизнес-логики репозитория

if stopped := stopRollback(); stopped {
    log.Print("коммит выполнен")
}

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

1) Не блокирует текущую горутину, выполняется в новой
2) Нет конфликта между несколькими AfterFunc
3) Если контекст уже отменен, то стартует мгновенно
4) Может спасти от deadlock, если, например, забыли unlock сделать мьютексу по таймауту

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

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

Функция завершает работу, если значение true. Функция stop не ожидает завершения работы функции, поэтому для контроля состояния рекомендуется явно взаимодействовать с ней.

Не забудьте снимать stop после завершения работы. Если нужна синхронность - нужно сделать самим.

Также не забывайте, если вы используете AfterFunc, что функция или метод должны быть имподентны, иначе может случиться гонка. Потому что контекст отменяется и функция стартует в тот же момент, когда вызывается stop(). Например, tx.Rollback() уже является имподентным, поэтому все нормально, но вот при работе с файлом надо добавлять sync.Once().

WithoutCancel

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

WithoutCancel полезен в ситуациях, где определенные операции должны завершиться независимо от состояния родительского контекста. Это может быть полезно для фоновых задач, логирования, кэширования, и любых других задач, которые должны завершиться даже при отмене основной операции, например, rollback операции.

Контекст не возвращает Deadline или Err. Значение канала Done — nil. Чтение из него приведёт к блокировке программы.

Таймеры

Также существует отмена с дедлайном и с таймаутом, по сути своей это одно и то же под капотом, просто разные типы, но это все - tmerCtx

  • WithDeadline(parent Context, d time.Time) (Context, CancelFunc) - создает дочерний контекст с помощью метода отмены из родительского контекста, за исключением того, что контекст будет автоматически отменен по достижении заданного времени

  • context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) - то же самое, что и WithDeadline, за исключением того, что он указывает время ожидания от текущего времени

Паттерны cancelCtx

Начнем с простых правил организации работы ваших контекстов:

defer cancel() сразу после WithCancel, WithTimeout. Если не вызвать - дочерний контекст и его таймеры будут висеть до отмены родителя и течь по памяти.

ctx, cancel := context.WithTimeout(parent, 100*time.Millisecond)
defer cancel()

Простейший пример:

func main() {
	fmt.Println("горутины до:", runtime.NumGoroutine())

	for i := 0; i < 500; i++ {
		ctx, _ := context.WithTimeout(context.Background(), time.Minute)
		go func() { <-ctx.Done() }() // каждая создаёт таймер и горутину
	}

	fmt.Println("горутин после :", runtime.NumGoroutine())

	time.Sleep(2 * time.Second)
}

Вывод:

горутины до: 1
горутин после : 501

Закрывайте воркеры каскадно, так как одно cancel в родителей закрывает канал Done у всех потомков.

go worker(ctx) // <-ctx.Done()
...
cancel()

WithTimeout и WithDeadline автоматически отменят запрос, даже если вы забыли вызвать cancel()

ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
res, err := httpClient.Do(req.WithContext(ctx))

Не забываем оборачивать после WithoutCancel контекста в дочерний с временем отмены!

Остальные же паттерны идут как "база", если вы знакомы с самим определением и работой контекста и являются общими.

closedchan

var closedchan = make(chan struct{})

func init() { close(closedchan) }

Изначально пакет содержит закрытый канал, чтобы первая отмена контекста проходила без лишних аллокаций и без двойного закрытия. Когда cancelCtx.cancel вызывается впервые, он проверяет, канал done ещё не создан ли, иначе, вместо make(chan struct{})просто пишет ссылку на уже закрытый closedchan.

Зачем это нужно:

1) Избегаем аллокации, чтобы использовали уже готовый обьект
2) Нет паники, если еще раз вызовем close. Все посл. вызовы увидят, что указывает на закрытый канал.
3) Служит только для сигнала отмены

Заключение

Статья получилась емкая и несложная, где затронули основные аспекты отмены контекста, если есть какие-то предложение по улучшению статьи, либо дополнению, то с радостью приму предложения! Спасибо всем, кто прочитал ее до конца!

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