listen 内核实现 —— 从"空壳"到"服务端"的转折点
上一篇 socket-kernel-internals 造好了 socket 对象并绑定了地址,下一篇 accept-kernel-internals 从队列取连接。本篇聚焦四件套的第三步:
listen()怎么把 socket 从TCP_CLOSE推到TCP_LISTEN,以及那两张让accept()得以工作的队列是怎么来的。
一、listen 做了什么
一句话:listen() 把 socket 标记为 TCP_LISTEN(至此内核才开始处理 SYN),同时创建两张队列——半连接队列和全连接队列。 没有 listen(),accept() 什么也拿不到。
| 动作 | 详情 | 位置 |
|---|---|---|
标记 TCP_LISTEN | sk->sk_state = TCP_LISTEN,内核此后才处理 SYN | sk 状态字段 |
| 建半连接队列 | reqsk_queue_alloc() 分配 hash 表,用于暂存收到 SYN 但还没完成握手的 request_sock | icsk->icsk_accept_queue |
| 建全连接队列 | 复用同一数据结构,三次握手完成后移入此处,等 accept() 取走 | 同上 |
| 设全连接队列上限 | sk->sk_max_ack_backlog = min(backlog, somaxconn) —— 会被截断 | sk 字段 |
二、两队列模型
客户端 SYN 到达
│
▼
┌─────────────────┐
│ 半连接队列 │ ← 收到 SYN,回 SYN-ACK,等着客户端回 ACK
│ (SYN queue) │ 上限: tcp_max_syn_backlog
└────────┬────────┘
│ 客户端回 ACK,三次握手完成
▼
┌─────────────────┐
│ 全连接队列 │ ← 握手完成,等 accept() 取走
│ (accept queue) │ 上限: min(somaxconn, backlog)
└────────┬────────┘
│ accept() 取走
▼
用户进程拿到 newfd图析:两队列是串联的过滤器。半连接队列是"门厅"——只验证 SYN 来源是否合理(默认防 SYN flood 靠的是它的大小限制 + syncookies 旁路),通过后给客户端回 SYN-ACK 并暂存 request_sock。全连接队列是"候客室"——三次握手完成、连接已建立,就等应用
accept()领走。两个队列的上限由不同参数控制,这是最容易配错的地方。
| 属性 | 半连接队列(SYN queue) | 全连接队列(accept queue) |
|---|---|---|
| 存放内容 | 收到 SYN 并回 SYN-ACK 后,等客户端回 ACK 的 request_sock | 三次握手完成的 request_sock,其 .sk 已指向子 sock |
| 加入时机 | tcp_conn_request() 收到 SYN | inet_csk_reqsk_queue_add() 握手完成 |
| 移除时机 | 收到客户端 ACK → 升级移入全连接 | reqsk_queue_remove() → accept 取走 |
| 上限控制 | net.ipv4.tcp_max_syn_backlog | min(net.core.somaxconn, listen() backlog) |
| 满了的行为 | 丢新 SYN(或启用 syncookies 绕过) | 默认丢 ACK 让客户端重传(不直接 RST) |
关键区分:
listen(fd, 128)的第二个参数只控制全连接队列,不控制半连接队列。很多人以为 backlog 调大了就能防 SYN flood——不能,半连接队列的上限由tcp_max_syn_backlog单独控制。
三、listen() 调用链总览
用户态: listen(sockfd, 128)
内核态:
__sys_listen(fd, backlog)
│
├─ sockfd_lookup_light(fd) // fd → struct socket
│
├─ backlog = min(backlog, somaxconn) // ★ 截断!somaxconn 是天花板
│
├─ security check
│
└─ sock->ops->listen(sock, backlog) // AF_INET → inet_listen()
│
▼
inet_listen(sk, backlog)
│
├─ 状态检查:必须是 TCP_CLOSE 或 TCP_LISTEN
│ • 首次 listen → 走 inet_csk_listen_start() 全量初始化
│ • 已经是 TCP_LISTEN → 只更新 backlog(不改队列)
│
└─ inet_csk_listen_start(sk, backlog)
│
├─ reqsk_queue_alloc() 分配半连接队列 hash 表
├─ sk->sk_max_ack_backlog = backlog
├─ sk->sk_state = TCP_LISTEN ★ 转折点
└─ sk->sk_prot->hash(sk) 注册到全局 TCP hash 表图析:调用链最关键的三个节点——(1)
min(backlog, somaxconn)在 VFS 层就把 backlog 截断了,你传 65535 进去、somaxconn=128 的话实际生效的是 128;(2)inet_listen的状态检查区分首次和重复——已 LISTEN 时只改 backlog、不重建队列,保证运行时热调;(3)inet_csk_listen_start是真正的"开张"——分配队列 hash 表、标记TCP_LISTEN、注册到全局 hash(从此能被 SYN 包匹配到)。
重复 listen:如果 socket 已经是 TCP_LISTEN,再次调用 listen() 不重建队列,只更新 sk_max_ack_backlog——已有的连接不受影响。这允许运行时调大 backlog 而不断开已有连接。
四、listen() 入口到协议层的时序

图析:这一阶段把"门面工作"做完——验证 fd 有效、截断 backlog 到 somaxconn 上限、通过安全模块检查、确认 TCP 状态允许
listen()。其中min(backlog, somaxconn)是最容易踩的坑:listen(fd, 4096)写多大都没用,实际天花板在sysctl net.core.somaxconn。如果 somaxconn 还是默认的 128,backlog 传 4096 也只能拿到 128。
五、inet_csk_listen_start() 执行时序——真正的开张

图析:
inet_csk_listen_start执行的五步中没有一步是可以省略的。(1)reqsk_queue_alloc是唯一一次分配内存——两队列共享这个 hash 表结构,上限受tcp_max_syn_backlog控制;(2)sk_ack_backlog = 0清空计数器,全连接队列当前积压 = 0;(3)sk_max_ack_backlog = 128设全连接队列上限;
(4)
sk_state = TCP_LISTEN是整条调用链的核心转折——此前的 socket 对网络包来说是透明的,此后的 socket 会接收并响应 SYN;(5)sk_prot->hash把 socket 注册到全局 hash 表,让后续到达的 SYN 包能匹配到这个 socket。自此,服务端正式"开张"。
六、观测与调优
| 手段 | 命令 | 看什么 |
|---|---|---|
| 看 LISTEN socket | ss -tlnp | State 显示 LISTEN,Recv-Q/Send-Q 显示全连接队列积压/上限 |
| 调 somaxconn | sysctl net.core.somaxconn=65535 | 全连接队列的天花板 |
| 调半连接上限 | sysctl net.ipv4.tcp_max_syn_backlog=8192 | SYN queue 上限 |
| 查溢出计数 | netstat -s | grep "overflowed" | 看到队列溢出次数 |
延伸阅读:accept-kernel-internals(accept 怎么从队列取连接)、kernel-tuning-net(somaxconn / tcp_max_syn_backlog 完整调优)。
总结
listen() 是三行代码里最关键的一行:它把 sk_state 从 TCP_CLOSE 推到 TCP_LISTEN(至此内核才开始响应 SYN),创建半连接+全连接两队列作为连接的"暂存间",并设好全连接队列上限(被 somaxconn 截断)。没有 listen,socket+bind 造出来的只是有名字的空白卡片——listen 把它变成了真正的服务端。