accept 内核实现 —— 四件套的收尾:连接怎么取走
上一篇 listen-kernel-internals 建好了两队列,本篇聚焦四件套的第四步:
accept()怎么从全连接队列取连接、没连接时睡在哪、谁唤醒它、以及多线程下锁竞争的本质。全文呼应 demo 程序../../demos/cpu-demo/main.cpp(多线程场景下的并发模型)。
一、accept 的本质:生产者-消费者
一句话:accept() 不创造连接——它只是从内核全连接队列里取走一个。 真正"建立连接"的是 TCP 协议栈,在软中断上下文中默默完成三次握手。
生产端(软中断 TCP 协议栈) 消费端(用户进程 accept)
│ │
│ 收到 SYN → 放入半连接队列 │
│ 收到 ACK → 完成握手 → 放入全连接队列 │
│ ↓ │
│ wake_up(sk->sk_wq) ──────────────→ 醒来 → reqsk_queue_remove()
│ │
用户拿到 newfd图析:生产和消费完全解耦——TCP 协议栈在软中断中生产连接(不受进程调度影响),
accept()在进程上下文中消费连接。这意味着即使应用进程被抢占、被 cgroup throttle、甚至被 kill -STOP,TCP 握手仍能完成,队列仍能积压——直到队列满才丢包。这套解耦设计让内核能在应用层"罢工"时继续履行协议义务,但也意味着应用必须及时消费,否则队列溢出就是性能雪崩的起点。
二、accept 的三种路径
| 场景 | 行为 | 返回值 |
|---|---|---|
| 全连接队列有货 | reqsk_queue_remove() 出队,FIFO 顺序,立刻返回 | 新 fd |
| 全连接队列空 + 阻塞模式(默认) | 进程睡在 sk->sk_wq,TASK_INTERRUPTIBLE,等软中断唤醒 | 阻塞直到有连接或信号 |
全连接队列空 + 非阻塞(SOCK_NONBLOCK) | 不睡眠,立刻返回 | -1,errno = EAGAIN |
阻塞等待机制:睡在哪、谁来叫
inet_csk_accept(sk)
│
├─ lock_sock(sk) 获取 sock 锁
├─ reqsk_queue_empty()?
│ ├─ false → 有货 → 出队返回
│ └─ true → 空
│ ├─ O_NONBLOCK? → 返回 -EAGAIN
│ └─ 阻塞模式:
│ └─ inet_csk_wait_for_connect()
│ │
│ ├─ prepare_to_wait_exclusive(sk->sk_wq)
│ │ 注册到等待队列,排他(只唤醒一个 waiter)
│ ├─ schedule_timeout()
│ │ ★ 进程睡眠,放弃 CPU
│ │ ★ 状态: TASK_INTERRUPTIBLE(可被信号打断)
│ │
│ │ ... 软中断中 TCP 握手完成 ...
│ │
│ ├─ 被 wake_up_interruptible_sync_poll() 唤醒
│ ├─ finish_wait() → 出队
│ └─ goto 重新检查队列
│
└─ release_sock(sk)图析:阻塞路径的四个关键细节——(1) 先拿锁再查队列:
lock_sock(sk)保护icsk_accept_queue,避免和软中断的入队操作竞争;(2) 先预分配后睡眠:在__sys_accept4进入时就已经sock_alloc+get_unused_fd好了新 fd,即使队列空、进程要睡,新资源也不会浪费——阻塞期间对调用者不可见;(3)prepare_to_wait_exclusive:排他等待,多线程同时 accept 同一个 socket 时,软中断只唤醒一个 waiter,避免惊群;(4)TASK_INTERRUPTIBLE:可以被信号打断,accept()返回-EINTR。
关键点:
prepare_to_wait_exclusive:排他等待。多个线程同时 accept 同一个 listen socket 时,只唤醒一个,避免惊群(thundering herd)。TASK_INTERRUPTIBLE:可以被信号打断(accept()返回-EINTR)。
三、时序图一:accept 入口到进入等待

图析:从用户调用到进入睡眠,看似简单的两步里藏着关键设计。(1) VFS 层的预分配:
__sys_accept4在把请求交给协议层之前,就已经分配好了新的 socket、file 和 fd——这种"先占位"的设计让协议层解耦了 fd 管理,失败时统一回滚即可。(2) 协议层的锁:lock_sock(sk)拿的是 listen socket 的sk_lock,它保护的范围包括icsk_accept_queue的检查和后续的等待队列操作——这把锁正是多线程 accept 的瓶颈所在(见第四节)。(3) 阻塞模式 vs 非阻塞:只在队列为空时才产生差异——这是性能关键路径上的快速判断。
四、时序图二:从睡眠到被唤醒、取出连接并返回

图析:唤醒到返回这一段是整个 accept 机制的精髓。(1) 释放锁再睡眠——如果
lock_sock(sk)不释放在schedule_timeout之前,软中断的tcp_v4_rcv→inet_csk_reqsk_queue_add就没法拿锁入队,形成死锁;(2)prepare_to_wait_exclusive+wake_up_interruptible_sync_poll——这对排他等待/唤醒的组合保证了多线程 accept 同一 socket 时只唤醒一个线程,其余继续睡,天然防惊群;(3) 醒来后重新检查队列——不一定就是"你"的连接到了,可能只是信号打断或假唤醒,必须再次reqsk_queue_empty();(4)sock_graft+fd_install——把协议层返回的子 sock 挂到之前预分配的 newfile 上,fd 变得对外可见,三次握手建立的连接至此完成从内核到用户态的交接。
五、多线程 accept:锁竞争的本质
多线程共享同一个 listen socket 调用 accept() 时,瓶颈在哪?
Thread-1: lock_sock(sk) 成功 → 进入等待/取连接
Thread-2: lock_sock(sk) 阻塞 → 排队等锁
Thread-3: lock_sock(sk) 阻塞 → ...
Thread-4: lock_sock(sk) 阻塞 → ...图析:每次 accept 都要争同一把
lock_sock(sk)——这把锁保护的是icsk_accept_queue(两队列的数据结构)。即使你有 128 核、100 万连接,只要多个线程共享一个 listen socket,lock_sock就是串行化点。锁竞争是吞吐的天花板,与连接数、CPU 核数无关。 这就是为什么SO_REUSEPORT对高并发服务端是刚需而非可选项。
根治方案:SO_REUSEPORT(Linux 3.9+)
SO_REUSEPORT: 多个线程各自创建独立 listen socket,绑定同 IP:Port
listen_fd_0 → icsk_accept_queue_0 → lock_sock(sk_0)
listen_fd_1 → icsk_accept_queue_1 → lock_sock(sk_1)
listen_fd_2 → icsk_accept_queue_2 → lock_sock(sk_2)
listen_fd_3 → icsk_accept_queue_3 → lock_sock(sk_3)
↑
四元组哈希 → 分发到哪个子 socket图析:
SO_REUSEPORT的"无锁"不是真的没锁——而是把一把大锁拆成了 N 把小锁。每个线程持有独立的 listen socket、独立的icsk_accept_queue、独立的lock_sock,线程之间完全无竞争。内核通过四元组 hash(源IP/源端口/目标IP/目标端口)把新连接分发到不同的子 socket,同一个客户端四元组始终落在同一个线程上,天然无惊群、无锁竞争。唯一的代价是多占几个 fd。
| 方案 | 原理 | 适用场景 |
|---|---|---|
SO_REUSEPORT | 每线程独立 listen socket,内核分发 | 推荐,彻底消除锁竞争 |
EPOLLEXCLUSIVE(4.5+) | epoll 只唤醒一个 waiter | 必须共享 listen socket 时 |
| 用户态锁 | pthread_mutex 保护 accept | 简单但退化为单线程 |
六、观测与排查
| 手段 | 命令 | 看什么 |
|---|---|---|
| 看队列积压 | ss -lnt | Recv-Q(当前积压)/ Send-Q(上限) |
| 看溢出次数 | netstat -s | grep "overflowed" | 全连接队列溢出计数 |
| 看 SYN 丢包 | netstat -s | grep "SYNs to LISTEN" | 半连接队列溢出 |
| strace 跟踪 | strace -e trace=accept,accept4 ./program | 看到 accept 阻塞/返回 |
| top 线程视图 | top -H -p <PID> | 看各 accept 线程的 CPU 分布 |
延伸阅读:listen-kernel-internals(两队列的创建与 backlog 语义)、accept-bottleneck(单点 accept 瓶颈的观测与五种优化方案)、epoll(epoll + accept 配合使用)、ss(ss 命令详解)。
总结
accept() 是消费端——连接是软中断里 TCP 协议栈建好的,accept() 只是从全连接队列 FIFO 取走一个。队列空时睡在 sk->sk_wq(排他等待,避免惊群),软中断完成握手后通过 wake_up_interruptible_sync_poll 唤醒。多线程瓶颈的根在共享 sock 锁(lock_sock 保护两队列),根治方案是 SO_REUSEPORT——每线程独立 socket、独立队列、独立锁,天然无竞争。