1. 议题
在 Golang 中通过 net.Listen("tcp", ":8080") 一个函数就能实现 socket 监听,在 Netty 中也是通过一个方法 b.bind(port) 实现的,那为什么在 Linux 中要分成 bind 和 listen 两个系统调用呢?
让我们继续请 AI 老师来为我们解惑。
2. 对话
2.1 问
我在学习 Linux socket 编程时发现 Linux 将 socket 监听分成两步:bind 和 listen。而我之前用 Golang 和 Netty 都是通过一个函数完成,那为什么 Linux 要设计两个系统调用呢?这不是增加了 API 的复杂度吗?
2.2 答
这是个特别好的问题,而且你不是第一个被它“绊住”的人 🙂表面看确实像是:Linux 把一件事拆成了两步,Golang / Netty 却一步到位。但这背后其实是分层设计 + 历史包袱 + 灵活性三件事叠加的结果。
bind 和 listen 本质上不是一回事
bind:只是“占个地址”,它做的事情非常单纯:
- • 把 socket 和 本地地址(IP + 端口) 关联起来
listen:改变 socket 的“角色”,做的是一件状态转换的事情:
- • 把 socket 从「主动 socket(client / 未定义)」➜ 「被动 socket(server)」
此时这个 socket 才真正成为:一个能被 accept() 的监听 socket
为什么 Linux 不直接来一个 listen(addr)?
因为在设计 Linux socket API 的那个年代(80 年代),Socket API 是“通用通信接口”。
当年 socket 的目标不仅是 TCP Server,而是:
所以他们想要的是:
每一步语义清晰、职责单一。
这真的算“API 复杂度”吗?
从内核视角看:不是复杂,是原子化
这其实是一种经典的分层哲学:
内核:能力完整、原语清晰↓语言:合理默认、适度封装↓框架:极致易用、业务导向
如果 Linux 一开始就做成 listen(addr),那么 UDP、UDS和自定义协议反而会被限制住。
2.3 思考
我明白了!Linux 把 bind 和 listen 拆开,是为了能力最大化;Golang / Netty 把它们合并,是为了体验最优化。
当下我最想深入研究的是 TCP 协议,特别是异常处理的部分。
2.4 问
我理解了 bind 和 listen 的语义。那么对于 TCP 协议,Linux 内核分别做了哪些操作呢?在执行流程中会产生哪些异常情况呢?
2.5 答
TCP bind 的核心动作
- • IP 是否属于本机(或
INADDR_ANY) - • 是否启用了
SO_REUSEADDR / SO_REUSEPORT
- • 从
ip_local_port_range 中选一个
bind 后 TCP socket 的状态仍是 TCP_CLOSE。
listen 对 TCP 做了哪些关键事情
- • 使内核能够把到来的 SYN 定位到这个 socket
- • 监听 socket 进入 listen hash 表
bind 和 listen 阶段可能遇到的异常汇总bind 阶段异常
listen 阶段异常
2.6 思考
我明白了,下面动手实验一下各种异常情况。
3. 实验
完整代码位于我的 gitee 仓库[1]。
3.1 bind 端口冲突
printf("[%s] 尝试绑定端口 %d...\n", name, PORT);if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { printf("[%s] bind 失败: %s (errno=%d)\n", name, strerror(errno), errno); if (errno == EADDRINUSE) { printf("[%s] 端口已被占用!\n", name); } close(sockfd); return;}printf("[%s] bind 成功!\n", name);
./01_bind_conflict second=== 实验1: bind 端口冲突 ===[Server2] 尝试绑定端口 8888...[Server2] bind 失败: Address already in use (errno=98)[Server2] 端口已被占用!
3.2 不 bind 直接 listen
// 不调用bind,直接尝试listenprintf("\n尝试直接调用 listen()(没有先bind)...\n");if (listen(sockfd, 5) < 0) { // ...} else { printf("✓ listen 成功(意外,某些系统可能允许自动bind)\n"); // 获取实际绑定的地址和端口struct sockaddr_in actual_addr; socklen_t addr_len = sizeof(actual_addr); if (getsockname(sockfd, (struct sockaddr *)&actual_addr, &addr_len) < 0) { perror("getsockname"); } else { printf(" 实际绑定地址: %s:%d\n", inet_ntoa(actual_addr.sin_addr), ntohs(actual_addr.sin_port)); }}
./02_no_bind_listen=== 实验2: 不bind直接listen ===✓ socket 创建成功尝试直接调用 listen()(没有先bind)...✓ listen 成功(意外,某些系统可能允许自动bind) 实际绑定地址: 0.0.0.0:31751
这个结果有点儿意外,如果没有显式调用 bind,那么在 listen 的时候会隐式随机分配一个端口。
3.3 backlog 溢出
./03_backlog_test 3 0=== 实验3: listen 的 backlog 参数 ===监听端口: 7777设置 backlog = 3注意:实际队列长度可能被系统限制(/proc/sys/net/core/somaxconn)系统 somaxconn = 16384实际 backlog = min(3, 16384) = 3✓ listen 成功!模式: 不接受连接(用于测试 backlog 溢出)服务器将只监听但不调用 accept()当连接数超过 backlog 时,新连接将被拒绝或超时
在 listen 时设置 backlog 为 3。
使用 nc 命令建立多个连接,发现第4个还是成功,到第5个就卡住了。
nc: connect to 127.0.0.1 port 7777 (tcp) failed: Connection timed out
tcpdump抓包发现,客户端在重发 SYN 包,但是服务端不响应。
19:03:37.979238 IP 127.0.0.1.64330 > 127.0.0.1.7777: Flags [S], seq 2867556772, win 43690, options [mss 65495,sackOK,TS val 2627938407 ecr 0,nop,wscale 10], length 019:03:39.041866 IP 127.0.0.1.64330 > 127.0.0.1.7777: Flags [S], seq 2867556772, win 43690, options [mss 65495,sackOK,TS val 2627939470 ecr 0,nop,wscale 10], length 019:03:41.089873 IP 127.0.0.1.64330 > 127.0.0.1.7777: Flags [S], seq 2867556772, win 43690, options [mss 65495,sackOK,TS val 2627941518 ecr 0,nop,wscale 10], length 019:03:45.121870 IP 127.0.0.1.64330 > 127.0.0.1.7777: Flags [S], seq 2867556772, win 43690, options [mss 65495,sackOK,TS val 2627945550 ecr 0,nop,wscale 10], length 0
通过 ss 命令确认服务端全连接队列已满:
ss -ltnp | grep 7777LISTEN 4 3 0.0.0.0:7777 0.0.0.0:* users:(("03_backlog_test",pid=553353,fd=3))
这里有点儿疑问:
- • 全连接队列满时,看起来就不响应建连的 SYN 包了。
4. 内核实现
带着实验中的疑问,接下来看看 Linux 内核的实现。
4.1 任务
请梳理 @net/ 目录下 bind 和 listen 两个系统调用的源码,生成报告。我有几个疑问,需要解释清楚:
- • 不 bind 直接 listen 为什么会随机分配端口?具体的分配策略是什么?
- • 设定全连接队列长度为 3,为什么能够接收 4 个连接?
- • 全连接队列满时,为什么对建连的 SYN 包不再响应了?
4.2 报告
内核版本: Linux 5.15.198分析目录:net/, include/net/
完成的报告在 03_bind_listen_源码分析报告.md[1],这里仅摘要关键点。
listen 调用链:
listen(sockfd, backlog) └─> SYSCALL_DEFINE2(listen) └─> __sys_listen() [net/socket.c:1749] └─> sock->ops->listen() └─> inet_listen() [net/ipv4/af_inet.c:196] └─> inet_csk_listen_start() [net/ipv4/inet_connection_sock.c:1089] └─> sk->sk_prot->get_port() // 如果没有 bind,分配端口 └─> sk->sk_prot->hash() // 加入 hash 表
端口分配策略 (net/ipv4/inet_connection_sock.c:194-276):
- 1. 使用 prandom_u32() 伪随机数生成器选择起始位置
- 2. 强制使用奇数端口(避免与 connect 偏好的偶数端口冲突)
- 5. 对于设置了 SO_REUSEADDR 的 socket,先尝试端口范围的一半
端口范围:/proc/sys/net/ipv4/ip_local_port_range
SYN 包处理调用链:
SYN 包到达 └─> tcp_v4_rcv() [net/ipv4/tcp_ipv4.c] └─> tcp_v4_do_rcv() └─> tcp_rcv_state_process() └─> tcp_v4_conn_request() [net/ipv4/tcp_ipv4.c:1540] └─> tcp_conn_request() [net/ipv4/tcp_input.c:6948] ├─> 检查 1: inet_csk_reqsk_queue_is_full() // 半连接队列 ├─> 检查 2: sk_acceptq_is_full() // 全连接队列 ├─> inet_reqsk_alloc() // 分配 request_sock └─> af_ops->send_synack() // 发送 SYN-ACK
全连接队列长度为 3 能接收 4 个连接,因为判断条件是 > 而不是 >=:
static inline bool sk_acceptq_is_full(const struct sock *sk){ return READ_ONCE(sk->sk_ack_backlog) > READ_ONCE(sk->sk_max_ack_backlog);}
- •
backlog=3 时,sk_max_ack_backlog = 3 - • 队列满的条件是
sk_ack_backlog > 3 - • 因此
sk_ack_backlog 可以取值 0, 1, 2, 3(共 4 个连接)
全连接队列满时不再响应 SYN 包,因为 tcp_conn_request() 直接丢弃 SYN:
if (sk_acceptq_is_full(sk)) { NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; // 直接丢弃,不发送 SYN-ACK}
最终 ACK 处理调用链:
ACK 包到达 (完成三次握手) └─> tcp_v4_rcv() [net/ipv4/tcp_ipv4.c] └─> tcp_v4_do_rcv() └─> tcp_check_req() [net/ipv4/tcp_minisocks.c:656] └─> syn_recv_sock() [实际为 tcp_v4_syn_recv_sock()] ├─> 检查: sk_acceptq_is_full() // 再次检查全连接队列 │ └─> 如果满,返回 NULL → 跳转到 listen_overflow ├─> tcp_create_openreq_child() // 创建子 socket └─> inet_csk_reqsk_queue_add() [net/ipv4/inet_connection_sock.c:1150] └─> sk_acceptq_added() // sk_ack_backlog++ 【队列满时的处理】listen_overflow 分支: └─> 检查 sysctl_tcp_abort_on_overflow ├─> = 0 (默认): 静默丢弃 ACK,设置 acked=1 │ └─> 客户端会超时重传 └─> = 1: 发送 RST 拒绝连接 └─> req->rsk_ops->send_reset() └─> 客户端立即收到 ECONNREFUSED
而在收到最终 ACK 时,行为取决于 tcp_abort_on_overflow 参数:
if (!READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_abort_on_overflow)) { inet_rsk(req)->acked = 1; return NULL; // 静默丢弃 ACK,不发送 RST}embryonic_reset:if (!(flg & TCP_FLAG_RST)) { req->rsk_ops->send_reset(sk, skb); // 发送 RST}
行为总结:
- 1. 初始 SYN:无论什么情况,都是直接丢弃,不发 SYN-ACK
- •
tcp_abort_on_overflow = 0 (默认):静默丢弃,客户端超时重传 - •
tcp_abort_on_overflow = 1:发送 RST,客户端立即失败
- 3. 这是防止队列溢出的保护机制,默认配置对流量突发更友好
5. 总结
通过与 AI 老师的讨论,我从宏观上理解了 bind 和 listen 系统调用设计的精妙,通过实验也理解了一些内核的处理细节。
但是有两个名称相似的配置项 —— SO_REUSEADDR 和 SO_REUSEPORT,我还没有理解透彻,下次再与 AI 老师讨论。
1. https://gitee.com/shichaoyuan/learn-linux-with-ai↩︎