четверг, 24 октября 2019 г.

Паттерны конкурентности в Golang: Context

На Go серверах каждый входящий запрос обрабатывается в своей собственной goroutine. Обработчики запросов часто запускают дополнительные goroutine для доступа к бэкэндам, таким как базы данных и службы RPC. Множеству goroutine, работающих над запросом, обычно требуется доступ к специфическим для запроса значениям, таким как личность (identity) конечного пользователя, токены авторизации и крайний срок запроса (request's deadline). Когда запрос отменяется или истекает время ожидания, все goroutine, работающие над этим запросом, должны быстро завершиться, чтобы система могла вернуть любые ресурсы, которые они используют.

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

Context (контекст)

Ядром пакета контекста является тип Context:

// Context переносит крайний срок (deadline), 
// сигнал отмены и значения в области видимости запроса 
// через границы API. 
// Его методы безопасны для одновременного использования 
// несколькими goroutine.
type Context interface {
    // Done возвращает канал, 
    // который закрыт при отмене этого Context
    // или при истечении времени ожидания.
    Done() <-chan struct{}

    // Err указывает, почему этот контекст 
    // был отменен после закрытия канала Done.
    Err() error

    // Deadline возвращает время, 
    // когда этот Context будет отменен, 
    // если таковой имеется.
    Deadline() (deadline time.Time, ok bool)

    // Value возвращает значение, 
    // связанное с key или nil, если нет.
    Value(key interface{}) interface{}
}

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

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

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

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

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

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

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

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

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

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

// WithCancel возвращает копию родительского элемента, 
// чей канал Done закрыт 
// как только parent.Done закрыт или вызвана отмена.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFunc отменяет Context.
type CancelFunc func()

// WithTimeout возвращает копию родительского элемента, 
// чей канал Done закрыт
// как только parent.Done закрыт, 
// вызвана отмена (cancel) или истекло время ожидания. 
// Deadline нового Context более ранний 
// чем (текущее времени + тайм-аут) 
// и крайний срок для родителя, если он есть. 
// Если таймер все еще работает, 
// функция отмены освобождает его ресурсы.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

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

// WithValue возвращает копию родительского элемента, 
// метод Value которого возвращает val для key.
func WithValue(parent Context, key interface{}, val interface{}) Context

Лучший способ увидеть, как использовать пакет context - это работающий пример.

Пример: веб-поиск Google

Данный пример это HTTP-сервер, который обрабатывает URL-адреса, такие как /search?q=golang&timeout=1s, перенаправляя запрос "golang" в API веб-поиска Google (Google Web Search API) и представляя результаты. Параметр timeout указывает серверу отменить запрос по истечении этого срока.

Код разбит на три пакета:

  • server предоставляет main функцию и обработчик для /search.
  • userip предоставляет функции для извлечения IP-адреса пользователя из запроса и связывания его с Context.
  • google предоставляет функцию Search для отправки запроса в Google.

Программа server

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

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

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

    // Проверяем поисковый запрос.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Сохраняем IP-адрес пользователя в ctx 
    // для использования кодом в других пакетах.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

Обработчик вызывает google.Search с помощью ctx и запроса:

    // Запустить поиск Google и распечатать результаты.
    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 предоставляет создание map ключ-значение, где ключ и значение имеют тип interface{}. Типы ключа должны поддерживать равенство, а значения должны быть безопасными для одновременного использования несколькими goroutine. Пакеты, такие как userip, скрывают детали создания этой map и предоставляют строго типизированный доступ к определенному значению Context.

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

// Тип ключа не экспортируется 
// для предотвращения конфликтов 
// с ключами контекста, определенными в других пакетах.
type key int

// userIPkey - это контекстный ключ 
// для IP-адреса пользователя. 
// Его нулевое значение произвольно. 
// Если этот пакет определит другие контекстные ключи, 
// они будут иметь разные целочисленные значения.
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) {
    // ctx.Value возвращает nil, 
    // если ctx не имеет значения для ключа;
    // утверждение типа net.IP 
    // возвращает ok=false для nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

Пакет google

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

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

func Search(ctx context.Context, query string) (Results, error) {
    // Подготовливаем запрос API поиска Google.
    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)

    // Если ctx передает IP-адрес пользователя, 
    // перенаправляем его на сервер.
    // API Google используют 
    // IP-адрес пользователя для различения запросов, 
    // инициированных сервером 
    // от запросов конечного пользователя.
    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()

        // Обрабатываем JSON результат поиска.
        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
    })
    // httpDo ожидает возврата из предоставленного нами 
    // замыкания, поэтому безопасно читать результаты здесь.
    return results, err

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

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Запускаем HTTP-запрос в goroutine 
    // и передаем ответ в f.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // Ожидаем пока f вернется.
        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 также предоставляет методы для ожидания выхода из этих goroutine, аналогично sync.WaitGroup. В tomb.go предоставлена реализация Context, которая отменяется, когда отменяется родительский Context или уничтожается предоставленный Tomb.

Заключение

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

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


Читайте также:


Комментариев нет:

Отправить комментарий