178 lines
6.9 KiB
Markdown
178 lines
6.9 KiB
Markdown
---
|
||
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 开销
|