﻿# Day 1 深入论述：TCP Echo 的内核原理

> 本文是 Day 1 的**原理深度篇**，配合 [实践指南](README.md) 阅读。
> 包含 `socket()`/`bind()`/`listen()` 内核实现、`sk_buff` 设计与组织、MSS/MTU/Nagle/cwnd 机制、以及"为什么 `read()` 返回量不可预测"的完整四层分析。

---

## 深入论述一：`socket()`、`bind()`、`listen()`、`accept()` 在内核做了什么

这三个系统调用看似简单，但背后涉及 VFS、协议族注册、端口管理、hash 表插入、有限状态机转换等大量内核机制。理解它们不是"背 API"，而是理解后续所有 TCP 性能问题的基础。

### 一、前置骨架：网络栈的"五层数据结构"与挂载关系

理解 `socket()`/`bind()`/`listen()` 之前，先把内核里的**一组嵌套结构体**看清——它们层层指针挂载，构成网络栈的骨架。后续每一节都会引用这张"挂载图"。

```plantuml
@startuml
skinparam shadowing false
hide empty members
skinparam classAttributeIconSize 0

class "task_struct\n(进程描述符)" as task {
  +files : files_struct *
}

class "files_struct" as files {
  +fdtab : fd_table
  +count : atomic_t
}

class "struct file\n(VFS 通用文件)" as file {
  +f_op : file_operations *
  +f_inode : inode *
  +private_data : void *
}

class "struct socket\n(VFS 抽象层)" as sock {
  +ops : proto_ops *
  +sk : struct sock *
  +state : socket_state
  +file : file *
  +wq : wait_queue_head_t
}

class "struct sock\n(协议层基类)" as sk {
  +sk_prot : proto *
  +sk_state : __u8 (TCP_LISTEN/ESTABLISHED/...)
  +sk_receive_queue : sk_buff_head
  +sk_write_queue : sk_buff_head
  +sk_backlog : sk_buff_head
  +sk_data_ready : callback
}

class "struct inet_sock\n(IPv4 扩展)" as inetsk {
  +inet_saddr : __be32 (发送源 IP)
  +inet_rcv_saddr : __be32 (接收目标 IP)
  +inet_daddr : __be32 (对端 IP)
  +inet_sport : __be16 (本端端口)
  +inet_dport : __be16 (对端端口)
  +inet_num : __be16 (绑定的端口)
}

class "inet_connection_sock\n(面向连接扩展)" as inetcs {
  +icsk_accept_queue : request_sock_queue
  +icsk_inet : inet_sock
}

class "struct tcp_sock\n(TCP 专属扩展)" as tcpsk {
  +srtt : u32 (平滑 RTT)
  +cwnd : u32 (拥塞窗口)
  +snd_wscale : u8 (发送窗口缩放)
}

task --> files : files_struct *
files --> file : fdtab[fd] 指向
file --> sock : private_data = socket
sock --> sk : sock->sk
sk <|-- inetsk : inet_sk() (嵌在 sk 头部)
inetsk <|-- inetcs : inet_csk() (嵌在 inetsk 头部)
inetcs <|-- tcpsk : tcp_sk() (嵌在 inetcs 头部)

note right of sock
  **VFS 抽象层**——把 socket 伪装成文件
  让 read/write/close/poll 都能作用于网络
end note

note right of sk
  **每条 socket 对应一个 sk**
  三个 skb 队列归属这里（rx/tx/backlog）
end note

note right of tcpsk
  **TCP 专属**：cwnd/rtt/ssthresh/窗口缩放
  强转 macro：tcp_sk(sk) = (tcp_sock*)sk
  强转依据：tcp_sock 的第一个成员就是 inet_connection_sock
end note

@enduml
```

**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()` —— 内核做了三件事

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<user>> #E3F2FD
  BorderColor<<user>> #1565C0
  BackgroundColor<<kernel>> #FFF8E1
  BorderColor<<kernel>> #F9A825
}
title socket(AF_INET, SOCK_STREAM, 0) 的内核执行路径

rectangle "用户态" <<user>> as user {
  rectangle "socket(AF_INET, SOCK_STREAM, 0)" as call
}

rectangle "内核态" <<kernel>> as kernel {
  rectangle "1. socket 系统调用入口\n__sys_socket()" as entry
  rectangle "2. 创建 struct socket\nsock_alloc()" as alloc
  rectangle "3. 协议族查找 & proto 绑定\ninet_create()" as create
  rectangle "4. 创建 struct sock\nsk_alloc()" as skalloc
  rectangle "5. 分配文件描述符\nalloc_fd() + fd_install()" as fd
  rectangle "结果" as result {
    rectangle "fd=4 → file → socket → sock" as chain
  }
}

call --> entry
entry --> alloc
alloc --> create
create --> skalloc
skalloc --> fd
fd --> result

note right of create
  AF_INET → inet_family_ops.create
  SOCK_STREAM → tcp_prot (proto_ops)
  **三层绑定**：
    socket.ops = &inet_stream_ops
    sock.sk_prot = &tcp_prot
  TCP 协议栈函数指针全部就位:
  connect/sendmsg/recvmsg/close...
end note

note right of skalloc
  sock_init_data() 初始化:
  - 三个队列 (receive/transmit/backlog)
  - 各种超时定时器
  - 内存压力会计
  - 初始状态: SS_UNCONNECTED
end note

@enduml
```

**第一步：分配 `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] = file
```

`current->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()` —— 内核做了两件事

```plantuml
@startuml
skinparam shadowing false
title bind(fd, {INADDR_ANY, 8080}) 的内核执行路径

rectangle "用户态" as user {
  rectangle "bind(fd, addr, len)" as bindcall
}

rectangle "内核态" as kernel {
  rectangle "1. 参数校验 & 权限检查" as check
  rectangle "2. 协议族 bind: inet_bind()" as inetbind
  
  rectangle "端口冲突检查" as conflict {
    rectangle "遍历 inet_bind_bucket hash 表" as hashwalk
    rectangle "SO_REUSEADDR 选项判断" as reuse
  }
  
  rectangle "绑定地址到 sock" as bindaddr {
    rectangle "inet_saddr = addr" as set_saddr
    rectangle "inet_rcv_saddr = addr" as set_rcvaddr
    rectangle "sk_reuse = ..." as set_reuse
  }
  
  rectangle "插入 bind hash 表\ntcp_hash()" as hash
}

user --> check
check --> inetbind
inetbind --> conflict
conflict --> bindaddr
bindaddr --> hash

note right of conflict
  端口冲突判定规则：
  - 同一个 IP:PORT 只能绑定一次
  - INADDR_ANY 0.0.0.0 是"我全部 IP"
  - SO_REUSEADDR 可复用 TIME_WAIT 的端口
end note

@enduml
```

**第一步：端口冲突检查 —— 遍历 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 服务器 |

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<reuseaddr>> #BBDEFB
  BorderColor<<reuseaddr>> #1565C0
  BackgroundColor<<reuseport>> #C8E6C9
  BorderColor<<reuseport>> #2E7D32
}
title SO_REUSEADDR vs SO_REUSEPORT

rectangle "SO_REUSEADDR：时间复用" <<reuseaddr>> as ra {
  rectangle "T0: 进程A监听:8080，连接ESTABLISHED" as ra_t0
  rectangle "T1: A重启→旧socket TIME-WAIT\n新进程B bind(:8080) → EADDRINUSE" as ra_t1
  rectangle "T2: 加SO_REUSEADDR后\n新socket抢回TIME-WAIT端口 ✓" as ra_t2
  note right of ra
    同一时刻只有一个进程监听
    旧socket已close但四元组残留
    →允许新socket重新占用
  end note
  ra_t0 -down-> ra_t1
  ra_t1 -down-> ra_t2
}

rectangle "SO_REUSEPORT：空间复用 (Linux 3.9+)" <<reuseport>> as rp {
  rectangle "进程A、B、C同时bind(:8080)+SO_REUSEPORT" as rp_bind
  rectangle "内核五元组哈希分发\n客户端1→A / 客户端2→B / 客户端3→C" as rp_lb
  note right of rp
    多个进程同时活跃监听
    同一IP:PORT
    无惊群，内核精准分发
  end note
  rp_bind -down-> rp_lb
}

ra -down-> rp : 正交关系，生产环境通常两个都设

@enduml
```

**`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 选项。这么做有两个教学目的：

1. **先暴露问题**：没有 `SO_REUSEADDR` 时，服务端 Ctrl-C 后再启动会碰到 `bind: Address already in use`（因为旧连接的 TIME-WAIT 还没消失）。这是初学者最容易遇到的第一道网络编程坑。
2. **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 路径**不分配内存**，两次握手之间内核不保存任何连接状态。

```plantuml
@startuml
skinparam shadowing false
title SYN 队列工作流程 — 收到 SYN 到回复 SYN+ACK

actor "客户端" as C
participant "内核 TCP 协议栈" as K
participant "SYN 队列 (半连接)" as SQ

C -> K: ① 发送 SYN\n   ISN_C, 选项: MSS/WS/SACK
activate K
K -> SQ: ② 检查队列是否已满\n   max = min(backlog, somaxconn, tcp_max_syn_backlog)

alt 队列未满（正常路径）
  SQ --> K: 有空位
  K -> SQ: ③ 创建条目 (SYN_RECV)\n   存储: ISN_C, ISN_S, TCP选项\n   约 200 字节
  K -> C: ④ 回复 SYN+ACK\n   ISN_S, ACK=ISN_C+1
  note right of SQ
    正常路径: 内核为每个
    半连接保留 ~200B 状态
  end note

else 队列已满 (SYN Cookies 路径)
  note right of K
    不分配队列条目!
    省 ~200 字节/连接
  end note
  K -> K: ⑤ 将连接信息加密\n   编码到 ISN_S 中\n   (时间戳 + MSS + 客户端 IP/Port 等)
  K -> C: ⑥ 回复 SYN+ACK\n   ISN_S=加密cookie, ACK=ISN_C+1
  note right of SQ
    SYN cookie 路径:
    没有分配 SYN 队列条目
    内核不保存任何连接状态!
    → 丢失 TCP 选项
    (窗口缩放/SACK/时间戳)
  end note
end

deactivate K
@enduml
```

**时序图要点**：正常路径下，内核为每个 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 协议栈自动管理——应用程序不需要、也无法干预三次握手的细节。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<syn>> #FFE0B2
  BorderColor<<syn>> #EF6C00
  BackgroundColor<<accept>> #C8E6C9
  BorderColor<<accept>> #2E7D32
  BackgroundColor<<app>> #E3F2FD
  BorderColor<<app>> #1565C0
  BackgroundColor<<tcp>> #F3E5F5
  BorderColor<<tcp>> #7B1FA2
}
title listen() 后内核维护的两个队列

rectangle "内核TCP协议栈" <<tcp>> as tcp {
  rectangle "收到SYN→放入SYN队列→回复SYN+ACK" as rcv_syn
  rectangle "收到ACK→SYN移入Accept队列→唤醒accept()" as rcv_ack
}

rectangle "TCP LISTEN socket" as listen {
  rectangle "SYN队列(半连接)" <<syn>> as synq {
    rectangle "SYN_RECV #1..#N\nsize=min(backlog,somaxconn,tcp_max_syn_backlog)" as syn_entries
  }
  rectangle "Accept队列(全连接)" <<accept>> as acceptq {
    rectangle "ESTABLISHED #1..#N\nsize=min(backlog,somaxconn)" as acc_entries
  }
  synq -down-> acceptq : 第三次握手完成（移入）
}

rectangle "应用进程" <<app>> as app {
  rectangle "accept()取走→返回fd" as do_accept
}

tcp -down-> listen : 写入队列
listen -down-> app : accept()消费

note right of acceptq
  accept队列满=最常见"连不上"原因
  ss -lnt看Send-Q(当前)/Recv-Q(最大)
end note

@enduml
```

#### 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，客户端在无知中白白等待。

```plantuml
@startuml
skinparam shadowing false
title Accept 队列工作流程 — 第三次握手 ACK 到 accept() 返回

actor "客户端" as C
participant "内核 TCP 协议栈" as K
participant "SYN 队列 (半连接)" as SQ
participant "Accept 队列 (全连接)" as AQ
participant "应用进程 (阻塞在 accept)" as APP

C -> K: ① 发送 ACK\n    ACK=ISN_S+1 (第三次握手)
activate K
K -> SQ: ② 查找匹配的 SYN_RECV 条目\n    (五元组匹配)

alt 找到 SYN_RECV 条目
  note right of SQ
    若是 SYN cookie
    则解密 ISN_S 还原
    连接参数 (无 TCP 选项)
  end note
  K -> SQ: ③ 删除 SYN_RECV 条目\n   释放 ~200 字节
  K -> AQ: ④ 检查 Accept 队列\n   max = min(backlog, somaxconn)

  alt Accept 队列未满（正常路径）
    K -> AQ: ⑤ 创建 ESTABLISHED 条目\n   struct sock + struct file\n   约 2KB
    K -> APP: ⑥ 唤醒阻塞在 accept() 的进程
    deactivate K
    APP -> AQ: ⑦ accept() 从队列前端取走连接
    activate AQ
    AQ -> APP: ⑧ 返回新 fd
    deactivate AQ
    APP -> APP: ⑨ read()/write() 开始收发数据
    note right of APP
      正常路径: 内核与应用的
      交接点就是 Accept 队列
      accept() 只是出队操作
    end note

  else Accept 队列满（瓶颈！）
    deactivate K
    note right of K
      丢弃 ACK，不发 RST
      (除非 tcp_abort_on_overflow=1)
    end note
    C -> C: 客户端等待数据...\n(它以为连接已建立)
    note right of C
      客户端视角:
      发了 SYN → 收到 SYN+ACK
      → 发了 ACK → 以为连上了
      → 开始发数据或等待

      实际: 服务端没承认
      ACK 超时后重传
      若仍满 → 连接超时失败
    end note
  end

else 找不到条目（过期/伪造）
  K -> C: 回复 RST (连接拒绝)
  deactivate K
  note right of K
    SYN cookie 超时
    或恶意 ACK 攻击
  end note
end

@enduml
```

**时序图要点**：整个流程中最反直觉的是 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()` 不会睡眠。这是 epoll `EPOLLIN` 事件驱动的必要条件——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()` 各自创建的机制，整体流程是：

1. **`listen()` 阶段**：创建 SYN 队列 + Accept 队列，socket 进入 `TCP_LISTEN` 状态，开始接收 SYN
2. **收到 SYN**：内核放入 SYN 队列 → 回复 SYN+ACK（客户端视角第一次握手完成）
3. **收到 ACK**（第三次握手）→ 从 SYN 队列删除 → 放入 Accept 队列 → 唤醒 `accept()`
4. **`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 再无关系。**

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<before>> #FFE0B2
  BorderColor<<before>> #EF6C00
  BackgroundColor<<during>> #FFF8E1
  BorderColor<<during>> #F9A825
  BackgroundColor<<after>> #C8E6C9
  BorderColor<<after>> #2E7D32
}
title accept() 取走连接：三步数据结构迁移

rectangle "调用前：sk_new 在 Accept 队列" <<before>> as before {
  rectangle "Listen Socket (fd=3)\n  · icsk_accept_queue = [sk_new, #2, #3]\n  · fdtable[3] → file_A → sock_A (listen)" as b1
  rectangle "sk_new 状态: ESTABLISHED\n  属于 Accept 队列, 有完整五元组+缓冲区\n  但应用无法访问(无 fd)" as b2
}

rectangle "执行中：三步迁移" <<during>> as during {
  rectangle "① get_unused_fd() → fd=4" as d1
  rectangle "② sock_alloc_file(sk_new) → file_B\n   file_B.private_data = sk_new\n   file_B.f_op = socket_file_ops" as d2
  rectangle "③ 从 icsk_accept_queue 摘除 sk_new\n   sk_new 不再属于任何队列" as d3
}

rectangle "返回后：独立 socket (fd=4)" <<after>> as after {
  rectangle "Listen Socket (fd=3)\n  · icsk_accept_queue = [#2, #3]\n  · 不受影响, 继续监听" as a1
  rectangle "Connected Socket (fd=4)\n  · fdtable[4] → file_B → sk_new\n  · 可 read/write/close\n  · close(4)不影响fd=3" as a2
}

before -down-> during : accept() 执行
during -down-> after : 返回新 fd

note bottom of after
  本质是所有权迁移: 同一个 sk_new 从队列→fd
  不是拷贝, 是新 fd 持有了原来的 sk_new
end note

@enduml
```

**第一步：分配新文件描述符**

```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()` 可以避免单连接阻塞后续连接的处理。

---

## 深入论述二：`sk_buff` 的设计与组织

`sk_buff`（Socket Buffer，内核常称 `skb`）是 Linux 网络栈贯穿所有层级的**唯一数据结构**。一个以太网帧从网卡 DMA 进内存、到 IP 层拆头、到 TCP 层排序重组、到 socket 层等待 `read()`——**全程都是同一个 `sk_buff`**（或其克隆），只是各级指针偏移量不同。

### 一、`struct sk_buff` 核心字段

```plantuml
@startuml
skinparam shadowing false
skinparam class {
  BorderColor #757575
  BackgroundColor #FAFAFA
}
title struct sk_buff 的内存布局

class sk_buff {
  -- 链表指针 --
  next : sk_buff*
  prev : sk_buff*
  sk : sock*
  dev : net_device*
  -- 四指针窗口 --
  head : u8*
  data : u8*
  tail : u8*
  end : u8*
  -- 元数据 --
  len : u32
  data_len : u32
  truesize : u32
  protocol : __be16
  pkt_type : u8
  tstamp : ktime_t
}

note right of sk_buff
  **四指针含义**
  head → 数据区起始
  data → 当前协议层数据起始
  tail → 当前数据结尾
  end → 数据区物理末尾
  
  各层只偏移 data, 零拷贝:
  TCP收: data -= TCP头大小
  IP收:  data -= IP头大小
end note

@enduml
```

**四大指针（`head` / `data` / `tail` / `end`）的工作模型**

这是 `sk_buff` 最精妙的设计——各层协议只偏移 `data` 指针，不拷贝数据：

```bash
head ─────────────────────────────────────────────────────── end
      │ MAC头 │ IP头 │ TCP头 │        Payload        │ 预留 │
              ↑              ↑                        ↑
            data(IP层)    data(TCP层)               tail
```

| 操作 | `data` 变动 | 示例 |
|------|------------|------|
| 网卡收包 | `data = head`（整个帧） | 刚 DMA 完毕 |
| IP 层处理 | `data += MAC 头长度`（14 字节） | IP 层只看 IP 头+载荷 |
| TCP 层处理 | `data += IP 头长度`（20 字节） | TCP 层只看 TCP 段 |
| socket 层 `read()` | 从 `data` 位置拷贝到用户缓冲区 | 只拷贝 TCP payload |
| 逐层发包 | 反过来：先设 `data` 到 TCP payload，逐层 `data -= 头长度` | — |

**关键字段对照表**：

| 字段 | 类型 | 含义 |
|------|------|------|
| `next` / `prev` | `struct sk_buff *` | 将 skb 组织成双向链表（socket 接收队列、发送队列都是这样的链表） |
| `sk` | `struct sock *` | 指向所属的 socket 控制块，`read()` 时用于唤醒等待进程 |
| `head` | `unsigned char *` | 数据区的物理起始地址（`kmalloc` 返回的值） |
| `data` | `unsigned char *` | 当前协议层关心的数据起始位置 |
| `tail` | `unsigned char *` | 当前协议层数据的结尾位置 |
| `end` | `unsigned char *` | 数据区的物理末尾 |
| `len` | `unsigned int` | 线性数据区 + 分页数据区的总长度（`= tail - data + data_len`） |
| `data_len` | `unsigned int` | 分页（non-linear / paged）数据区的长度 |
| `truesize` | `unsigned int` | 此 skb 实际消耗的总内存（`struct sk_buff` + 数据区 + 分页） |
| `protocol` | `__be16` | L3 协议类型（`ETH_P_IP` / `ETH_P_IPV6` / `ETH_P_ARP`） |
| `tstamp` | `ktime_t` | 收包时间戳（SO_TIMESTAMP 控制是否记录） |

### 二、sk_buff 链表组织形式

```plantuml
@startuml
skinparam shadowing false
title 同一个 socket 上 sk_buff 的三种链表

rectangle "sock (tcp_sock)" as sock {
  rectangle "sk_receive_queue\n已接收,等read()拿走" as rcvq {
    (skb #1: seq=1   ~1460字节) as r1
    (skb #2: seq=1461~2920字节) as r2
    (skb #3: seq=2921~4380字节) as r3
    r1 --> r2
    r2 --> r3
  }
  rectangle "sk_write_queue\n已发送,等ACK确认" as writeq {
    (skb #A: seq=1    未确认) as w1
    (skb #B: seq=1461 未确认) as w2
    (skb #C: seq=2921 未确认) as w3
    w1 --> w2
    w2 --> w3
  }
  rectangle "sk_backlog\n刚到达,等软中断处理" <<backlog>> as blog {
    (等待处理的入站 skb) as bl1
    (等待处理的入站 skb) as bl2
    bl1 --> bl2
  }
}

note bottom of sock
  receive_queue: 内核接收缓冲区,read()从这里取; 满→窗口=0→发送端停
  write_queue: write()最终到达处, 等ACK期间skb不释放
  backlog: 入站skb暂存, 等软中断搬入receive_queue
end note
@enduml
```

| 队列名 | 用途 | `read()`影响 | 满的行为 |
|--------|------|------------|----------|
| `sk_receive_queue` | 已重组好的数据，等 `read()` | `read()` 从此队列拷贝，用完释放 skb | 通告窗口=0（零窗口），发送端停止发送 |
| `sk_write_queue` | 已发送但未确认的段 | 无关 | 发送阻塞，或返回 `EAGAIN`（非阻塞） |
| `sk_backlog` | 新进入的段，等待软中断处理 | 无关 | 可能丢包或关闭连接 |

> **关键工程含义**：`read()` 慢 → `sk_receive_queue` 满 → 窗口=0 → 发送端暂停 → **整个连接被接收端阻塞**。这是 Day 1 单进程 Echo 最致命的瓶颈——`accept()` 阻塞意味着前面连接的 `read()` 没处理完，新连接的数据堆积在接收队列里，唯一处理线程却在 `accept()` 上被唤醒。

#### 2.1 全生命周期：一个 sk_buff 如何经过这三个队列

上面那张图是一个**静态快照**——某个时刻三个队列里各有什么。但 sk_buff 不会凭空出现在队列里。下面用序列图展示一个 TCP 段从网卡到 `receive_queue` 的完整路径，以及应用 `write()` 后 sk_buff 如何进入 `write_queue`。

```plantuml
@startuml
skinparam shadowing false
skinparam participant {
  BackgroundColor<<hw>> #FFE0B2
  BorderColor<<hw>> #EF6C00
  BackgroundColor<<ks>> #E3F2FD
  BorderColor<<ks>> #1565C0
  BackgroundColor<<app>> #C8E6C9
  BorderColor<<app>> #2E7D32
}
title sk_buff 生命周期：从网卡到 receive_queue / 从 write() 到 write_queue

participant "网卡\n(硬中断)" as nic <<hw>>
participant "NET_RX_SOFTIRQ\n(软中断/ksoftirqd)" as sirq <<ks>>
participant "TCP协议栈\n(tcp_v4_rcv)" as tcp <<ks>>
participant "sock\n(sk_receive_queue)" as sockq <<ks>>
participant "应用进程\n(read/write)" as app <<app>>

== 收包路径：sk_buff → backlog → receive_queue ==

nic -> sirq: ① DMA完成,硬中断触发\n  napi_schedule()
note over nic
  每个到达的TCP段
  都是一个独立的sk_buff
  (网卡驱动alloc_skb分配)
end note

sirq -> sirq: ② NAPI poll: 从RX Ring\n  取出sk_buff, 放入backlog

note over sirq
  **sk_backlog 的角色**
  · 硬中断只做最小工作(DMA→skb入backlog)
  · 立即调度NET_RX_SOFTIRQ
  · backlog是硬中断→软中断的**交接区**
  · 目的: 减少硬中断持有时间
end note

sirq -> tcp: ③ 从backlog取出skb\n  tcp_v4_rcv() 处理

tcp -> tcp: ④ TCP层处理\n  · 校验checksum\n  · 查五元组找sock\n  · 乱序排队/合并\n  · 更新rcv_nxt\n  · 更新通告窗口

tcp -> sockq: ⑤ 有序段放入receive_queue
note over sockq
  **此时skb在receive_queue**
  等待应用read()取走
  若队列满→通告窗口=0
end note

sockq -> app: ⑥ read(fd,buf,n)\n  从receive_queue拷贝→用户buf\n  释放skb(kfree_skb)

== 发包路径：write() → write_queue → 网卡 ==

app -> tcp: ⑦ write(fd,buf,50000)\n  → tcp_sendmsg()
note over app
  write()不直接操作网卡
  而是创建skb放入write_queue
  内核异步发送
end note

tcp -> sockq: ⑧ 创建skb, 放入write_queue
note over sockq
  **此时skb在write_queue**
  等待TCP发送引擎取走
  受cwnd/rwnd限制
end note

sockq -> sirq: ⑨ TCP发送引擎\n  (tcp_transmit_skb)\n  取走skb→网卡发送

sirq -> nic: ⑩ DMA发送

note over sockq
  **skb保留在write_queue**
  直到收到对端ACK
  才真正释放(kfree_skb)
  因为可能需要重传
end note

@enduml
```

**每个 TCP 段都是独立的 `sk_buff`**：

网卡每收到一个以太网帧，驱动就分配一个 `sk_buff`（通过 `alloc_skb`），DMA 把数据写入其线性区。即使同一个 `write()` 调用写了 50KB，TCP 层也会按 MSS 拆成 ~35 个段，**每个段是独立的 `sk_buff`**，各自有自己的 `seq` 号，各自走完上述生命周期。

#### 2.2 `sk_backlog` 是什么？—— 硬中断与软中断之间的"交接区"

NIC 硬中断的黄金法则是**越快越好**——中断持有期间其他中断被屏蔽。所以硬中断处理函数只做三件事：

1. 确认中断（写 NIC 寄存器）
2. 把 RX Ring 里的 `sk_buff` 搬到 `sk_backlog`
3. 调度 `NET_RX_SOFTIRQ`，然后立即返回

之后由软中断（`ksoftirqd` 内核线程或 `softirq` 上下文）从 backlog 里取出 skb，调用 `tcp_v4_rcv()` 做真正的 TCP 协议处理（校验、排序、更新窗口、放入 `receive_queue`）。

> **那为什么不直接在硬中断里把 skb 放进 `receive_queue`？**
>
> 把 skb 放进 receive_queue 本身只是一个链表操作——确实不重。**但放之前你必须先知道它属于哪个 socket 的 receive_queue**，而要确定归属，必须执行 TCP 协议栈处理：
>
> ```
> 收到一个 TCP 段后，要放入 receive_queue 必须经历的步骤：
> ① 校验 TCP checksum          → 不校验？垃圾数据也会入库
> ② 查五元组哈希表找 sock       → 否则不知道往哪个 socket 的 receive_queue 放
> ③ 检查 seq 号是否在窗口内     → 窗口外的直接丢
> ④ 乱序检测（SACK/重排队列）    → 不是每段都能直接放入，可能需要暂存等待前序段
> ⑤ 更新 rcv_nxt、通告窗口      → 放进去后要告知对端新窗口大小
> ```
>
> 这就是 `tcp_v4_rcv()` 的工作——**不是"放"的动作重，是"决定往哪放、能不能放"这串判断重**（几十 μs，涉及多次 spinlock 获取）。而在硬中断上下文中，当前 IRQ 线被屏蔽，**所有其他中断都在排队等**——如果你在硬中断里跑 30μs 的 TCP 协议处理，时钟中断、磁盘中断全部被阻塞。
>
> 所以 backlog 本质上是一个**上下文切换点**，不是 performance hack，而是硬中断不可延迟/不可抢占的约束下，唯一的正确做法。NIC 驱动把 skb 从 DMA Ring 捞出来往 backlog 一塞就返回（<1μs），后面的事交给可被抢占的软中断去慢慢做。

```bash
收包中断处理的分工：
┌─────────────── 硬中断上下文 (top half) ───────────────┐
│  ① 确认中断   ② skb → sk_backlog   ③ 调度软中断       │
│  耗时: < 1μs                                         │
└──────────────────────┬───────────────────────────────┘
                       ↓
┌────────────── 软中断上下文 (bottom half) ──────────────┐
│  从 backlog 取 skb → TCP 协议处理 → 放入 receive_queue │
│  耗时: 可达几十 μs                                     │
└──────────────────────────────────────────────────────┘
```

> **一句话**：`backlog` 是一个**瞬态**队列——skb 在这里停留的时间极短（从硬中断结束到软中断开始处理），正常情况下你很难观察到 backlog 里有积压。只有当软中断处理不过来（如 CPU 被其他软中断占满），backlog 才会堆积。

#### 2.3 三个队列的关系总结

```plantuml
@startuml
skinparam shadowing false
title 三个队列的数据流向（接收端视角）

rectangle "应用层" <<app>> as app {
  rectangle "read()" as r
  rectangle "write()" as w
}

rectangle "内核协议栈" <<ks>> as ks {
  rectangle "receive_queue\n(持久, 等read)" as rq
  rectangle "backlog\n(瞬态, 交接区)" as bl
  rectangle "write_queue\n(持久, 等ACK)" as wq
}

rectangle "网卡" as nic

nic -down-> bl : 硬中断倒入
bl -down-> rq : 软中断处理后放入
rq -down-> r : read()消费
w -down-> wq : write()创建skb
wq -down-> nic : TCP发送引擎发出\n(保留到ACK)

note bottom of ks
  **核心区别**
  · backlog: 瞬态, 硬→软中断交接, 停留极短
  · receive_queue: 持久, 等应用read(), 满了→窗口=0
  · write_queue: 持久, 等对端ACK, 满了→write()阻塞
end note

@enduml
```

| 队列 | 生命周期 | 谁写入 | 谁取出 | 最大长度 | 满了怎么办 |
|------|---------|--------|--------|---------|-----------|
| `backlog` | 瞬态（μs级） | 硬中断 | 软中断(NET_RX_SOFTIRQ) | `netdev_max_backlog`(默认1000) | 丢包 |
| `receive_queue` | 持久（ms~s级） | 软中断(TCP处理后) | 应用 `read()` | `tcp_rmem[2]`(默认~6MB) | 通告窗口=0 |
| `write_queue` | 持久（ms~s级） | 应用 `write()` | TCP发送引擎(发后等ACK) | `tcp_wmem[2]`(默认~64KB) | `write()`阻塞/EAGAIN |

#### 2.4 `ss -tlp` 看到的 Recv-Q / Send-Q 是不是这两个链表？

**是的，但有区分**——`ss` 展示的值取决于 socket 是 LISTEN 还是 ESTABLISHED：

| socket 状态 | Recv-Q 含义 | Send-Q 含义 |
|-------------|------------|------------|
| **LISTEN** | Accept 队列当前长度（已 EST 但未 accept 的数量） | backlog 值（Accept 队列最大容量） |
| **ESTABLISHED** | `receive_queue` 中尚未 `read()` 的字节数 | `write_queue` 中尚未 ACK 的字节数 |

```bash
# 示例输出解读
$ ss -tlnp | grep 8080
LISTEN  0  128  0.0.0.0:8080
        ↑   ↑
       Recv-Q=0            Send-Q=128 (backlog)
       (Accept队列空)       (最多128个已完成连接排队)

$ ss -tnp | grep ESTAB
ESTAB  8192  0  192.168.1.5:8080  192.168.1.8:54321
        ↑    ↑
     Recv-Q=8192           Send-Q=0
     (有8KB数据在           (所有发送数据已ACK)
      receive_queue等read)
```

> **关键认知**：`ss` 对 LISTEN socket 展示的是**连接个数**（Accept 队列），对 ESTABLISHED socket 展示的是**字节数**（receive_queue / write_queue）。两者用的是同一个内核字段 `sk->sk_ack_backlog` / `sk->sk_wmem_queued` 等，但语义完全不同。

**排查技巧**：

| 现象 | 含义 | 排查方向 |
|------|------|---------|
| LISTEN 的 Recv-Q 接近 Send-Q | Accept 队列即将满，应用 `accept()` 太慢 | 加大 backlog / 优化 accept 速度 / 上多进程 |
| ESTAB 的 Recv-Q 持续增长 | 应用 `read()` 太慢，数据堆积在 receive_queue | 优化业务逻辑 / 上 epoll / 加大 `tcp_rmem` |
| ESTAB 的 Send-Q 持续增长 | 对端 ACK 太慢（网络或对端问题），write_queue 堆积 | 查 RTT / 丢包率 / 对端 `read()` 是否慢 |
| Recv-Q 和 Send-Q 都高 | 双向拥塞——两端都可能有问题 | `ss -ti` 看 `cwnd`/`rwnd`/`rtt` 定位 |

### 三、sk_buff 的内存管理：线性区 vs 分页区

`sk_buff` 的数据存储分两层：

```bash
sk_buff
├── 线性数据区 (linear data): head ~ end
│    用 kmalloc 分配，适用于小数据（≤ 一个页面）
│    包的头信息（MAC / IP / TCP）肯定在线性区
│    data / tail 指针描述的"窗口"在线性区内
│
└── 分页数据区 (paged / non-linear data):
     用 page 结构分配（alloc_page）
     适用于大数据（> MTU），如 sendfile() 零拷贝
     frags[] 数组描述每个分页
```

```plantuml
@startuml
skinparam shadowing false
title sk_buff 的 linear + paged 数据模型

rectangle "sk_buff" {
  rectangle "线性区 (kmalloc)" as linear {
    rectangle "MAC头 14B | IP头 20B | TCP头 20B" as linear_pkt
  }
  rectangle "分页区 (alloc_page)" as paged {
    rectangle "页 0 : 4096字节 payload" as page0
    rectangle "页 1 : 4096字节 payload" as page1
    rectangle "页 2 : 2048字节 payload" as page2
  }
}

note right of linear
  len = 54 (头)
  data_len = 0 (无分页)
  truesize ≈ sizeof(skb) + 54
  适用于：小数据，如 HTTP 请求、ack 段
end note

note right of paged
  len = 54 (线性) + 10240 (分页)
  data_len = 10240
  truesize ≈ sizeof(skb) + 54 + 12288 (3页)
  适用于：sendfile() 大文件传输
  优势：TCP 重传时不重新拷贝！
end note

@enduml
```

**分页区的工程意义**：

1. **零拷贝发送（`sendfile()`）**：文件页直接挂到 skb 的 `frags[]`，不经过用户态缓冲区。这是 Nginx 高性能的基石之一
2. **TCP 重传无需重读磁盘**：分页引用的是 page cache 中的页，重传时直接重发
3. **`truesize` 与实际负载的关系**：内核用 `truesize` 做内存会计（决定是否收缩窗口），不是看 `len`

---

## 深入论述三：MSS/MTU、Nagle 算法、cwnd 拥塞窗口

这三个概念在上一节的"网络传输层面"已初步提及，但它们对 TCP 性能的影响远比表面看起来复杂——特别是**三者之间的相互作用**，是理解 TCP 延迟和吞吐量的关键。

**术语速查**：

| 缩写 | 全称 | 一句话定义 | 在哪层生效 |
|------|------|-----------|-----------|
| **MTU** | Maximum Transmission Unit | 链路层单帧最大载荷（以太网默认 1500 字节） | L2 链路层 |
| **MSS** | Maximum Segment Size | TCP 单段最大载荷，`MSS = MTU - 40`（IP头20 + TCP头20），三次握手协商 | L4 TCP 层 |
| **PMTUD** | Path MTU Discovery | 通过 ICMP "Frag Needed" 发现路径上最小 MTU | L3 IP 层 |
| **Nagle** | Nagle 算法 (RFC 896) | 合并小 `write()`：任一时刻最多一个未确认小段 | L4 TCP 层 |
| **cwnd** | Congestion Window | 发送端维护的拥塞窗口，限制在途数据量 | L4 TCP 发送端 |
| **rwnd** | Receive Window | 接收端通告的接收窗口，`read()` 慢 → rwnd 小 | L4 TCP 接收端 |
| **ssthresh** | Slow Start Threshold | 慢启动阈值，cwnd 超过它后进入拥塞避免 | L4 TCP 发送端 |
| **RTT** | Round Trip Time | 数据从发送到收到 ACK 的往返时间 | 贯穿 L2-L4 |
| **BDP** | Bandwidth-Delay Product | 带宽×延迟积，理论上能填满管道的在途数据量 | 端到端 |

> **核心关系**：`MSS` 决定每段大小 → `Nagle` 决定何时合并小段 → `cwnd` 决定能发多少段 → 实际吞吐量 = `min(cwnd, rwnd) / RTT`。四个参数像串行的阀门，任何一个关小了，整条连接就被限速。

### 一、MSS/MTU —— 为什么"不要自己拼大包"

MSS 决定了 TCP 单段能装多少有效载荷，MTU 决定了 IP 层单帧能承载多少字节。两者之间差了一个固定开销（IP 头 20 + TCP 头 20 = 40 字节）。MSS 在三次握手中协商确定，**一旦确定，整个连接期间不再改变**——这意味着如果路径中间存在 MTU 更小的链路，就需要 PMTUD 来自动发现，否则会产生 IP 分片。

下面这张时序图展示了 MSS 如何在 SYN/SYN+ACK 中完成协商：

```plantuml
@startuml
skinparam shadowing false
title MSS 的决定过程：三次握手中的协商

participant "客户端" as client
participant "网络路径" as path
participant "服务端" as server

client -> server : SYN (MSS=1460, wscale=7, SACK-permitted)
note right of server
  服务端收到 SYN 后决定 MSS:
  MSS = min(peer_MSS, 本端网卡MTU-40, 路由MTU-40)
       = min(1460, 1500-40, ...)
       = 1460
end note
server -> client : SYN+ACK (MSS=1460, wscale=7, SACK-permitted)

note over client, server
  **整个连接期间 MSS=1460，不再改变**
  如果路径中间有更小的 MTU:
  - PMTUD (Path MTU Discovery) 通过 ICMP "Frag Needed" 触发
  - 但很多网络封 ICMP → PMTUD 失效 → 丢包/超时
end note

@enduml
```

**MSS 的六个决定因素**：

| 序号 | 因素 | 说明 |
|:---:|------|------|
| 1 | 对端通告的 MSS | 三次握手中 SYN 包的 MSS 选项 |
| 2 | 本端网卡 MTU | `MTU - 40`（IP 头 20 + TCP 头 20） |
| 3 | 路径 MTU (PMTU) | PMTUD 发现的最小中间链路 MTU |
| 4 | `net.ipv4.tcp_base_mss` | 内核的默认值，当没有 MSS 选项时使用（通常 512） |
| 5 | `advmss`（路由表） | `ip route` 可设置每条路由的 MSS 上限 |
| 6 | TCP 选项占用 | 时间戳选项（12 字节）、SACK 选项等会挤占有效载荷 |

**为什么 MSS 很重要？——分片的代价**

| 分片情况 | 发生位置 | 对端收到 | 代价 |
|---------|---------|---------|------|
| IP 分片 | IP 层 | 两个 IP fragment（需重组） | **丢任意一片 = 整个包重传** |
| TCP 分段 | TCP 层 | 两个独立 TCP 段 | 丢了只重传丢的那个 |

> **核心原则**：永远让 TCP 自己去分段（基于 MSS），不要让 IP 层做分片。IP 分片没有重传机制——丢一片整个 IP 包作废。

### 二、Nagle 算法 —— 减少小包但引入延迟

**算法定义**（RFC 896，John Nagle 1984）：

```bash
在一条 TCP 连接上，任何时刻最多只能有一个未确认的小段（< MSS）。
如果这个小段还没有被确认，就不能发下一个。
```

下面这张序列图对比了**启用 Nagle**（默认）和**禁用 Nagle**（`TCP_NODELAY`）两种情况下，连续三次 1 字节 `write()` 的发送效果——注意合并行为如何影响接收端 `read()` 拿到的内容：

```plantuml
@startuml
skinparam shadowing false
title Nagle 算法对连续小 write() 的合并效果

participant "应用层 write A" as w1
participant "应用层 write B" as w2
participant "应用层 write C" as w3
participant "TCP 栈" as tcp
participant "网络" as net
participant "接收端" as recv

== 启用 Nagle（默认） ==

w1 -> tcp : 写入 "A" (1字节)
tcp -> net : 段1: "A" (1字节) — 小段，等待 ACK
note right of tcp : 发送窗口未满\n但不能发下一个小段
w2 -> tcp : 写入 "B" (1字节)
tcp -> tcp : **暂存** (Nagle)
w3 -> tcp : 写入 "C" (1字节)
tcp -> tcp : **暂存** (Nagle)
net --> tcp : ACK for 段1
tcp -> net : 段2: "BC" (2字节) — 合并两个小段
net --> recv : "A" → "BC"
note right of recv : read("A") → read("BC")\n这就是"read() 拿到合并内容"的原因

== 禁用 Nagle (TCP_NODELAY) ==

w1 -> tcp : 写入 "A" (1字节)
tcp -> net : 段1: "A" (1字节)
w2 -> tcp : 写入 "B" (1字节)
tcp -> net : 段2: "B" (1字节)
w3 -> tcp : 写入 "C" (1字节)
tcp -> net : 段3: "C" (1字节)
note right of net : 3个TCP段 = 120字节头\n只为传3字节数据\n(头开销 97.5%)

@enduml
```

**Nagle 的适用性判断**：

| 场景 | Nagle 是否合适 | 原因 |
|------|:---:|------|
| 大块数据传输（文件下载） | ✅ 保持开启 | 数据块接近 MSS，Nagle 几乎不触发 |
| 交互式 SSH/Telnet | ❌ 通常关掉 | 每次按键 1 字节，Nagle 引入 40ms 延迟（等 ACK） |
| HTTP 请求-响应 | ✅ 通常 OK | 写完整个请求后等响应，不连续写 |
| WebSocket / 游戏协议 | ❌ 关掉 | 频繁小消息，Nagle 延迟不可接受 |
| **当前 Day 1 Echo** | ⚠️ 影响不大 | 一次 read + 一次 write，不等 ACK 就 close() |

> **Nagle 与 Delayed ACK 的灾难性交互**：Delayed ACK（延迟 40ms 等数据合并 ACK） + Nagle（等 ACK 才能发下一个小段）= 死锁等待 40ms。这是为什么很多低延迟应用同时设置 `TCP_NODELAY` + `TCP_QUICKACK`。

### 三、cwnd（拥塞窗口）与 rwnd（接收窗口）—— TCP 吞吐的双窗口控制模型

TCP 发送端实际上受**两种窗口**的共同约束——cwnd（拥塞窗口）和 rwnd（接收窗口）。`min(cwnd, rwnd)` 决定了真正允许的"在途数据量"（bytes in flight），进而通过 RTT 决定了吞吐量上限。

#### 3.1 双窗口递进序列图：cwnd 与 rwnd 在多轮 RTT 中的协同变化

下面这个序列图是本节最重要的一张图。它从冷启动（TCP 连接刚建立）开始，跟踪**六轮 RTT** 中发送端的 cwnd 增长、接收端 rwnd 的通告变化，以及两者如何共同决定"每轮实际能发多少数据"。

**假设条件**：RTT=10ms，MSS=1460 字节，初始 cwnd=10 MSS（RFC 6928），接收缓冲区=128KB，窗口缩放因子 wscale=7（即窗口值需左移 7 位），应用层每 RTT 调用一次 `read(fd, buf, 8192)`。

```plantuml
@startuml
skinparam shadowing false
skinparam participant {
  BackgroundColor<<send>> #BBDEFB
  BorderColor<<send>> #1565C0
  BackgroundColor<<recv>> #C8E6C9
  BorderColor<<recv>> #2E7D32
}
title TCP 双窗口递进（一）：RTT 0-2，cwnd 主导

participant "发送方" as S <<send>>
participant "网络" as NET
participant "接收方" as R <<recv>>

== RTT 0：初始 ==

note over S
  cwnd=10MSS≈14.6KB
  ssthresh≈64MB
  inflight=0
  有效窗口=14.6KB
end note

note over R
  buf_free=128KB
  rwnd≈128KB(wscale=7)
end note

== RTT 1：慢启动 ==

S -> NET: ① 发10段(14.6KB)
NET -> R: ② 到达
note over R
  buf_free:128→113KB
  read取8K→121KB
end note
R -> NET: ③ 10ACK,rwnd≈121K
NET -> S: ④ ACK抵达

note over S
  每ACK→cwnd+1MSS
  cwnd:10→20MSS≈29.2KB
  有效=min(29.2,121)=29.2K
  (cwnd是瓶颈)
end note

== RTT 2：cwnd翻倍 ==

S -> NET: ⑤ 发20段(29.2KB)
NET -> R: ⑥ 到达
note over R
  buf_free:121→100KB
end note
R -> NET: ⑦ 20ACK,rwnd≈100K
NET -> S: ⑧ ACK抵达

note over S
  cwnd:20→40MSS≈58.4KB
  有效=min(58.4,100)=58.4K
end note

@enduml
```

**图一解读——cwnd 主导阶段（RTT 0-2）**：

这是连接建立后的前两轮往返周期。阅读时关注三个关键变化：

1. **发送端的 cwnd 指数增长**：每收到一个 ACK，cwnd+1 MSS。RTT 1 结束后 cwnd 从 10→20 MSS，RTT 2 结束后 20→40 MSS。这是慢启动的经典行为——窗口每个 RTT 翻倍。
2. **接收端 rwnd 缓缓下降**：每轮到达的数据（14.6KB → 29.2KB）开始蚕食 128KB 的接收缓冲区，虽然 `read()` 每轮取走 8KB，但到达速度更快→缓冲区净减少。
3. **cwnd 是瓶颈**：此时 `min(cwnd, rwnd)` 的结果是 cwnd。接收端说"我可以收 121KB"，但发送端因为慢启动的限制只敢发 14.6KB→29.2KB。**发送端的保守发送恰恰让接收端很从容。**

> **关键认知**：RTT 1-2 里 cwnd 远小于 rwnd 是正常状态——TCP 设计上就是让连接的初始阶段由发送端的"自律"来控制节奏。这个阶段吞吐量增长飞快（翻倍），但没有超过接收端的处理能力。

**一个经典误解——"慢启动"到底慢在哪里？**

指数增长，每轮 RTT 翻倍——这看起来一点也不慢，为什么叫"慢启动"？这个问题困惑了很多开发者，答案需要从两个层次理解：

**层次一：慢的不是增长率，是起点。**

```bash
对比：如果 TCP 一上来就"敞开发"
├─ 假设：BDP = 10MB（带宽 1Gbps × RTT 80ms）
├─ 如果 TCP 没有慢启动，连接一建立就发 10MB
│  └─ 路由器缓冲区瞬间爆满 → 大量丢包 → 全局 TCP 同步震荡
│
├─ 有了慢启动：
│  RTT 0:  cwnd = 10 MSS = 14.6KB    ← 起点极小
│  RTT 1:  cwnd = 20 MSS = 29.2KB
│  RTT 2:  cwnd = 40 MSS = 58.4KB
│  RTT 3:  cwnd = 80 MSS = 116.8KB
│  ...
│  要经过 log₂(BDP/init_cwnd) = log₂(10MB/14.6KB) ≈ 9.5 个 RTT 才能填满管道
│  └─ 从"一根头发丝"开始，逐轮翻倍，这叫"慢"
```

"慢"是相对于**管道容量**而言的：TCP 花了好几轮 RTT 才把 cwnd 从一根头发丝涨到能填满管道。对于小文件传输（HTTP 请求只需要 1-2 个 RTT 就传完了），慢启动可能整个传输过程中 cwnd 都没涨到 BDP——这就是为什么短连接的吞吐量利用率远低于长连接。

**层次二：不慢的话，互联网早就崩了。**

1986 年，Van Jacobson 观察到互联网第一次"拥塞崩溃"（congestion collapse）：吞吐量从 32Kbps 跌到 40bps，下降了 1000 倍。原因是 TCP 没有拥塞控制——连接一建立就全力发，路由器丢包后重传、再丢包再重传，网络被重传流量填满，有效数据几乎为零。

慢启动（Slow Start, 1988）就是为了解决这个问题：**连接刚建立时，发送端不知道网络容量是多少**，所以从最小值开始探测，指数增长快速逼近真实容量。注意 Jacobon 的命名逻辑——"slow"是相对于之前的"一股脑全发"而言的，而不是说算法增长慢。实际上指数增长是 TCP 能找到的最快探测方式了。

> **一句话**：慢启动的"慢"是相对于 BDP 管道的绝对容量而言的——从 14.6KB 起步，每个 RTT 翻倍，要翻 10 轮才能填满 10MB 的管道。但对于刚建立的连接而言，这是在不炸掉网络的前提下**最快的**容量探测方式。没有慢启动，1986 年的互联网崩溃就是代价。

```plantuml
@startuml
skinparam shadowing false
skinparam participant {
  BackgroundColor<<send>> #BBDEFB
  BorderColor<<send>> #1565C0
  BackgroundColor<<recv>> #C8E6C9
  BorderColor<<recv>> #2E7D32
}
title TCP 双窗口递进（二）：RTT 3-4，瓶颈转向

participant "发送方" as S <<send>>
participant "网络" as NET
participant "接收方" as R <<recv>>

== RTT 3：cwnd逼近rwnd ==

S -> NET: ⑨ 发40段(58.4KB)
NET -> R: ⑩ 到达
note over R
  buf_free≈50KB
  缓冲区开始吃紧！
end note
R -> NET: ⑪ 40ACK,rwnd≈50K
NET -> S: ⑫ ACK抵达

note over S
  cwnd:40→80MSS≈116.8KB
  有效=min(116.8,50)=50K
  *** rwnd变成瓶颈！***
end note

== RTT 4：rwnd成为瓶颈 ==

S -> NET: ⑬ 发≈34段(50KB)
note right of S
  即使cwnd=116.8K
  实际受rwnd=50K限制
end note
NET -> R: ⑭ 到达
note over R
  buf_free:50→8KB
  read跟不上到达速度！
end note
R -> NET: ⑮ 34ACK,rwnd≈8K
NET -> S: ⑯ ACK抵达

note over S
  cwnd:80→114MSS≈166.4K
  有效=min(166.4,8)=8K
  cwnd增长已无意义
end note

@enduml
```

**图二解读——临界点与瓶颈转移（RTT 3-4）**：

这两轮是整个故事的转折点，注意两个质变：

1. **RTT 3：cwnd 历史上第一次超过了 rwnd**。cwnd 翻到 80 MSS（116.8KB），但接收端通告的 rwnd 只有 50KB。`min(116.8, 50)` = 50KB——**发送端第一次不是因为自己"不敢发"，而是因为接收端说"只能收这么多"**。这个瞬间标志着控制权从发送端向接收端转移。
2. **RTT 4：rwnd 崩溃式缩小**。RTT 3 只发了 50KB，RTT 4 中接收端缓冲区从 50KB 跌到 8KB。原因很简单——`read()` 每轮只取 8KB，但每轮到达 50KB+。净流失 42KB/轮。接收端已经撑不住了，通告 rwnd=8KB。
3. **cwnd 的尴尬**：RTT 4 结束后 cwnd=114 MSS（166.4KB）——很漂亮，但毫无意义。实际能发的只有 `min(166.4, 8)` = 8KB。cwnd 的数字正在变成摆设。

> **关键认知**：从 RTT 3 开始，问题已经不是"网络能不能承载更多数据"，而是"接收端能不能消化它已有的数据"。这是 Day 1 单进程 Echo 的核心瓶颈——一个 `read()` 卡住，所有数据堵在接收缓冲区里，rwnd 归零，发送端停摆。

```plantuml
@startuml
skinparam shadowing false
skinparam participant {
  BackgroundColor<<send>> #BBDEFB
  BorderColor<<send>> #1565C0
  BackgroundColor<<recv>> #C8E6C9
  BorderColor<<recv>> #2E7D32
}
title TCP 双窗口递进（三）：RTT 5+，rwnd主导稳态

participant "发送方" as S <<send>>
participant "网络" as NET
participant "接收方" as R <<recv>>

== RTT 5：零窗口逼近 ==

S -> NET: ⑰ 发≈5段(8KB)
NET -> R: ⑱ 到达
note over R
  buf_free≈8KB(满的边缘)
  吞吐=应用层读速度
end note
R -> NET: ⑲ 5ACK,rwnd≈8K
NET -> S: ⑳ ACK抵达

note over S
  cwnd已非常大但无意义
  有效窗口被rwnd压在8KB
  RTT=10ms→吞吐≤800KB/s
  **即使10Gbps网络也一样**
end note

== RTT 6+：稳态总结 ==

note over S, R
  **稳态特征**
  · 发送:inflight≤rwnd
  · 接收:buf在满边缘波动
  · 吞吐=rwnd/RTT

  **瓶颈转移三阶段**
  1. RTT 1-2: cwnd瓶颈(慢启动)
  2. RTT 3:   临界(cwnd≈rwnd)
  3. RTT 4+:   rwnd瓶颈(读太慢)

  **TCP双窗口设计意义**
  · 网络好+应用快→cwnd决定
  · 网络好+应用慢→rwnd决定
  · 网络差+应用快→cwnd决定
end note

@enduml
```

**序列图核心要点**：

| RTT | cwnd (KB) | rwnd 通告 (KB) | 有效窗口 (KB) | 瓶颈在谁？ | 关键事件 |
|:---:|:---:|:---:|:---:|:---:|------|
| 1 | 14.6 → 29.2 | 121 | 29.2 | cwnd | 慢启动，窗口翻倍 |
| 2 | 29.2 → 58.4 | 100 | 58.4 | cwnd | 慢启动继续 |
| 3 | 58.4 → 116.8 | 50 | 50 | **rwnd 开始成为瓶颈** | **临界点！** cwnd > rwnd |
| 4 | 116.8 → 166.4 | 8 | 8 | rwnd | rwnd 严重不足 |
| 5 | 166.4+ | 8 | 8 | rwnd（锁定） | 吞吐被应用层读速度锁死 |
| 6+ | 持续增长 | ~8 | ~8 | rwnd | 稳态：rwnd / RTT |

> **核心洞察**：前两轮 RTT 中，cwnd 决定了吞吐量（慢启动阶段，窗口尚小）。但从 RTT 3 开始，rwnd 反超成为瓶颈——不是因为网络拥塞，而是因为**应用层 `read()` 速度跟不上数据到达的速度**。TCP 的双窗口模型保证了一条腿断了另一条腿还能撑住——但撑住的上限由那条更短的腿决定。

#### 3.2 两种窗口的本质区别

cwnd 和 rwnd 虽然都叫"窗口"，但创建者、控制逻辑、和目的完全不同：

| 维度 | cwnd（拥塞窗口） | rwnd（接收窗口） |
|------|-----------------|-----------------|
| **谁维护** | 发送端（内部变量，不对端不可见） | 接收端（通过 ACK 的 Window 字段告知对端） |
| **目的** | 防止发送端注入过多数据 → 网络拥塞 | 防止接收端缓冲区溢出 → 数据丢失 |
| **初始值** | 10 MSS (RFC 6928) | 接收缓冲区大小（受 `sk_rcvbuf` 控制） |
| **变化触发** | 收到 ACK（增长） / 丢包（缩减） | 缓冲区空间变化（数据到达减少，`read()` 释放增加） |
| **增长方式** | 慢启动：指数；拥塞避免：线性 | 随 `read()` 释放空间即时恢复 |
| **缩减方式** | 丢包 → cwnd /= 2（超时 → cwnd = 1） | 数据到达 → rwnd 减少（等 `read()` 释放再恢复） |
| **最坏情况** | cwnd = 1 MSS（超时重传后重新开始） | rwnd = 0（零窗口，发送端完全停止） |
| **对端可见？** | 否（发送端私有） | 是（每个 ACK 都携带） |
| **内核参数** | `tcp_init_cwnd`, `tcp_congestion_control` | `net.core.rmem_default/max`, `net.ipv4.tcp_rmem` |

> **关键区别**：cwnd 是发送端**单方面**做的"自律"——它在没有外部指令的情况下，自己决定"我不发那么快，怕网络受不了"。rwnd 是接收端**主动通告**的"指令"——"我这里只有这么多空位了，你别发了"。两者合在一起：发送端自己能发多少自己说了算（cwnd），但对面说停就得停（rwnd）。

#### 3.3 rwnd 的生成机制：从内核缓冲区到 ACK 窗口字段

rwnd 不是凭空出现的数字，它来源于接收端内核中 `tcp_sock` 维护的接收缓冲区：

```plantuml
@startuml
skinparam shadowing false
title rwnd 的生成路径：从内核 recv_buf 到 ACK 窗口字段

rectangle "接收端内核" as kernel {
  rectangle "tcp_sock" as tcpsk {
    rectangle "sk_rcvbuf = 128KB\n(可通过 SO_RCVBUF 设置)" as rcvbuf_param
  }
  rectangle "sk_receive_queue\n(已接收、等待 read() 的 skb 链表)" as rcvq {
    rectangle "skb #1: 1460B" as s1
    rectangle "skb #2: 1460B" as s2
    rectangle "skb #3: 1460B" as s3
    rectangle "..." as smore
  }
  rectangle "rwnd 计算" as calc {
    rectangle "rwnd = sk_rcvbuf - sk_receive_queue 占用\n - tcp_mem 会计预留\n - 乱序窗口预留"
    rectangle "再应用 wscale 缩放:\nACK.window = rwnd >> wscale"
  }
}

rectangle "ACK 包" as ack {
  rectangle "TCP Header" as tcp_hdr {
    rectangle "Window Size: encoded_val"
  }
}

rcvq --> calc
rcvbuf_param --> calc
calc --> ack

note bottom of calc
  **重要**：rwnd 通告的不是"剩余空间"
  而是"接收窗口右边界 - 已确认字节"
  TCP 规范要求窗口不能"退缩"（shrinking）
  → 内核必须保证新通告的 rwnd
  不小于旧值（除非右边界推进）
end note

@enduml
```

**rwnd 的三个决定因素**：

| 因素 | 内核参数 | 说明 |
|------|---------|------|
| 总缓冲区大小 | `net.ipv4.tcp_rmem[2]`（default 默认值，max 最大值），`SO_RCVBUF`（per-socket） | 决定了 rwnd 的上限 |
| 已占用空间 | `sk_receive_queue` 中所有 skb 的 `truesize` 总和 | 已到达但未被 `read()` 取走的数据 |
| 窗口缩放因子 | 三次握手协商的 `wscale`（0~14） | rwnd_actual = ACK.window × 2^wscale |

**rwnd 的动态变化节奏**：

```bash
数据到达 → recv_buf 占用增加 → rwnd 减小
                              ↑ 通告给发送端（下一个 ACK）
应用 read() → recv_buf 释放 → rwnd 增大
                              ↑ 通告给发送端（窗口更新）
```

**零窗口（rwnd=0）的处理**：当接收缓冲区满时，接收端通告 rwnd=0。发送端进入"零窗口探针"模式——每隔一段时间发一个 1 字节的探测段（window probe），检查接收端是否恢复了空间。这个机制防止了"接收端 read() 后通告了窗口更新但该 ACK 丢包 → 死锁"的情况。

#### 3.4 cwnd 的四个阶段（慢启动 → 拥塞避免 → 快速恢复 → 超时）

上面三张序列图展示了 cwnd 在正常情况下的增长轨迹（慢启动阶段）。但在实际网络中，cwnd 不是只涨不跌的——它随时可能因为丢包或超时而大幅回撤。TCP 将 cwnd 的生命周期划分为四个阶段，通过阶段之间的切换来适应网络状态的变化。

下图展示了这四个阶段以及它们之间的切换边界。理解这个状态机是理解 TCP 吞吐量波动的关键——任何一次丢包都可能触发阶段跳变，cwnd 可能从几百 KB 瞬间缩到 1 MSS，吞吐量也随之跳水。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<slow>> #FFE0B2
  BorderColor<<slow>> #EF6C00
  BackgroundColor<<ca>> #C8E6C9
  BorderColor<<ca>> #2E7D32
  BackgroundColor<<recovery>> #F3E5F5
  BorderColor<<recovery>> #7B1FA2
}
title TCP 拥塞窗口 (cwnd) 的四个阶段

rectangle "1. 慢启动 (Slow Start)" <<slow>> as ss {
  rectangle "cwnd = 1 MSS → 2 → 4 → 8 → 16 → 32 ..." as ss_track
  note right of ss
    指数增长，每收到一个 ACK 加 1 MSS
    直到 cwnd >= ssthresh (慢启动阈值)
    初始 cwnd = 10 MSS (RFC 6928, 原 2~4)
  end note
}

rectangle "2. 拥塞避免 (Congestion Avoidance)" <<ca>> as ca {
  rectangle "cwnd 线性增长 ≈ 每 RTT 加 1 MSS" as ca_track
  note right of ca
    保守增长，每收到一个 ACK:
    cwnd += MSS * MSS / cwnd
    即每 RTT 只增加一个 MSS
  end note
}

rectangle "3. 快速重传/恢复" <<recovery>> as recovery {
  rectangle "收到 3 个 dup ACK → 重传 → cwnd /= 2 → 快速恢复 → 线性增长" as recovery_track
  note right of recovery
    不会降到慢启动 (不归零)
    cwnd = ssthresh + 3*MSS
    比超时重传的代价小得多
  end note
}

rectangle "4. 超时重传 (RTO)" as rto {
  rectangle "超时 → cwnd = 1 MSS → 重新慢启动" as rto_track
  note right of rto
    最严重的惩罚
    说明网络严重拥塞
  end note
}

ss --> ca : cwnd ≥ ssthresh
ca --> recovery : 3 dup ACK
recovery --> ca : 恢复完成
ca --> rto : RTO 超时
rto --> ss : 重新开始

@enduml
```

**四个阶段的切换边界**：

| 阶段 | 增长率 | 触发条件 | 退出条件 | 典型 cwnd 范围 |
|------|--------|---------|---------|-------------|
| 慢启动 | 指数：每 ACK +1 MSS | 连接建立 / RTO 超时后 | cwnd ≥ ssthresh 或丢包 | 10 MSS → ssthresh |
| 拥塞避免 | 线性：每 RTT +1 MSS | cwnd ≥ ssthresh | 丢包（3 dup ACK 或 RTO） | ssthresh → BDP |
| 快速恢复 | cwnd = ssthresh + 3 | 3 dup ACK | 该丢包的 ACK 到达 | ssthresh 附近 |
| 超时重传 | cwnd = 1 MSS | RTO 到期 | 慢启动重新开始 | 1 MSS |

**拥塞窗口里有"指数退避"吗？——没有，指数退避在 RTO 定时器上。**

这个区分很重要，因为"指数退避"和"cwnd 回退"作用在不同层面，容易混淆：

```bash
RTO = 指数退避（定时器层面）
├─ 第一次超时：RTO = 1s，重传，cwnd = 1
├─ 又超时：    RTO = 2s   ← 翻倍！
├─ 又超时：    RTO = 4s   ← 再翻倍！
├─ 又超时：    RTO = 8s
├─ ...
└─ 上限通常为 120s（Linux tcp_retries2 控制）
    → 这之所以叫"退避"，是因为连续失败时等待越来越久，给网络更多恢复时间

cwnd = 硬重置（拥塞控制层面）
├─ 无论第几次超时，cwnd 都直接归 1 MSS
├─ 不是说"第一次归 1，第二次归 0.5，第三次归 0.25..."
└─ cwnd 不做指数退避，它只做一刀切的重置
```

两个层面合在一起的效果是：连续丢包时，TCP **发得越来越少（cwnd=1）**，而且**等得越来越久（RTO 翻倍）**——双层惩罚叠加，给拥塞网络最大的恢复空间。但"指数退避"这个词严格属于 RTO 定时器行为，不属于 cwnd。

> **一句话**：拥塞窗口没有指数退避——超时后 cwnd 直接归 1，不会逐次递减。指数退避发生在 RTO 定时器上（1s→2s→4s→8s...），两者的共同作用让 TCP 在持续丢包时"发得少 + 等得久"。

#### 3.5 主流拥塞控制算法清单与对比

前面 3.1-3.4 都在讲"cwnd 怎么涨、怎么跌"——但**涨跌的具体规则取决于你用的是哪个拥塞控制算法**。不同的算法对丢包、RTT 变化的反应完全不同，选错算法的后果是：相同的网络条件下，吞吐量可以差出 10 倍。

**算法分类与对比**：

| 算法 | 年代 | 类型 | cwnd 增长方式 | 丢包反应 | 适合场景 | 不适合场景 |
|------|------|------|-------------|---------|---------|-----------|
| **Reno** | 1990 | 基于丢包 | 慢启动+拥塞避免(AIMD) | cwnd /= 2 | 低丢包率网络 | 高带宽、高延迟、有损网络 |
| **New Reno** | 1996 | 基于丢包 | 同 Reno + 快速恢复改进 | cwnd /= 2（改进了部分窗口多个丢包） | 低丢包率，单个窗口少量丢包 | 同上 |
| **CUBIC** | 2008 | 基于丢包 | 三次函数增长（非 AIMD 线性） | cwnd × 0.8 | **Linux 默认**，通用场景，长肥管道比 Reno 快 | 高丢包率（>1%）性能急剧下降 |
| **Vegas** | 1994 | 基于延迟 | 通过 RTT 变化预测拥塞，提前降速 | 检测到 RTT 增大即降速 | 希望避免丢包（低延迟敏感场景） | 与丢包类算法竞争带宽时不公平 |
| **BBR** | 2016 | 基于 BDP | 周期性探测 BDP 和 RTT，不盲目涨 cwnd | 不依赖丢包信号 | **高丢包/长肥管道最优**（YouTube/Google 内部使用） | 会抢占 Reno/CUBIC 的带宽（不公平） |
| **BBRv2** | 2019 | 基于 BDP+丢包 | 同 BBR + 丢包作为辅助信号 | 丢包时也减速（比 BBR v1 更公平） | BBR 场景 + 需要与丢包类算法共存 | 实现较新，旧内核不支持 |

**算法的核心分歧——用什么信号判断拥塞？**

```bash
基于丢包（Loss-based）：Reno, New Reno, CUBIC
├─ 逻辑："丢包 = 拥塞"
├─ 行为：一直加速直到丢包，丢包后再减速
├─ 问题：在有损链路（WiFi/移动网络）上，随机丢包被误判为拥塞
│        → 明明网络不堵，但因为丢包率1%，cwnd被反复砍半
└─ 本质：需要"先撞墙"才知道墙在哪

基于延迟/带宽（Delay/BW-based）：Vegas, BBR
├─ 逻辑："RTT 增大 = 缓冲区开始堆积 = 即将拥塞"
├─ 行为：在丢包发生之前就检测到拥塞信号，主动降速
├─ 优势：不需要用丢包来"试探"网络容量
└─ 问题：与丢包类算法共存时，主动让出带宽 → 被 CUBIC 挤占
```

> **一句话选型**：内网低丢包 → CUBIC 就行；跨公网/丢包率 >0.5% → BBR；WiFi/移动网络 → BBR 优势巨大。

**应用层如何控制和选择？**

拥塞控制算法有**三层控制粒度**，从粗到细：

```bash
第一层：系统级默认算法（重启后仍生效）
# 查看当前默认算法
$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic

# 查看内核支持哪些算法
$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr

# 修改系统默认
# sysctl -w net.ipv4.tcp_congestion_control=bbr

# 如果没有 bbr，先加载模块
# modprobe tcp_bbr

第二层：per-socket 设置（应用层代码控制，最灵活）
int fd = socket(AF_INET, SOCK_STREAM, 0);

// 在 connect() 之前或之后都可以设置
const char *algo = "bbr";
socklen_t len = strlen(algo);
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, algo, len);

// 读取当前连接使用的算法
char current[16];
socklen_t optlen = sizeof(current);
getsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, current, &optlen);
printf("current cc: %s\n", current);  // "bbr"

第三层：不同的连接用不同的算法
// 举例：一个服务同时维护两种连接
// - 文件下载连接 → BBR（大块传输，需要带宽）
// - 实时控制连接 → CDG 或 Vegas（延迟敏感，避免排队）
// 每个 accept() 得到的 fd 可以独立 setsockopt
```

> **工程实践要点**：
>
> 1. `TCP_CONGESTION` 可以在连接建立后、数据传输过程中动态切换——内核会平滑过渡 cwnd。但不要在每次 `read()`/`write()` 之后都切，切换本身有一定开销。
> 2. 用 `ss -ti` 确认当前连接的算法和 cwnd 值：`ss -ti | grep -E 'cubic|bbr|cwnd'`
> 3. BBR 需要内核 ≥ 4.9，BBRv2 需要内核 ≥ 5.x。容器环境中注意宿主机内核版本。
> 4. 混合部署 CUBIC 和 BBR 时，BBR 可能会抢占 CUBIC 的带宽——如果公平性重要，统一算法或都上 BBRv2。

#### 3.6 双窗口协同的四种典型场景

| 场景 | cwnd vs rwnd | 实际限制 | 典型例子 | 优化方向 |
|------|:---:|------|------|------|
| **网络拥塞限制** | cwnd << rwnd | cwnd | 跨公网传输、丢包率高 | 换拥塞控制算法（BBR）、降低延迟 |
| **接收端限制** | rwnd << cwnd | rwnd | 应用层 read() 太慢、recv_buf 太小 | 调大 `tcp_rmem`、优化应用读速度 |
| **带宽延迟积限制** | cwnd ≈ BDP | BDP | 长肥管道（跨洋链路） | 增大 `tcp_init_cwnd`、调大 `tcp_wmem` |
| **零窗口悬挂** | rwnd ≈ 0 | rwnd（≈0） | 接收端进程挂死、GC 暂停 | 打开 window probe 日志、应用层心跳 |

**实际吞吐量的计算公式**：

```bash
吞吐量 = min(cwnd, rwnd) / RTT

当 cwnd 是瓶颈:
  吞吐量由拥塞控制算法决定
  需要在"多占带宽"和"不引发丢包"之间平衡

当 rwnd 是瓶颈:
  吞吐量 = recv_buf_free / RTT（≈ read()速度）
  **网络带宽再高也没用**
```

#### 3.7 为什么 cwnd 导致 `read()` 拿不到全部数据？

回到序列图 RTT 1-3 的场景——发送端有 500KB 数据待发送：

| RTT # | cwnd | rwnd (约) | 有效窗口 | 本轮可发 | 累计到达 | 一次 `read(8192)` 拿到 |
|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| 1 | 10 MSS | 128 KB | 14.6 KB | 14.6 KB | 14.6 KB | 8192 B（最多） |
| 2 | 20 MSS | 100 KB | 29.2 KB | 29.2 KB | 43.8 KB | 8192 B |
| 3 | 40 MSS | 50 KB | 50 KB | 50 KB | 93.8 KB | 8192 B |
| 4 | 80 MSS | 8 KB | 8 KB | 8 KB | 101.8 KB | 8192 B |
| 5 | 114 MSS | 8 KB | 8 KB | 8 KB | 109.8 KB | 8192 B |

> **结论**：`read()` 每次只能拿到 8192 字节（应用自己设的读取量上限），但发送端可能已经发了 500KB。这些数据**大部分在路上**（in flight）或者**还没发**（被 cwnd/rwnd 卡在发送端）。这就是"`read()` 返回量不确定"的最深层根源——前两个是 MSS 分段和 Nagle 合并，第三个是 cwnd 限制了在途数据量，第四个是 rwnd 因应用层读太慢而缩小。

#### 3.8 工程参数速查

| 参数 | 默认值 | 含义 | 增大后果 | 减小后果 |
|------|--------|------|---------|---------|
| `tcp_init_cwnd` | 10 MSS (~14KB) | 初始拥塞窗口 | 首次 RTT 多发数据，适合短连接（HTTP），但可能加重突发 | 慢启动更慢，小文件传输更慢 |
| `tcp_slow_start_after_idle` | 1（开启） | 空闲超时后重置 cwnd | — | 设为 0 保持 cwnd（长连接闲置再恢复时更快） |
| `tcp_congestion_control` | cubic | 拥塞控制算法 | — | bbr 在高丢包/长肥管道更优 |
| `tcp_rmem[2]` | 默认接收缓冲区 | rwnd 上限 | 更多数据缓存在内核，延迟 ACK 策略更有效 | rwnd 容易成为瓶颈 |
| `tcp_adv_win_scale` | 1（默认） | rwnd = buf / 2^adv_win_scale | 数值越小，rwnd 越接近 buf 总大小 | 预留给开销的空间增大，实际 rwnd 减小 |

> **调参警告**：增大 `tcp_init_cwnd` 或 `tcp_rmem` 之前，先理解当前瓶颈在哪。用 `ss -ti` 看 `cwnd` / `rwnd` / `rtt` 的实际值。盲目调参最常见的后果是：cwnd 调大了 → 网络拥塞加剧 → 丢包率上升 → 实际吞吐反而下降。

---

## 深入论述四：为什么 `read()` 返回的字节数有时比发送的少？

### 一、协议设计层面的根本原因

TCP 被设计为**字节流（Byte Stream）**协议，这个"流"字是理解一切的关键。

#### 1. TCP 字节流 vs UDP 数据报

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<tcp>> #BBDEFB
  BorderColor<<tcp>> #1565C0
  BackgroundColor<<udp>> #C8E6C9
  BorderColor<<udp>> #2E7D32
  BackgroundColor<<net>> #ECEFF1
  BorderColor<<net>> #78909C
}
title TCP 字节流 与 UDP 数据报 的根本区别

rectangle "TCP (字节流协议)" <<tcp>> as tcp {
  rectangle "发送端" as tcps {
    rectangle "write(AB)" as w1
    rectangle "write(CDE)" as w2
    rectangle "write(FGHI)" as w3
  }
  rectangle "网络传输" <<net>> as tcpn {
    note as tcpnote
      TCP段1: ABCDE
      TCP段2: FG
      TCP段3: HI
    end note
  }
  rectangle "接收端" as tcpr {
    rectangle "read->ABCD" as r1
    rectangle "read->EFG" as r2
    rectangle "read->HI" as r3
  }
}

rectangle "UDP (数据报协议)" <<udp>> as udp {
  rectangle "发送端" as udps {
    rectangle "sendto(AB)" as u1
    rectangle "sendto(CDE)" as u2
    rectangle "sendto(FGHI)" as u3
  }
  rectangle "网络传输" <<net>> as udpn {
    note as udpnote
      数据报1: AB
      数据报2: CDE
      数据报3: FGHI
    end note
  }
  rectangle "接收端" as udpr {
    rectangle "recvfrom->AB" as ur1
    rectangle "recvfrom->CDE" as ur2
    rectangle "recvfrom->FGHI" as ur3
  }
}

w1 --> tcpr
w2 --> tcpr
w3 --> tcpr
u1 --> udpr
u2 --> udpr
u3 --> udpr

note bottom of tcpr : 三次 write 被合并/拆分，read() 结果完全不可预测
note bottom of udpr : 每次 sendto 边界完整保留

@enduml
```

核心结论：**TCP 没有"消息"的概念**。无论应用层调用多少次 `write()`，TCP 只看到一条连续的字节流。接收端调用的 `read()` 从这条流里取数据，**取多少取决于当前缓冲区里有多少**，而非发送端当初"装了多少个包裹"。

#### 2. OSI 模型视角

| 协议层 | TCP 做的事 | 对 read() 的影响 |
|--------|-----------|-----------------|
| 应用层（L7） | 调用 `write(fd, buf, 8192)` | 一次写入 8192 字节 |
| 传输层（L4/TCP） | 将字节流拆分成 TCP 段（每段 ≤ MSS），加 TCP 头 | 8192 → 约 6 个 TCP 段 |
| 网络层（L3/IP） | 每个 TCP 段再拆分为 IP 分片（≤ MTU） | 可能进一步拆分 |
| 链路层（L2） | 封装为以太网帧，逐个发送 | 逐个帧到达对端网卡 |

**TCP 向应用层承诺的是"字节的可靠有序交付"，而不是"消息的可靠有序交付"。** 消息边界是应用层协议（如 HTTP 的 `\r\n`、自定义长度头）自己维护的事情。

> **协议层要点**：TCP 是字节流，不是消息队列。发送端调了多少次 `write()`、每次写了多少字节，接收端的 `read()` 完全感知不到——它只从连续的字节流里取当前可读的部分。UDP 则是数据报模型，每次 `sendto()` 对应一个完整数据报，接收端一次 `recvfrom()` 拿一个完整包。

### 二、内核实现层面的原因

#### 1. 套接字接收缓冲区（sk_buff 链表）

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<app>> #E3F2FD
  BorderColor<<app>> #1565C0
  BackgroundColor<<kernel>> #FFF8E1
  BorderColor<<kernel>> #F9A825
  BackgroundColor<<nic>> #F3E5F5
  BorderColor<<nic>> #7B1FA2
}
title 内核 TCP 接收缓冲区的工作模型

rectangle "应用进程" <<app>> as app {
  rectangle "read(fd, buf, 1500)" as rd
}

rectangle "内核空间" <<kernel>> as kernel {
  rectangle "socket 接收缓冲区 (默认 ~87KB)" as rcvbuf {
    rectangle "sk_buff #1: 1460字节" as skb1
    rectangle "sk_buff #2: 1460字节" as skb2
    rectangle "sk_buff #3: 1460字节" as skb3
    rectangle "sk_buff #4: 976字节" as skb4
    rectangle "..." as skb_more
  }
}

rectangle "网卡中断-软中断" <<nic>> as nic {
  rectangle "数据包到达" as pkt
}

nic --> skb1 : 段1到达
nic --> skb2 : 段2到达
nic --> skb3 : 段3到达
nic --> skb4 : 段4到达
app --> rcvbuf : read() 请求 1500 字节

note bottom of app : 返回值可能为 1460/1500/全部可读量，read() 不保证一次拿完

@enduml
```

Linux 内核中，TCP 接收到的数据以 `sk_buff`（Socket Buffer）结构组织成链表。每个 `sk_buff` 通常携带一个 TCP 段的有效载荷（Payload），大小受 MSS（Maximum Segment Size，最大段大小）限制，典型值为 1460 字节（以太网 MTU 1500 - IP 头 20 - TCP 头 20）。

当应用调用 `read(fd, buf, 1500)` 时：

1. 内核遍历 sk_buff 链表，从链表头开始拷贝数据到用户缓冲区
2. 内核**不会等待所有数据到达后才返回**——有数据就立刻返回
3. 返回值是**实际拷贝的字节数**，可能 ≤ 请求的字节数

**这就是"有时比发送的少"的最初起点——应用层的 `read()` 和内核的 sk_buff 粒度之间天然存在不匹配。**

#### 2. 为什么内核不等数据齐了再返回？

如果内核等所有数据到齐，会引入两个严重问题：

- **延迟不可控**：如果发送端只发了 100 字节然后停止了（去处理别的事情），接收端将永远阻塞在 `read()` 上
- **与 TCP 流模型矛盾**：TCP 本身就没有"完整消息"的定义，内核无从判断"数据是否到齐"

因此 POSIX 标准规定 `read()` 的行为是：**尽可能多地返回当前可读的数据，但允许少于请求量**。

> **内核层要点**：数据以 `sk_buff` 为单位逐个到达内核缓冲区，`read()` 从缓冲区链表头拷贝数据，有就返回，不等。内核没法判断"消息有没有到齐"——TCP 本身就没有消息的概念。POSIX 标准明确允许 `read()` 返回少于请求量。

### 三、网络传输层面的因素

> 本节侧重说明这些因素对 `read()` 返回值的影响。MSS/MTU、Nagle、cwnd 的完整内核实现详见 → [深入论述三：MSS/MTU、Nagle 算法、cwnd 拥塞窗口](#深入论述三mssmtunagle-算法cwnd-拥塞窗口)。

#### 1. MSS/MTU 分段

TCP 段的最大大小由 MSS 限制，而 MSS 又受制于路径 MTU（Maximum Transmission Unit，最大传输单元）。以太网默认 MTU = 1500 字节：

```bash
MTU 1500 = IP头(20) + TCP头(20) + TCP Payload(1460)
                                 ↑ 这就是 MSS
```

如果用户调用 `write(fd, buf, 8192)`，TCP 协议栈会：
1. 将 8192 字节拆分成多个 TCP 段（每段 ≤ 1460 字节）
2. 逐个段发出
3. 接收端可能在不同时间收到这些段

接收端一次 `read()` 可能刚好跨在某个段边界上。

#### 2. Nagle 算法（合并发送）

Nagle 算法的核心逻辑是：**在一个 TCP 连接上，最多只能有一个未被确认的小段**（小于 MSS 的段）。

如果发送端连续、快速地调用多次小 `write()`：
1. 第一次 `write("AB", 2)` → TCP 发出段（只有这个段未被确认）
2. 第二次 `write("CD", 2)` → TCP **暂存不发送**，等待第一次的 ACK 或缓冲区凑满 MSS
3. 第三次 `write("EF", 2)` → 继续暂存
4. 第一次的 ACK 到达 → 内核将暂存的 "CDEF" 合并为一个段发出

接收端一次 `read()` 就收到了 "AB"+"CDEF" 的混合内容。

> **Nagle 是粘包的核心原因吗？——不是。**
>
> Nagle 确实会在**发送端**把小写合并成一个段——这是粘包的一种形式（发送端主动合并）。但即使关掉 Nagle（`TCP_NODELAY`），粘包照样发生：
>
> ```
> TCP_NODELAY 开启、Nagle 关闭的情况：
> 发送端: write("AB") → 立即发出段1 (seq=1, len=2)
> 发送端: write("CD") → 立即发出段2 (seq=3, len=2)
>
> 接收端: 段1 和段2 几乎同时到达 sk_receive_queue
> 接收端: read(fd, buf, 4096) → 返回 "ABCD"
>          ↑ 照样粘包！Nagle 明明是关着的
> ```
>
> **粘包的根源只有一个：TCP 是字节流，不保留消息边界。** Nagle、MSS 分段、cwnd 限制、RTT 波动——这些只是影响"数据以什么形态到达接收端"的因素，增加或减少了粘包的概率，但不是根因。根因是 TCP 协议本身就没有"这条消息到此结束"的概念。只要 `read()` 时接收队列里有数据，它就会全取出来——不管这些数据是发送端写了 1 次还是 10 次。
>
> 解决粘包的唯一正确方式：**在应用层自己维护消息边界**（长度前缀、分隔符、定长消息等），不要指望 TCP 替你分包。

#### 3. TCP 拥塞控制窗口（cwnd）

如果发送端有大量数据要发送（如 1MB 文件），但网络拥塞导致 cwnd = 10 × MSS：

- 发送端只能发出 10 个段（约 14.6KB），然后等待 ACK
- 接收端第一次 `read()` 只拿到 14.6KB，而实际还有 985KB 在路上

**拥塞控制让数据不是一次性全部到达，进一步加剧了 read() 返回量的不确定性。**

#### 4. 三层因素的叠加效应

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<app>> #E3F2FD
  BorderColor<<app>> #1565C0
  BackgroundColor<<tcp>> #C8E6C9
  BorderColor<<tcp>> #2E7D32
  BackgroundColor<<ip>> #FFE0B2
  BorderColor<<ip>> #EF6C00
  BackgroundColor<<recv>> #F3E5F5
  BorderColor<<recv>> #7B1FA2
}
title read() 返回量不确定的三层因果链

rectangle "应用层" <<app>> as l7 {
  rectangle "write(fd,buf,50000)" as l7w
  rectangle "read(fd,buf,50000)" as l7r
}

rectangle "传输层 TCP" <<tcp>> as l4 {
  rectangle "Nagle 合并小段" as n1
  rectangle "MSS 1460 拆大段" as n2
  rectangle "cwnd 限制飞行量" as n3
}

rectangle "网络层 IP" <<ip>> as l3 {
  rectangle "MTU 1500 分片" as m1
  rectangle "路由/乱序" as m2
  rectangle "丢包/重传" as m3
}

rectangle "对端接收" <<recv>> as recv {
  rectangle "sk_buff 逐个到达" as r1
  rectangle "乱序等待重排" as r2
  rectangle "缓冲区可被 read" as r3
}

l7 --> l4
l4 --> l3
l3 --> recv

note bottom of recv : 发送 50000 字节，一次 read() 可能只拿到 8320 字节

@enduml
```

> **网络层要点**：MSS/MTU 把大数据拆开，Nagle 把小数据合并，cwnd 限制飞行量——三层叠加，接收端 `read()` 拿到多少 = 这些因素的综合结果。不是某个参数调了就能"一次拿到全部"。

### 四、read() 系统调用的语义

#### 1. 与 recv() 的返回值对比

| 系统调用 | 协议 | 边界语义 |
|---------|------|---------|
| `read()` / `recv()` | TCP (SOCK_STREAM) | 无边界，返回当前可读的数据 |
| `recvfrom()` / `recvmsg()` | UDP (SOCK_DGRAM) | **保留边界**，一次接收一个完整数据报 |
| `read()` on pipe | 管道 | 无边界，但受 PIPE_BUF 原子写入限制 |

UDP 之所以能保持边界，是因为每个 UDP 数据报在内核中是一个独立的 `sk_buff`，`recvfrom()` 一次只取一个。

#### 2. read() 返回 0 的特殊含义

```bash
read() > 0  → 读到了 N 字节数据
read() = 0  → 对端已关闭连接（FIN），不是"没数据"
read() < 0  → 错误（EAGAIN/EINTR/ECONNRESET 等）
```

**read() 返回 0 是 TCP 连接关闭的唯一信号，不是"本次没数据读"。** 这就是为什么循环终止条件是 `n <= 0`。

> **系统调用要点**：`read() > 0` 是数据，`read() == 0` 是对端关了（FIN），`read() < 0` 是错误。不要混淆"返回 0"和"没数据"——TCP 中没数据会阻塞等，不会返回 0。对应的 `write()` 也有同样的"不保证一次写完"特性。

### 五、对本项目的工程影响

#### 1. 正确的处理模式

```c
/* 模式一：循环读取直到对端关闭（当前 echo server 使用） */
while ((n = read(conn_fd, buf, sizeof(buf))) > 0) {
    write(conn_fd, buf, n);
}

/* 模式二：读固定长度的消息（应用层协议需要） */
size_t read_exact(int fd, void *buf, size_t len) {
    size_t total = 0;
    while (total < len) {
        ssize_t n = read(fd, (char*)buf + total, len - total);
        if (n <= 0) return total;  // 连接关闭或出错
        total += n;
    }
    return total;
}
```

当前 echo server 用模式一就够了——因为我们不关心消息边界，只是原样返回所有收到的字节，直到客户端关闭。

#### 2. 反面案例：假设一次读完整条"消息"会怎样？

```c
/* 错误的写法——假设一次 read 就能拿完整条消息 */
char buf[4096];
int n = read(fd, buf, sizeof(buf));     // 假设拿到完整的 HTTP 请求
char *method = strtok(buf, " ");         // 解析 GET
char *path   = strtok(NULL, " ");        // 解析 /index.html
// ↑ 如果 read() 只拿到了 "GET /ind"，path 就是 "ex.html" —— 完全错误！
```

这就是著名的 **TCP 粘包/拆包** 问题的根源——**根因是 TCP 字节流无消息边界**，Nagle / MSS 分段 / cwnd / RTT 只是放大器，不是根因。在本项目 Day 29（流式分包 Echo）中将专门处理这个问题。

#### 3. 另一个坑：write() 也并不保证一次写完

和 read() 对称，write() 的返回值也可能小于请求写入的字节数。只不过对于小数据量 + 本地测试通常不触发，等后面压测加负载后才容易观察到。

> **工程要点**：当前 echo 只需循环读写即可。但一旦需要解析消息（如 Day 29 流式分包），就必须用 `read_exact()` 模式自己维护消息边界。记住反面案例：一次 `read()` 就拿到的假设是 TCP 粘包/拆包 bugs 的万恶之源。

### 六、小结

| 层次 | 原因 | 影响 |
|------|------|------|
| 协议设计 | TCP 是字节流，不保留消息边界 | `read()` 返回值与 `write()` 大小无关 |
| 内核实现 | sk_buff 链表粒度、缓冲区水线 | `read()` 取到的是缓冲区当前可读量 |
| 网络传输 | MSS/MTU 分段、Nagle 合并、cwnd 限制 | 数据分批到达，进一步增加不确定性 |
| 系统调用 | POSIX 语义允许返回少于请求量 | 应用层必须循环读取 |

> **一句话总结**：TCP 的"流"模型从根本上决定了 `read()` 返回量不可预测——它不是缺陷，而是协议特征。正确的做法是循环读取直到对端关闭（当前 echo server），或设计应用层协议自己维护消息边界（Day 29 的流式 echo）。

---

## 全文要点总结

本文围绕 Day 1 的 TCP Echo 服务器，从四个维度深入剖析了其内核原理。以下是每节的核心结论速查。

### 一、四个系统调用的内核全景

| 系统调用 | 核心做了什么 | 最容易忽略的点 |
|---------|-------------|--------------|
| `socket()` | 创建 `fd → file → socket → sock` 四层链，绑定 `inet_stream_ops` + `tcp_prot` 两张函数表 | 所有后续网络操作都走这条四层链，perf/strace/ss 看到的异常本质都是链上某一跳的问题 |
| `bind()` | 端口冲突检查（遍历 `bhash`），绑定地址后插入 bind hash 表 | `SO_REUSEADDR`（时间复用）和 `SO_REUSEPORT`（空间复用）是正交的两个选项，解决完全不同的两个问题 |
| `listen()` | 创建 SYN 队列 + Accept 队列，socket 进入 `TCP_LISTEN`，backlog 被 `somaxconn` 截断 | `listen()` 非阻塞立即返回，但内核此后自动完成三次握手——应用还没 `accept()`，连接已经可能建好了 |
| `accept()` | 从 Accept 队列取走 `sk_new`，包一层新 `file` + 新 fd 返回给应用 | "取走"不是拷贝，是所有权迁移——同一个 `sk_new` 从队列迁移到 fd，新 socket 与 listen socket 再无关系 |

### 二、`sk_buff` 的设计精要

- **四指针窗口**（`head/data/tail/end`）：各层协议只偏移 `data` 指针，零拷贝贯穿协议栈
- **三个队列**：`sk_backlog`（瞬态交接区，硬中断放进、软中断取出）、`sk_receive_queue`（等 `read()`）、`sk_write_queue`（等 ACK）。backlog 存在的根本原因不是"放 receive_queue 动作重"，而是硬中断里**不能跑 TCP 协议处理**（校验/查表/排序/更新窗口）来决定该放进哪个 socket 的 receive_queue
- **线性区 + 分页区**：头信息在线性区，大 payload 在分页区——`sendfile()` 零拷贝的基石
- **性能瓶颈链路**：`read()` 慢 → `sk_receive_queue` 满 → 接收窗口=0 → 发送端停止 → 整个连接被接收端阻塞

### 三、MSS / Nagle / cwnd 的核心关系

| 机制 | 做什么 | 对 `read()` 的影响 |
|------|--------|------------------|
| **MSS/MTU 分段** | 大 `write()` 被拆成多个 ≤1460 字节的 TCP 段 | 一次 `read()` 可能跨分段边界，只拿到部分 |
| **Nagle 合并** | 小 `write()` 被暂存合并后再发 | 发送端合并是粘包放大器，但**不是根因**——TCP_NODELAY 也不能消除粘包 |
| **cwnd 拥塞窗口** | 限制在途数据量，`min(cwnd, rwnd)` 决定实际吞吐 | 大量数据被卡在发送端，`read()` 只能拿到已到达的部分 |

**双窗口递进规律**：连接初期 cwnd 是瓶颈（慢启动），中后期 rwnd 反超成为瓶颈（应用层 `read()` 太慢）。`min(cwnd, rwnd) / RTT` 就是吞吐量上限。

### 四、`read()` 返回量不可预测的完整因果链

```bash
应用层 write(50000)
  → TCP 层: MSS 拆成 ~35 个段 / Nagle 合并小段 / cwnd 限制飞行量
    → IP 层: MTU 分片 / 路由乱序 / 丢包重传
      → 对端: sk_buff 逐个到达 / 乱序等待重排 / 缓冲区有就返回
        → 应用层 read(50000) 可能只拿到 8320
```

**根本原因不是 bug，是协议设计**：TCP 是字节流（不是消息队列），POSIX 明确允许 `read()` 返回少于请求量。应用层要么循环读写到底（当前 echo），要么自己维护消息边界（Day 29）。

### 五、Day 1 代码故意留下的坑

| 坑 | 表现 | Day 2 解药 |
|----|------|-----------|
| 无 `SO_REUSEADDR` | 服务端重启时 `bind: Address already in use` | `setsockopt(SO_REUSEADDR)` |
| 单进程阻塞 | 一个客户端阻塞 `read()`，所有其他客户端排队 | 多进程 `SO_REUSEPORT` 或 epoll |
| 无 `SO_REUSEPORT` | 多进程无法 bind 同一端口 | `setsockopt(SO_REUSEPORT)` (Linux 3.9+) |

> **设计意图**：Day 1 的极简实现不是偷懒，而是故意先暴露这些坑——没有经历过 TIME-WAIT 的 `EADDRINUSE`，就不理解 `SO_REUSEADDR` 为什么存在；没有经历过单连接阻塞全服务，就不理解 epoll/多进程为什么必要。

