当前位置:首页>Linux>跟 AI 学 Linux:为什么 Linux 将 socket 监听分为 bind 与 listen 两个系统调用

跟 AI 学 Linux:为什么 Linux 将 socket 监听分为 bind 与 listen 两个系统调用

  • 2026-09-02 20:34:32
跟 AI 学 Linux:为什么 Linux 将 socket 监听分为 bind 与 listen 两个系统调用

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 + 端口) 关联起来
  • • 不代表这个 socket 会接收连接

listen:改变 socket 的“角色”,做的是一件状态转换的事情:

  • • 把 socket 从「主动 socket(client / 未定义)」➜  「被动 socket(server)」
  • • 内核开始为它维护:
    • • 半连接队列(SYN queue)
    • • 全连接队列(accept queue)

此时这个 socket 才真正成为:一个能被 accept() 的监听 socket

为什么 Linux 不直接来一个 listen(addr)?

因为在设计 Linux socket API 的那个年代(80 年代),Socket API 是“通用通信接口”。

当年 socket 的目标不仅是 TCP Server,而是:

  • • TCP
  • • UDP
  • • 本地 socket(AF_UNIX)
  • • RAW socket
  • • 甚至奇奇怪怪的协议

所以他们想要的是:

  • • socket()   → 创建通信端点
  • • bind()     → 指定地址
  • • connect()  → 主动连接
  • • listen()   → 被动接收连接

每一步语义清晰、职责单一。

这真的算“API 复杂度”吗?

从内核视角看:不是复杂,是原子化

  • • 每个系统调用只做一件事
  • • 组合方式交给用户
  • • 高级语言 / 框架负责“简化”

这其实是一种经典的分层哲学:

内核:能力完整、原语清晰↓语言:合理默认、适度封装↓框架:极致易用、业务导向

如果 Linux 一开始就做成 listen(addr),那么 UDP、UDS和自定义协议反而会被限制住。

2.3 思考

我明白了!Linux 把 bind 和 listen 拆开,是为了能力最大化;Golang / Netty 把它们合并,是为了体验最优化。

当下我最想深入研究的是 TCP 协议,特别是异常处理的部分。

2.4 问

我理解了 bind 和 listen 的语义。那么对于 TCP 协议,Linux 内核分别做了哪些操作呢?在执行流程中会产生哪些异常情况呢?

2.5 答

TCP bind 的核心动作

  1. 1. 地址合法性检查:
    • • IP 是否属于本机(或 INADDR_ANY)
    • • 端口范围是否合法
    • • IPv4 / IPv6 协议族匹配
    • • 是否启用了 SO_REUSEADDR / SO_REUSEPORT
  2. 2. 端口占用检查(非常关键)
    • • 已存在 BIND socket
    • • TIME_WAIT socket
    • • 已连接 socket(active)
  3. 3. 自动分配端口(如果 port=0)
    • • 从 ip_local_port_range 中选一个
    • • 插入 TCP bind hash 表

bind 后 TCP socket 的状态仍是 TCP_CLOSE。

listen 对 TCP 做了哪些关键事情

  1. 1. TCP 状态迁移
    • • TCP_CLOSE  →  TCP_LISTEN
  2. 2. 初始化两个关键队列(非常重要)
    • • 半连接队列(SYN queue)
    • • 全连接队列(accept queue)
      • • 保存三次握手已完成的连接
      • • accept() 从这里取 socket
  3. 3. 注册监听端口到 listen hash
    • • 使内核能够把到来的 SYN 定位到这个 socket
    • • 监听 socket 进入 listen hash 表

bind 和 listen 阶段可能遇到的异常汇总bind 阶段异常

错误码
原因
EADDRINUSE
端口被占用
EADDRNOTAVAIL
IP 不属于本机
EACCES
权限不足
EINVAL
地址非法

listen 阶段异常

错误码
原因
EINVAL
socket 类型不支持 listen
EOPNOTSUPP
非 TCP 协议
EBADF
fd 非 socket

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))

这里有点儿疑问:

  • • 队列长度为 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. 1. 使用 prandom_u32() 伪随机数生成器选择起始位置
  2. 2. 强制使用奇数端口(避免与 connect 偏好的偶数端口冲突)
  3. 3. 从随机位置开始向上扫描,步长为 2
  4. 4. 奇数端口扫描失败后尝试偶数端口
  5. 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. 1. 初始 SYN:无论什么情况,都是直接丢弃,不发 SYN-ACK
  2. 2. 最终 ACK:
    • • tcp_abort_on_overflow = 0 (默认):静默丢弃,客户端超时重传
    • • tcp_abort_on_overflow = 1:发送 RST,客户端立即失败
  3. 3. 这是防止队列溢出的保护机制,默认配置对流量突发更友好

5. 总结

通过与 AI 老师的讨论,我从宏观上理解了 bind 和 listen 系统调用设计的精妙,通过实验也理解了一些内核的处理细节。

但是有两个名称相似的配置项 —— SO_REUSEADDR 和 SO_REUSEPORT,我还没有理解透彻,下次再与 AI 老师讨论。


1. https://gitee.com/shichaoyuan/learn-linux-with-ai↩︎

最新文章

随机文章