理论

握手过程

我们知道,客户端与服务端之间建立 TCP 连接需要经过三次握手的过程:

  1. 客户端向服务端发送 SYN
  2. 服务端返回 SYN + ACK
  3. 客户端发送 ACK

至于 TCP 连接为什么需要三次握手,这三次握手是怎样确保传输的可靠性的? 这个问题,请阅读这篇文章

而这篇文章的重点是,在这三次握手的过程中,服务端是怎样维护一个连接的?

两种状态 两个队列

一图胜千言:

Screen Shot 2019-12-24 at 4.40.43 PM.png

站在服务端的角度来看,自 Linux kernel 2.2 之后,整个过程分为如下几个步骤:

  1. 服务端进行 listen 系统调用(上层函数可能与 listen syscall 不同名)之后,端口处于监听状态。
  2. 在第一次握手之后,服务端收到了客户端发来的 SYN 包,将该连接标记为 SYN_RCVD(半连接状态),并将其加入到 syns queue,这个队列的大小受 Linux 内核参数 /proc/sys/net/ipv4/tcp_max_syn_backlog 的影响,可以使用 sysctl 命令进行内核参数的调整,内核参数文件均位于 /proc/sys/ 下。
  3. 服务端对客户端的 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 参数进行指定。
  4. 我们知道 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-QSend-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=1024nginx -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


Xavier
448 声望28 粉丝

最近的关注重心: