bigywater.tech
Python · 71

socket:一次 TCP 连接如何从 Python 代码走到内核

Python 的 socket 只是一层很薄的包装:真正的握手、排队、缓冲和关闭状态都在内核里。这篇用一个脚本在本机真实走完「bind → listen → connect → accept → 收发 → 关闭」,外面另起一个循环不停采样 netstat,看每一步内核里到底变成了什么。

0.377 ms
accept() 从未被调用,connect() 就已返回,两端已是 ESTABLISHED
3 → 1
三次 send(6+6+4 字节)被一次 recv 合并读出
TIME_WAIT
先 close 的客户端一端留下,服务端那条连接直接消失

做的事:一个脚本走完一条连接的一生

外面的 shell 循环每 0.08 秒采一次 netstat,脚本在每个阶段打时间戳并停 0.7 秒,让状态稳定下来
实验脚本(节选) srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.bind(('127.0.0.1', 0)) # 端口 0:让内核挑一个空闲端口 srv.listen(5) cli = socket.socket() cli.connect(('127.0.0.1', port)) # 注意:此时服务端还没有 accept conn, addr = srv.accept() # 之后才 accept for m in (b"hello|", b"world|", b"tcp!"): cli.send(m) # 连发三次 data = conn.recv(4096) # 一次 recv cli.close(); conn.close(); srv.close()

为什么要在脚本外面采样:Python 自己的子进程里跑 netstat 在这台机器上看不到 TCP 连接(返回里 0 行 tcp4),而直接在 shell 里能看到——所以采样放在了脚本之外,靠时间戳把两边对齐。

验证一:socket() 拿到的是一个内核文件描述符

Python 对象只是壳
实测 · 本机执行输出
srv.fileno() = 3 | family = AF_INET | type = SOCK_STREAM
     cli.connect() 返回耗时 0.377 ms, cli fd=4, 本端=('127.0.0.1', 62719), 对端=('127.0.0.1', 62714)
[ 2.02s] S3 cli.connect() 已返回, 但服务端从未调用 accept()
     srv.accept() -> ('127.0.0.1', 62719), 新 fd=5 (监听 fd=3)
[ 2.73s] S4 srv.accept() 之后

服务端监听 socket 是 fd 3,客户端是 fd 4,accept() 返回的新 socket 是 fd 5——这就是普通的文件描述符编号,0/1/2 被标准输入输出占了,所以第一个 socket 从 3 开始。accept() 之后监听 socket 依然存在,服务端因此同时握着两个 fd:一个负责接新连接,一个专属这条连接。

验证二:服务端不 accept,连接照样建立

这是整篇最反直觉的一条
实测 · 本机执行输出
S3 cli.connect() 已返回, 但服务端从未调用 accept()
    本地端口 62714  对端     *  LISTEN
    本地端口 62714  对端 62719  ESTABLISHED
    本地端口 62719  对端 62714  ESTABLISHED

上面是 cli.connect() 返回、服务端还没有调用过 accept() 的那一刻,netstat 看到的三行:一条 LISTEN,以及同一对端口的两个方向都是 ESTABLISHED。connect 本身只用了 0.377 ms。

三次握手是内核代办的。listen(5) 之后,内核就替这个端口接客:SYN 来了自己回 SYN-ACK,ACK 回来后把「已完成握手的连接」放进 accept 队列。Python 的 accept() 做的只是从队列里取一个出来、包成新 fd。所以「服务端没来得及 accept」和「连接没建立」是两回事——只要队列没满,客户端就已经认为自己连上了,并且可以先把数据 send 出去,数据会躺在内核缓冲区里等着。

验证三:TCP 是字节流,消息边界不存在

send 三次,recv 一次
实测 · 本机执行输出
     cli.send(b'hello|') 返回 6
     cli.send(b'world|') 返回 6
     cli.send(b'tcp!') 返回 4
     conn.recv(4096) 一次拿到 b'hello|world|tcp!'

三次 send 分别返回 6、6、4,合计 16 字节;服务端一次 recv(4096) 拿到的是 b'hello|world|tcp!'——三条「消息」粘在一起。TCP 只保证字节顺序和不丢,并不知道你在应用层是怎么切分的。所以协议要自己带边界:定长头、长度前缀或分隔符(我在示例里手动放了 |,就是为了让粘连看得出来)。反过来同样成立:一次 send 的大块数据也可能被多次 recv 才读完,recv(n) 里的 n 只是「最多」。

验证四:关闭时状态落在哪一端

CLOSE_WAIT、FIN_WAIT_2 和 TIME_WAIT
实测 · 本机执行输出
S6 客户端 cli.close() 之后 (服务端 conn 还开着)
    本地端口 62714  对端     *  LISTEN
    本地端口 62714  对端 62719  CLOSE_WAIT
    本地端口 62719  对端 62714  FIN_WAIT_2
S7 服务端 conn.close() 之后
    本地端口 62714  对端     *  LISTEN
    本地端口 62719  对端 62714  TIME_WAIT
S8 监听 socket 也关掉之后
    本地端口 62719  对端 62714  TIME_WAIT

客户端先 close():客户端一端是 FIN_WAIT_2(自己的 FIN 已被确认,等对方的 FIN),服务端一端是 CLOSE_WAIT(收到了对方的 FIN,但自己的 socket 还没关)。服务端 conn.recv() 此时返回 b'',这就是「对方已关闭」的信号。等服务端也 close(),这条连接在服务端一侧消失,而先关闭的客户端一端留下了 TIME_WAIT,即使把监听 socket 也关掉它仍在。

CLOSE_WAIT 是「我的程序没有 close」造成的:如果服务端代码读到 b'' 之后忘了关闭 conn,netstat 里就会一直挂着 CLOSE_WAIT。TIME_WAIT 则是「先关闭的一方」付出的代价——本次是客户端。

验证五:没人监听时,connect 直接被拒

实测 · 本机执行输出
     无人监听时 connect: ConnectionRefusedError errno=61 ECONNREFUSED - Connection refused

服务端的监听 socket 关掉后再 connect 同一个端口,得到 ConnectionRefusedError。本机 macOS 上 errno 是 61;errno 的数字随系统不同,代码里应该用 errno.ECONNREFUSED 或直接捕获 ConnectionRefusedError,不要写死数字。

验证六:Python 层能看到的只有一小部分

用 sys.addaudithook 记录脚本里全部 socket 事件
实测 · 本机执行输出
socket.__new__(<socket fd=-1>, 2, 1, 0)
socket.bind(<socket fd=3>, ('127.0.0.1', 0))
socket.__new__(<socket fd=-1>, 2, 1, 0)
socket.connect(<socket fd=4>, ('127.0.0.1', 62714))
socket.__new__(<socket fd=-1>, 2, 1, 0)
socket.getaddrinfo('127.0.0.1', 62714, 0, 1, 0)
socket.__new__(<socket fd=-1>, 2, 1, 6)
socket.connect(<socket fd=3>, ('127.0.0.1', 62714))

审计钩子只看到 socket.__new__、bind、connect、getaddrinfo;listen、accept、send、recv 这次都没有对应事件。所以想靠审计钩子完整还原网络行为是不够的;真要看系统调用,得靠系统层工具。另外注意最后两条:socket.create_connection 内部先 getaddrinfo 解析地址,再新建 socket(第四个参数 6 就是 TCP 协议号)并 connect,这是它比手写 socket()+connect() 多做的一步。

分步演示:同一条连接,八个时刻内核里的状态

每一步的 netstat 行都是上面实验采样到的原始结果
一条 TCP 连接的一生(本机真实采样)
当前被触发的角色 / 已建立的连接行

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 socket、sys.addaudithook;状态采样用系统自带 netstat -an,每 0.08 秒一次
    • 只测了回环地址 127.0.0.1;没有涉及跨主机网络、SO_REUSEADDR、accept 队列被打满时的行为,也没有测其他操作系统的 errno 与状态命名
    京ICP备2026067502号-1京公网安备11010502063487号