﻿# accept 内核实现 —— 四件套的收尾：连接怎么取走

> 上一篇 [listen-kernel-internals.md](/concepts/network/listen-kernel-internals.md) 建好了两队列，本篇聚焦四件套的**第四步**：`accept()` 怎么从全连接队列取连接、没连接时睡在哪、谁唤醒它、以及多线程下锁竞争的本质。全文呼应 demo 程序 [../../demos/cpu-demo/main.cpp](/demos/cpu-demo/main.cpp)（多线程场景下的并发模型）。

## 一、accept 的本质：生产者-消费者

一句话：**`accept()` 不创造连接——它只是从内核全连接队列里取走一个。** 真正"建立连接"的是 TCP 协议栈，在软中断上下文中默默完成三次握手。

```bash
生产端（软中断 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` |

### 阻塞等待机制：睡在哪、谁来叫

```bash
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 入口到进入等待

```plantuml
@startuml
skinparam shadowing false
participant "用户进程" as U #C8E6C9
participant "__sys_accept4\n(VFS 层)" as VFS #BBDEFB
participant "inet_csk_accept\n(协议层)" as CSK #BBDEFB
participant "全连接队列" as AQ #FFCCBC
U -> VFS : accept(listen_fd, NULL, NULL)
VFS -> VFS : sockfd_lookup_light(listen_fd)\nfd → struct socket
VFS -> VFS : sock_alloc() → newsock\nget_unused_fd() → newfd\nalloc_file() → newfile
note right: ★ 新 fd/socket/file 提前分配\n即使队列为空也不浪费\n——阻塞期间调用者看不到
VFS -> CSK : sock->ops->accept()\n→ inet_csk_accept(sk, flags)
CSK -> CSK : lock_sock(sk)
note right: 保护 icsk_accept_queue
CSK -> AQ : reqsk_queue_empty()?
AQ --> CSK : true（队列为空）
CSK -> CSK : 非 O_NONBLOCK\n决定走阻塞等待
note right of U : 接下来：注册等待、释放 CPU\n等待 TCP 协议栈送来连接
@enduml
```

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

## 四、时序图二：从睡眠到被唤醒、取出连接并返回

```plantuml
@startuml
skinparam shadowing false
participant "inet_csk_accept" as CSK #BBDEFB
participant "sk->sk_wq\n等待队列" as WQ #FFF9C4
participant "TCP 协议栈\n(softirq 上下文)" as TCP #FFECB3
participant "全连接队列" as AQ #FFCCBC
participant "__sys_accept4" as VFS #BBDEFB
participant "用户进程" as U #C8E6C9
CSK -> WQ : inet_csk_wait_for_connect()
CSK -> WQ : prepare_to_wait_exclusive(sk->sk_wq)
note right: 排他等待——多个 waiter 中\n只唤醒一个，防惊群
CSK -> CSK : release_sock(sk)
note right: ★ 释放锁再睡眠！\n否则软中断无法入队\n（入队也要拿 sk_lock）
CSK -> CSK : schedule_timeout()
note right: ★★ 进程睡眠 ★★\nTASK_INTERRUPTIBLE
... 若干时间后，客户端完成三次握手 ...
TCP -> AQ : 三次握手完成\ninet_csk_reqsk_queue_add()
note right: 软中断上下文中，不经过\n任何用户进程调度
TCP -> WQ : wake_up_interruptible_sync_poll()
note right: 只唤醒排他队列中\n的第一个 waiter
CSK <- WQ : 进程被唤醒
CSK -> CSK : lock_sock(sk)
note right: 重新拿锁
CSK -> AQ : reqsk_queue_empty()?
AQ --> CSK : false（有货了！）
CSK -> AQ : reqsk_queue_remove()
note right: FIFO 出队，拿到子 sock
CSK -> CSK : release_sock(sk)
CSK --> VFS : return newsk
VFS -> VFS : sock_graft(newsk, newsock)\nfd_install(newfd, newfile)
VFS --> U : return newfd
@enduml
```

> **图析**：唤醒到返回这一段是整个 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()` 时，瓶颈在哪？

```bash
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+）

```bash
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.md](/concepts/network/listen-kernel-internals.md)（两队列的创建与 backlog 语义）、[accept-bottleneck.md](/concepts/network/accept-bottleneck.md)（单点 accept 瓶颈的观测与五种优化方案）、[epoll.md](/concepts/network/epoll.md)（epoll + accept 配合使用）、[../../tools/network/ss.md](/tools/network/ss.md)（ss 命令详解）。

## 一句话总结

**`accept()` 是消费端——连接是软中断里 TCP 协议栈建好的，`accept()` 只是从全连接队列 FIFO 取走一个。队列空时睡在 `sk->sk_wq`（排他等待，避免惊群），软中断完成握手后通过 `wake_up_interruptible_sync_poll` 唤醒。多线程瓶颈的根在共享 sock 锁（`lock_sock` 保护两队列），根治方案是 `SO_REUSEPORT`——每线程独立 socket、独立队列、独立锁，天然无竞争。**

