socket 是什么:从 fd 到四元组

难度 ★★★★★ · 前置:文件描述符:一个整数背后的三层跳转 | 一句话结论:socket 不是连接,是一个端点。一条 TCP 连接由 (源IP, 源端口, 目标IP, 目标端口) 四元组唯一确定,而 socket 只是持有其中一端、外加一堆收发缓冲的内核对象。

那条 ESTAB 了一切正常的连接

2024 年秋天有一次排查,我 curl 一个内部接口,命令挂在那儿不动,Ctrl-C 重来还是不动,同一个地址在同事机器上却是秒回,我当时的第一反应是服务端出了问题,因为客户端这边连一行错误都没打印。查机器,ss -tnp | grep curl 打出来一行,连接是 ESTAB,两个队列都是 0,握手完成了,缓冲也空着,可 curl 就是不返回,这行输出在我眼里什么信息都没有,因为在我脑子里 socket 就等于一个文件描述符,而一个 fd 号除了告诉我是 5 还是 6,还能告诉我什么呢。那个下午我一直在猜是超时没设还是对端卡死,其实都是在同一个错误前提上打转。

那行输出长这样。

1
ss -tnp | grep curl
1
ESTAB  0  0  10.0.3.14:48122  10.0.5.9:8080  users:(("curl",pid=2201,fd=5))

一切看起来都正常,连接建起来了,本地缓冲都空着。

我第一次处理是去翻 curl 的参数,怀疑超时没设,第二次是去查对端服务是不是卡死,这两步都没错,但都没往前走一步,因为如果只把 socket 理解成一个文件描述符,这条 ss 输出里能读的东西就只剩下一个 fd 号,而 fd 号本身不携带任何状态。真正的问题是这行输出里另有三样东西我没在看,连接现在处在状态机的哪一格,收发队列里有没有东西,对端到底回没回。

换成内核对象去看,它挂着一堆队列和一个状态机,同一行字立刻变成三个问题。

我这个转折来得有点晚,后来在别的故障里也反复出现同一个模式,就是我先给一个东西定了一个太窄的定义,然后在这个定义里怎么找都找不到答案。(这件事我当时还跟人争论过,我说 fd 就够用了,对方说等你遇到 backlog 溢出就知道了,一年后我真的遇到了。)

fd 后面是四个结构体

前面讲文件描述符那篇说过 fd 可以指向文件,也可以指向别的东西(下面 ss 输出里的 ESTAB 是一种连接状态,表示握手已经完成、能收发数据了,具体状态怎么流转讲 TCP 那几篇会说),socket 就是那个别的东西,它不是文件,只是借用了文件的那套接口。

想看它长什么样,起一个监听中的进程,然后去翻它的 fd 目录就行。

1
2
3
python3 -c "import socket,time; s=socket.socket(); s.bind(('127.0.0.1',9000)); s.listen(); time.sleep(60)" &
PID=$!
ls -l /proc/$PID/fd
1
2
3
4
lrwx------ 1 user user 64 Nov 13 20:12 0 -> /dev/pts/0
lrwx------ 1 user user 64 Nov 13 20:12 1 -> /dev/pts/0
lrwx------ 1 user user 64 Nov 13 20:12 2 -> /dev/pts/0
lrwx------ 1 user user 64 Nov 13 20:12 3 -> socket:[24510]

3 就是那个监听端点的句柄,方括号里的 24510 是它在 sockfs 上的 inode 号,注意它不是普通文件系统里的 inode,而是内核给 socket 单独挂的一个伪文件系统,这样 ls -l 才有东西可显示。(那个方括号里的数字我查了很久才搞懂它是什么,一开始我以为它就是 fd 号。)它和文件的 fd 共用同一套机制,read 和 write 都能用,select 和 epoll 都能等,close 都会关,正是这套接口的一致性让人误以为它就是个文件。

但从 fd 到协议栈是四层跳转,不是一层。

struct file 里的 private_data 指向 struct socket,struct socket 的 sk 指针指向 struct sock,struct sock 再按协议强转成 struct tcp_sock 之类,中间这两次强转没有任何类型检查,全靠 sk->sk_prot 里的函数表保证调用到对的实现。这套分层的好处是让一切皆文件的接口能挂到协议栈上,send 最终走到 tcp_sendmsg,recv 走到 tcp_recvmsg,而 socket 层完全不需要知道 TCP 有序列号这回事。

身份是四元组,不是 fd

同一条 TCP 连接,两端各有一个 socket,服务端那个监听 socket 只负责有人来敲门这件事,它本身不承载任何一条连接。

客户端连上来之后,内核干的是下面这一串动作。

这张图里有两件事值得单独说。

accept 返回的是新 fd,监听的那个 fd 一直活着,继续 LISTEN,所以一个服务进程里监听 fd 只有一个,连接 fd 有 N 个,/proc/PID/fd 里那些 socket:[...] 绝大多数都是连接而不是监听,这个比例在压测的时候能到几千比一。新 socket 的本地地址和监听 socket 一样,都是 10.0.1.5:8080,但四元组不同,因为对端 IP 和端口不同,所以四元组才是身份,端口只是一个匹配维度。这句话听着像废话,但它解释了一个经常让人困惑的现象,服务端成千上万个 socket 里有一大批本地端口完全一样,ss 里刷屏都是 8080,第一次看的人会以为同一个端口被重复绑定了。

ss 是最好的观测工具,因为它直接读内核里的 socket 表,不经过任何用户态缓存。

1
ss -tnp 'sport = :8080'
1
2
3
State    Recv-Q  Send-Q  Local Address:Port   Peer Address:Port  Process
ESTAB 0 0 10.0.1.5:8080 10.0.1.9:51234 users:(("nginx",pid=812,fd=5))
ESTAB 0 0 10.0.1.5:8080 10.0.1.9:51240 users:(("nginx",pid=812,fd=7))

这里的 Recv-Q 和 Send-Q 跟监听 socket 上的含义完全不同,我第一次看的时候按同一个意思去理解,结果判断全错。

| socket 类型 | Recv-Q | Send-Q |

| — | — | — |

| LISTEN | 当前 accept 队列长度(已完成握手待 accept 的连接数) | backlog 值,实际被 net.core.somaxconn 截断 |

| ESTABLISHED | 已到达但应用还没 read 掉的字节数 | 已写入但还没被对端 ACK 的字节数 |

所以 LISTEN 状态下 Recv-Q 持续接近 Send-Q,就是 accept 队列快满了,应用 accept 不过来,这个信号在压测时特别值得盯,它比 CPU 使用率更早告诉你瓶颈在应用侧,而且它涨起来是有过程的,不像 CPU 那样一下顶到 100 你才知道。我第一次对比这两行输出的时候盯了很久才接受同一个列名在两种状态里意思完全不同这件事,后来翻 ss 的手册才发现文档里写得清清楚楚,只是没人会在第一次读的时候注意到,因为大家都是从生产环境的截图里学的。

backlog 限制的是哪条队列

listen(fd, backlog) 里那个数,内核会先取 min(backlog, net.core.somaxconn),然后拿它去限制 accept 队列的长度,注意它管的是全连接队列,不是 SYN 队列,SYN 队列的上限是另一个参数 tcp_max_syn_backlog。somaxconn 的默认值是 4096,但这个值在 5.4 才改的,5.3 及更早是 128,所以 listen(1024) 明明写了 1024、ss 里 Send-Q 只有 128 这种事在 CentOS 7 上非常常见,那台机器的内核是 3.10。队列满了之后,客户端会认为自己连上了,服务端却根本不知道有这条连接,表现为偶发连接超时,而且只在压力上来的时候出现,这种 bug 靠加机器治不好,只会摊薄,加得越多每台机器碰到的概率越低,但永远不是零。具体是哪条队列满了、SYN 队列和 accept 队列怎么分工,三次握手那篇讲得更细。

要改就得单独动 net.core.somaxconn,listen 里写多大都会被它截。

1
2
sysctl net.core.somaxconn
sudo sysctl -w net.core.somaxconn=8192

调大之后还得重启应用才生效,因为 listen() 是在启动时调用的,值那时候就已经定死了,sysctl 改的是内核的默认上限,不会回头改已经建好的队列。

端口不是无限的

客户端每建一条连接都要占一个本地端口,可用的范围在内核里写着。

1
2
cat /proc/sys/net/ipv4/ip_local_port_range
# 32768 60999

约 28000 个,这个数字我第一次算出来的时候觉得少得离谱,后来才明白它限制的是单个源 IP 到单个目标地址这一个方向。所以单个客户端 IP 到单个服务端地址,理论上最多约 28000 条连接,实际还要扣掉被占用的和停在 TIME_WAIT 里的。超过之后报 Cannot assign requested address,错误来自 connect,errno 是 EADDRNOTAVAIL。这不是机器连不了太多的问题,而是同一个源 IP 到同一个目标 IP 和端口这条路径上的端口用完了,换个目标端口立刻就有额度。解法通常是加源 IP,多网卡、多出口、走代理池,而不是调大端口范围,因为调大也只能从 28000 提到 64000 左右,量级没变。真正被这条限制卡住的客户端多半还会同时看到一堆 TIME_WAIT,那部分在TCP 状态那篇里。

ss -s 能一眼看出当前的状态分布。

1
ss -s
1
2
3
4
5
6
7
8
9
Total: 731
TCP: 412 (estab 156, closed 198, orphaned 0, timewait 190)

Transport Total IP IPv6
RAW 1 1 0
UDP 12 8 4
TCP 412 300 112
INET 425 309 116
FRAG 0 0 0

TCP 和 UDP 的 sock 长得不一样,接口一样

SOCK_STREAM 走 struct tcp_sock,里面有序列号、拥塞窗口、重传队列、乱序队列,send 只是把数据拷进发送缓冲就返回,真正发不发、什么时候发、发多大由内核决定,应用层完全插不上手。

SOCK_DGRAM 走 struct udp_sock,没有连接这个概念,sendto 一次把整个报文塞进去,recvfrom 一次取一个,它没有重传,没有顺序保证,没有拥塞控制,丢了就丢了,内核甚至不会通知你。

(UDP 的 sendto 返回成功不代表对面收到了,这点我在第一次写 UDP 服务时踩过。)但用户态的接口几乎一样,同样的 socket、bind、send、recv,同样的 fd 语义,同样的 epoll 可等待性,所以在代码里换协议往往只改一个常量。差别只在有没有连接,以及 send 返回值的含义,TCP 的 send 返回拷进缓冲了多少字节,UDP 的 sendto 返回这个报文已经发出去了,丢不丢不管。

手动走一遍 socket 的生命周期

1
2
3
4
5
6
7
8
9
10
11
# 先装上 nc(如果还没有)。OpenBSD 版最全,ncat 和 busybox nc 的选项不一样
sudo apt install netcat-openbsd

# 终端 1:起一个只监听不处理的 socket
nc -l 127.0.0.1 9000

# 终端 2:连上去
nc 127.0.0.1 9000

# 终端 3:观测
ss -tnp | grep 9000
1
2
ESTAB  0  0  127.0.0.1:9000  127.0.0.1:57312  users:(("nc",pid=7231,fd=4))
ESTAB 0 0 127.0.0.1:57312 127.0.0.1:9000 users:(("nc",pid=7240,fd=3))

两条记录,一条是服务端视角,一条是客户端视角,四元组互相对应,同一个 TCP 连接在两端的内核里各有一个 struct sock,各有一份收发缓冲,两边看到的东西完全对称,这就是为什么抓包工具能在任何一端都看到完整的会话。

再抓监听那条。

1
ss -lntp | grep 9000
1
2
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 0 1 127.0.0.1:9000 0.0.0.0:* users:(("nc",pid=7231,fd=3))

Send-Q 是 1,那是 nc 传的 backlog,小得离谱,但 nc 一次只接一个连接,够用,这是程序知道自己要干什么所以敢把值写死的例子。

让 accept 队列溢出一次,服务端把 backlog 设成 1,并且故意不 accept。

1
2
3
4
5
6
7
8
python3 - <<'EOF'
import socket, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('127.0.0.1', 9001))
s.listen(1) # backlog = 1
time.sleep(120) # 一直不 accept
EOF

另开终端,并发发起 5 条连接。

1
2
3
4
for i in $(seq 5); do
(timeout 3 bash -c 'exec 3<>/dev/tcp/127.0.0.1/9001; sleep 2') &
done
wait

再看队列。

1
ss -lnt 'sport = :9001'
1
2
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port
LISTEN 2 1 127.0.0.1:9001 0.0.0.0:*

Recv-Q 是 2,已经超过 Send-Q 的 1 了,因为 Recv-Q 数的是已进入 accept 队列的条数,而队列上限的判断还有 sk_max_ack_backlog 的细节,Send-Q 只显示你传进去的那个数。更实用的判据是只要 Recv-Q 长时间贴着 Send-Q,就说明 accept 跟不上。

在客户端那侧看不到任何错误,connect 全都返回成功,因为握手是在内核里完成的,客户端认为连接建好了,它不知道服务端的应用层根本没碰过这条连接。这就是 backlog 溢出最阴的地方,错误只出现在服务端,而且没有任何日志,你只能靠 ss 里那个数字自己发现。

对上一个 fd 和一条连接

1
ss -tnpi | grep -A1 'ino:24510'
1
2
3
ESTAB 0 0 10.0.1.5:8080 10.0.1.9:51234
cubic wscale:7,7 rto:204 rtt:0.4/0.2 ato:40 mss:1448 ...
skmem:(r0,rb131072,t0,tb46080,f1632,w0,o0,bl0,d0) ino:24510

ino:24510 跟 /proc/PID/fd 里的 socket:[24510] 是同一个号,这样就能把内核里的一条连接和进程里的一个 fd 对上,是排查 fd 泄漏和连接泄漏的关键一步。skmem 那一串是内存账,rb 是接收缓冲上限,tb 是发送缓冲上限,f 是已分配的片段,排查内存暴涨的时候这几个数比 ss -m 更容易读。

一个端口,不止一个进程

一个端口只能一个进程用,这句默认成立,但 SO_REUSEPORT 允许 N 个进程各自 bind 同一端口,内核按四元组哈希分流,nginx 和 envoy 的多 worker 就是这么用的,SO_REUSEADDR 是另一回事,它只放宽 TIME_WAIT 状态下的端口不能被重新 bind 这一条限制,不做分流。

accept 之后监听 socket 就没了,不对,监听 socket 贯穿整个服务生命周期,accept 只从队列里摘一条出来。close 就断开连接也不对,close 只关了一个 fd,内核还要走四次挥手,如果还有别的地方持有同一个 struct file,比如 dup 过或者 fork 过,连接根本不会断,这就是进程重启了但端口还占着的常见原因,某个漏掉的子进程还拿着它。ss 里 Send-Q 大就是网络堵,这个判断太急,Send-Q 是已写入但未 ACK 的字节数,它大只说明对端 ACK 得慢,可能是对端在读但应用层不 read,接收窗口满了,也可能是网络在丢包,要分清得同时看 rtt、retrans 和 cwnd,ss -tni 全都有。UDP 没有 socket 状态也是错的,ss -una 能看到 UNCONN 状态,那是 UDP 的无连接态,connect 过的 UDP socket 也有一条记录,只是没有握手。

三句话

  1. socket 是一个 fd,底下是 struct socket → struct sock → 协议私有结构,它表示端点而不是连接。

  2. 连接的身份是四元组;accept 为每条连接新建一个 fd,监听 fd 一直留着。

  3. LISTEN 状态下 Recv-Q 逼近 Send-Q 就是 accept 跟不上,而客户端对此毫无感知。

再往里就是协议本身了,三次握手在双方内核里各改了哪些字段,以及为什么连接建立在服务端比客户端早一步。写到这一篇我才算真正把 fd 和 socket 分开看,之前我一直以为它们是同一个东西的两种叫法,这个误解持续了大概一年半。