Руководство по net/http таймаутам в Go.

32 minute read

Перевод статьи "The complete guide to Go net/http timeouts".

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

HTTP представляет собой комплексный мультиступенчатый протокол, в котором нет возможности указать единый таймаут, одинаковый для всех случаев. Мы можем его использовать для стриминга, JSON API или Comet. И конечно же, неразумно использовать одинаковые параметры по умолчанию для всех случаев.

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

SetDeadline

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

Дедлайн - это предельный абсолютный срок за который должны завершиться все операции ввода/вывода, иначе в системе возникнет ошибка таймаута. Этот крайний срок можно установить с помощью метода Set[Read|Write]Deadline(time.Time) у net.Conn.

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

Вероятно, вы не очень хотите вызывать SetDeadline вручную и были бы не против, если net/http будет делать это за вас через использование более высокоуровневых таймаутов. Однако, нужно помнить, что все таймауты реализуются на базе дедлайнов, а это означает, что нет необходимости переустанавливать их в момент отправки или получения данных.

Серверные таймауты

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

http: Accept error: accept tcp [::]:80: accept4: too many open files; retrying in 5ms

Но у наст есть два таймаута в http.Server: ReadTimeout и WriteTimeout. Вы можете указать их при инициализации сервера:

srv := &http.Server{  
    ReadTimeout: 5 * time.Second,
    WriteTimeout: 10 * time.Second,
}
log.Println(srv.ListenAndServe())  

ReadTimeout покрывает время с момента принятия соединения до момента, когда тело запроса будет полностью прочитано(если вы не читаете тело запроса, то до момента прочтения заголовков). Этот механизм реализован в net/http через вызов SetReadDeadline сразу же после Accept.

WriteTimeout покрывает время от завершения чтения заголовков запроса до конца записи ответа(время работы ServeHTTP) и реализован через вызов SetWriteDeadline в конце вызова readRequest.

Кроме того, когда соединение работает по HTTPS, SetWriteDeadline вызывается сразу же после Accept. Это справедливо для всех пакетов, записанных как часть TLS рукопожатия. Досадно, но это означает, что в таком случае WriteTimeout учитывает время от начала чтения заголовков и ожидание записи первого байта.

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

Стоит упомянуть http.TimeoutHandler. Это не параметр сервера. Это такой себе Handler врапер, который может ограничить максимальное время выполнения ServeHTTP. Происходит буферизация ответа, а в случае истечения дедлайна, клиенту посылается 504 Gateway Timeout. Нужно помнить, что это сломали в 1.6 и пофиксили в 1.6.2.

http.ListenAndServe делает это не правильно

Все выше перечисленное означает, что удобные обертки над http.Server, такие как http.ListenAndServe, http.ListenAndServeTLS и http.Serve во многих ситуациях не самый хороший выбор.

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

Лучше всего, всегда используйте http.Server и всегда устанавливайте ReadTimeout и WriteTimeout. Можно написать свои аналогичные методы, которые будут использовать таймауты.

О стриминге

К сожалению, нет никакого способа добраться к net.Conn из ServeHTTP. Поэтому, сервер который планирует использовать стриминг, должен изначально запускаться с выключенными таймаутами(возможно, именно поэтому таймауты по умолчанию выключены). Без возможности добраться до net.Conn нет никакого способа вызвать SetWriteDeadline перед каждой записью Write, если мы хотим реализовать разовый(не абсолютный) таймаут.

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

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

Я отправил предложение по этому поводу) и буду рад вашей поддержке и отзывам.

Клиентские таймауты

Настройка таймаутов на клиенте может быть как проще, так и намного сложнее чем на сервере. Тем не менее, клиентские таймауты очень важны и точно также могут влиять на утечку ресурсов.

Самый простой способ - использовать поле Timeout у структуры http.Client. Этот таймаут покрывает все время выполнения запроса, от вызова Dial(если соединение не переиспользуется) до прочтения тела запроса.

c := &http.Client{  
    Timeout: 15 * time.Second,
}
resp, err := c.Get("https://blog.filippo.io/")  

Так же как и при создании сервера, функции из пакета, такие как http.Get, используют Client без указания таймаутов. А мы уже знаем, что такое использование может привести к проблемам.

Для более точного контроля, существует целый набор таймаутов, которые вы можете использовать:

  • net.Dialer.Timeout ограничивает время на установление TCP соединения(если нужно открывать новое соединение).
  • http.Transport.TLSHandshakeTimeout ограничивает время на выполнене TLS рукопожатия.
  • http.Transport.ResponseHeaderTimeout ограничивает время на чтения заголовков ответа.
  • http.Transport.ExpectContinueTimeout ограничивает время ожидания клиентом между отправкой заголовка Expect: 100-continue и получением подтверждения для отправки тела сообщения. Замечу, что при использовании этой настройки а 1.6 будет отключен HTTP/2. (в случае с DefaultTransport это специальный кейс 1.6.2).
c := &http.Client{  
    Transport: &Transport{
        Dial: (&net.Dialer{
                Timeout:   30 * time.Second,
                KeepAlive: 30 * time.Second,
        }).Dial,
        TLSHandshakeTimeout:   10 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    }
}

Нет никакого способа по умолчанию ограничить время, потраченное на обработку конкретного запроса. Время на чтение тела запроса можно контролировать вручную с помощью time.Timer, так как это происходит уже после вызова метода из структуры Client(ниже я расскажу, как отменять запросы).

В Go 1.7 появился новый параметр http.Transport.IdleConnTimeout. С его помощью не получится контролировать заблокированные запросы, но можно указать как долго заблокированные соединения должны находится в пуле.

Нужно помнить, что по умолчанию Client следует по всем редиректам. http.Client.Timeout это общее время, независимо от того, сколько редиректоа произошло. А все все таймауты настроенные в http.Transport отвечают за каждый запрос, так как это более низкоуровневый элемент, не учитывающий редиректы.

Отмена и контекст

net/http предоставляет два способа для отмены контекста: Request.Cancel и Context(нововведение в 1.7).

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

Мы можем использовать Request.Cancel и time.Timer для еще более тонкого управления таймаутами, которые позволяют стримить, и переустанавливать дедлайны как только мы смогли прочитать часть данных:

package main

import (  
    "io"
    "io/ioutil"
    "log"
    "net/http"
    "time"
)

func main() {  
    c := make(chan struct{})
    timer := time.AfterFunc(5*time.Second, func() {
        close(c)
    })

    // Выдаем 256 байтов каждую секунду.
    req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)
    if err != nil {
        log.Fatal(err)
    }
    req.Cancel = c

    log.Println("Sending request...")
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer resp.Body.Close()

    log.Println("Reading body...")
    for {
        // Можно поменять на: timer.Reset(50 * time.Millisecond)
        timer.Reset(2 * time.Second)
        _, err = io.CopyN(ioutil.Discard, resp.Body, 256)
        if err == io.EOF {
            break
        } else if err != nil {
            log.Fatal(err)
        }
    }
}

В примере выше мы выделяем 5 секунд на Do фазу запроса, затем мы потратим 8 секунд на чтение тела запроса в 8 этапов, каждый из которых длится не больше 2 секунд. Мы могли бы работать с потоком в таком режиме, без боязни "залипнуть". Если за 2 секунды мы не смогли получить данные из тела запроса, то io.CopyN должен вернуть net/http: request canceled.

В 1.7 в стандартную библиотеку добавился пакет context. Конечно, это намного более функциональный инструмент, но мы воспользуемся только той частью, которая может заменить Request.Cancel.

Для использования контекста, нам достаточно просто создать новый экземпляр с помощью context.WithCancel, а затем создать Request, привязав к нему наш контекст через Request.WithContext. Когда нам нужно будет отменить запрос, достаточно просто вызвать cancel()(вместо закрытия канала Cancel в Request):

ctx, cancel := context.WithCancel(context.TODO())  
timer := time.AfterFunc(5*time.Second, func() {  
    cancel()
})

req, err := http.NewRequest("GET", "http://httpbin.org/range/2048?duration=8&chunk_size=256", nil)  
if err != nil {  
    log.Fatal(err)
}
req = req.WithContext(ctx)  

У контекстов есть одно преимущество: если родительский контекст(который мы указали в context.WithCancel) выполняет отмену, то наши контексты делают тоже самое. Таким образом, команда отмены распространяется по всему конвейеру.

На этом все. Надеюсь, я не превысил ваш ReadDeadline!

Если вам понравилось это погружение в стандартную библиотеку Go, то приходите к нам на работу в Лондон, Остин (TX), Шампейн (IL), Сан-Франциско и Сингапур..

comments powered by Disqus