company_banner

Разбираемся с пакетом Context в Golang

Автор оригинала: Parikshit Agnihotry
  • Перевод

image


Пакет context в Go полезен при взаимодействиях с API и медленными процессами, особенно в production-grade системах, которые занимаются веб-запросами. С его помощью можно уведомить горутины о необходимости завершить свою работу.


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


(Прим. пер.: Контекст используется во многих пакетах, например, в работе с Docker).


Перед тем, как начать


Чтобы использовать контексты, вы должны понимать, что такое горутина и каналы. Я постараюсь кратко их рассмотреть. Если вы с ними уже знакомы, переходите непосредственно к разделу Контекст (Context).


Горутина


В официальной документации говорится, что «Горутина — это легковесный поток выполнения». Горутины легче, чем потоки, поэтому управление ими сравнительно менее ресурсозатратно.


Песочница


package main

import "fmt"

// Функция, которая выводит Hello
func printHello() {
    fmt.Println("Hello from printHello")
}

func main() {
    // Встроенная горутина
    // Определяем функцию внутри и вызываем ее
    go func(){fmt.Println("Hello inline")}()
    // Вызываем функцию как горутину
    go printHello()
    fmt.Println("Hello from main")
}

Если вы запустите эту программу, то увидите, что распечатывается только Hello from main. На самом деле запускается обе горутины, но main завершает работу раньше. Значит, горутинам нужен способ сообщать main об окончании своего выполнения, и чтобы та ждала этого. Здесь нам на помощь приходят каналы (channels).


Каналы (channels)


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


Скажем, у нас есть канал ch типа int. Если вы хотите послать что-то в канал, синтаксис будет ch <- 1. Что-то получить из канала можно так: var := <- ch, т.е. забрать из канала значение и сохранить его в переменной var.


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


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


Песочница


package main

import "fmt"

// Печатает на стандартный вывод и отправляет int в канал
func printHello(ch chan int) {
    fmt.Println("Hello from printHello")
    // Посылает значение в канал
    ch <- 2
}

func main() {
    // Создаем канал. Для этого нам нужно использовать функцию make
    // Каналы могут быть буферизированными с заданным размером:
    // ch := make(chan int, 2), но это выходит за рамки данной статьи.
    ch := make(chan int)

    // Встроенная горутина. Определим функцию, а затем вызовем ее.
    // Запишем в канал по её завершению
    go func(){
        fmt.Println("Hello inline")
        // Отправляем значение в канал
        ch <- 1
    }()

    // Вызываем функцию как горутину
    go printHello(ch)
    fmt.Println("Hello from main")

    // Получаем первое значение из канала
    // и сохраним его в переменной, чтобы позже распечатать
    i := <- ch
    fmt.Println("Received ",i)

    // Получаем второе значение из канала
    // и не сохраняем его, потому что не будем использовать
    <- ch
}

Контекст (Context)


Пакет context в go позволяет вам передавать данные в вашу программу в каком-то «контексте». Контекст так же, как и таймаут, дедлайн или канал, сигнализирует прекращение работы и вызывает return.


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


И здесь как раз может пригодиться контекст тайм-аута или дедлайна.


Создание контекста


Пакет context позволяет создавать и наследовать контекст следующими способами:


context.Background() ctx Context


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


ctx, cancel := context.Background()

Прим. пер.: В оригинале статьи имеется неточность, правильный пример использования context.Background будет следующий:


ctx := context.Background()

context.TODO() ctx Context


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


ctx, cancel := context.TODO()

Прим. пер.: В оригинале статьи имеется неточность, правильный пример использования context.TODO будет следующий:


ctx := context.TODO()

Что интересно, взгляните на код, это абсолютно то же самое, что и background. Разница лишь в том, что в данном случае можно пользоваться инструментами статического анализа для проверки валидности передачи контекста, что является важной деталью, поскольку эти инструменты помогают выявлять потенциальные ошибки на ранней стадии и могут быть включены в CI/CD пайплайн.


Отсюда:


var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)

context.WithValue(parent Context, key, val interface{}) (ctx Context, cancel CancelFunc)


Прим. пер.: В оригинале статьи имеется неточность, правильная сигнатура для context.WithValue будет следующей:


context.WithValue(parent Context, key, val interface{}) Context

Эта функция принимает контекст и возвращает производный от него контекст, в котором значение val связано с key и проходит через всё контекстное дерево. То есть, как только вы создадите контекст WithValue, любой производный контекст получит это значение.


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


ctx := context.WithValue(context.Background(), key, "test")

context.WithCancel(parent Context) (ctx Context, cancel CancelFunc)


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


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


ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))

Прим. пер.: В оригинале статьи автор, видимо, ошибочно для context.WithCancel привёл пример с context.WithDeadline. Правильный пример для context.WithCancel будет следующим:


ctx, cancel := context.WithCancel(context.Background())

context.WithDeadline(parent Context, d time.Time) (ctx Context, cancel CancelFunc)


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


ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))

context.WithTimeout(parent Context, timeout time.Duration) (ctx Context, cancel CancelFunc)


Эта функция похожа на context.WithDeadline. Разница в том, что в качестве входных данных используется длительность времени. Эта функция возвращает производный контекст, который отменяется при вызове функции отмены или по истечении времени.


ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(2 * time.Second))

Прим. пер.: В оригинале статьи автор, видимо, ошибочно для context.WithTimeout привёл пример с context.WithDeadline. Правильный пример для context.WithTimeout будет следующим:


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

Приём и использование контекстов в ваших функциях


Теперь, когда мы знаем, как создавать контексты (Background и TODO) и как порождать контексты (WithValue, WithCancel, Deadline и Timeout), давайте обсудим, как их использовать.


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


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


Пример, который следует за этим разделом, это полностью готовая программа на go, которая иллюстрирует timeout’ы и функции отмены.


// Функция, выполняющая какую-то медленную работу с использованием контекста
// Заметьте, что контекст - это первый аргумент
func sleepRandomContext(ctx context.Context, ch chan bool) {

    // Выполнение (прим. пер.: отложенное выполнение) действий по очистке
    // Созданных контекстов больше нет
    // Следовательно, отмена не требуется
    defer func() {
        fmt.Println("sleepRandomContext complete")
        ch <- true
    }()

    // Создаем канал
    sleeptimeChan := make(chan int)

    // Запускаем выполнение медленной задачи в горутине
    // Передаём канал для коммуникаций
    go sleepRandom("sleepRandomContext", sleeptimeChan)

    // Используем select для выхода по истечении времени жизни контекста
    select {
        case <-ctx.Done():
            // Если контекст истекает, выбирается этот случай
            // Высвобождаем ресурсы, которые больше не нужны из-за прерывания работы
            // Посылаем сигнал всем горутинам, которые должны завершиться (используя каналы)
            // Обычно вы посылаете что-нибудь в канал,
            // ждете выхода из горутины, затем возвращаетесь
            // Или используете группы ожидания вместо каналов для синхронизации
            fmt.Println("Time to return")

        case sleeptime := <-sleeptimeChan:
            // Этот вариант выбирается, когда работа завершается до отмены контекста
            fmt.Println("Slept for ", sleeptime, "ms")
    }
}

Пример


Как мы увидели, с помощью контекстов можно работать с дедлайнами, таймаутами, а также вызвать функцию отмены, тем самым дав понять всем функциям, использующим производный контекст, что надо завершить свою работу и выполнить return. Рассмотрим пример:


функция main:


  • Создает контекст с функцией отмены
  • Вызывает функцию отмены по истечении произвольного таймаута

функция doWorkContext:


  • Создаёт производный контекст с тайм-аутом
  • Этот контекст отменяется, когда функция main вызывает cancelFunction, истекает таймаут, либо doWorkContext вызывет свою cancelFunction.
  • Запускает горутину для выполнения какой-нибудь медленной задачи, передавая полученный контекст
  • Ждет завершения горутины или отмены контекста от main, в зависимости от того, что произойдет первым

функция sleepRandomContext :


  • Запускает горутину для выполнения какой-нибудь медленной задачи
  • Ждет, пока завершится горутина, или
  • Ждет отмены контекста функцией main, таймаута или вызова своей собственной cancelFunction

функция sleepRandom:


  • Засыпает на рандомное время

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


Песочница (Выглядит так, будто рандомное время, которое я использую в песочнице, практически не меняется. Попробуйте выполнить это на вашем локальном компьютере, чтобы увидеть случайность)


Github


package main

import (
    "context"
    "fmt"
    "math/rand"
    "Time"
)

// Медленная функция
func sleepRandom(fromFunction string, ch chan int) {
    // Отложенная функция очистки
    defer func() { fmt.Println(fromFunction, "sleepRandom complete") }()

    // Выполним медленную задачу
    // В качестве примера,
    // «заснем» на рандомное время в мс
    seed := time.Now().UnixNano()
    r := rand.New(rand.NewSource(seed))
    randomNumber := r.Intn(100)
    sleeptime := randomNumber + 100

    fmt.Println(fromFunction, "Starting sleep for", sleeptime, "ms")
    time.Sleep(time.Duration(sleeptime) * time.Millisecond)
    fmt.Println(fromFunction, "Waking up, slept for ", sleeptime, "ms")

    // Напишем в канал, если он был передан
    if ch != nil {
        ch <- sleeptime
    }
}

// Функция, выполняющая медленную работу с использованием контекста
// Заметьте, что контекст - это первый аргумент
func sleepRandomContext(ctx context.Context, ch chan bool) {

    // Выполнение (прим. пер.: отложенное выполнение) действий по очистке
    // Созданных контекстов больше нет
    // Следовательно, отмена не требуется
    defer func() {
        fmt.Println("sleepRandomContext complete")
        ch <- true
    }()

    // Создаем канал
    sleeptimeChan := make(chan int)

    // Запускаем выполнение медленной задачи в горутине
    // Передаем канал для коммуникаций
    go sleepRandom("sleepRandomContext", sleeptimeChan)

    // Используем select для выхода по истечении времени жизни контекста
    select {
        case <-ctx.Done():
            // Если контекст отменен, выбирается этот случай
            // Это случается, если заканчивается таймаут doWorkContext или
            // doWorkContext или main вызывает cancelFunction
            // Высвобождаем ресурсы, которые больше не нужны из-за прерывания работы
            // Посылаем сигнал всем горутинам, которые должны завершиться (используя каналы)
            // Обычно вы посылаете что-нибудь в канал,
            // ждете выхода из горутины, затем возвращаетесь
            // Или используете группы ожидания вместо каналов для синхронизации
            fmt.Println("sleepRandomContext: Time to return")

        case sleeptime := <-sleeptimeChan:
            // Этот вариант выбирается, когда работа завершается до отмены контекста
            fmt.Println("Slept for ", sleeptime, "ms")
    }
}

// Вспомогательная функция, которая в реальности может использоваться для разных целей
// Здесь она просто вызывает одну функцию
// В данном случае, она могла бы быть в main
func doWorkContext(ctx context.Context) {

    // От контекста с функцией отмены создаём производный контекст с тайм-аутом
    // Таймаут 150 мс
    // Все контексты, производные от этого, завершатся через 150 мс
    ctxWithTimeout, cancelFunction := context.WithTimeout(ctx, time.Duration(150)*time.Millisecond)

    // Функция отмены для освобождения ресурсов после завершения функции
    defer func() {
        fmt.Println("doWorkContext complete")
        cancelFunction()
    }()

    // Создаем канал и вызываем функцию контекста
    // Можно также использовать группы ожидания для этого конкретного случая,
    // поскольку мы не используем возвращаемое значение, отправленное в канал
    ch := make(chan bool)
    go sleepRandomContext(ctxWithTimeout, ch)

    // Используем select для выхода при истечении контекста
    select {
        case <-ctx.Done():
            // Этот случай выбирается, когда переданный в качестве аргумента контекст уведомляет о завершении работы
            // В данном примере это произойдёт, когда в main будет вызвана cancelFunction
            fmt.Println("doWorkContext: Time to return")

        case <-ch:
            // Этот вариант выбирается, когда работа завершается до отмены контекста
            fmt.Println("sleepRandomContext returned")
    }
}

func main() {
    // Создаем контекст background
    ctx := context.Background()
    // Производим контекст с отменой
    ctxWithCancel, cancelFunction := context.WithCancel(ctx)

    // Отложенная отмена высвобождает все ресурсы
    // для этого и производных от него контекстов
    defer func() {
       fmt.Println("Main Defer: canceling context")
       cancelFunction()
    }()

    // Отмена контекста после случайного тайм-аута
    // Если это происходит, все производные от него контексты должны завершиться
    go func() {
       sleepRandom("Main", nil)
       cancelFunction()
       fmt.Println("Main Sleep complete. canceling context")
    }()

    // Выполнение работы
    doWorkContext(ctxWithCancel)
}

Подводные камни


Если функция использует контекст, убедитесь, что уведомления об отмене обрабатываются должным образом. Например, что exec.CommandContext не закрывает канал чтения, пока команда не выполнит все форки, созданные процессом (Github), т.е., что отмена контекста не приведет к немедленному возврату из функции, если вы ждете с cmd.Wait(), пока все форки внешней команды не завершат обработку.


Если вы используете таймаут или дедлайн с максимальным временем выполнения, он может работать не так, как ожидается. В таких случаях, лучше реализовать таймауты с помощью time.After.


Лучшие практики


  1. context.Background следует использовать только на самом высоком уровне, как корень всех производных контекстов.
  2. context.TODO должен использоваться, когда вы не уверены, что использовать, или если текущая функция будет использовать контекст в будущем.
  3. Отмены контекста рекомендуются, но эти функции могут занимать время, чтобы выполнить очистку и выход.
  4. context.Value следует использовать как можно реже, и его нельзя применять для передачи необязательных параметров. Это делает API непонятным и может привести к ошибкам. Такие значения должны передаваться как аргументы.
  5. Не храните контексты в структуре, передавайте их явно в функциях, предпочтительно в качестве первого аргумента.
  6. Никогда не передавайте nil-контекст в качестве аргумента. Если сомневаетесь, используйте TODO.
  7. Структура Context не имеет метода cancel, потому что только функция, которая порождает контекст, должна его отменять.

От переводчика


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


  • Логирование
  • Обработка сигналов на завершение приложений, reload и logrotate
  • Работа с pid-файлами
  • Работа с конфигурационными файлами
  • И другое

Поэтому в какой-то момент мы решили обобщить весь накопленный нами опыт и создали вспомогательные пакеты, значительно упрощающие написание приложений (особенно приложений, имеющих API). Свои разработки мы выложили в открытый доступ и любой желающий может ими воспользоваться. Ниже приводятся некоторые ссылки на пакеты, полезные для решения таких задач:



Также читайте другие статьи в нашем блоге:


Nixys
Эксперты в DevOps и Kubernetes

Похожие публикации

Комментарии 4

    +1
    У вас в качестве примера WithCancel приведён пример WithDradline
      0
      Здравствуйте!

      Спасибо за Ваше сообщение. Действительно и в самом оригинале статьи, и в нашем переводе имеется указанная неточность. Добавил уточнение к переводу.
        0
        C WithTimeout та же самая проблема.
          0
          Приветствую!

          И вновь огромное спасибо, поправил!
          Проверил и поправил остальные прототипы и примеры, где были неточности. Теперь с этим проблем быть быть не должно ))

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

    Самое читаемое