Skip to content

第4章 Socket、地址、端口和四元组

从三个客户端提出问题

假设有一个服务端正在监听 127.0.0.1:50007,同一台电脑上有三个客户端同时向它发起连接。尽管这三个客户端访问的是同一个 IP 和端口,操作系统却依然能准确无误地把数据交还给对应的客户端。

我们不妨先猜一猜:系统连接表里到底会出现几个 Socket?这三个连接有哪些字段是相同的,它们又是靠哪个字段来互相区分的?

要搞清楚这些问题,我们需要理清三个核心概念:

  1. Socket(套接字):这是操作系统为了网络通信而抽象出来的“端点”。在 Python 里,socket.socket 对象就是对底层*系统句柄(Handle)*的封装。
  2. 地址与端口:它们共同标识了通信端点在网络栈中的具体位置。
  3. 四元组(4-Tuple):用于唯一标识一条已建立的 TCP 连接,明确了通信双方的两个端点。
Socket 句柄是什么?

句柄是进程引用操作系统内核对象的标识。应用持有 Socket 句柄,通过它发起 bindconnectsendrecv 等系统操作;真正的协议状态和缓冲区由内核维护。

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 对象,随后程序就可以通过它来调用 bindconnectsendrecv 等网络操作了。

刚刚创建出来的 Socket 还处在生命周期的起点,就像个“空壳”。随着后续方法的调用,它才会被逐渐填入本地地址、远端地址以及连接状态。一个进程可以同时拥有很多个 Socket。更重要的是,对于每一个已建立连接的 Socket,操作系统都会为它们各自维护一套独立的发送缓冲区(Send Buffer)、接收缓冲区(Receive Buffer)、TCP 状态和错误信息。

需要注意的是,端口号(Port)和进程 ID(PID)是完全独立的两套命名体系。进程通过 Socket 来占用和使用端口;一个进程可以同时使用多个不同的端口,反过来,同一个服务端端口也会出现在成千上万个已连接的 Socket 中。

监听 Socket 与已连接 Socket

在服务端,我们通常会先创建一个 Socket,然后依次调用 bindlisten

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.1IPv4 回环地址连接本机 IPv4 服务
::1IPv6 回环地址连接本机 IPv6 服务
localhost由名称解析得到的本地主机名结果可能包含 127.0.0.1::1 或两者
0.0.0.0IPv4 通配绑定地址让服务端在所有合适的本机 IPv4 地址上接收连接
::IPv6 通配绑定地址让服务端在所有合适的本机 IPv6 地址上接收连接
局域网地址当前局域网中的接口地址同一局域网内跨主机实验
公网地址可在公网路由语境中使用的地址经过路由器、防火墙或 NAT 的远程通信
::1 是什么?

::1 是 IPv6 回环地址,作用相当于 IPv4 的 127.0.0.1。发往它的数据只在本机 IPv6 网络栈中流转,适合本地服务与测试。

localhost 可能同时解析出 ::1127.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.py
powershell
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 脚本,并让它们再次停留在等待输入的界面即可。

理解检查

  1. 服务端那个监听在 127.0.0.1:50007 的 Socket 到底跟多少个确定的客户端建立了直接关联?
  2. 当三个不同的客户端同时和同一台服务端的同一个端口建立连接时,操作系统通常是靠什么字段把它们区分开的?
  3. 通配地址 0.0.0.0 应该被填在 bind 调用里,还是被当作 connect 的目标参数?
  4. 为什么在自己电脑上跑测试,每建立一条本地回环连接,系统的连接表里就会蹦出两条 Established 记录?
  5. 如果你想跟同事严谨地描述某条特定的网络连接流,光扔给他一个四元组够吗?还缺哪些必要的上下文?

参考答案

  1. 零个。监听 Socket 就像餐厅的迎宾员,只负责接待新客人,不陪客人吃饭。只有当调用 accept 后,系统才会派出一个专属的“已连接 Socket”(服务员)去和具体的客户端对接。
  2. 主要靠客户端临时端口。正是因为这几个随机分配的高位端口各不相同,才保证了这三条连接的四元组(源 IP、源端口、目的 IP、目的端口)是全局唯一的。
  3. 绝对是 bind0.0.0.0 意味着服务端愿意接收发往本机所有网卡 IP 的请求。而客户端在调用 connect 发起连接时,目标必须是一个明确具体的 IP 地址,不能是这种模棱两可的通配符。
  4. 因为连接的发送方和接收方都在同一台电脑上。操作系统为了同时管理发起连接的客户端 Socket 和被动建立连接的服务端 Socket,自然要在内核表里为它们各自维护一份 Established 状态。
  5. 至少还得说清楚底层协议(比如是 TCP 还是 UDP),以及这是在哪台主机、哪个**网络命名空间(Network Namespace)**下的四元组。如果网络链路中经过了 NAT(地址转换)设备,你甚至还得强调这组数据是在路由器的哪一侧、哪张网卡上抓到的。

本章小结

简单总结一下:监听 Socket 专职负责迎接新连接,而新生成的已连接 Socket 则扛起了特定客户端与服务端之间数据收发的重任。服务端的端口通常是固定不变的,而客户端因为分配了不同的临时端口,巧妙地让每一条并发连接都获得了全宇宙唯一的四元组。在接下来的章节里,我们将会把刚才提到的这些对象,放到更加完整的 Socket API 生命周期中去盘一盘,仔细观察每一个网络系统调用(System Call)究竟在什么时候才会返回。

上一章:第3章 TCP 是字节流 · 所属篇:第二篇 · 下一章:第5章 用一个最小程序建立 TCP 连接 · 教程总览

Licensed under CC BY-NC-SA 4.0.