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

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)
}

The Server.Serve method:
- Continuously calls
<span>listener.Accept()</span>, waiting for and accepting new client connections - 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:
- The request header does not contain
<span>Connection: close</span> - 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:
- First
<span>readRequest</span>→ parse the first<span>GET /hello</span> <span>ServeHTTP</span>processes and returns the response- Check if the connection can be reused
- Continue the
<span>for</span>loop - Second
<span>readRequest</span>→ parse the second<span>GET /world</span> - Again
<span>ServeHTTP</span> - 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, ""
}
- 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.
- 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

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.
- If there are no connection limits, create a new connection.
- If the current
<span>{scheme, host:port}</span>connection count has not reached the limit, create a connection. - 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:
- 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. - Add
<span>pconn</span>to<span>t.idleConn[key]</span> - 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

After obtaining the connection, the next step is to send the request.
- Send a write request to the
<span>writeLoop</span>via<span>writech</span> - Submit the request metadata requestAndChan to the
<span>reqch</span>of the readLoop. - The readLoop, upon receiving, starts waiting for the server’s response.
- Once the readLoop receives the server’s response, it writes the result into the requestAndChan.ch.
- 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.
- 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.
- 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.