Appearance
深入论述一:socket/bind/listen/accept 在内核做了什么
所属 Day:Day 1 从零编码 · 更新时间:2026-08-20(重构:从原 deep-dive.md 108KB 拆分为 4 篇独立原理文档,本文内容零丢失)
上一篇:无(Day 1 实践指南 → README.md) 下一篇:深入论述二:sk_buff 的设计与组织
这三个系统调用看似简单,但背后涉及 VFS、协议族注册、端口管理、hash 表插入、有限状态机转换等大量内核机制。理解它们不是"背 API",而是理解后续所有 TCP 性能问题的基础。
一、前置骨架:网络栈的"五层数据结构"与挂载关系
理解 socket()/bind()/listen() 之前,先把内核里的一组嵌套结构体看清——它们层层指针挂载,构成网络栈的骨架。后续每一节都会引用这张"挂载图"。

5 个嵌套层级 + 4 次指针挂载(每行就是一次箭头):
| 层级 | 数据结构 | 挂载字段 | 内核访问宏 | 在调用链中的角色 |
|---|---|---|---|---|
| L1 | task_struct | files | current->files | 进程的根描述符 |
| L2 | files_struct / fdtable | fdtab[fd] | fdget(fd) | fd → file 的查表 |
| L3 | struct file | private_data | file->private_data | 把 socket 暴露为文件 |
| L4 | struct socket | sk | sock->sk | VFS 层抽象(一次 socket() 完成) |
| L5a | struct sock | (基类,嵌在 inetcs 头部) | sock_i_ino() 等 | 协议层基类,三队列 |
| L5b | struct inet_sock | 嵌在 inetcs 头部 | inet_sk(sk) | IPv4 地址/端口 |
| L5c | inet_connection_sock | 嵌在 tcp_sock 头部 | inet_csk(sk) | accept 队列管理 |
| L5d | struct tcp_sock | (最外层,TCP 全状态) | tcp_sk(sk) | cwnd/rtt/ssthresh |
tcp_sk(sk)强制转换的依据:tcp_sock的第一个成员就是inet_connection_sock,inet_connection_sock的第一个成员又是inet_sock,inet_sock的第一个成员还是sock_common,sock_common里再嵌sock。这条链路下来,(tcp_sock *)sk可以合法地把任意一个层级指针当成另一个层级指针用。container_of宏实现"已知子结构体指针求父结构体指针",是理解所有 Linux 内核嵌套结构的关键。
read(fd, ...) 的一次完整调用链(走到 tcp_recvmsg() 为止):
c
// 用户态
n = read(fd, buf, len);
// ↑
// 内核态:
// 1. fdget(fd)
// → current->files->fdtab[fd] 返回 struct file *file
// 2. file->f_op->read(file, buf, len)
// → file->f_op 在 socket_alloc_file() 时被设为 socket_file_ops
// → 进入 sock_read()
// 3. sock_read(file, buf, len, off)
// → sock = file->private_data (取回 struct socket)
// → sock->ops->recvmsg(sock, msg, len, flags)
// = inet_stream_ops.recvmsg
// = inet_recvmsg()
// 4. inet_recvmsg() → sk = sock->sk (再下一层)
// 5. sk->sk_prot->recvmsg(sk, msg, size, flags, ...)
// = tcp_prot.recvmsg
// = tcp_recvmsg() ← TCP 真正从这里开始每一跳都在这张"挂载图"上:L1→L2(fdget)、L2→L3(fdtab[fd])、L3→L4(private_data)、L4→L5(sock->sk)。后续章节所有 perf/strace/ss 观察到的现象,本质上都是这次调用链上某一跳出了问题。
二、socket() —— 内核做了三件事

第一步:分配 struct socket(VFS 层的"通用套接字")
c
// net/socket.c: sock_alloc()
struct socket *sock = sock_alloc(); // 从 sock_inode_cache slab 分配
sock->type = SOCK_STREAM; // 流式
sock->state = SS_UNCONNECTED; // 初始状态:未连接struct socket 是 VFS 层的抽象——它把套接字伪装成一个文件,让 read()/write()/close() 能作用于套接字。核心字段:
| 字段 | 类型 | 作用 |
|---|---|---|
state | socket_state | 套接字高层状态(SS_UNCONNECTED / SS_CONNECTED / SS_DISCONNECTING) |
ops | struct proto_ops * | 操作函数表指针,SOCK_STREAM → inet_stream_ops |
file | struct file * | 反向指回 VFS file 结构 |
sk | struct sock * | 指向协议族私有结构(TCP 的 tcp_sock) |
wq | wait_queue_head_t | 等待队列,accept() 等阻塞操作在此睡眠 |
第二步:协议族查找 → 创建 struct sock(协议层的"TCP 控制块")
c
// net/ipv4/af_inet.c: inet_create()
struct sock *sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, 1);
// answer_prot = &tcp_prot (当 type=SOCK_STREAM 时)
// 绑定操作函数表
sock->ops = &inet_stream_ops; // socket 层:read/write/poll/ioctl 等
// 最终都会调到下面 sk->sk_prot 的具体实现
// sk->sk_prot = &tcp_prot // 协议层:connect/sendmsg/recvmsg/close 等这是最关键的一步——内核根据 AF_INET + SOCK_STREAM 确定了两张函数表:
bash
应用层 write(fd, buf, len)
→ VFS: file->f_op->write()
→ socket 层: sock->ops->sendmsg() = inet_sendmsg() ← 统一入口
→ 协议层: sk->sk_prot->sendmsg() = tcp_sendmsg() ← TCP 真正逻辑sock->ops(inet_stream_ops):面向 VFS 的通用接口,让套接字能像文件一样操作sk->sk_prot(tcp_prot):TCP 协议的具体实现,拥塞控制、重传、分段都在这里
第三步:分配文件描述符,建立 fd → file → socket → sock 四层链
c
// fs/file.c
int fd = get_unused_fd_flags(0); // 从 fdtable 里找一个空闲的 fd 号
struct file *file = sock_alloc_file(sock, ...); // 把 socket 包装成 file
fd_install(fd, file); // fdtable[fd] = filecurrent->files->fdtab[fd] 指向 struct file,file->private_data 指向 struct socket,socket->sk 指向 struct sock(TCP 下即 tcp_sock)。当用户调用 read(fd, ...) 时,内核走这条链找到 tcp_recvmsg()。
这个四层链 (
fd → file → socket → sock) 是 Linux 网络栈"一切皆文件"哲学的基石,也解释了为什么能用 shell 的>/dev/tcp/IP/PORT发数据、用ss -p反向查 fd。
三、bind() —— 内核做了两件事

第一步:端口冲突检查 —— 遍历 inet_bind_hashbucket
内核维护一个全局 hash 表 tcp_hashinfo.bhash[],以 (端口号, 网络命名空间) 为键。inet_csk_find_open_port() 遍历对应 bucket 的冲突链:
c
// net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum) {
// 在 bhash[port % bhash_size] 链上检查:
// - 同一 IP:PORT 是否已被占用
// - SO_REUSEADDR 是否允许复用
// - 端口号是否为 0(内核自动分配)
tb_found = &head->chain;
inet_bind_bucket_for_each(tb, &head->chain) {
if (net_eq(ib_net(tb), net) && tb->port == snum)
goto tb_found; // 找到冲突
}
}三种典型结果:
| 场景 | 结果 |
|---|---|
| 端口未被占用 | 新建 inet_bind_bucket,插入 hash 表 |
| 端口被占用 + 相同 IP | 返回 -EADDRNOTAVAIL |
| 端口被占用 + 不同 IP | 允许(只要 IP 不冲突) |
端口被占用 + SO_REUSEADDR | 允许(即使相同 IP,常用于快速重启) |
第二步:绑定地址 → 插入 bind hash 表
c
// net/ipv4/af_inet.c: inet_bind()
inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;
// inet_saddr = 发送源地址, inet_rcv_saddr = 接收目标地址
// bind() 两个同时设;connect() 只设 inet_saddr
// 将 sock 挂到 bind hash 表的冲突链上
inet_bind_hash(sk, tb, port); // sk 加入 tb->owners 链表inet_bind_hashbucket 是后续多连接场景的关键数据结构——同一端口的所有连接共用同一个 bucket。
3.1 SO_REUSEADDR vs SO_REUSEPORT —— 名字相似,解决完全不同的问题
SO_REUSEADDR 和 SO_REUSEPORT 是两个极易混淆的 socket 选项。只差一个词,但本质上解决的是两个正交维度的问题。
一句话先说结论:
| 选项 | 解决的问题 | 同时 bind 同一 IP:PORT? | 典型用例 |
|---|---|---|---|
SO_REUSEADDR(地址重用) | 时间维度:服务端重启后 TIME-WAIT 期间端口仍被旧连接占用 | 不能——同一时刻只有一个 listener | 服务端快速重启 |
SO_REUSEPORT(端口重用) | 空间维度:多个进程/线程同时监听同一端口做负载分担 | 能——多个活跃 socket 同时监听同一 IP:PORT | 多进程 echo/web 服务器 |

SO_REUSEADDR 的工作细节:
SO_REUSEADDR 在端口冲突检查时生效,允许新的 bind() 成功即使旧 socket 的相同四元组仍处于 TIME-WAIT 状态。但它不允许两个活跃的 socket 同时绑定相同的 IP:PORT。
c
// 典型使用场景:服务端重启
// 1. 老进程退出,listen socket 上的连接进入 TIME-WAIT(持续 60s)
// 2. 新进程立即启动,bind(8080) 失败 → EADDRINUSE
// 3. 加上 SO_REUSEADDR 后,bind(8080) 成功
int fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(fd, (struct sockaddr*)&addr, sizeof(addr)); // 即使 TIME-WAIT 也能成功SO_REUSEPORT 的工作细节(Linux 3.9+):
SO_REUSEPORT 允许多个 socket 同时 bind() 到完全相同的 IP:PORT 上,内核会自动将新连接分发给这些 socket。分发算法基于连接五元组(源 IP、源端口、目标 IP、目标端口、协议)的哈希值,同一个五元组的连接总是路由到同一个 socket。
c
// 每个进程都这样做:
int fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
bind(fd, (struct sockaddr*)&addr, sizeof(addr));
listen(fd, SOMAXCONN);
// 内核自动将 accept 分发到不同进程
while (1) {
int cfd = accept(fd, NULL, NULL);
// 每个进程只处理自己分到的连接
}两个选项的本质区别总结:
| 维度 | SO_REUSEADDR | SO_REUSEPORT |
|---|---|---|
| 复用维度 | 时间复用(先后占用) | 空间复用(同时占用) |
| 触发条件 | 旧 socket 已 close 但仍在 TIME-WAIT | 多个 socket 同时活跃监听 |
| 能同时 bind 同一 IP:PORT? | 不能——同一时刻只有一个 listener | 能——多个 listener 共享同一 IP:PORT |
| 内核分发 | 不涉及(只有一个 socket) | 五元组哈希分发到不同 socket |
| 可用场景 | 服务端 + 客户端 | 仅服务端有意义 |
| Linux 版本 | 所有版本 | Linux 3.9+ |
| setsockopt 时机 | bind() 之前 | bind() 之前 |
| 多进程安全 | 不支持(需要外部协调) | 内置支持(内核提供负载均衡) |
| 惊群效应 | 有(多进程 epoll 同一 fd 时) | 无(每个进程独立 socket,内核精准分发) |
工程上的典型组合用法:
c
// 生产环境多进程服务端的标准配置:
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); // 防 TIME-WAIT
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt)); // 多进程负载均衡
bind(fd, ...);
listen(fd, backlog);为什么两个都要设?
SO_REUSEADDR解决"重启时端口还被占用"的问题,SO_REUSEPORT解决"多个进程怎么共享一个端口"的问题。它们是正交的——一个管时间维度,一个管空间维度。Nginx 从 1.9.1 开始默认同时开启两个选项。
Day 1 的代码为什么没设任何 reuse 选项?
day-01 的 echo server 故意从最简形式开始——一个单进程阻塞 accept() 的 echo 服务器,不设任何 socket 选项。这么做有两个教学目的:
- 先暴露问题:没有
SO_REUSEADDR时,服务端 Ctrl-C 后再启动会碰到bind: Address already in use(因为旧连接的 TIME-WAIT 还没消失)。这是初学者最容易遇到的第一道网络编程坑。 - day-02 再给解药:day-02 的多进程版(
echo-server-mp)会同时引入SO_REUSEPORT(多进程共享端口)和SO_REUSEADDR(防 TIME-WAIT),对比 day-01 的痛点一目了然。
四、listen() —— 内核做了两件事 + backlog 机制展开
listen() 是 TCP 状态机从 CLOSED / SS_UNCONNECTED 进入 LISTEN 状态的关键步骤:
c
// net/ipv4/inet_connection_sock.c
int inet_csk_listen_start(struct sock *sk, int backlog) {
// 步骤 1:初始化 accept 队列 (icsk_accept_queue)
reqsk_queue_alloc(&icsk->icsk_accept_queue);
// 步骤 2:设置 backlog(**限于 /proc/sys/net/core/somaxconn 上限**)
sk->sk_max_ack_backlog = min(backlog, net->core.sysctl_somaxconn);
// ↑ 这是关键:应用程序传 128,但内核 sysctl_somaxconn=128 时有效值=128
// 应用程序传 1024,但 somaxconn=128 时有效值=128(被截断!)
// 步骤 3:状态机转换
inet_sk_state_store(sk, TCP_LISTEN); // sk->sk_state = TCP_LISTEN
// 步骤 4:检查是否有连接在 SYN_RECV 等待完成
// (fastopen 或 listen() 之前就有 SYN 到达的情况)
if (!skb_queue_empty(&icsk->icsk_accept_queue.rskq_accept_head))
inet_csk_wait_for_connect(sk, ...); // 唤醒等待的 accept()
}4.1 内核中的两个队列(真正的 backlog 机制)
listen() 在内核中创建了两个独立队列,分别对应 TCP 三次握手的不同阶段。backlog 参数影响的是 Accept 队列的大小,而 SYN 队列的大小另有独立参数控制。理解这两个队列是排查"连接超时但服务端 CPU 空闲"这类诡异问题的关键。
SYN 队列(半连接队列,syn_queue)
当服务器收到客户端发来的第一个 SYN 包时,内核在 SYN 队列中创建一个条目,状态标记为 SYN_RECV,并回复 SYN+ACK。此时连接尚未建立——三次握手只完成了一次半。
SYN 队列的大小由三个参数的最小值决定:
bash
max_syn_queue = min(backlog, somaxconn, tcp_max_syn_backlog)backlog:listen()传入的参数somaxconn:/proc/sys/net/core/somaxconn,系统级限制tcp_max_syn_backlog:/proc/sys/net/ipv4/tcp_max_syn_backlog,专用于 SYN 队列的额外限制
每个条目存储客户端 ISN(初始序列号)、本端 ISN、客户端通告的 MSS/窗口缩放/时间戳等 TCP 选项,约占用 200 字节。
SYN 队列的特殊性——SYN Cookies:当 SYN 队列满时,Linux ≥ 2.2 默认启用 SYN cookies 机制。内核不再分配 SYN 队列条目,而是把连接信息加密编码到回复的 SYN+ACK 的 ISN 中,收到客户端 ACK 时再解密还原。这意味着 SYN 队列满并不一定导致新连接失败——但代价是丢失 TCP 选项(窗口缩放、SACK 等),连接性能会变差。
SYN 队列的完整时序:
下面的时序图展示了"收到第一个 SYN 到回复 SYN+ACK"的两条分支路径——正常分配条目 vs SYN cookie 绕行。关键点:SYN cookie 路径不分配内存,两次握手之间内核不保存任何连接状态。

时序图要点:正常路径下,内核为每个 SYN 分配约 200 字节的 request_sock;SYN cookie 路径下完全不分配内存,把连接信息(时间戳、MSS、五元组哈希)加密编码到 32 位的 ISN 中。收到客户端 ACK 时,内核通过解密 ISN 还原连接参数。代价是 ISN 只有 32 位,能编码的信息极其有限——TCP 窗口缩放、选择性确认(SACK)、TCP 时间戳等高级选项全部丢失,连接建立后的性能会明显下降(没有窗口缩放意味着接收窗口最大只有 64KB)。
4.2 listen() 创建的数据结构全景
调用 listen() 之后,内核在 inet_connection_sock 中初始化了两个队列的数据结构,此后所有客户端 SYN 和连接都由内核 TCP 协议栈自动管理——应用程序不需要、也无法干预三次握手的细节。

4.3 listen() 调用后的 socket 状态
listen() 调用本身是非阻塞的——它立即返回 0(成功)或 -1(失败),不会等待任何客户端连接。真正的"等待"发生在后续的 accept() 调用中。
listen() 完成后 socket 发生的实质变化:
| 维度 | listen() 之前 | listen() 之后 |
|---|---|---|
sk->sk_state | TCP_CLOSE | TCP_LISTEN |
| 能否接收 SYN | 否(SYN 包被丢弃或回 RST) | 是(内核协议栈自动处理三次握手) |
icsk_accept_queue | 未初始化 | 已分配(空队列) |
sk_max_ack_backlog | 0 | min(backlog, somaxconn) |
sk->sk_prot->hash | 无绑定 | 绑定到监听哈希表 (tcp_hashinfo.listening_hash) |
| 应用程序可对 socket 做什么 | 只能 bind() / listen() | 可以 accept() / epoll_ctl(EPOLLIN) |
关键认知:listen() 返回之后,内核已经悄悄开始接收 SYN 包并执行三次握手——即便应用程序还没调用 accept()。三次握手完全由内核 TCP 协议栈自动化完成,应用程序唯一要做的是通过 accept() 把"成品连接"从队列里取走。
"listen socket" 与 "connected socket" 的区别:
listen()创建的 fd 是监听 socket,它只用于accept(),不能用来read()/write()。每次accept()返回的是一个新的 connected socket fd,两者是不同的struct file,共享同一个struct sock的监听端口号。
4.4 backlog 为什么不是越大越好?
| 维度 | 解释 |
|---|---|
| 内存 | 每个 SYN 队列条目 ~200 字节(含 TCP 选项),Accept 队列条目 ~2KB(含整个 struct sock)。backlog=65535 且队列全满时,SYN 队列 ~13MB,Accept 队列 ~130MB |
| syn flood 防御 | 队列大 = 攻击者用更少的包就能撑满队列。SYN cookies 绕过了这个问题(不占 SYN 队列),但会丢失 TCP 选项(窗口缩放、时间戳等) |
| 延迟 | 队列越长,新连接在队列中等待越久。应用处理不过来时,TCP 保活可能在队列里就已超时 |
| 应用处理能力 | 如果应用是单进程阻塞,backlog 再大也没用——accept() 每秒只能取几个连接。瓶颈在应用处理速度,不在 backlog |
backlog 经验值:高并发场景
somaxconn=65535+listen(fd, 65535)+tcp_max_syn_backlog=65535。常规服务somaxconn=2048+listen(fd, 2048)即可。
一句话总结:listen() 创建了 SYN 队列和 Accept 队列,并将 socket 转入 TCP_LISTEN 状态——此后内核自动处理三次握手,应用程序只需靠 accept() 从 Accept 队列取成品连接。
五、accept() —— 从 Accept 队列取走已完成的连接
如果说 listen() 是"打开店门",accept() 就是"从等候区领走一位客人"。accept() 本身不参与三次握手——它只是从 Accept 队列里取走内核已经建好的连接。
Accept 队列(全连接队列,accept_queue)
当客户端回复 ACK(第三次握手完成)时,连接从 SYN 队列转移到 Accept 队列,状态变为 ESTABLISHED,并唤醒阻塞在 accept() 上的应用进程。此时内核层面连接已经建好,但应用还没取走。
Accept 队列的大小由两个参数的最小值决定:
bash
max_accept_queue = min(backlog, somaxconn)每个条目是一个完整的 struct sock + 关联的 struct file(返回给 accept() 的 fd),约 2KB。
这是"连不上"最常见的瓶颈:Accept 队列满时,内核会丢弃新到的第三次握手 ACK。客户端以为连接已建立(它发的 SYN+ACK 收到了 ACK),但实际上服务端没有承认。客户端重传 ACK,如果在限定次数内服务端 Accept 队列有了空位,连接成功;否则客户端超时。
5.1 Accept 队列的完整时序
下面的时序图展示了"第三次握手 ACK 到 accept() 返回 fd"的完整流程。重点关注 Accept 队列满时的分支——内核不发 RST,直接丢弃 ACK,客户端在无知中白白等待。

时序图要点:整个流程中最反直觉的是 Accept 队列满时的行为——内核丢弃 ACK 但不发 RST(默认 tcp_abort_on_overflow=0)。设计意图是:应用可能只是短暂繁忙(GC、突发流量),丢弃 ACK 让客户端重传,给应用腾出队列空间的机会。但这造成了诊断困难——客户端 connect() 已成功返回(它认为连接已经建立),服务端 ss -lnt 才能看到 Recv-Q > Send-Q 的异常。打开 tcp_abort_on_overflow 会让内核直接发 RST 拒绝,客户端立即收到 ECONNRESET,问题更早暴露,但代价是一点重试机会都不给。
5.2 accept() 的阻塞 vs 非阻塞
accept() 的默认行为由监听 socket 的文件描述符标志决定:
| 模式 | 设置方式 | Accept 队列空时的行为 | 使用场景 |
|---|---|---|---|
| 阻塞(默认) | listen(fd, backlog) | 进程睡眠,直到有连接到达 | 简单 echo 服务器(当前 Day 1) |
| 非阻塞 | fcntl(fd, F_SETFL, O_NONBLOCK) | 立即返回 -1,errno=EAGAIN | epoll/select/poll 事件循环 |
O_NONBLOCK 对 accept() 至关重要:如果监听 socket 是非阻塞模式,当 Accept 队列为空时
accept()不会睡眠。这是 epollEPOLLIN事件驱动的必要条件——epoll 通知 fd 可读 → 非阻塞accept()取走连接 → 继续事件循环。Day 2 的 epoll 版 echo 服务器就是基于这个语义。
5.3 accept() 的返回值语义
| 返回值 | 含义 | 处理方式 |
|---|---|---|
> 0 | 新连接的 fd | 正常使用:read()/write()/close() |
-1 + errno=EAGAIN/EWOULDBLOCK | 非阻塞模式下无可用连接 | 返回事件循环,等待下一次 epoll 通知 |
-1 + errno=EINTR | 被信号中断 | 重试 accept() |
-1 + errno=ECONNABORTED | 连接在队列里就已中止(极罕见) | 忽略,继续下一次 accept() |
-1 + errno=EMFILE | 进程 fd 数量达到上限 | 严重问题:需要增加 ulimit -n |
-1 + errno=ENFILE | 系统 fd 数量达到上限 | 系统级瓶颈,调 /proc/sys/fs/file-max |
关键:accept() 即使返回错误,监听 socket 仍然有效——不需要重新 listen()。最常见的坑是 EMFILE 时空转:Accept 队列里堆积了连接,但每次 accept() 都失败,导致既不消费队列、也不报 fatal error,最终 Accept 队列爆满。
5.4 两个队列的协作流程
回顾 listen() 和 accept() 各自创建的机制,整体流程是:
listen()阶段:创建 SYN 队列 + Accept 队列,socket 进入TCP_LISTEN状态,开始接收 SYN- 收到 SYN:内核放入 SYN 队列 → 回复 SYN+ACK(客户端视角第一次握手完成)
- 收到 ACK(第三次握手)→ 从 SYN 队列删除 → 放入 Accept 队列 → 唤醒
accept() accept()阶段:从 Accept 队列取走 → 返回新 fd 给应用程序
listen() 创建机制,accept() 只做消费——这是理解"listen backlog 到底控制了什么"的关键。backlog 参数在 listen() 时设置,但它同时影响 SYN 队列和 Accept 队列(两个公式都依赖 backlog),而实际运行时,两队列的满/空行为完全不同。
5.4.1 "取走"到底意味着什么?—— accept() 返回 fd 的完整内部过程
前面说 accept() "从 Accept 队列取走连接",但这句话省略了太多细节。一个已完成的 TCP 连接从 Accept 队列到变成应用手里可用的 fd,内核实际上做了一套数据结构迁移——不是简单的"拿出来"。
一句话说结论:accept() 把一个内核已建好的 struct sock(Accept 队列中的条目)从 listen socket 的 Accept 队列里摘下来,用一个新的 struct file + 新 fd 把它包装成独立 socket,返回给应用。这之后,新 socket 与 listen socket 再无关系。

第一步:分配新文件描述符
c
// fs/file.c
int newfd = get_unused_fd_flags(0); // 从当前进程的 fdtable 里找一个空闲位置这个操作与 socket() 调用中的 fd 分配一样——在 current->files->fdtab[] 里找一个空闲槽位。此时这个 fd 还是"空的",只是占了一个位置。
第二步:创建新的 struct file 和 struct socket,挂到新 fd 上
c
// net/socket.c: sock_alloc_file()
struct file *newfile = sock_alloc_file(sk_new, O_RDWR, NULL);
// sock_alloc_file 内部做了三件事:
// 1. 创建一个新的 struct socket,sock->sk = sk_new
// 2. 创建一个新的 struct file,file->private_data = socket
// 3. file->f_op = &socket_file_ops(让 read/write/close 能用于这个 fd)
fd_install(newfd, newfile); // fdtable[newfd] = newfile这是最关键的一步:sk_new 是 Accept 队列中的条目——它在第三次握手完成时已经是一个完整的 struct sock(状态 ESTABLISHED,包含五元组、接收/发送缓冲区、序列号等全部 TCP 状态)。sock_alloc_file() 把它包了一层 struct socket + struct file,让应用程序能通过 fd 操作它。
第三步:从 listen socket 的 Accept 队列中摘除
c
// net/ipv4/inet_connection_sock.c: inet_csk_accept()
struct sock *sk_new = reqsk_queue_get_child(&icsk->icsk_accept_queue, sk);
// 从 icsk_accept_queue 链表中摘除 sk_new
// 此时 sk_new 完全独立——它不属于任何队列摘除之后,sk_new 与 listen socket 之间不再有任何数据结构上的关联。它们唯一共享的是同一个本地端口号——仅此而已。
accept() 完成后的关键事实:
| 维度 | listen socket (fd=3) | connected socket (fd=4) |
|---|---|---|
sk->sk_state | TCP_LISTEN | TCP_ESTABLISHED |
能 read() / write() 吗? | 不能(只能 accept()) | 能 |
| 有自己的接收/发送缓冲区? | 无(不传输数据) | 有(独立的 sk_receive_queue / sk_write_queue) |
| 有自己的 cwnd / rwnd? | 无 | 有 |
close() 后影响对方? | 停止接收新连接,不影响已有连接 | 关闭这一条 TCP 连接 |
| 五元组 | (IP, Port, *, *) — 不完整 | (SRC_IP, SRC_PORT, DST_IP, DST_PORT, TCP) — 完整 |
"取走"的本质是所有权转移:
bash
Accept 队列中的 sk_new:
- 由 listen socket 的 icsk_accept_queue 持有
- 应用程序无法访问(没有 fd)
- 占用 Accept 队列的一个槽位
↓ accept() 执行
独立的新 socket:
- 由新 fd 持有(fdtable[newfd] → file → socket → sk_new)
- 应用程序通过 read/write/close 自由操作
- Accept 队列槽位释放,可以容纳下一个连接核心认知:
accept()不是一个"拷贝"操作——sk_new这个内核对象只有一个实例,它在accept()之前属于 Accept 队列,在accept()之后属于新 fd。所谓"取走",本质是所有权从队列迁移到文件描述符。这就是为什么close(fd)会释放整个 TCP 连接——因为close()触发file→socket→sk的析构链,最终销毁sk_new。
5.5 队列满的三种行为(历史演变)
Linux 内核在两个队列满时的行为经历过一次重要变更:
| 时期 | SYN 队列满 | Accept 队列满 |
|---|---|---|
| Linux < 2.2 | 丢弃 SYN,客户端触发超时重传 | 丢弃 SYN(客户端无感知,以为丢包) |
| Linux ≥ 2.2 (默认) | SYN cookies 绕过队列 | 丢弃新到的 ACK(第三次握手),但保留 SYN 队列中的记录 |
开启 tcp_abort_on_overflow=1 | SYN cookies | 直接发 RST(客户端收到 "Connection reset") |
tcp_abort_on_overflow 的利弊:
| 设置 | 行为 | 优点 | 缺点 |
|---|---|---|---|
=0(默认) | 丢弃 ACK,不重传 SYN+ACK | 客户端自动重试,可能最终成功 | 客户端卡在 connect() 上不知道原因 |
=1 | 发 RST 立即断开 | 客户端立刻知道连接失败 | 正当的短时突发也被拒绝 |
backlog 经验值:高并发场景
somaxconn=65535+listen(fd, 65535)+tcp_max_syn_backlog=65535。常规服务somaxconn=2048+listen(fd, 2048)即可。
一句话总结:accept() 从 Accept 队列取走内核已建好的连接并返回 fd——它本身不参与三次握手,慢 accept() 导致的 Accept 队列满是"连不上"的主因。配合 epoll 非阻塞 accept() 可以避免单连接阻塞后续连接的处理。