Files

178 lines
6.9 KiB
Markdown
Raw Permalink Normal View History

2026-05-24 11:42:38 +08:00
---
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 开销