Files
cs-note/hhs/NETWORK/06-Socket编程/01-SocketAPI与backlog详解.md
2026-05-24 11:42:38 +08:00

178 lines
6.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [计算机网络, Socket API, listen, accept, backlog, SYN队列]
create time: 2026-05-18 04:30
---
# Socket API 与 backlog 深度解析
## 概述
Socket 是应用层与传输层之间的抽象接口。理解 Socket API 如何映射到 TCP/IP 协议栈的各层操作,以及 `listen()` 中 backlogged 参数的真实含义——它是两个队列的组合而非一个——是从入门走向高性能网络编程的关键一步。
## Socket API 到 OSI 层的映射
| 系统调用 | OSI 层级 | 做了什么? | 关键参数 |
|----------|---------|-----------|---------|
| `socket()` | N/A | 创建 socket 对象 | domain(AF_INET/AF_UNIX), type(SOCK_STREAM/SOCK_DGRAM) |
| `bind()` | L4/L3 | 绑定本地地址+端口 | struct sockaddr_in{family, port, addr} |
| `listen()` | L4 (TCP) | 转为被动监听模式 | backlog = 半连接队列 + 全连接队列 |
| `accept()` | L4 (TCP) | 从已完成队列取出一个连接 | 返回新 conn fd + peer 地址 |
| `connect()` | L4 (TCP) | 发起三次握手 | struct sockaddr_in{remote_port, remote_addr} |
| `send()/write()` | L4→L7 | 将数据放入发送缓冲区 | buffer, length, flags(MSG_DONTWAIT...) |
| `recv()/read()` | L4←L7 | 从接收缓冲区读取 | buffer, length, flags(MSG_PEEK...) |
| `setsockopt()` | 各层 | 修改 socket 选项 | SO_REUSEADDR / TCP_NODELAY / etc. |
| `close()` | L4 | 发起四次挥手 | — |
```mermaid
flowchart LR
Client["客户端"] -->|"connect() → SYN"| Server
subgraph "服务端"
Server["服务器"] -->|"listen()"+|"创建两层队列"| TwoQ["半连接队列 + 完成队列"]
TwoQ -->|"accept()"+"取出"| App["应用程序"]
end
style App fill:#DDA0DD,color:#000
```
### Go 中的标准启动流程
```go
// Go 中的标准 HTTP 服务器启动流程
ln, err := net.Listen("tcp", ":8080")
// 等价 C 代码序列:
// fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
// setsockopt(fd, SO_REUSEADDR, 1) // 允许地址快速复用
// bind(fd, {addr=0.0.0.0, port=8080})
// listen(fd, SOMAXCONN) // ⬅️ backlog 在这里起作用
// for {
// conn_fd, _ = accept(fd, &peer_addr) // 从完成队列取
// go handle(conn_fd) // 每个连接一个 goroutine
// }
```
## backlog 参数的奥秘 —— 两个队列!
这是很多开发者容易误解的地方:`listen(fd, backlog)` 中的 backlog **不是一个队列的大小**,而是控制两个不同队列的组合。
```mermaid
flowchart TD
Client["客户端<br/>发出 SYN"] -->|"三次握手进行中"| SyncQ["半连接队列<br/>SYN_RECV 状态<br/>容量: tcp_max_syn_backlog"]
SyncQ -->|"握手完成"| CompQ["已完成连接队列<br/>ESTABLISHED 状态<br/>容量: min(backlog, somaxconn)"]
CompQ -->|"accept() 取出"| App["应用程序"]
SyncQ -.->|"队列满时"| Drop["丢弃后续 SYN<br/>客户端超时重传"]
CompQ -.->|"队列满时"| Reject["拒绝 accept<br/>对客户端表现为延迟"]
style SyncQ fill:#FFD700,color:#000
style CompQ fill:#98FB98,color:#000
style Drop fill:#FF6B6B,color:#fff
style Reject fill:#FFA07A,color:#000
```
```bash
# Linux 相关内核参数(通过 sysctl 查看)
$ sysctl net.ipv4.tcp_max_syn_backlog # SYN 半连接队列最大数 (默认 ~1024)
$ sysctl net.core.somaxconn # 全局 backlog 上限 (默认 128! 🔴 生产要改)
$ sysctl net.ipv4.tcp_abort_on_overflow # 超过限制时的行为: 0=静默丢弃, 1=RST
```
> [!QUESTION] 为什么 backlog 默认只有 128?
> 这个保守设计源于通用场景:普通桌面或小型服务器不可能同时有大量连接。但生产级 Web/API 服务器轻松面对上万并发,128 就是瓶颈。
### 半连接队列 vs 已完成队列的行为差异
| 维度 | 半连接队列 (SYN Queue) | 已完成队列 (Accept Queue) |
|------|----------------------|-------------------------|
| 内容 | 正在三次握手中的连接 | 已完成握手的连接 |
| 增长方式 | connect() 发 SYN 时加入 | 握手完成后自动移入 |
| 减少方式 | 握手完成/超时清理 | accept() 取出时移除 |
| 满了的后果 | 丢弃 SYN,客户端重传 | 客户端收到旧数据而非错误 |
| 可调参数 | tcp_max_syn_backlog | net.core.somaxconn |
```go
// Go 中如何设置大的 backlog
ln, _ := net.Listen("tcp", ":8080")
// Go 内部调用 syscall.Listen(fd, SOMAXCONN),实际取:
// min(user_specified_backlog, net.core.somaxconn)
// 所以即使 Go 写了 65535,内核 still 受限于 somaxconn=128
// 必须先 sysctl 调大内核参数!
```
> [!tip] 生产环境 backlog 配置黄金公式
> ```ini
> # /etc/sysctl.conf
> net.core.somaxconn = 65535
> net.ipv4.tcp_max_syn_backlog = 8192
> # 重启后生效: sudo sysctl -p
> ```
## bind() 的高级选项
### SO_REUSEADDR — 快速重启的关键
```go
// 在 C 中必须在 bind() 之前设置
fd, _ := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
opt := 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, size)
bind(fd, ...)
```
```go
// Go 的 net.Listen 已经内置了 SO_REUSEADDR
ln, _ := net.Listen("tcp", ":8080")
// 不需要手动设置!Go runtime 已处理
```
`SO_REUSEADDR` 允许一个新的 socket 绑定到一个处于 `TIME_WAIT` 状态的地址上,避免了等待 2MSL 后才能重新绑定的尴尬。
### SO_REUSEPORT — 多进程负载均衡
```bash
# 多个进程/线程各自 listen 同一个端口
# kernel 在收到 SYN 后选择一个进程来 accept
# 避免单进程 accept 竞争锁
```
这在 Nginx master-worker 模型、Go 中使用 `GOMAXPROCS` 多线程监听同一端口时有用。
## connect() — 客户端视角
```go
// Go 标准连接方式(阻塞)
conn, err := net.Dial("timeout", "server.example.com:443")
// 等价于:
// fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
// connect(fd, {server_ip, 443}) // 阻塞直到握手完成或超时
```
```go
// 非阻塞 connect + poll 实现带超时的连接
func dialWithTimeout(addr string, timeout time.Duration) (net.Conn, error) {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return nil, err
}
return conn, nil
}
// 实际 Go net.DialTimeout 内部使用 OS 级超时机制
// 底层仍是 non-blocking connect + select/poll 等待就绪
```
> [!warning] DNS 解析耗时
> `net.Dial` 会先做 DNS 查询再发起 connect。如果需要独立控制 DNS 和 TCP 连接时间:
> ```go
> addrs, _ := net.LookupHost("server.example.com") // DNS 阶段
> conn, _ := net.DialTimeout("tcp", addrs[0]+":443", timeout) // TCP 阶段
> ```
## 关联笔记
- [[hhs/NETWORK/TCP三次握手与四次挥手]] — connect() 触发的三次握手完整过程
- [[hhs/NETWORK/TCP段结构与状态机]] — bind/listen/accept 对应 TCP 的状态转换
- [[hhs/NETWORK/HTTPS与TLS握手]] — TLS 握手中的 connect() 阻塞
- [[hhs/NETWORK/01-带宽延迟RTT与吞吐量]] — connect() 造成的第一个 RTT 开销