套接字编程 HOWTO

作者:

Gordon McMillan

套接字

我只会讨论 INET(即 IPv4)套接字,但它们至少占了 99% 的套接字。我只会讨论 STREAM(即 TCP)套接字 - 除非你真的知道自己在做什么(在这种情况下,这个 HOWTO 不适合你!),否则你会从 STREAM 套接字中获得比其他任何东西更好的行为和性能。我会尝试解开套接字是什么的神秘面纱,以及一些关于如何处理阻塞和非阻塞套接字的提示。但我会先从讨论阻塞套接字开始。你需要了解它们的工作原理才能处理非阻塞套接字。

理解这些东西的部分困难在于,“套接字”可以根据上下文意味着许多微妙的不同事物。因此,首先,让我们区分“客户端”套接字 - 对话的端点,以及“服务器”套接字,它更像是交换机操作员。客户端应用程序(例如你的浏览器)专门使用“客户端”套接字;它正在与之通信的 Web 服务器同时使用“服务器”套接字和“客户端”套接字。

历史

在各种形式的 IPC 中,套接字是迄今为止最受欢迎的。在任何给定的平台上,可能存在其他形式的 IPC 速度更快,但对于跨平台通信,套接字几乎是唯一的选择。

它们是在伯克利作为 Unix 的 BSD 版本的一部分发明的。它们随着互联网像野火一样蔓延。有充分的理由 - 套接字与 INET 的结合使得与世界各地任意机器的通信变得异常容易(至少与其他方案相比)。

创建套接字

粗略地说,当你点击将你带到此页面的链接时,你的浏览器做了类似以下的事情

# create an INET, STREAMing socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# now connect to the web server on port 80 - the normal http port
s.connect(("www.python.org", 80))

connect 完成时,套接字 s 可用于发送页面文本的请求。同一个套接字将读取回复,然后被销毁。没错,销毁。客户端套接字通常只用于一次交换(或一组小的顺序交换)。

Web 服务器中发生的事情稍微复杂一些。首先,Web 服务器创建一个“服务器套接字”

# create an INET, STREAMing socket
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# bind the socket to a public host, and a well-known port
serversocket.bind((socket.gethostname(), 80))
# become a server socket
serversocket.listen(5)

需要注意的是:我们使用了 socket.gethostname(),以便套接字对外部世界可见。如果我们使用了 s.bind(('localhost', 80))s.bind(('127.0.0.1', 80)),我们仍然会拥有一个“服务器”套接字,但它只在同一台机器内可见。 s.bind(('', 80)) 指定套接字可以通过机器拥有的任何地址访问。

需要注意的第二件事是:低端口号通常保留给“众所周知”的服务(如 HTTP、SNMP 等)。如果你只是玩玩,请使用一个较高的端口号(4 位数)。

最后,listen 的参数告诉套接字库,我们希望它在拒绝外部连接之前,最多排队 5 个连接请求(正常最大值)。如果代码的其他部分编写正确,这应该足够了。

现在我们有了监听 80 端口的“服务器”套接字,我们可以进入 Web 服务器的主循环。

while True:
    # accept connections from outside
    (clientsocket, address) = serversocket.accept()
    # now do something with the clientsocket
    # in this case, we'll pretend this is a threaded server
    ct = client_thread(clientsocket)
    ct.run()

实际上,这个循环有三种通用方法:派生一个线程来处理 clientsocket,创建一个新进程来处理 clientsocket,或者重构这个应用程序以使用非阻塞套接字,并使用 select 在我们的“服务器”套接字和任何活动的 clientsocket 之间进行多路复用。我们稍后会详细介绍。现在要理解的关键是:这仅仅是“服务器”套接字所做的全部工作。它不会发送任何数据,也不会接收任何数据。它只是生成“客户端”套接字。每个 clientsocket 都是响应某个其他“客户端”套接字对我们绑定的主机和端口执行 connect() 操作而创建的。一旦我们创建了 clientsocket,我们就会回到监听更多连接。这两个“客户端”可以自由地进行通信 - 它们使用的是一些动态分配的端口,这些端口将在对话结束后被回收。

IPC

如果你需要在一台机器上的两个进程之间进行快速 IPC,你应该考虑使用管道或共享内存。如果你决定使用 AF_INET 套接字,请将“服务器”套接字绑定到 'localhost'。在大多数平台上,这将绕过几层网络代码,速度会快很多。

另请参阅

multiprocessing 将跨平台 IPC 集成到更高级别的 API 中。

使用套接字

首先要注意的是,Web 浏览器的“客户端”套接字和 Web 服务器的“客户端”套接字是相同的。也就是说,这是一个“对等”对话。或者换句话说,作为设计者,你必须决定对话的礼仪规则。通常,connecting 套接字通过发送请求或登录来启动对话。但这只是一个设计决策 - 它不是套接字的规则。

现在有两组动词可用于通信。你可以使用 sendrecv,或者你可以将你的客户端套接字转换为类似文件的对象,并使用 readwrite。后者是 Java 展示其套接字的方式。我在这里不会讨论它,只是提醒你,你需要在套接字上使用 flush。这些是带缓冲的“文件”,一个常见的错误是 write 某些内容,然后 read 回复。如果没有 flush,你可能会永远等待回复,因为请求可能仍然在你的输出缓冲区中。

现在我们遇到了套接字的主要障碍 - sendrecv 在网络缓冲区上操作。它们不一定处理你传递给它们(或期望从它们接收)的所有字节,因为它们的主要重点是处理网络缓冲区。通常,它们在关联的网络缓冲区已满(send)或清空(recv)时返回。然后它们会告诉你它们处理了多少字节。有责任再次调用它们,直到你的消息完全处理完毕。

recv 返回 0 字节时,这意味着对方已关闭(或正在关闭)连接。你将不会再收到此连接上的任何数据。你可能能够成功发送数据;我将在稍后详细介绍。

像 HTTP 这样的协议只使用一个套接字进行一次传输。客户端发送请求,然后读取回复。就这样。套接字被丢弃。这意味着客户端可以通过接收 0 字节来检测回复的结束。

但是,如果您计划将套接字用于进一步的传输,您需要意识到套接字上没有EOT我再说一遍:如果套接字sendrecv在处理 0 字节后返回,则连接已断开。如果连接没有断开,您可能会永远等待recv,因为套接字不会告诉您现在没有更多内容可读。现在,如果您稍微思考一下,您就会意识到套接字的一个基本事实:消息必须是固定长度(糟糕),或者被分隔(耸肩),或者指示它们的长度(好多了),或者通过关闭连接来结束。选择完全由您决定,(但有些方法比其他方法更正确)。

假设您不想结束连接,最简单的解决方案是固定长度消息

class MySocket:
    """demonstration class only
      - coded for clarity, not efficiency
    """

    def __init__(self, sock=None):
        if sock is None:
            self.sock = socket.socket(
                            socket.AF_INET, socket.SOCK_STREAM)
        else:
            self.sock = sock

    def connect(self, host, port):
        self.sock.connect((host, port))

    def mysend(self, msg):
        totalsent = 0
        while totalsent < MSGLEN:
            sent = self.sock.send(msg[totalsent:])
            if sent == 0:
                raise RuntimeError("socket connection broken")
            totalsent = totalsent + sent

    def myreceive(self):
        chunks = []
        bytes_recd = 0
        while bytes_recd < MSGLEN:
            chunk = self.sock.recv(min(MSGLEN - bytes_recd, 2048))
            if chunk == b'':
                raise RuntimeError("socket connection broken")
            chunks.append(chunk)
            bytes_recd = bytes_recd + len(chunk)
        return b''.join(chunks)

这里的发送代码几乎适用于任何消息传递方案 - 在 Python 中,您发送字符串,并且可以使用len()来确定其长度(即使它包含嵌入的\0字符)。主要是接收代码变得更加复杂。(在 C 中,情况并没有糟糕多少,只是您不能使用strlen,如果消息包含嵌入的\0)。

最简单的增强是使消息的第一个字符成为消息类型的指示符,并让类型确定长度。现在您有两个recv - 第一个获取(至少)第一个字符,以便您可以查找长度,第二个在循环中获取其余部分。如果您决定走分隔符路线,您将以某种任意块大小接收(4096 或 8192 通常与网络缓冲区大小非常匹配),并扫描您收到的内容以查找分隔符。

需要注意的一个复杂情况是:如果您的对话协议允许将多个消息背靠背发送(没有任何类型的回复),并且您将recv传递一个任意块大小,您最终可能会读取后续消息的开头。您需要将其放在一边并保留它,直到需要它。

在消息前面加上其长度(例如,作为 5 个数字字符)会变得更加复杂,因为(信不信由你),您可能无法在一个recv中获得所有 5 个字符。在玩耍时,您会摆脱它;但在网络负载很高的情况下,您的代码很快就会崩溃,除非您使用两个recv循环 - 第一个确定长度,第二个获取消息的数据部分。讨厌。这也是您会发现send并不总是能够在一遍中摆脱所有内容的时候。尽管您已经阅读了这一点,但您最终还是会被它咬住!

为了节省空间,构建您的角色,(并保持我的竞争地位),这些增强留给读者作为练习。让我们继续清理。

二进制数据

通过套接字发送二进制数据是完全可能的。主要问题是并非所有机器都使用相同的二进制数据格式。例如,网络字节序是大端序,最高有效字节在前,因此值为1的 16 位整数将是两个十六进制字节00 01。但是,大多数常见处理器(x86/AMD64、ARM、RISC-V)是小端序,最低有效字节在前 - 相同的1将是01 00

套接字库有用于转换 16 位和 32 位整数的调用 - ntohl, htonl, ntohs, htons,其中“n”表示网络,“h”表示主机,“s”表示,“l”表示。在网络顺序是主机顺序的地方,这些什么也不做,但在机器字节颠倒的地方,这些会适当地交换字节。

在当今的 64 位机器时代,二进制数据的 ASCII 表示通常比二进制表示更小。这是因为在令人惊讶的很多情况下,大多数整数的值为 0,或者可能为 1。字符串"0"将是两个字节,而一个完整的 64 位整数将是 8 个字节。当然,这与固定长度消息不匹配。决定,决定。

断开连接

严格来说,在关闭套接字之前,应该使用 shutdownshutdown 是对另一端套接字的建议。根据传递给它的参数,它可以表示“我不再发送,但我仍然会监听”,或者“我不再监听,再见!”。但是,大多数套接字库都习惯了程序员忽略使用此礼仪,因此通常 close 等同于 shutdown(); close()。因此,在大多数情况下,不需要显式 shutdown

有效使用 shutdown 的一种方法是在类似 HTTP 的交换中。客户端发送请求,然后执行 shutdown(1)。这告诉服务器“此客户端已完成发送,但仍可以接收”。服务器可以通过接收 0 字节来检测“EOF”。它可以假设它已收到完整的请求。服务器发送回复。如果 send 成功完成,那么确实,客户端仍在接收。

Python 将自动关闭更进一步,并表示当套接字被垃圾回收时,如果需要,它将自动执行 close。但是依赖于此是一个非常糟糕的习惯。如果您的套接字在没有执行 close 的情况下消失,另一端的套接字可能会无限期地挂起,认为您只是速度很慢。在完成时 close 您的套接字。

当套接字死亡时

使用阻塞套接字最糟糕的事情可能是当另一方突然关闭(没有执行 close)时会发生什么。您的套接字可能会挂起。TCP 是一种可靠的协议,它会在放弃连接之前等待很长时间。如果您使用的是线程,则整个线程实际上已死。您对此无能为力。只要您没有做一些愚蠢的事情,例如在执行阻塞读取时持有锁,线程实际上不会消耗太多资源。不要尝试杀死线程 - 线程比进程更有效的部分原因是它们避免了与资源自动回收相关的开销。换句话说,如果您设法杀死了线程,那么您的整个进程可能会被搞砸。

非阻塞套接字

如果您理解了前面内容,那么您已经了解了使用套接字机制的大部分内容。您仍然会使用相同的调用,以几乎相同的方式。只是,如果您做对了,您的应用程序将几乎是反过来的。

在 Python 中,您使用 socket.setblocking(False) 来使其非阻塞。在 C 中,它更复杂(一方面,您需要在 BSD 风格的 O_NONBLOCK 和几乎无法区分的 POSIX 风格的 O_NDELAY 之间进行选择,这与 TCP_NODELAY 完全不同),但它完全相同的想法。您在创建套接字后,但在使用它之前执行此操作。(实际上,如果您很疯狂,您可以来回切换。)

主要的机械差异是 sendrecvconnectaccept 可以返回而无需执行任何操作。您有(当然)许多选择。您可以检查返回值和错误代码,并让您自己发疯。如果您不相信我,那就试试吧。您的应用程序会变得庞大、有错误并且会占用 CPU。因此,让我们跳过脑残的解决方案,并正确地做。

使用 select

在 C 语言中,编码 select 相当复杂。在 Python 中,它非常简单,但它与 C 版本非常接近,所以如果你理解 Python 中的 select,你就能轻松地在 C 语言中使用它。

ready_to_read, ready_to_write, in_error = \
               select.select(
                  potential_readers,
                  potential_writers,
                  potential_errs,
                  timeout)

你向 select 传递三个列表:第一个包含所有你可能想要尝试读取的套接字;第二个包含所有你可能想要尝试写入的套接字,最后一个(通常为空)包含你想要检查错误的套接字。请注意,一个套接字可以出现在多个列表中。 select 调用是阻塞的,但你可以为它设置超时时间。这通常是一个明智的做法 - 设置一个较长的超时时间(例如一分钟),除非你有充分的理由这样做。

作为回报,你将获得三个列表。它们包含实际可读、可写和出错的套接字。每个列表都是你传入的对应列表的子集(可能为空)。

如果一个套接字出现在输出可读列表中,你可以几乎肯定地认为,对该套接字进行 recv 操作将返回某些内容。可写列表也是同样的道理。你将能够发送某些内容。可能不是你想要发送的所有内容,但某些内容总比没有好。(实际上,任何正常工作的套接字都会返回为可写 - 这仅仅意味着出站网络缓冲区空间可用。)

如果你有一个“服务器”套接字,请将其放入 potential_readers 列表中。如果它出现在可读列表中,你的 accept 将(几乎肯定)成功。如果你创建了一个新的套接字来与其他人 connect,请将其放入 potential_writers 列表中。如果它出现在可写列表中,你很有可能已经连接成功。

实际上,即使是阻塞套接字,select 也非常有用。它是一种确定你是否会阻塞的方法 - 当缓冲区中有内容时,套接字会返回为可读。但是,这仍然无法解决确定对方是否已完成,还是只是忙于其他事情的问题。

可移植性警告:在 Unix 上,select 同时适用于套接字和文件。不要在 Windows 上尝试这样做。在 Windows 上,select 仅适用于套接字。还要注意,在 C 语言中,许多更高级的套接字选项在 Windows 上的实现方式不同。事实上,在 Windows 上,我通常使用线程(它们工作得非常好)来处理我的套接字。