为什么 Go 一个 HTTP 服务可以同时处理数万连接

Why: 一个 Go 服务为什么可以同时处理几万甚至几十万连接?

答案并不复杂,Go net/http 的实现采用了每个 TCP 连接对应一个 Goroutine 的模型。

服务启动流程

一个 HTTP 服务通常从下面这行代码开始:

http.ListenAndServe(":8080", nil)

它内部的调用流程如下:

核心代码:

	for {
		rw, err := l.Accept()
		if err != nil {
			if s.shuttingDown() {
				return ErrServerClosed
			}
			if ne, ok := err.(net.Error); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				s.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
				time.Sleep(tempDelay)
				continue
			}
			return err
		}
		connCtx := ctx
		if cc := s.ConnContext; cc != nil {
			connCtx = cc(connCtx, rw)
			if connCtx == nil {
				panic("ConnContext returned nil")
			}
		}
		tempDelay = 0
		c := s.newConn(rw)
		c.setState(c.rwc, StateNew, runHooks) // before Serve can return
		go c.serve(connCtx)
	}

流程非常简单:

  1. 监听端口。
  2. 不断调用 Accept() 接收新的 TCP 连接。
  3. 每接收到一个连接,就启动一个新的 Goroutine 执行 conn.serve()

因此,一个 TCP 连接对应一个 conn.serve Goroutine。


为什么可以支持数万连接?

假设:

  • 有 10000 个客户端在线。
  • 每个客户端保持一个 Keep-Alive 连接。

那么服务端大约会维护:

10000 TCP Connection
10000 conn.serve Goroutine

Go 的 Goroutine 初始栈只有几 KB,并由运行时负责调度,因此创建数万个 Goroutine 的成本远低于传统线程模型。

这也是 Go 非常适合编写网络服务器的重要原因之一。

总结一下

在 http1.1 前提,开启 Keep-Alive 的情况下:

  • 每一个tcp连接一个conn.serve goroutine
  • 浏览器通常限制同域约6条TCP并发
  • 服务端 goroutine 数量约等于所有客户端 TCP 连接总和,比如 1万用户 x 6 约等于 6 万TCP连接,约等于6万 groutine

keep-alive 价值

  • 减少 TCP 三次握手
  • 减少 TLS 握手(HTTPS)
  • 减少 TIME_WAIT
  • 减少 socket 创建关闭
  • 减少 conn.serve goroutine 的创建销毁
  • 提高连接复用率,降低延迟