Understanding the Implementation Principles of the Go HTTP Standard Library

Table of Contents

  • Introduction
    • Connection Reuse Mechanism
  • Server
    • Registering Routes
    • Starting the Server
    • Handling Client Requests
    • Route Matching
  • Client
    • Data Structures
    • Initiating Requests
    • Obtaining Connections
      • Reusing Idle Connections
      • Creating or Waiting for Connections
      • Creating Connections
        • Read Goroutine
        • Write Goroutine
      • Returning Connections
    • Sending Requests
    • Two Companion Read and Write Goroutines

Introduction

This article will introduce the implementation principles of the Go HTTP standard library, mainly including:

  • The server’s route matching mechanism, connection reuse, and service startup process
  • The client’s connection reuse and companion read and write goroutines

Connection Reuse Mechanism

In HTTP/1.1, Keep-Alive is enabled by default, which means:

  • A single TCP connection can carry multiple HTTP requests/responses
  • The connection will not be closed after each request
  • Both parties negotiate when to close the connection

The Go <span>net/http</span> package fully adheres to this standard. The <span>Client</span> and <span>Server</span> both implement connection reuse, following the <span>Keep-Alive</span> rules of the HTTP protocol, alternating between handling requests and responses on the same TCP connection, forming a connection reuse loop.

As for how connection reuse is implemented, it will be detailed below.

Server

Registering Routes

Understanding the Implementation Principles of the Go HTTP Standard Library

To register a route and its handler, and start the HTTP service, only the following two lines of code are needed:

func main() {
    http.HandleFunc("/ready", func(writer http.ResponseWriter, request *http.Request) {
        // Interface handling logic
    })

    // Start HTTP server    
    http.ListenAndServe(":8080", nil)
}

The HandleFunc method calls the HandleFunc method of the global variable DefaultServeMux.

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

The global variable is of type ServeMux.

var DefaultServeMux = &defaultServeMux

var defaultServeMux ServeMux

Its structure is:

type ServeMux struct {
    mu    sync.RWMutex
    m     map[string]muxEntry
    es    []muxEntry 
}
  • <span>m map[string]muxEntry</span>: stores all registered <span>path</span> and handler (<span>Handler</span>) mappings for exact matching. When processing requests, if the request path can exactly match a certain pattern, the corresponding handler is directly retrieved from <span>m</span>.
  • <span>es []muxEntry</span>: used for prefix matching (i.e., patterns ending with <span>/</span>). When the request path cannot be exactly matched in <span>m</span>, the system traverses <span>es</span> to find the longest matching prefix.

The muxEntry structure is as follows:

type muxEntry struct {
    h       Handler // Handler
    pattern string // Request path
}

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Continuing with the ServeMux.HandleFunc method:

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    // ...
    mux.Handle(pattern, HandlerFunc(handler))
}
func (mux *ServeMux) Handle(pattern string, handler Handler) {
    mux.mu.Lock()
    defer mux.mu.Unlock()
    
    // Cannot register multiple times, Go considers this a serious error and should panic directly during service startup
    if _, exist := mux.m[pattern]; exist {
       panic("http: multiple registrations for " + pattern)
    }

    e := muxEntry{h: handler, pattern: pattern}
    // Store it in mux.m for subsequent exact matching lookups
    mux.m[pattern] = e
    // If the pattern ends with / (e.g., /static/), it indicates it can be a fuzzy match
    if pattern[len(pattern)-1] == '/' {
       // The details of appendSorted are omitted here; its function is to insert e into the es list and keep the list sorted by pattern length from longest to shortest
       mux.es = appendSorted(mux.es, e)
    }
}

Starting the Server

Next, let’s see how the server is started.

func main() {
    http.HandleFunc("/ready", func(writer http.ResponseWriter, request *http.Request) {

    })

    http.ListenAndServe(":8091", nil)
}
func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}
func (srv *Server) ListenAndServe() error {
    if srv.shuttingDown() {
       return ErrServerClosed
    }
    addr := srv.Addr
    
    // Create a TCP listening socket (listener)
    ln, err := net.Listen("tcp", addr)
    if err != nil {
       return err
    }
    return srv.Serve(ln)
}

Understanding the Implementation Principles of the Go HTTP Standard Library

The Server.Serve method:

  1. Continuously calls <span>listener.Accept()</span>, waiting for and accepting new client connections
  2. Each time a connection is successfully accepted, a goroutine is started to handle the requests for that connection, implementing the classic one connection one goroutine model, which is simple and efficient, fully utilizing Go’s lightweight concurrency.
func (srv *Server) Serve(l net.Listener) error {
   
    origListener := l
    l = &onceCloseListener{Listener: l}
    defer l.Close()
        
    for {
       // Call l.Accept() to wait for new connections
       rw, err := l.Accept()
       if err != nil {
          // Handle error
          return err
       }
       
       // Create a new *conn object, encapsulating rw (net.Conn)
       c := srv.newConn(rw)
       
       // Start a new goroutine to handle that connection
       go c.serve(connCtx)
    }
}

Handling Client Requests

How does conn specifically handle the requests sent by the client?

func (c *conn) serve(ctx context.Context) {
    defer func() {
       // Capture panic, clean up resources
    }()

    ctx, cancelCtx := context.WithCancel(ctx)
    c.cancelCtx = cancelCtx
    defer cancelCtx()

    c.r = &connReader{conn: c}
    // bufr: used to read request headers and body
    c.bufr = newBufioReader(c.r)
    // bufw: used for batch writing responses to improve performance
    c.bufw = newBufioWriterSize(checkConnErrorWriter{c}, 4<<10)
   
    for {
       //  Parse the request line (e.g., GET / HTTP/1.1) and request headers
       w, err := c.readRequest(ctx)
       if err != nil {
          // Send corresponding HTTP error code
       }

       // Call the handler for the HTTP request
       serverHandler{c.server}.ServeHTTP(w, w.req)
       
       // Check if the connection should be reused; if not, break the loop and continue reading the next request, then process it
    } 
}

In the <span>(*conn).serve()</span> method, after executing <span>serverHandler{c.server}.ServeHTTP(w, w.req)</span>, the current goroutine can continue executing the <span>for</span> loop as long as the condition is met: both the client and server agree to reuse the TCP connection for subsequent HTTP requests.

This is the key mechanism for improving performance in HTTP/1.1: reusing TCP connections, avoiding the overhead of frequently establishing and closing connections.

Specifically:

  1. The request header does not contain <span>Connection: close</span>
  2. The server configuration allows Keep-Alive (enabled by default)

For example: suppose the client sends two requests through the same TCP connection (HTTP/1.1 default Keep-Alive):

GET /hello HTTP/1.1
Host: example.com
(空行)

GET /world HTTP/1.1
Host: example.com
(空行)

The server processing flow:

  1. First <span>readRequest</span> → parse the first <span>GET /hello</span>
  2. <span>ServeHTTP</span> processes and returns the response
  3. Check if the connection can be reused
  4. Continue the <span>for</span> loop
  5. Second <span>readRequest</span> → parse the second <span>GET /world</span>
  6. Again <span>ServeHTTP</span>
  7. Check again if it can be reused… until a condition is not met, and the connection is closed

Now let’s look at the request handling logic:

serverHandler{c.server}.ServeHTTP(w, w.req)
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
       // If the server does not specify a handler, use the global variable DefaultServeMux for route matching
       handler = DefaultServeMux
    }
    
    handler.ServeHTTP(rw, req)
}
func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
    // ...
    
    // Decide which handler should handle based on the incoming HTTP request
    h, _ := mux.Handler(r)
    // Call the handler to process the request
    h.ServeHTTP(w, r)
}

Route Matching

The ServeMux.Handler method: determines which handler should handle based on the incoming HTTP request.

func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {

    // ...

    return mux.handler(host, r.URL.Path)
}
func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
        
    // ...
    if h == nil {
       // Core
       h, pattern = mux.match(path)
    }
   
    return
}

The core method is the ServeMux.match method: it finds the matching handler (<span>Handler</span>) based on the request path (<span>path</span>).

func (mux *ServeMux) match(path string) (h Handler, pattern string) {
    // First, try exact matching; if the requested path can exactly match a registered handler, return that handler
    v, ok := mux.m[path]
    if ok {
       return v.h, v.pattern
    }

    // Longest prefix matching
    for _, e := range mux.es {
       if strings.HasPrefix(path, e.pattern) {
          return e.h, e.pattern
       }
    }
    return nil, ""
}
  1. First, try exact matching; if the requested path can exactly match a registered handler, return that handler.
  • For example, if a request path is <span>/api/v1/users</span>, and there is also a key in mux.m with <span>/api/v1/users</span>, the exact match is successful.
  1. Next, try the longest prefix matching (used for patterns ending with <span>/</span>).

mux.es is a slice of <span>[]muxEntry</span> sorted by pattern length from longest to shortest, containing only those route patterns that end with <span>/</span>.

For example: registered <span>/api/</span>, <span>/static/</span>, <span>/images/</span>, etc., will all be placed in <span>mux.es</span>.

Since <span>mux.es</span> is sorted from longest to shortest, the first successful match is the longest prefix match. For example:

  • Registered <span>/api/v1/</span> and <span>/api/</span>, both ending with <span>/</span>.
  • The request path <span>/api/v1/users</span> will prioritize matching <span>/api/v1/</span> (longer) rather than <span>/api/</span>.

Client

Data Structures

type Client struct {
    // Responsible for executing HTTP requests, converting *http.Request to *http.Response
    Transport RoundTripper

    // Manages cookies 
    Jar CookieJar

    // Sets the total timeout for the entire HTTP request, starting from Client.Do until the response header and body are fully received
    Timeout time.Duration
}

The default implementation of RoundTripper is Transport: mainly maintains a connection pool.

type Transport struct {
    idleMu       sync.Mutex
        
    /**
    Idle connection pool
    key is connectMethodKey (representing each (scheme+host:port))
    value is the list of idle persistConn
    */
    idleConn     map[connectMethodKey][]*persistConn 

    // When there are no idle connections and the number of connections has not reached the limit, requests will queue here waiting
    idleConnWait map[connectMethodKey]wantConnQueue  
    // When the global connections reach MaxConnsPerHost, it decides which connection to eliminate
    idleLRU      connLRU

    // Number of active connections per host, used to implement MaxConnsPerHost limit
    connsPerHost     map[connectMethodKey]int
    // Queue of requests waiting to create new connections (when reaching MaxConnsPerHost limit) 
    connsPerHostWait map[connectMethodKey]wantConnQueue 

    // Maximum number of idle connections per {scheme,host:port}, default is 2
    MaxIdleConnsPerHost int

    // Global maximum total number of connections. 0 means unlimited
    MaxConnsPerHost int
}

Initiating Requests

Suppose we want to initiate an HTTP request:

resp, err = http.Post(s.URL()+"/foo", "application/json", nil)
func Post(url, contentType string, body io.Reader) (resp *Response, err error) {
    return DefaultClient.Post(url, contentType, body)
}

Constructing the request structure:

func (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {
    req, err := NewRequest("POST", url, body)
    if err != nil {
       return nil, err
    }
    req.Header.Set("Content-Type", contentType)
    return c.Do(req)
}
func (c *Client) Do(req *Request) (*Response, error) {
    return c.do(req)
}

When it reaches the client.do method, the for loop mainly handles some redirection-related logic, which is not the focus of this article.

The core is the internal call to the Client.send method.

func (c *Client) do(req *Request) (retres *Response, reterr error) {

    var (
       // Deadline calculated based on client.Timeout
       deadline      = c.deadline()
       resp          *Response
       
    )
    
    for {

       // ...
       
       // Core: send the request
       if resp, didTimeout, err = c.send(req, deadline); err != nil {
          // c.send() always closes req.Body
          return nil, uerr(err)
       }

       // If not redirecting, return resp
       var shouldRedirect bool
       redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
       if !shouldRedirect {
          return resp, nil
       }

       req.closeBody()
    }
}

Continuing with Client.send:

func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
    
    resp, didTimeout, err = send(req, c.transport(), deadline)
    if err != nil {
       return nil, didTimeout, err
    }
    
    return resp, nil, nil
}
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
    req := ireq 
    
    
    // Send the request
    resp, err = rt.RoundTrip(req)
    if err != nil {
       // ...
    }
    
    return resp, nil, nil
}

Entering the Transport.roundTrip method:

func (t *Transport) roundTrip(req *Request) (*Response, error) {
    
    ctx := req.Context()

    origReq := req
    cancelKey := cancelKey{origReq}
    req = setupRewindBody(req)

    for {
       // Check if ctx has been canceled before each retry
       select {
       case <-ctx.Done():
          req.closeBody()
          return nil, ctx.Err()
       default:
       }

       treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey}
       cm, err := t.connectMethodForRequest(treq)
       if err != nil {
          req.closeBody()
          return nil, err
       }

       // Obtain connection
       //   First try to reuse persistConn from the idle connection pool
       //   If not, create a new connection
       pconn, err := t.getConn(treq, cm)
       if err != nil {
          t.setReqCanceler(cancelKey, nil)
          req.closeBody()
          return nil, err
       }

       var resp *Response
       
       // Send the request
       resp, err = pconn.roundTrip(treq)

    }
}

Obtaining Connections

Understanding the Implementation Principles of the Go HTTP Standard Library

The method for obtaining connections in Transport.getConn is as follows:

func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
    req := treq.Request
    ctx := req.Context()
   
    // wantConn indicates "a request waiting to obtain a connection"
    w := &wantConn{
       cm:         cm,
       // Unique key for connection pool lookup
       key:        cm.key(),
       ctx:        ctx,
       // Channel notifying that the connection is ready
       ready:      make(chan struct{}, 1),
    }
    defer func() {
       if err != nil {
          w.cancel(t, err)
       }
    }()

    // Try to reuse idle connections
    if delivered := t.queueForIdleConn(w); delivered {
       pc := w.pc;
       
       t.setReqCanceler(treq.cancelKey, func(error) {})
       return pc, nil
    }

    // Asynchronously create a connection
    t.queueForDial(w)
    
    select {
    // Wait for the connection to be created successfully,
    case <-w.ready:
       // ...
       return w.pc, w.err
    // Or ctx canceled   
    case <-req.Context().Done():
       return nil, req.Context().Err()
    // ...
    }
}

Reusing Idle Connections

When the following conditions are met, it indicates that the connection can be reused:

  • The scheme (<span>http</span>/<span>https</span>) must be the same
  • The target address (<span>host:port</span>) must be the same
  • Keep-Alive has not been disabled

Regarding the criteria for judging the same scheme and target address, here are some examples:

Request 1 Request 2 Can Reuse
http://api.example.com/v1/users http://api.example.com/v1/orders ✅ Yes (same scheme, same host:port)
https://api.example.com:443/data https://api.example.com/status ✅ Yes (default 443)
http://blog.company.com/post1 http://shop.company.com/item1 ❌ No (different host)
http://example.com:8080 http://example.com ❌ No (different port)
https://example.com http://example.com ❌ No (different scheme)

Continuing to see how to attempt to reuse idle connections:

func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
    
    // oldTime: connections older than this time are considered expired
    var oldTime time.Time
    if t.IdleConnTimeout > 0 {
       oldTime = time.Now().Add(-t.IdleConnTimeout)
    }

    // Find idle connections
    if list, ok := t.idleConn[w.key]; ok {
       stop := false
       delivered := false
       for len(list) > 0 && !stop {
          // Take the last one, using LIFO strategy, prioritizing the reuse of the most recently used connection
          pconn := list[len(list)-1]

          
          tooOld := !oldTime.IsZero() &&& pconn.idleAt.Round(0).Before(oldTime)
          if tooOld {
             go pconn.closeConnIfStillIdle()
          }
          
          // If the connection is expired or broken, check the next connection
          if pconn.isBroken() || tooOld {
             list = list[:len(list)-1]
             continue
          }
          delivered = w.tryDeliver(pconn, nil)
          if delivered {
             if pconn.alt != nil {
               
             } else {
                // Remove the connection from the list
                t.idleLRU.remove(pconn)
                list = list[:len(list)-1]
             }
          }
          stop = true
       }
       
       // Update the idle connection pool
       if len(list) > 0 {
          t.idleConn[w.key] = list
       } else {
          delete(t.idleConn, w.key)
       }
       
       if stop {
          return delivered
       }
       
    }

    // Did not obtain a connection, join the waiting queue
    if t.idleConnWait == nil {
       t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
    }
    q := t.idleConnWait[w.key]
    q.cleanFront()
    q.pushBack(w)
    t.idleConnWait[w.key] = q
    return false
}

wantConn: assigns <span>persistConn</span><span> to </span><code><span>wantConn</span>

func (w *wantConn) tryDeliver(pc *persistConn, err error) bool {
    w.mu.Lock()
    defer w.mu.Unlock()

    // Assignment
    w.pc = pc
    w.err = err
    
    close(w.ready)
    return true
}

Creating or Waiting for Connections

When there are no idle connections, a new connection must be attempted to be created.

  1. If there are no connection limits, create a new connection.
  2. If the current <span>{scheme, host:port}</span> connection count has not reached the limit, create a connection.
  3. Otherwise, join the connsPerHostWait queue waiting until a connection is closed to wake up here.
func (t *Transport) queueForDial(w *wantConn) {
    // No connection limit, create a new connection
    if t.MaxConnsPerHost <= 0 {
       go t.dialConnFor(w)
       return
    }

    t.connsPerHostMu.Lock()
    defer t.connsPerHostMu.Unlock()
    
    // The current key's connection count has not reached the limit, create a connection
    if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
       if t.connsPerHost == nil {
          t.connsPerHost = make(map[connectMethodKey]int)
       }
       t.connsPerHost[w.key] = n + 1
       go t.dialConnFor(w)
       return
    }

    // Otherwise, queue waiting
    if t.connsPerHostWait == nil {
       t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)
    }
    q := t.connsPerHostWait[w.key]
    q.cleanFront()
    q.pushBack(w)
    t.connsPerHostWait[w.key] = q
}

Creating Connections

How to actually create a connection.

func (t *Transport) dialConnFor(w *wantConn) {
    // Create connection
    pc, err := t.dialConn(w.ctx, w.cm)
    delivered := w.tryDeliver(pc, err)
}

Transport.dialConn: actually creates a connection and starts two companion read and write goroutines.

As for why two companion read and write goroutines are needed, it will be analyzed at the end of the article.

func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
    pconn = &persistConn{
       t:             t,
       cacheKey:      cm.key(),
       // Channel for receiving read requests
       reqch:         make(chan requestAndChan, 1),
       // Channel for receiving write requests
       writech:       make(chan writeRequest, 1),
       closech:       make(chan struct{}),
    }
    
   
    if /**/ {
      // ...
    } else {
       // Establish TCP connection
       conn, err := t.dial(ctx, "tcp", cm.addr())
       if err != nil {
          return nil, wrapErr(err)
       }
       pconn.conn = conn
       // ...
    }

    // Initialize buffered Reader and Writer
    pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
    pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())
    
    // Start two core goroutines
    // readLoop: reads server responses
    go pconn.readLoop()
    
    // writeLoop: sends requests
    go pconn.writeLoop()
    return pconn, nil
}

Read Goroutine

This is the core logic for the client to read server responses.

func (pc *persistConn) readLoop() {

    // On exit
    defer func() {
       // Close the underlying connection
       pc.close(closeErr)
       // Remove this connection from the Transport's idle connection pool
       pc.t.removeIdleConn(pc)
    }()

    // Try to put the connection back into the idle pool
    tryPutIdleConn := func(trace *httptrace.ClientTrace) bool {
       if err := pc.t.tryPutIdleConn(pc); err != nil {
          // ...
          return false
       }
       return true
    }

    
    eofc := make(chan struct{})
    defer close(eofc) // unblock reader on errors

    alive := true
    for alive {
       
       // Receive requestAndChan (containing request, response channel, cancel key, etc.) from reqch.
       // This is placed by writeLoop before sending the request
       rc := <-pc.reqch

       var resp *Response
       if err == nil {
          // Parse HTTP response headers
          resp, err = pc.readResponse(rc, trace)
       } 

       waitForBodyRead := make(chan bool, 2)
       body := &bodyEOFSignal{
          body: resp.Body,
          earlyCloseFn: func() error {
             waitForBodyRead <- false
             <-eofc 
             return nil
          },
          fn: func(err error) error {
             isEOF := err == io.EOF
             waitForBodyRead <- isEOF
             if isEOF {
                <-eofc 
             } else if err != nil {
                if cerr := pc.canceled(); cerr != nil {
                   return cerr
                }
             }
             return err
          },
       }

       resp.Body = body
      
       select {
       // Return the response (including the wrapped Body) to roundTrip
       // After roundTrip returns *http.Response, the user starts reading Body
       case rc.ch <- responseAndError{res: resp}:
       
       }

       
       select {
       // Only when the Body is fully read, try to reuse the connection
       case bodyEOF := <-waitForBodyRead:
          alive = alive &&
             bodyEOF &&
             !pc.sawEOF &&
             pc.wroteRequest() &&
             replaced &&
             tryPutIdleConn(trace)
          if bodyEOF {
             eofc <- struct{}{}
          }
      
    }
}

Write Goroutine

func (pc *persistConn) writeLoop() {
    defer close(pc.writeLoopDone)
    for {
       select {
       // Receive write requests
       case wr := <-pc.writech:
          // Write the request
          err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
          
          if err == nil {
             // bufio.Writer is buffered, must Flush to actually write to net.Conn
             err = pc.bw.Flush()
          }
        
          if err != nil {
             // Write failed, close the connection
             pc.close(err)
             return
          }
       case <-pc.closech:
          return
       }
    }
}

The main loop is:

for {
    select {
        case wr := <-pc.writech:
            // Handle write requests
        case <-pc.closech:
            return
    }
}

All write operations are submitted through <span>writech</span>, ensuring serialized writing to avoid data corruption caused by multiple goroutines writing simultaneously.

Returning Connections

Since there is a mechanism for obtaining connections from the connection pool, there must also be a mechanism for returning connections to the pool. Returning connections mainly occurs in the read loop when a round of requests is completed and the connection is reusable.

select {
case bodyEOF := <-waitForBodyRead:
    // Return the connection
    tryPutIdleConn(trace)
}
tryPutIdleConn := func(trace *httptrace.ClientTrace) bool {
    pc.t.tryPutIdleConn(pc)
}

Returning idle connections:

  1. If there is a goroutine waiting for a connection for that <span>host</span>, try to deliver the connection directly to the first un-canceled waiter; if delivery is successful, it will not be returned to the pool.
  2. Add <span>pconn</span> to <span>t.idleConn[key]</span>
  3. If the total number of idle connections exceeds <span>MaxIdleConns</span>, remove and close the oldest connection (using LRU elimination).
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {

    key := pconn.cacheKey
    // Try to "wake up waiters"
    if q, ok := t.idleConnWait[key]; ok {
       done := false
       if pconn.alt == nil {
          // Traverse the waiting queue, trying to deliver to the first available waiter
          for q.len() > 0 {
             w := q.popFront()
             // Deliver the connection
             if w.tryDeliver(pconn, nil) {
                done = true
                break
             }
          }
       } else {
          // ...
       }
       
       if done {
          return nil
       }
    }

    // If the current host's idle connection count has reached MaxIdleConnsPerHost, do not put the connection back into the pool
    idles := t.idleConn[key]
    if len(idles) >= t.maxIdleConnsPerHost() {
       return errTooManyIdleHost
    }
    
    t.idleConn[key] = append(idles, pconn)
    t.idleLRU.add(pconn)
    
    // If the global idle connection total exceeds MaxIdleConns, remove the oldest connection (LRU)
    if t.MaxIdleConns != 0 && t.idleLRU.len() > t.MaxIdleConns {
       oldest := t.idleLRU.removeOldest()
       oldest.close(errTooManyIdle)
       t.removeIdleConnLocked(oldest)
    }

    // ...
    return nil
}

Sending Requests

Understanding the Implementation Principles of the Go HTTP Standard Library

After obtaining the connection, the next step is to send the request.

  1. Send a write request to the <span>writeLoop</span> via <span>writech</span>
  2. Submit the request metadata requestAndChan to the <span>reqch</span> of the readLoop.
    1. The readLoop, upon receiving, starts waiting for the server’s response.
    2. Once the readLoop receives the server’s response, it writes the result into the requestAndChan.ch.
  3. Obtain the response from requestAndChan.ch.
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {

    // gone is used to notify readLoop and writeLoop: the caller has given up waiting
    gone := make(chan struct{})
    defer close(gone)
    
    startBytesWritten := pc.nwrite
    writeErrCh := make(chan error, 1)
    // Send a write request to the writeLoop's writech
    pc.writech <- writeRequest{req, writeErrCh, continueCh}
    
    resc := make(chan responseAndError)
    // Submit request metadata to the readLoop's reqch
    // The readLoop, upon receiving, starts waiting for the server response
    pc.reqch <- requestAndChan{
       req:        req.Request,
       cancelKey:  req.cancelKey,
       // resc is used to receive the final response or error.
       ch:         resc,
    }

    cancelChan := req.Request.Cancel
    ctxDoneChan := req.Context().Done()
    pcClosed := pc.closech
    canceled := false
    for {
       select {
       // Handle write errors
       case err := <-writeErrCh:
          
          if err != nil {
             pc.close(fmt.Errorf("write error: %w", err))
             return nil, pc.mapRoundTripError(req, startBytesWritten, err)
          }
       // Connection closed   
       case <-pcClosed:
          pcClosed = nil
          if canceled || pc.t.replaceReqCanceler(req.cancelKey, nil) {
             
             return nil, pc.mapRoundTripError(req, startBytesWritten, pc.closed)
          }
       // Received response
       case re := <-resc:
          return re.res, nil
          
       // Timeout cancel
       case <-cancelChan:
          canceled = pc.t.cancelRequest(req.cancelKey, errRequestCanceled)
          cancelChan = nil
       case <-ctxDoneChan:
          canceled = pc.t.cancelRequest(req.cancelKey, req.Context().Err())
          cancelChan = nil
          ctxDoneChan = nil
       }
    }
}

Two Companion Read and Write Goroutines

At this point, we can explain why each connection of the client needs two companion read and write goroutines.

  1. It allows for precise control of timeouts and cancellations

<span>roundTrip</span>‘s main loop uses <span>select</span> to listen to multiple channels:

select {
case <-writeErrCh:     // Write error
case <-pc.closech:     // Connection closed
case <-respHeaderTimer: // Response header timeout
case <-resc:           // Received response
case <-ctx.Done():     // Context canceled
}

This way, <span>roundTrip</span> can respond to cancellations or timeouts at any stage.

  1. Increased parallelism, as the server may start sending response headers before the client has completely sent the request body.

For example:

  • <span>readLoop</span> immediately reads the response headers and returns through <span>resc</span>
  • <span>roundTrip</span> receives an error response and can immediately cancel <span>writeLoop</span> (via <span>cancelRequest</span>).
  • This avoids continuing to send unnecessary request bodies.

Suppose <span>roundTrip</span> adopts a synchronous mode:

err := pc.WriteRequest(req) // Block until the request is fully sent
if err != nil { return err }
resp, err := pc.ReadResponse() // Then read
return resp, err

It would not be able to achieve the above two points.

Leave a Comment