理解服务端对 TCP 握手的处理,以及什么是 backlog

理论

握手过程

我们知道,客户端与服务端之间建立 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 的技术博客
最近的关注重心: 1. 云原生 (Docker、Kubernetes) 2. 微服务 (网关 Kong、服务通讯 gRPC、通讯格式 Pro...

最近的关注重心:

442 声望
27 粉丝
0 条评论
推荐阅读
Linux 系统下如何将前台应用作为后台进程运行:nohup 与 & 命令的使用
COMMAND &amp; 形式前台进程变为后台进程。如果不指定输出重定向(例如:COMMAND &gt;out.log 2&gt;&amp;1 &amp;),输出仍然打印到前台。退出 shell 会话(其父进程),进程会收到 HUP 信号,从而退出。在另一个...

Xavier阅读 786

一个HTTP请求的曲折经历
作为程序员的我们每天都在和网络请求打交道,而前端程序员接触的最多的就是HTTP请求。平时工作中,处理网络请求之类的操作是最多的了。但是一个请求从客户端发出到被服务端处理、再回送响应,再被客户端接收这一...

nero24阅读 5.1k评论 1

前端如何入门 Go 语言
类比法是一种学习方法,它是通过将新知识与已知知识进行比较,从而加深对新知识的理解。在学习 Go 语言的过程中,我发现,通过类比已有的前端知识,可以更好地理解 Go 语言的特性。

robin23阅读 3.2k评论 6

封面图
Nginx 一网打尽:动静分离、压缩、缓存、黑白名单、跨域、高可用、性能优化...
早期的业务都是基于单体节点部署,由于前期访问流量不大,因此单体结构也可满足需求,但随着业务增长,流量也越来越大,那么最终单台服务器受到的访问压力也会逐步增高。时间一长,单台服务器性能无法跟上业务增...

民工哥23阅读 1k

封面图
Golang 中 []byte 与 string 转换
string 类型和 []byte 类型是我们编程时最常使用到的数据结构。本文将探讨两者之间的转换方式,通过分析它们之间的内在联系来拨开迷雾。

机器铃砍菜刀24阅读 58.1k评论 2

年度最佳【golang】map详解
这篇文章主要讲 map 的赋值、删除、查询、扩容的具体执行过程,仍然是从底层的角度展开。结合源码,看完本文一定会彻底明白 map 底层原理。

去去100216阅读 11.5k评论 2

年度最佳【golang】GMP调度详解
Golang最大的特色可以说是协程(goroutine)了, 协程让本来很复杂的异步编程变得简单, 让程序员不再需要面对回调地狱, 虽然现在引入了协程的语言越来越多, 但go中的协程仍然是实现的是最彻底的. 这篇文章将通过分析...

去去100215阅读 11.9k评论 4

最近的关注重心:

442 声望
27 粉丝
宣传栏