理解服务端对 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...

最近的关注重心:

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

Xavier阅读 670

SegmentFault 思否正式开源问答社区软件 Answer
作为国内领先的新一代技术问答社区,SegmentFault 思否团队在社区建设上有着多年积累。Answer 不仅拥有搭建问答平台(Q&A Platform)的基础功能,还在产品设计上融入了开发团队对社区发展的思考,并将其经验产品...

SegmentFault思否29阅读 4.2k评论 14

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

机器铃砍菜刀21阅读 54.6k评论 1

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

去去100214阅读 10.9k评论 2

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

去去100213阅读 11.1k评论 4

【已结束】SegmentFault 思否技术征文丨浅谈 Go 语言框架
亲爱的开发者们:我们的 11 月技术征文如期而来,这次主题围绕 「 Go 」 语言,欢迎大家来参与分享~征文时间11 月 4 日 - 11 月 27 日 23:5911 月 28 日 18:00 前发布中奖名单参与条件新老思否作者均可参加征文...

SegmentFault思否11阅读 4.6k评论 11

封面图
【Go微服务】开发gRPC总共分三步
之前我也有写过RPC相关的文章:《 Go RPC入门指南:RPC的使用边界在哪里?如何实现跨语言调用?》,详细介绍了RPC是什么,使用边界在哪里?并且用Go和php举例,实现了跨语言调用。不了解RPC的同学建议先读这篇文...

王中阳Go8阅读 3.5k评论 6

封面图

最近的关注重心:

426 声望
26 粉丝
宣传栏