内核怎么管理侦听 socket
Linux 内核管理监听 socket 的核心是:用全局哈希表(listening_hash)组织所有监听 socket,配合两套队列(SYN 队列和 accept 队列)完成连接建立和交接。
先读这条最重要的结论 :无论是否使用
SO_REUSEPORT,三次握手始终由内核(软中断上下文)独立完成,应用进程从不参与握手 。两种情况的唯一区别是:SYN 到达时候选侦听 socket 有几个、内核要不要"多选一"。进程从头到尾只负责一件事------调用accept()取走已经建立好的连接。
一、监听 socket 的内核表示
一个监听 socket 在内核中涉及三层结构:
| 结构 | 作用 |
|---|---|
struct socket |
用户态可见的 socket 抽象,对应一个文件描述符 |
struct sock |
内核传输控制块,保存协议状态(TCP 具体化为 struct tcp_sock) |
struct inet_connection_sock |
内嵌于 tcp_sock,增加连接管理字段(accept 队列、keepalive 等) |
当 listen() 被调用后,struct sock 的状态变为 TCP_LISTEN,并挂入全局监听哈希表。
二、全局监听哈希表:listening_hash
内核维护一个全局数组(32 个桶):
c
struct inet_listen_hashbucket listening_hash[INET_LHTABLE_SIZE];
每个桶是一条链表,哈希值由本地端口计算:
c
static inline unsigned int inet_lhashfn(struct net *net, const unsigned short num)
{
return num & (INET_LHTABLE_SIZE - 1);
}
所有处于 TCP_LISTEN 状态的 socket 都在这里。 这是内核管理监听 socket 的主索引,也是收包路径定位侦听 socket 的入口。
三、监听 socket 的生命周期
1. 创建:listen() 系统调用
c
int listen(int sockfd, int backlog);
内核执行:
- 如果 socket 尚未 bind,先自动绑定(autobind)分配一个临时端口------不是简单检查报错。
- 调用
inet_csk_listen_start():- 把
sk_state设为TCP_LISTEN; - 初始化 accept 队列(
icsk_accept_queue); - 调用
inet_hash()把 socket 插入listening_hash。
- 把
- accept 队列上限取
min(backlog, net.core.somaxconn)。
2. 运行:等待连接
监听 socket 不主动做任何事------没有线程轮询、没有定时器,只是挂在哈希表里等待 SYN 包 。内核收到 SYN 后通过 listening_hash 找到它,然后由内核完成握手。
3. 关闭:close() 或 shutdown()
- 调用
inet_csk_listen_stop(); - 从
listening_hash中移除; - 清空 SYN 队列和 accept 队列,释放所有未完成的连接;
- 状态变为
TCP_CLOSE。
四、两套队列:SYN 队列和 accept 队列
每个监听 socket 内部维护两个队列,这是内核管理连接建立的核心。
1. SYN 队列(半连接队列)
- 存放收到 SYN、但三次握手未完成的 request sock(迷你 sock,只含握手所需字段)。
- 数据结构:内核 4.4 及以前 是
request_sock_queue中的listen_opt半开哈希表;4.4 起 request_sock 以自身四元组直接插入全局 ehash,旧结构已移除(很多老文章仍写 syn_table,已过时)。 - 大小受
net.ipv4.tcp_max_syn_backlog约束。 - 作用:限制半连接数量,抵御 SYN Flood。
2. accept 队列(全连接队列)
- 存放三次握手完成、等待应用
accept()的 full sock。 - 数据结构:
icsk_accept_queue中的rskq_accept_head链表(FIFO)。 - 大小由
min(listen() 的 backlog, net.core.somaxconn)决定。 - 作用:缓冲已建立的连接,等待应用取走。
3. 队列满了会怎样?
| 队列 | 满了之后 |
|---|---|
| SYN 队列 | 默认丢弃新 SYN;启用 tcp_syncookies 后不存 request_sock,靠 cookie 继续服务 |
| accept 队列 | 默认丢弃第三次握手的 ACK(客户端超时重传);tcp_abort_on_overflow=1 时直接回 RST |
五、连接建立的完整流程
客户端 SYN
│
▼
内核收到 SYN(软中断上下文)
│
▼
__inet_lookup_listener() 在 listening_hash 中查找监听 socket
│
▼
创建 request sock,放入 SYN 队列 ← 内核完成,进程不参与
│
▼
内核回复 SYN+ACK ← 内核完成,进程不参与
│
▼
客户端 ACK(第三次握手)
│
▼
内核创建 full sock,放入 accept 队列 ← 内核完成,进程不参与
│
▼
唤醒等待在 accept()/epoll_wait 的进程 ← 进程此刻才被唤醒
│
▼
应用 accept() 取出连接,返回新 fd ← 进程唯一参与的一步
注意 :握手三步全部发生在进程被唤醒之前。即使应用进程被暂停(如 SIGSTOP),内核照样完成握手、填满 accept 队列------只是没人来取。
六、监听 socket 的查找机制
当 SYN 包到达时,内核调用 __inet_lookup_listener() 查找监听 socket。真实机制是按端口定位桶 + 按地址打分:
- 用目的端口 哈希定位
listening_hash的桶------端口必须精确匹配,这是进桶的前提; - 遍历桶内链表,按地址打分(score) :绑定 IP 与包的目的 IP 精确相等 得分高于绑定
0.0.0.0(通配),最高分者胜出; - 同时检查:
SO_BINDTODEVICE绑定的网卡、网络命名空间、SO_REUSEPORT分组。
⚠️ 常见误传:"查找优先级包含'端口通配'"。监听 socket 的查找不存在端口通配------端口永远精确匹配(桶就是按端口定位的);"通配 vs 精确"只存在于 IP 这一个维度。
多监听 socket 场景
| 场景 | 说明 |
|---|---|
| 不同端口 | 各自落在不同哈希桶,互不干扰 |
| 同一端口不同 IP(如 192.168.1.1:80 与 10.0.0.1:80) | 同桶共存,查找时精确 IP 匹配打分更高,优先命中 |
SO_REUSEPORT 多进程共享端口 |
同桶挂多个监听 socket,按四元组哈希多选一(见第七章) |
七、核心问题:谁负责建立 TCP 连接?(重用端口 vs 非重用端口)
这是最容易产生困惑的地方,先给统一结论,再分场景拆解。
7.1 统一结论
无论是否 SO_REUSEPORT,三次握手都由内核独立完成,应用进程不参与。 需要精确化的说法是:内核定位的是"监听 socket",不是"进程"。内核不关心 socket 属于哪个进程,它只负责:查表定位 socket → 完成握手 → 把连接放进该 socket 的 accept 队列 → 唤醒等待者。
两种情况的唯一区别:
| 非 SO_REUSEPORT | SO_REUSEPORT | |
|---|---|---|
| 同端口监听 socket 数量 | 1 个 | N 个 |
| SYN 到达时内核的动作 | 直接命中唯一 socket,没得选 | 按四元组哈希多选一 |
| 握手由谁完成 | 内核 | 内核(在被选中的 socket 上执行) |
| 连接进哪个 accept 队列 | 唯一 socket 的队列 | 被选中 socket 的队列 |
| 谁能 accept | 持有该 socket 的进程(们) | 只有被选中 socket 对应的进程 |
7.2 场景一:单进程监听(非重用端口)
最常见的情况。进程 listen() 后,内核创建唯一一个 监听 socket 挂到 listening_hash:
- SYN 到达,内核查表直接命中唯一监听 socket;
- 内核创建 request sock 入 SYN 队列,回 SYN+ACK,等第三次 ACK------全程应用不参与;
- 握手完成,内核创建 full sock 放入该 socket 的 accept 队列;
- 内核唤醒等待进程,进程
accept()取走连接。
不存在"选择哪个进程"的问题,因为只有一个监听 socket。
7.3 场景二:多进程共享同一个监听 fd(fork 模式,仍非重用端口)
父进程 listen() 后 fork 出多个子进程,子进程继承同一个监听 fd:
- 仍然只有一个监听 socket ,挂在
listening_hash; - 多个进程都阻塞在同一个 socket 的
accept()上; - SYN 到达、握手完成、连接入队,全部由内核完成;
- 连接入队后,内核通过互斥等待队列(exclusive wait)只唤醒一个等待进程(大致 FIFO),避免惊群;
- 哪个进程先被唤醒并完成
accept(),连接就归谁------进程之间靠 accept 队列的互斥机制竞争,保证一个连接只被一个进程取走。
注意 :这不是 SO_REUSEPORT------监听 socket 始终只有一个,内核没有"多选一"的动作,竞争发生在用户态的 accept 上。
7.4 场景三:SO_REUSEPORT(多个独立监听 socket)
多个进程各自创建自己的监听 socket,都绑定同一端口并设置 SO_REUSEPORT:
c
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on));
此时 listening_hash 同一端口下挂了 N 个监听 socket。SYN 到达时:
- 内核立即用四元组哈希多选一 :提取
{源IP, 源端口, 目的IP, 目的端口}计算哈希,对共享该端口的监听 socket 数量取模,选定一个; - 选择发生在 SYN 阶段,即握手开始之前。从这一刻起,这个半开连接(SYN_RCVD)就与被选中的监听 socket 绑定;
- 后续握手仍由内核完成 :SYN+ACK 的发送、等待第三次 ACK、创建 full sock,全部是内核在被选中的 socket 上执行的------不是被选中的进程在做;
- 握手完成后,full sock 进入被选中 socket 的 accept 队列 ,只有该 socket 对应的进程能
accept()到它。
两个精确化的点:
- 一致性保证的粒度是四元组,不是"客户端" :同四元组的包必然命中同一 socket;但同一客户端的不同连接使用不同源端口,四元组不同,可能被分散到不同进程。
- 代价 :被选中的进程如果崩溃,它名下未完成的半开连接和 accept 队列中已建立的连接会被内核直接丢弃,其他存活进程无法接管 (因为连接只挂在它的 socket 上)。这也是内核 4.19 引入
BPF_PROG_TYPE_SK_REUSEPORT的原因------允许用自定义 eBPF 程序替换默认哈希,实现进程重启时的平滑迁移等灵活调度。
7.5 常见误区纠正
| ❌ 错误说法 | ✅ 正确说法 |
|---|---|
| "SO_REUSEPORT 下,被选中的进程独立完成三次握手" | 握手永远由内核完成;SO_REUSEPORT 只决定连接进哪个 socket 的队列,进程只负责 accept |
| "内核在 SYN 阶段定位到进程" | 内核定位的是监听 socket;进程只是 socket 的持有者,非重用端口时内核根本不涉及"选进程" |
| "同一客户端的连接总是打到同一进程" | 哈希依据是四元组;同一客户端的不同连接(源端口不同)可能分散到不同进程 |
| "非重用端口时内核先建连接再通知进程,重用端口时进程自己建连接" | 两种情况下握手都由内核完成,区别仅在于候选 socket 是一个还是多个 |
八、关键内核参数
| 参数 | 作用 |
|---|---|
net.core.somaxconn |
accept 队列全局上限 |
net.ipv4.tcp_max_syn_backlog |
SYN 队列上限 |
net.ipv4.tcp_syncookies |
SYN Flood 防护 |
net.ipv4.tcp_abort_on_overflow |
accept 队列满时是否发 RST(0=丢 ACK,1=回 RST) |
九、总结
| 维度 | 机制 |
|---|---|
| 组织 | 全局 listening_hash,按本地端口哈希 |
| 生命周期 | listen() 插入(未绑定则先 autobind),close() 移除 |
| 连接建立 | SYN 队列存半连接,accept 队列存全连接,握手全程由内核完成 |
| 查找 | 按端口定位桶 + 按地址打分,IP 精确匹配优先于通配 |
| 复用 | SO_REUSEPORT 支持多监听 socket 共享端口,SYN 阶段按四元组哈希多选一 |
| 防护 | SYN 队列限半连接,accept 队列限待取连接,syncookies 兜底 |
一句话 :Linux 内核用 listening_hash 管理所有监听 socket,用 SYN 队列和 accept 队列管理连接建立过程;收到 SYN 时按端口查表定位监听 socket(SO_REUSEPORT 时按四元组哈希多选一),由内核独立完成三次握手 ,然后把连接放入对应 socket 的 accept 队列,唤醒应用来 accept()------进程从不参与握手,只负责取连接。