理论
握手过程
我们知道,客户端与服务端之间建立 TCP 连接需要经过三次握手的过程:
- 客户端向服务端发送
SYN
- 服务端返回
SYN
+ACK
- 客户端发送
ACK
至于 TCP 连接为什么需要三次握手,这三次握手是怎样确保传输的可靠性的? 这个问题,交给以后专门写篇文章来讲。
而这篇文章的重点是,在这三次握手的过程中,服务端是怎样维护一个连接的?
两种状态 两个队列
一图胜千言:
站在服务端的角度来看,自 Linux kernel 2.2 之后,整个过程分为如下几个步骤:
- 服务端进行 listen 系统调用(上层函数可能与
listen
syscall 不同名)之后,端口处于监听状态。 - 在第一次握手之后,服务端收到了客户端发来的
SYN
包,将该连接标记为SYN_RCVD
(半连接状态),并将其加入到 syns queue,这个队列的大小受 Linux 内核参数/proc/sys/net/ipv4/tcp_max_syn_backlog
的影响,可以使用 sysctl 命令进行内核参数的调整,内核参数文件均位于/proc/sys/
下。 - 服务端对客户端的
SYN
包进行回应(SYN
+ACK
),客户端再次发来ACK
包时,这时便确立了连接关系。服务端将该连接的状态标记为ESTABLISDED
(完全连接状态),之后将其加入到 accept queue,这个队列的大小受 Linux 内核参数/proc/sys/net/core/somaxconn
的影响。但其实 accept queue 的大小不完全取决于somaxconn
的值,其大小为min(somaxconn, backlog)
,这个backlog
值是 过程 1 中进行listen
系统调用时传入的(前面为了便于理解,没有代入说明),backlog
属于应用层参数,而somaxconn
属于内核参数,最终 accept queue 的大小取两者的最小值。另外,需要注意的是在 docker 容器中是无法通过sysctl
再对内核参数进行调整的,需要在容器启动的时候通过docker run --sysctl
参数进行指定。 - 我们知道 TCP 协议是分层的,TCP 连接的建立在传输层的工作就到此为止了,下一步等待应用层(可以理解为我们的程序或者进程)进行不断地(一般为无限循环)调用
accept
syscall,从 accept queue 中取得队首的 connection 进行下一步read
或其他操作。
实践
ss 命令
我们可以通过 ss -tln
命令查看当前系统处于监听状态的(-l
)TCP 协议的(-t
)sockets。
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 511 *:80 *:*
LISTEN 0 150 :::3306 :::*
可以看到我们的系统中有两个 TCP sockets 处于监听状态,80 端口为 Nginx 服务,3306 为 MySQL 服务。其中重要的两个列为 Recv-Q
和 Send-Q
,在 State
状态为 LISTEN
并且协议为 TCP 的行,Send-Q
代表的就是上面所述的 accept queue 容量大小,Recv-Q
代表的是 accept queue 中目前有多少个 ESTABLISHED
connection 等待应用层的 accept
调用。
在当前系统中,我将内核参数 somaxconn
设为了可接受的最大值 2 << 16 - 1
:
root@linux:/# sysctl -a | grep net.core.somaxconn
net.core.somaxconn = 65535
这说明 Nginx 启动服务时,执行 listen
默认传入的 backlog
大小为 511,MySQL 为 150
。一般情况下,我们观察到 Recv-Q
一列总为 0
,即使我们使用 telnet
或者 curl
频繁地访问 80
端口,这是因为应用程序在时刻不断地从 accept queue 中获取 connection。
accept queue 验证
如何来验证 accept queue 的存在?
一般的 TCP 服务程序代码中,在 listen
之后,都会循环地进行 accept
调用(Node.js 等事件型语言除外)。那么很简单,我们只需要在 listen
之后,不做任何 accept
操作,queue 中已建立的连接就会逐渐堆积,到时候我们再来看 Recv-Q
列的值。
我们先用 Go 来写一段简单的服务代码:
package main
import (
"log"
"net"
)
func main() {
listener, err := net.Listen("tcp", ":8899")
if err != nil {
log.Fatalf("listen failed: %v", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Printf("accept failed: %v", err)
continue
}
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
_, err := conn.Write([]byte("\nHello, TCP!\n"))
if err != nil {
log.Printf("write failed: %v", err)
}
}
启动服务后,再开启一个 Terminal 观察 Recv-Q
的变化:
root@linux:/# watch -n 0.1 ss -lnt
Every 0.1s: ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 65535 :::8899 :::*
再开启第三个 Ternimal 不断进行 telnel
访问:
root@linux:/# telnet localhost 8899
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^\]'.
Hello, TCP!
Connection closed by foreign host.
OK, 我们观察到 Recv-Q
值始终为 0
,我们的访问速度远远低于服务程序的处理速度。接下来我们将代码修改如下:
package main
import (
"log"
"net"
)
func main() {
_, err := net.Listen("tcp", ":8899")
if err != nil {
log.Fatalf("listen failed: %v", err)
}
for {
}
}
启动服务后,再用 telnet
进行多次访问,发现 accept queue 中已经出现了 connection 堆积:
root@linux:/# watch -n 0.1 ss -lnt
Every 0.1s: ss -lnt
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 3 65535 :::8899 :::*
设定合理的 backlog
在服务非常繁忙的时候,比如高并发场景,为了避免 accept queue overflow,可以适当地调高应用程序的 backlog 值(前提是内核 somaxconn
参数已经调得很高)。Nginx 可以在配置文件的 server
块的 listen
参数后面添加 backlog
参数,如 listen 80 backlog=1024
,nginx -s reload
后生效。
Go 目前没有提供相关的参数接口,默认使用内核参数 somaxconn
值作为 backlog
,但可以通过 syscall
包或者 golang.org/x/sys/unix 包直接进行 Listen
系统调用并传入 backlog
值,会比较麻烦。
when accept queue is full
至于如果应用 accept
的速度远远赶不上连接的新增速度,导致 accept queue 被填满,那 Linux 会怎样处理?请看这篇文章的解读:How TCP backlog works in Linux。
有疑问加站长微信联系(非本文作者)