The Go Blog

Go Concurrency Patterns: Context

Sameer Ajmani
29 July 2014

Introduction

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

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

Context

Ядро пакета context — это тип Context:

<span class="comment">// A Context carries a deadline, cancellation signal, and request-scoped values</span>
<span class="comment">// across API boundaries. Its methods are safe for simultaneous use by multiple</span>
<span class="comment">// goroutines.</span>
type Context interface {
  <span class="comment">// Done returns a channel that is closed when this Context is canceled</span>
  <span class="comment">// or times out.</span>
  Done() <-chan struct{}
  <span class="comment">// Err indicates why this context was canceled, after the Done channel</span>
  <span class="comment">// is closed.</span>
  Err() error
  <span class="comment">// Deadline returns the time when this Context will be canceled, if any.</span>
  Deadline() (deadline time.Time, ok bool)
  <span class="comment">// Value returns the value associated with key or nil if none.</span>
  Value(key interface{}) interface{}
}

(Это сокращенное описание; полная документация доступна в godoc.)

Метод Done возвращает канал, который служит сигналом отмены для функций, выполняющихся от имени Context: когда канал закрывается, функции должны прекратить выполнение и вернуться. Метод Err возвращает ошибку, указывающую причину отмены Context. Статья Pipelines and Cancellation подробнее обсуждает идиому канала Done.

Context не имеет метода Cancel по той же причине, по которой канал Done является только для чтения: функция, получающая сигнал отмены, обычно не является той, которая отправляет сигнал. В частности, когда родительская операция запускает горутины для подопераций, эти подоперации не должны иметь возможности отменить родителя. Вместо этого функция WithCancel (описанная ниже) предоставляет способ отменить новое значение Context.

Context безопасен для одновременного использования несколькими горутинами. Код может передавать один Context любому количеству горутин и отменить этот Context, чтобы сигнализировать всем им.

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

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

Производные контексты

Пакет context предоставляет функции для производного создания новых значений Context из существующих. Эти значения образуют дерево: когда Context отменяется, все Context, производные от него, также отменяются.

Background является корнем любого дерева Context; он никогда не отменяется:

<span class="comment">// Background возвращает пустой Context. Он никогда не отменяется, не имеет дедлайна,</span>
<span class="comment">// и не содержит значений. Background обычно используется в main, init и тестах,</span>
<span class="comment">// а также как корневой Context для входящих запросов.</span>
func Background() Context

WithCancel и WithTimeout возвращают производные значения Context, которые могут быть отменены раньше родительского Context. Context, связанный с входящим запросом, обычно отменяется, когда обработчик запроса завершает работу. WithCancel также полезен для отмены избыточных запросов при использовании нескольких реплик. WithTimeout полезен для установки дедлайна на запросы к серверам бэкенда:

<span class="comment">// WithCancel возвращает копию parent, канал Done которого закрывается как только</span>
<span class="comment">// parent.Done закрывается или вызывается cancel.</span>
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
<span class="comment">// CancelFunc отменяет Context.</span>
type CancelFunc func()
<span class="comment">// WithTimeout возвращает копию parent, канал Done которого закрывается как только</span>
<span class="comment">// parent.Done закрывается, cancel вызывается, или истекает таймаут. Новый</span>
<span class="comment">// Context получает дедлайн, который является более ранним из now+timeout и дедлайна родителя,</span>
<span class="comment">// если таковой имеется. Если таймер все еще работает, функция cancel освобождает</span>
<span class="comment">// свои ресурсы.</span>
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue предоставляет способ связать значения, специфичные для запроса, с Context:

<span class="comment">// WithValue возвращает копию parent, метод Value которого возвращает val для key.</span>
func WithValue(parent Context, key interface{}, val interface{}) Context

Лучший способ понять, как использовать пакет context, — это рассмотреть рабочий пример.

Наш пример — это HTTP-сервер, который обрабатывает URL-адреса вроде /search?q=golang&timeout=1s, пересылая запрос «golang» в API поиска в Google и отображая результаты. Параметр timeout указывает серверу отменить запрос по истечении указанного времени.

Код распределён по трём пакетам:

Программа сервера

Программа server обрабатывает запросы вроде /search?q=golang, отображая первые несколько результатов поиска Google по запросу golang. Она регистрирует handleSearch для обработки конечной точки /search. Обработчик создаёт начальный Context, называемый ctx, и организует его отмену при завершении работы обработчика. Если в запросе указан параметр URL timeout, то Context автоматически отменяется по истечении времени:

func handleSearch(w http.ResponseWriter, req *http.Request) {
  <span class="comment">// ctx — это Context для данного обработчика. Вызов cancel закрывает</span>
  <span class="comment">// канал ctx.Done, который служит сигналом отмены для запросов,</span>
  <span class="comment">// запущенных этим обработчиком.</span>
  var (
    ctx    context.Context
    cancel context.CancelFunc
  )
  timeout, err := time.ParseDuration(req.FormValue("timeout"))
  if err == nil {
    <span class="comment">// Запрос содержит таймаут, поэтому создаётся контекст,</span>
    <span class="comment">// который автоматически отменяется по истечении времени.</span>
    ctx, cancel = context.WithTimeout(context.Background(), timeout)
  } else {
    ctx, cancel = context.WithCancel(context.Background())
  }
  defer cancel() <span class="comment">// Отменить ctx как только handleSearch завершится.</span>

Обработчик извлекает запрос из запроса и извлекает IP-адрес клиента, вызвав пакет userip. IP-адрес клиента необходим для запросов к серверу, поэтому handleSearch привязывает его к ctx:

<span class="comment">// Проверить поисковый запрос.</span>
query := req.FormValue("q")
if query == "" {
  http.Error(w, "no query", http.StatusBadRequest)
  return
}
<span class="comment">// Сохранить IP пользователя в ctx для использования кодом в других пакетах.</span>
userIP, err := userip.FromRequest(req)
if err != nil {
  http.Error(w, err.Error(), http.StatusBadRequest)
  return
}
ctx = userip.NewContext(ctx, userIP)

Обработчик вызывает google.Search с ctx и query:

<span class="comment">// Запустить поиск в Google и вывести результаты.</span>
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)

Если поиск завершается успешно, обработчик отображает результаты:

if err := resultsTemplate.Execute(w, struct {
  Results          google.Results
  Timeout, Elapsed time.Duration
}{
  Results: results,
  Timeout: timeout,
  Elapsed: elapsed,
}); err != nil {
  log.Print(err)
  return
}

Пакет userip

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

Чтобы избежать конфликтов ключей, пакет userip определяет незаводимый тип key и использует значение этого типа в качестве ключа контекста:

<span class="comment">// Тип ключа не экспортируется, чтобы предотвратить конфликты с ключами контекста, определёнными в</span>
<span class="comment">// других пакетах.</span>
type key int
<span class="comment">// userIPkey — это ключ контекста для IP-адреса пользователя. Его значение равно нулю,</span>
<span class="comment">// что является произвольным. Если бы этот пакет определял другие ключи контекста,</span>
<span class="comment">// они имели бы разные целочисленные значения.</span>
const userIPKey key = 0

FromRequest извлекает значение userIP из http.Request:

func FromRequest(req *http.Request) (net.IP, error) {
  ip, _, err := net.SplitHostPort(req.RemoteAddr)
  if err != nil {
    return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
  }

NewContext возвращает новый Context, который содержит переданное значение userIP:

func NewContext(ctx context.Context, userIP net.IP) context.Context {
  return context.WithValue(ctx, userIPKey, userIP)
}

FromContext извлекает значение userIP из Context:

func FromContext(ctx context.Context) (net.IP, bool) {
  <span class="comment">// ctx.Value возвращает nil, если в ctx нет значения для ключа;</span>
  <span class="comment">// утверждение типа net.IP возвращает ok=false для nil.</span>
  userIP, ok := ctx.Value(userIPKey).(net.IP)
  return userIP, ok
}

Пакет google

Функция google.Search выполняет HTTP-запрос к API поиска в Google и разбирает результат в формате JSON. Она принимает параметр Context ctx и немедленно возвращает результат, если ctx.Done закрывается во время выполнения запроса.

Запрос к API Google Web Search включает поисковый запрос и IP-адрес пользователя в качестве параметров запроса:

func Search(ctx context.Context, query string) (Results, error) {
  <span class="comment">// Подготовить запрос к Google Search API.</span>
  req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
  if err != nil {
    return nil, err
  }
  q := req.URL.Query()
  q.Set("q", query)
  <span class="comment">// Если ctx содержит IP-адрес пользователя, передать его серверу.</span>
  <span class="comment">// Google APIs используют IP-адрес пользователя, чтобы отличать запросы, инициированные сервером,</span>
  <span class="comment">// от запросов конечного пользователя.</span>
  if userIP, ok := userip.FromContext(ctx); ok {
    q.Set("userip", userIP.String())
  }
  req.URL.RawQuery = q.Encode()

Search использует вспомогательную функцию httpDo для выполнения HTTP-запроса и его отмены, если ctx.Done закрывается во время обработки запроса или ответа. Search передаёт замыкание в httpDo для обработки HTTP-ответа:

var results Results
err = httpDo(ctx, req, func(resp *http.Response, err error) error {
  if err != nil {
    return err
  }
  defer resp.Body.Close()
  <span class="comment">// Разобрать результаты поиска в формате JSON.</span>
  <span class="comment">// https://developers.google.com/web-search/docs/#fonje</span>
  var data struct {
    ResponseData struct {
      Results []struct {
        TitleNoFormatting string
        URL               string
      }
    }
  }
  if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
    return err
  }
  for _, res := range data.ResponseData.Results {
    results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
  }
  return nil
})
<span class="comment">// httpDo ожидает, пока замыкание, переданное нами, завершится, поэтому безопасно</span>
<span class="comment">// читать results здесь.</span>
return results, err

Функция httpDo выполняет HTTP-запрос и обрабатывает его ответ в новой горутине. Она отменяет запрос, если ctx.Done закрывается до завершения горутины:

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
  <span class="comment">// Выполнить HTTP-запрос в горутине и передать ответ в f.</span>
  c := make(chan error, 1)
  req = req.WithContext(ctx)
  go func() { c <- f(http.DefaultClient.Do(req)) }()
  select {
    case <-ctx.Done():
      <-c <span class="comment">// Дождаться завершения f.</span>
      return ctx.Err()
      case err := <-c:
        return err
      }
    }

Адаптация кода для Context

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

Например, пакет Gorilla github.com/gorilla/context позволяет обработчикам сопоставлять данные с входящими запросами, предоставляя отображение из HTTP-запросов в пары ключ-значение. В файле gorilla.go предоставляется реализация Context, метод Value которой возвращает значения, связанные с конкретным HTTP-запросом в пакете Gorilla.

Другие пакеты предоставляли поддержку отмены, аналогичную Context. Например, пакет Tomb предоставляет метод Kill, который сигнализирует об отмене путём закрытия канала Dying. Tomb также предоставляет методы для ожидания завершения этих горутин, аналогично sync.WaitGroup. В файле tomb.go мы предоставляем реализацию Context, которая отменяется либо когда отменяется родительский Context, либо когда предоставленный Tomb закрывается.

Заключение

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

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

Дальнейшее чтение

Следующая статья: Go at OSCON
Предыдущая статья: Go will be at OSCON 2014
Blog Index

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang