Overview

When using HTTP, whether in a browser or an SDK’s HTTP client, we often have the need to reuse HTTP connections. This means making multiple HTTP requests and responses over the same connection, a concept commonly known as HTTP persistent connection (Keep-Alive).

In this article, we will explore persistent connections from both the protocol support perspective and its practical implementation in Go.

Protocol Support

The RFC for HTTP/1.1 dedicates an entire section to discussing persistent connections. Right at the beginning, it outlines the benefits of using persistent connections:

  • Reduces resource consumption across all involved devices (such as CPU and memory usage on clients, servers, and routers).
  • Enables multiple requests and responses to be pipelined over the same connection, improving connection efficiency.
  • Decreases the number of TCP connection establishments and closures, reducing network congestion by lowering the overall number of transmitted packets.
  • Lowers response latency by eliminating the overhead of establishing new connections.
  • Improves error handling since persistent connections allow for error reporting, whereas short connections can only indicate failures by terminating the connection.

According to the HTTP/1.1 RFC, persistent connections are the default behavior in HTTP/1.1. A connection is only considered short-lived if either the client or the server explicitly sends a Connection: Close header.

For persistent connections, if the connection is unexpectedly closed during transmission, the client should retry idempotent requests but must not retry non-idempotent requests. The HTTP/1.1 RFC specifies that GET, HEAD, PUT, and DELETE are idempotent request methods.

Go Implementation

Next, let’s explore how Go 1.22 handles the HTTP/1.1 protocol, with separate discussions for the Client and Server implementations.

Client

In Go, connection management is handled through the Transport layer, which implements the RoundTripper interface:

  1. [root@liqiang.io]# cat client.go
  2. type RoundTripper interface {
  3. RoundTrip(*Request) (*Response, error)
  4. }

Since RoundTripper is an interface, we need to provide an implementation. However, by default, the HTTP client uses Go’s built-in default RoundTripper:

  1. [root@liqiang.io]# cat transport.go
  2. var DefaultTransport RoundTripper = &Transport{
  3. Proxy: ProxyFromEnvironment,
  4. DialContext: defaultTransportDialContext(&net.Dialer{
  5. Timeout: 30 * time.Second,
  6. KeepAlive: 30 * time.Second,
  7. }),
  8. ForceAttemptHTTP2: true,
  9. MaxIdleConns: 100,
  10. IdleConnTimeout: 90 * time.Second,
  11. TLSHandshakeTimeout: 10 * time.Second,
  12. ExpectContinueTimeout: 1 * time.Second,
  13. }
  14. func (t *Transport) roundTrip(req *Request) (*Response, error) {
  15. ... ...
  16. for {
  17. select {
  18. case <-ctx.Done():
  19. req.closeBody()
  20. return nil, ctx.Err()
  21. default:
  22. }
  23. treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
  24. cm, err := t.connectMethodForRequest(treq)
  25. ... ...
  26. pconn, err := t.getConn(treq, cm)
  27. ... ...
  28. resp, err = pconn.roundTrip(treq)
  29. ... ...
  30. } else if !pconn.shouldRetryRequest(req, err) {
  31. ... ...
  32. return nil, err
  33. }
  34. ... ...
  35. // Rewind the body if we're able to.
  36. req, err = rewindBody(req)
  37. if err != nil {
  38. return nil, err
  39. }
  40. }
  41. }

From this simple implementation, we can see that Go’s HTTP Client uses persistent connections by default. It allows up to 100 concurrent persistent connections and has a default idle timeout of 90 seconds. Additionally, in certain cases, the client will automatically retry requests. Below are some scenarios where retries may occur:

  • When too many streams are opened on a single connection in an HTTP/2 scenario.
  • When the request does not contain a body or when the body can be re-acquired (in this case, Go does not explicitly distinguish between idempotent request methods).
  • When a non-EOF error occurs before reading the first byte of the response.
  • When the server closes an idle connection.

From the above code snippet, we can see that Go’s HTTP client implements these features. Now, let’s dive deeper into the underlying code logic to explore additional details:

  1. [root@liqiang.io]# cat transport.go
  2. func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
  3. ... ...
  4. w := &wantConn{
  5. cm: cm,
  6. key: cm.key(),
  7. ctx: ctx,
  8. ready: make(chan struct{}, 1),
  9. beforeDial: testHookPrePendingDial,
  10. afterDial: testHookPostPendingDial,
  11. }
  12. if delivered := t.queueForIdleConn(w); delivered {
  13. pc := w.pc
  14. ... ...
  15. t.setReqCanceler(treq.cancelKey, func(error) {})
  16. return pc, nil
  17. }
  18. ... ...
  19. }
  20. func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
  21. if t.DisableKeepAlives {
  22. return false
  23. }
  24. t.idleMu.Lock()
  25. defer t.idleMu.Unlock()
  26. ... ...
  27. var oldTime time.Time
  28. if t.IdleConnTimeout > 0 {
  29. oldTime = time.Now().Add(-t.IdleConnTimeout)
  30. }
  31. // Look for most recently-used idle connection.
  32. if list, ok := t.idleConn[w.key]; ok {
  33. stop := false
  34. delivered := false
  35. for len(list) > 0 && !stop {
  36. pconn := list[len(list)-1]
  37. tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
  38. if tooOld {
  39. go pconn.closeConnIfStillIdle()
  40. }
  41. if pconn.isBroken() || tooOld {
  42. list = list[:len(list)-1]
  43. continue
  44. }
  45. delivered = w.tryDeliver(pconn, nil)
  46. if delivered {
  47. if pconn.alt != nil {
  48. } else {
  49. t.idleLRU.remove(pconn)
  50. list = list[:len(list)-1]
  51. }
  52. }
  53. stop = true
  54. }
  55. if len(list) > 0 {
  56. t.idleConn[w.key] = list
  57. } else {
  58. delete(t.idleConn, w.key)
  59. }
  60. if stop {
  61. return delivered
  62. }
  63. }
  64. if t.idleConnWait == nil {
  65. t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
  66. }
  67. q := t.idleConnWait[w.key]
  68. q.cleanFront()
  69. q.pushBack(w)
  70. t.idleConnWait[w.key] = q
  71. return false
  72. }

From the code, we can see that Go’s implementation does not follow the RFC’s specification, which allows multiple ongoing requests over a single connection. Instead, in Go, each connection handles only one active request at a time. To manage multiple connections efficiently, Go maintains a connection pool, where connections are indexed using a key composed of the schema, method, and address.

Another interesting detail in the code is the use of an LRU (Least Recently Used) data structure. This structure is employed when the number of connections exceeds the limit—Go will close the longest idle connection to free up resources.

Server

Compared to the client, Go’s server-side implementation is relatively simple. In terms of the Goroutine model, it follows a Per Request, Per Goroutine approach:

  1. [root@liqiang.io]# cat server.go
  2. func (srv *Server) Serve(l net.Listener) error {
  3. for {
  4. rw, err := l.Accept()
  5. ... ...
  6. connCtx := ctx
  7. ... ...
  8. tempDelay = 0
  9. c := srv.newConn(rw)
  10. c.setState(c.rwc, StateNew, runHooks) // before Serve can return
  11. go c.serve(connCtx)
  12. }
  13. }
  14. func (c *conn) serve(ctx context.Context) {
  15. if ra := c.rwc.RemoteAddr(); ra != nil {
  16. c.remoteAddr = ra.String()
  17. }
  18. ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
  19. var inFlightResponse *response
  20. ... ...
  21. ctx, cancelCtx := context.WithCancel(ctx)
  22. c.cancelCtx = cancelCtx
  23. defer cancelCtx()
  24. c.r = &connReader{conn: c}
  25. c.bufr = newBufioReader(c.r)
  26. c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)
  27. for {
  28. w, err := c.readRequest(ctx)
  29. if c.r.remain != c.server.initialReadLimitSize() {
  30. // If we read any bytes off the wire, we're active.
  31. c.setState(c.rwc, StateActive, runHooks)
  32. }
  33. ... ...
  34. req := w.req
  35. ... ...
  36. c.curReq.Store(w)
  37. ... ...
  38. inFlightResponse = w
  39. serverHandler{c.server}.ServeHTTP(w, w.req)
  40. inFlightResponse = nil
  41. w.cancelCtx()
  42. if c.hijacked() {
  43. return
  44. }
  45. ... ...
  46. w.finishRequest()
  47. c.rwc.SetWriteDeadline(time.Time{})
  48. c.setState(c.rwc, StateIdle, runHooks)
  49. c.curReq.Store(nil)
  50. if !w.conn.server.doKeepAlives() {
  51. return
  52. }
  53. if d := c.server.idleTimeout(); d != 0 {
  54. c.rwc.SetReadDeadline(time.Now().Add(d))
  55. } else {
  56. c.rwc.SetReadDeadline(time.Time{})
  57. }
  58. if _, err := c.bufr.Peek(4); err != nil {
  59. return
  60. }
  61. c.rwc.SetReadDeadline(time.Time{})
  62. }

From this implementation, we can see that server-side connections are also reused, and similar to the client, each connection processes at most one active request at a time. However, not all connections are directly reused, as determined by the shouldReuseConnection function. Below are scenarios where a connection cannot be reused:

  • When the connection is too large or the client has explicitly sent a Connection: Close header.
  • When the response is incomplete (usually due to a write error).
  • When there is a write error in the connection.
  • When the request reading encounters an error.

Middleware

In real-world applications, we usually do not expose Go’s HTTP client directly to external services. Instead, middleware components such as load balancers (e.g., Nginx) are often placed in between. When using persistent connections, it is important to consider the middleware’s handling of keep-alive connections. Below, we will use Nginx as an example to discuss this aspect.

Nginx

Nginx’s keep-alive parameters can be categorized into two sections:

  1. Nginx <-> Upstream (Backend Servers)
  2. Client <-> Nginx

According to Nginx’s documentation, the key keepalive parameters are as follows:

Connection Parameter Syntax Default Value Description
Client keepalive_disable keepalive_disable none &#124; msie &#124; safari ...; keepalive_disable msie6; Disables keep-alive for specific browsers.
Client keepalive_requests keepalive_requests number; keepalive_requests 1000; Maximum number of requests a single connection can handle.
Client keepalive_time keepalive_time time; keepalive_time 1h; Maximum lifetime of a persistent connection before it is closed.
Client keepalive_timeout keepalive_timeout timeout [header_timeout]; keepalive_timeout 75s; Maximum idle time for a connection. Some browsers support Keep-Alive: timeout=time.
Server (Upstream) keepalive keepalive connections; - Maximum number of connections each worker process can maintain with upstream servers.
Server (Upstream) keepalive_requests keepalive_requests number; keepalive_requests 1000; Maximum number of requests a connection can handle.
Server (Upstream) keepalive_time keepalive_time time; keepalive_time 1h; Maximum lifespan of each connection before it is closed.
Server (Upstream) keepalive_timeout keepalive_timeout timeout; keepalive_timeout 60s; Idle timeout for connections.

References