Appearance
第4章 Socket、地址、端口和四元组
从三个客户端提出问题
假设有一个服务端正在监听 127.0.0.1:50007,同一台电脑上有三个客户端同时向它发起连接。尽管这三个客户端访问的是同一个 IP 和端口,操作系统却依然能准确无误地把数据交还给对应的客户端。
我们不妨先猜一猜:系统连接表里到底会出现几个 Socket?这三个连接有哪些字段是相同的,它们又是靠哪个字段来互相区分的?
要搞清楚这些问题,我们需要理清三个核心概念:
- Socket(套接字):这是操作系统为了网络通信而抽象出来的“端点”。在 Python 里,
socket.socket对象就是对底层*系统句柄(Handle)*的封装。 - 地址与端口:它们共同标识了通信端点在网络栈中的具体位置。
- 四元组(4-Tuple):用于唯一标识一条已建立的 TCP 连接,明确了通信双方的两个端点。
Socket 句柄是什么?
句柄是进程引用操作系统内核对象的标识。应用持有 Socket 句柄,通过它发起 bind、connect、send、recv 等系统操作;真正的协议状态和缓冲区由内核维护。
Windows 常称其为 Socket handle,Unix-like 系统通常把 Socket 暴露为文件描述符。标识值只在特定进程和生命周期内有意义。
Socket:操作系统眼中的通信端点
当 Python 执行下面这行代码时,不仅会在应用层创建一个 socket.socket 对象,同时也会请求操作系统在底层创建一个对应的 IPv4 TCP 端点:
python
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)其中,AF_INET 指定使用 IPv4 地址族,而 SOCK_STREAM 则表示这是一个面向字节流(Byte Stream)的 Socket。当协议参数采用默认值时,这种组合指的就是 TCP。变量 s 拿到了 Python 封装好的 Socket 对象,随后程序就可以通过它来调用 bind、connect、send、recv 等网络操作了。
刚刚创建出来的 Socket 还处在生命周期的起点,就像个“空壳”。随着后续方法的调用,它才会被逐渐填入本地地址、远端地址以及连接状态。一个进程可以同时拥有很多个 Socket。更重要的是,对于每一个已建立连接的 Socket,操作系统都会为它们各自维护一套独立的发送缓冲区(Send Buffer)、接收缓冲区(Receive Buffer)、TCP 状态和错误信息。
需要注意的是,端口号(Port)和进程 ID(PID)是完全独立的两套命名体系。进程通过 Socket 来占用和使用端口;一个进程可以同时使用多个不同的端口,反过来,同一个服务端端口也会出现在成千上万个已连接的 Socket 中。
监听 Socket 与已连接 Socket
在服务端,我们通常会先创建一个 Socket,然后依次调用 bind 和 listen:
text
socket → bind(127.0.0.1, 50007) → listen调用 listen 之后,它就摇身一变成为了一个监听 Socket(Listening Socket)。从这一刻起,操作系统内核开始接管发往本机 127.0.0.1:50007 的新连接请求。内核会自动完成 TCP 三次握手,并把建好的连接放进队列里,等待应用程序来认领。值得一提的是,监听 Socket 只有明确的本地端点,它的远端信息是处于通配(Wildcard)状态的——因为它随时准备迎接来自四面八方的客户端。
应用层调用 accept 时,如果队列为空,默认会阻塞等待;如果在调用前客户端已经完成了握手,它就能直接拿到连接。当已完成连接队列(Accept Queue)里有连接可领时,操作系统会“孵化”并返回一个新的 Socket——已连接 Socket(Connected Socket):
text
监听 Socket ──accept──▶ 已连接 Socket A:对话客户端 A
├─accept──▶ 已连接 Socket B:对话客户端 B
└─accept──▶ 已连接 Socket C:对话客户端 C如上图所示,监听 Socket 的职责非常专一,它只负责迎宾,接收源源不断的新连接请求。而每一个已连接 Socket 则负责在后台一对一地和具体的客户端进行数据收发。这些已连接 Socket 虽然都共享着服务端的本地端点 127.0.0.1:50007,但它们各自的远端端点(客户端 IP 和端口)都不一样,操作系统正是依靠这一点将它们区分开来。
这也就解释了服务端开发中一个常见的新手疑惑:为什么服务端程序只监听了一个 80 或 443 端口,却能同时和成千上万个客户端保持连接?答案就在于系统为每次连接都创建了独立的新 Socket。当然,服务器到底能扛住多少并发连接,最终还是取决于系统的内存、*文件描述符(File Descriptor)*限制、等待队列大小、CPU 处理速度,以及客户端可用的临时端口等软硬件综合因素。
文件描述符是什么?
文件描述符是 Unix-like 系统在进程内使用的小整数,用于引用打开的文件、Socket、管道等内核对象。每条监听或已连接 Socket 通常都会占用一个描述符,因此进程和系统的描述符上限会限制可同时持有的连接数。
Windows 的 Socket 使用专门句柄语义;Python 的 fileno() 会提供可供运行时和事件接口使用的整数表示,跨平台代码仍应通过 Socket API 管理它。
IP 地址:回答“哪台主机的哪个接口”
在后续的教程中,我们会经常打交道以下几类 IP 地址:
| 写法 | 含义 | 典型用途 |
|---|---|---|
127.0.0.1 | IPv4 回环地址 | 连接本机 IPv4 服务 |
::1 | IPv6 回环地址 | 连接本机 IPv6 服务 |
localhost | 由名称解析得到的本地主机名 | 结果可能包含 127.0.0.1、::1 或两者 |
0.0.0.0 | IPv4 通配绑定地址 | 让服务端在所有合适的本机 IPv4 地址上接收连接 |
:: | IPv6 通配绑定地址 | 让服务端在所有合适的本机 IPv6 地址上接收连接 |
| 局域网地址 | 当前局域网中的接口地址 | 同一局域网内跨主机实验 |
| 公网地址 | 可在公网路由语境中使用的地址 | 经过路由器、防火墙或 NAT 的远程通信 |
::1 是什么?
::1 是 IPv6 回环地址,作用相当于 IPv4 的 127.0.0.1。发往它的数据只在本机 IPv6 网络栈中流转,适合本地服务与测试。
localhost 可能同时解析出 ::1 和 127.0.0.1,客户端最终选择哪个地址取决于解析顺序、地址选择策略和服务监听情况。
0.0.0.0 是什么?
服务端在 bind 中使用 0.0.0.0,表示在所有合适的本机 IPv4 接口地址上接收连接。它是通配绑定地址,不代表一台可供客户端连接的具体远端主机。
绑定范围扩大后,局域网或公网接口也可能暴露服务,是否可达还取决于路由、防火墙和网络边界配置。
IPv6 的 :: 通配地址是什么?
服务端在 bind 中使用 ::,表示在所有合适的本机 IPv6 接口地址上接收连接。它对应 IPv6 的未指定地址,也常写作 ::/128 的地址值。
该监听是否同时接受 IPv4 映射连接由平台和 IPV6_V6ONLY 设置决定,跨平台程序应显式验证或配置。
这里有一个重点:通配地址(如 0.0.0.0)是专门给服务端 bind 用的。客户端发起连接时,绝不能填通配地址,必须指明具体的目标 IP(比如 127.0.0.1 或者某台服务器的局域网 IP)。当服务端绑定了 0.0.0.0:50007,如果此时有客户端把请求发到了网卡 IP 192.168.1.20 上,系统创建出的那个已连接 Socket,它的本地地址就会自动变成实际接收请求的那个本机 IP(即 192.168.1.20),而不再是泛泛的 0.0.0.0。
另外,localhost 并不是 IP,而是一个主机名(Hostname),要想使用它必须先经过系统的 DNS 解析。如果你好奇 Python 会把 localhost 解析成什么,可以在命令行里跑一下这个测试:
powershell
python -X utf8 -c "import socket; print(socket.getaddrinfo('localhost', 50007, type=socket.SOCK_STREAM))"为了排除干扰,本章实验统一使用纯净的 IPv4 地址 127.0.0.1。以后如果想试水 IPv6,记得在代码里把地址族切换成 socket.AF_INET6。需要补充的是,如果服务端监听了 IPv6 通配地址 ::,它能否同时接收来自 IPv4 客户端的“映射连接”,往往取决于操作系统的底层行为和 IPV6_V6ONLY 选项的设置。
端口:回答“交给系统里的哪个通信端点”
在 TCP 报文头部(Header)中,端口字段占 16 个比特(bit),因此它的取值范围是 0 到 65535。为了让客户端能顺利找上门,服务端通常会挑选并固定监听一个已知端口(Well-known Port 或 Registered Port)。本章的实验里,我们随便挑了一个高位端口 50007。
相比之下,客户端则随意得多。在调用 connect 时,客户端通常无需关心自己用哪个 IP 和端口发起连接,它会把选择权全权委托给操作系统。此时,系统内核会根据当前的路由表决定选用哪个网卡(本地 IP),并从系统的临时端口(Ephemeral Port)池中随机挑一个没被占用的端口分配给它。比如:
text
客户端本地端点:127.0.0.1:53124
服务端远端端点:127.0.0.1:50007如果紧接着运行下一个客户端,它分到的端口可能就是 53125。至于临时端口的具体可用范围和分配算法,这完全属于操作系统的内部实现细节,不同平台(如 Linux 和 Windows)不仅默认范围不同,每次运行分到的结果也难以预测。
顺带提一句,如果我们在调用 bind 时故意把端口写成 0,其实是在对操作系统说:“我懒得选了,你随便帮我挑一个空闲端口吧”。等系统分配完毕后,我们可以通过 getsockname() 获取到底分到了哪个真实端口;并且在后续抓包时,TCP 报文里记录的也会是这个真实分配的值,绝不会出现端口号为 0 的报文。
四元组:TCP 连接的唯一身份证
从客户端向服务端发送数据的方向来看,一条 TCP 连接可以被抽象成这四个元素的组合:
text
(源 IP, 源端口, 目标 IP, 目标端口)
(127.0.0.1, 53124, 127.0.0.1, 50007)而当数据从服务端发回客户端时,视角的改变导致源和目标发生了对调:
text
(127.0.0.1, 50007, 127.0.0.1, 53124)千万别被弄晕了,不管方向怎么反转,它们描述的始终是同一条 TCP 连接。在系统层面讨论连接状态时,为了统一口径,我们习惯用“本地端点 + 远端端点”来描述;但在使用 Wireshark 抓包分析报文(Segment)时,四元组通常是严格按照当前报文头部携带的源与目标信息来书写的。
回到开篇的那个问题,三个客户端并发请求同一个服务端,它们的连接状态可以用四元组表示为:
text
(127.0.0.1, 53124, 127.0.0.1, 50007)
(127.0.0.1, 53125, 127.0.0.1, 50007)
(127.0.0.1, 53126, 127.0.0.1, 50007)尽管这三条连接的目标端点(服务端的 IP 和端口)完全相同,但由于客户端操作系统分配了不同的临时端口,这三条连接依然拥有了各自独一无二的四元组。当操作系统的网络协议栈收到一个 TCP 报文段时,它会结合传输层协议类型(TCP)、*网络命名空间(Network Namespace)*以及报文首部的四元组信息,在内核中精准锁定对应的已连接 Socket。如果查无此人,且报文带有建连标志(SYN),系统才会退而求其次,依据报文的目标 IP 和端口去查找有没有匹配的监听 Socket。值得强调的是,TCP 和 UDP 协议在内核中维护了相互独立的端口空间,因此它们完全可以“撞号”而不发生冲突(比如 TCP 和 UDP 可以同时使用 80 端口)。
网络命名空间是什么?
网络命名空间把网络接口、IP 地址、路由表、邻居表、端口和连接状态隔离成独立视图。Linux 容器常借助它让多个环境各自拥有看似独立的网络栈。
相同四元组可以同时存在于不同网络命名空间中,因此现场记录连接时还要注明主机、容器或命名空间上下文。
在实际的复杂网络环境中,中间的 NAT(网络地址转换)路由器极有可能会为了转发包而偷偷篡改报文的 IP 地址甚至端口。这就导致在网络拓扑的不同位置抓包,你看到的同一条流的四元组可能是截然不同的。因此,在出具网络排障报告时,务必要写清楚你是在哪台机器、哪个网卡上抓的包,否则很容易鸡同鸭讲。
动手实验:用代码验证并发连接
新建 tuple_server.py:
python
import socket
HOST = "127.0.0.1"
PORT = 50007
def main() -> None:
accepted: list[socket.socket] = []
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.bind((HOST, PORT))
listener.listen(3)
print("listener:", listener.getsockname())
try:
for number in range(1, 4):
conn, peer = listener.accept()
accepted.append(conn)
print(
f"connection {number}:",
"local=", conn.getsockname(),
"peer=", peer,
)
input("三条连接均已建立,按 Enter 关闭服务端:")
finally:
for conn in accepted:
conn.close()
if __name__ == "__main__":
main()再新建 tuple_clients.py:
python
import socket
SERVER = ("127.0.0.1", 50007)
def main() -> None:
clients: list[socket.socket] = []
try:
for number in range(1, 4):
client = socket.create_connection(SERVER, timeout=5)
client.settimeout(None)
clients.append(client)
print(
f"client {number}:",
"local=", client.getsockname(),
"peer=", client.getpeername(),
)
input("三条连接均已建立,按 Enter 关闭客户端:")
finally:
for client in clients:
client.close()
if __name__ == "__main__":
main()在这两段代码中,我们利用了 Python 的上下文管理器(with)和 finally 块来确保程序退出时能干净地关闭 Socket。现在,准备好打开两个 PowerShell 窗口,依次执行它们:
powershell
python -X utf8 .\tuple_server.pypowershell
python -X utf8 .\tuple_clients.py当服务端和客户端的控制台都打印出连接信息,并挂起在等待 Enter 键的提示语时,请保持它们运行。接着,新开第三个 PowerShell 窗口,输入以下命令来窥探操作系统的网络状态(通过管道过滤出和端口 50007 相关的条目):
powershell
Get-NetTCPConnection |
Where-Object { $_.LocalPort -eq 50007 -or $_.RemotePort -eq 50007 } |
Sort-Object State, LocalPort, RemotePort |
Format-Table State, LocalAddress, LocalPort, RemoteAddress, RemotePort, OwningProcess预期现象
- 服务端程序的终端里,会清晰地打印出三次
accept拿到的对端信息(peer),你能看到三个截然不同的客户端临时端口。 - 反观客户端程序的终端,三个建立好连接的 Socket 查询出来的远端地址(
peer)则是惊人的一致,全都是127.0.0.1:50007。 - 只要此时系统中没有之前实验残留的僵尸连接(TIME_WAIT 状态等),上述 PowerShell 命令通常会精确列出 7 条相关的系统记录:1 条处于
Listen(监听)状态的记录,外加 6 条处于Established(已建立连接)状态的记录。 - 为什么 3 条连接会变出 6 条
Established记录?那是因为我们是在本机测试(本地回环)。连接的客户端和服务端都在这一台电脑上,所以对于每条连接,操作系统的内核表里都会同时呈现客户端侧和服务端侧的“双重视角”。 - 仔细观察输出结果的
OwningProcess列(进程 ID),你会发现客户端建立的这三个 Socket 全部归属于同一个进程,但它们各自占用的本地临时端口却各不相同。
实验结束后,先在客户端按回车,紧接着在服务端按回车,程序就能优雅退出并关闭所有 Socket。如果你刚才敲获取网络状态的命令太慢,发现查不到任何记录了,别着急,重新跑一遍 Python 脚本,并让它们再次停留在等待输入的界面即可。
理解检查
- 服务端那个监听在
127.0.0.1:50007的 Socket 到底跟多少个确定的客户端建立了直接关联? - 当三个不同的客户端同时和同一台服务端的同一个端口建立连接时,操作系统通常是靠什么字段把它们区分开的?
- 通配地址
0.0.0.0应该被填在bind调用里,还是被当作connect的目标参数? - 为什么在自己电脑上跑测试,每建立一条本地回环连接,系统的连接表里就会蹦出两条
Established记录? - 如果你想跟同事严谨地描述某条特定的网络连接流,光扔给他一个四元组够吗?还缺哪些必要的上下文?
参考答案
- 零个。监听 Socket 就像餐厅的迎宾员,只负责接待新客人,不陪客人吃饭。只有当调用
accept后,系统才会派出一个专属的“已连接 Socket”(服务员)去和具体的客户端对接。 - 主要靠客户端临时端口。正是因为这几个随机分配的高位端口各不相同,才保证了这三条连接的四元组(源 IP、源端口、目的 IP、目的端口)是全局唯一的。
- 绝对是
bind。0.0.0.0意味着服务端愿意接收发往本机所有网卡 IP 的请求。而客户端在调用connect发起连接时,目标必须是一个明确具体的 IP 地址,不能是这种模棱两可的通配符。 - 因为连接的发送方和接收方都在同一台电脑上。操作系统为了同时管理发起连接的客户端 Socket 和被动建立连接的服务端 Socket,自然要在内核表里为它们各自维护一份
Established状态。 - 至少还得说清楚底层协议(比如是 TCP 还是 UDP),以及这是在哪台主机、哪个**网络命名空间(Network Namespace)**下的四元组。如果网络链路中经过了 NAT(地址转换)设备,你甚至还得强调这组数据是在路由器的哪一侧、哪张网卡上抓到的。
本章小结
简单总结一下:监听 Socket 专职负责迎接新连接,而新生成的已连接 Socket 则扛起了特定客户端与服务端之间数据收发的重任。服务端的端口通常是固定不变的,而客户端因为分配了不同的临时端口,巧妙地让每一条并发连接都获得了全宇宙唯一的四元组。在接下来的章节里,我们将会把刚才提到的这些对象,放到更加完整的 Socket API 生命周期中去盘一盘,仔细观察每一个网络系统调用(System Call)究竟在什么时候才会返回。
上一章:第3章 TCP 是字节流 · 所属篇:第二篇 · 下一章:第5章 用一个最小程序建立 TCP 连接 · 教程总览