Python 的 socket 只是一层很薄的包装:真正的握手、排队、缓冲和关闭状态都在内核里。这篇用一个脚本在本机真实走完「bind → listen → connect → accept → 收发 → 关闭」,外面另起一个循环不停采样 netstat,看每一步内核里到底变成了什么。
实验脚本(节选) 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 里能看到——所以采样放在了脚本之外,靠时间戳把两边对齐。
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:一个负责接新连接,一个专属这条连接。
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 出去,数据会躺在内核缓冲区里等着。 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 只是「最多」。
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 也关掉它仍在。
b'' 之后忘了关闭 conn,netstat 里就会一直挂着 CLOSE_WAIT。TIME_WAIT 则是「先关闭的一方」付出的代价——本次是客户端。无人监听时 connect: ConnectionRefusedError errno=61 ECONNREFUSED - Connection refused
服务端的监听 socket 关掉后再 connect 同一个端口,得到 ConnectionRefusedError。本机 macOS 上 errno 是 61;errno 的数字随系统不同,代码里应该用 errno.ECONNREFUSED 或直接捕获 ConnectionRefusedError,不要写死数字。
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() 多做的一步。
socket、sys.addaudithook;状态采样用系统自带 netstat -an,每 0.08 秒一次