Socket 编程 HOWTO

作者:

Gordon McMillan

Socket

我将只讨论 INET(即 IPv4)Socket,但它们至少占所有使用中 Socket 的 99%。而且我将只讨论 STREAM(即 TCP)Socket——除非你真正知道自己在做什么(在这种情况下,本 HOWTO 不适合你!),否则你将从 STREAM Socket 获得比其他任何类型更好的行为和性能。我将尝试澄清 Socket 的神秘之处,并提供一些关于如何使用阻塞和非阻塞 Socket 的提示。但我将从阻塞 Socket 开始谈起。在处理非阻塞 Socket 之前,你需要了解它们的工作原理。

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

历史

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

它们是在 Berkeley 作为 BSD 版本 Unix 的一部分发明的。它们随着互联网像野火一样蔓延开来。这有充分的理由——socket 与 INET 的结合使得与世界各地任意机器的通信变得 unbelievably 容易(至少与其他方案相比)。

创建 Socket

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

# 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 完成后,socket s 可用于发送页面文本的请求。同一个 socket 将读取回复,然后被销毁。没错,被销毁。客户端 socket 通常只用于一次交换(或一小部分顺序交换)。

Web 服务器中发生的事情要复杂一些。首先,Web 服务器创建一个“服务器 socket”

# 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(),以便该 socket 对外部世界可见。如果使用 s.bind(('localhost', 80))s.bind(('127.0.0.1', 80)),我们仍然会有一个“服务器”socket,但它只能在同一台机器内可见。s.bind(('', 80)) 指定该 socket 可通过机器拥有的任何地址访问。

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

最后,传递给 listen 的参数告诉 socket 库我们希望它在拒绝外部连接之前,最多将 5 个连接请求(通常的最大值)排队。如果其余代码编写得当,这应该足够了。

现在我们有了一个“服务器”socket,监听 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 = make_client_thread(clientsocket)
    ct.start()

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

IPC

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

参见

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

使用 Socket

首先要注意的是,Web 浏览器的“客户端”socket 和 Web 服务器的“客户端”socket 是完全相同的。也就是说,这是一场“点对点”对话。或者换句话说,*作为设计者,你必须决定对话的礼仪规则*。通常,connecting socket 通过发送请求或登录信息来开始对话。但这只是一个设计决策——它不是 socket 的规则。

现在有两种通信动词集合可供使用。你可以使用 sendrecv,或者将你的客户端 socket 转换为一个文件状实体并使用 readwrite。后者是 Java 呈现其 socket 的方式。我在这里不会讨论它,除了警告你需要在 socket 上使用 flush。这些是带缓冲的“文件”,一个常见的错误是 write 一些东西,然后 read 等待回复。如果没有 flush,你可能会永远等待回复,因为请求可能仍然在你的输出缓冲区中。

现在我们遇到了 socket 的主要障碍——sendrecv 操作的是网络缓冲区。它们不一定能处理你交给它们(或期望它们)的所有字节,因为它们的主要关注点是处理网络缓冲区。一般来说,当相关的网络缓冲区被填满(send)或清空(recv)时,它们会返回。然后它们会告诉你处理了多少字节。*你的*责任是再次调用它们,直到你的消息被完全处理。

recv 返回 0 字节时,这意味着另一端已经关闭(或正在关闭)连接。你将不会再收到此连接上的任何数据。永远不会。你可能仍然可以成功发送数据;稍后我会详细讨论这一点。

像 HTTP 这样的协议仅用于一次传输的 socket。客户端发送请求,然后读取回复。仅此而已。socket 被丢弃。这意味着客户端可以通过接收 0 字节来检测回复的结束。

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

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

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 语言中,情况也好不了多少,只是如果消息包含嵌入的 \0,你就不能使用 strlen。)

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

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

在消息前加上其长度(例如,用 5 个数字字符表示)会变得更复杂,因为(信不信由你),你可能无法在一次 recv 中收到所有 5 个字符。在摸索中,你可能会侥幸成功;但在高网络负载下,除非你使用两个 recv 循环——第一个确定长度,第二个获取消息的数据部分——否则你的代码会很快崩溃。很糟糕。这时你还会发现 send 并不总是能一次性发送完所有内容。尽管你已经读过这些,但你最终还是会被它咬到!

出于节省空间、锻炼你的意志(并保持我的竞争地位)的考虑,这些增强留作读者的练习。让我们继续进行清理工作。

二进制数据

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

Socket 库有用于转换 16 位和 32 位整数的调用——ntohl, htonl, ntohs, htons,其中“n”代表*网络*,“h”代表*主机*,“s”代表*短整型*,“l”代表*长整型*。当网络字节序是主机字节序时,这些函数不执行任何操作,但当机器是字节反转时,它们会适当地交换字节。

在 64 位机器的时代,二进制数据的 ASCII 表示通常比二进制表示更小。这是因为在惊人的大部分时间里,大多数整数的值为 0,或者可能是 1。字符串 "0" 将是两个字节,而一个完整的 64 位整数将是 8 个字节。当然,这与固定长度消息不太吻合。抉择,抉择。

断开连接

严格来说,在 close 一个 socket 之前,你应该对它使用 shutdownshutdown 是对另一端的 socket 的一个建议。根据你传递给它的参数,它可以表示“我不再发送了,但我仍然监听”,或者“我不监听了,拜拜!”。然而,大多数 socket 库已经非常习惯于程序员忽略使用这个礼节性的步骤,因此通常 close 等同于 shutdown(); close()。所以在大多数情况下,不需要显式的 shutdown

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

Python 将自动关闭更进一步,并表示当 socket 被垃圾回收时,如果需要,它会自动执行 close。但依赖这个是非常糟糕的习惯。如果你的 socket 在没有执行 close 的情况下突然消失,另一端的 socket 可能会无限期地挂起,认为你只是慢。在完成使用后 close 你的 socket。

当 Socket 死亡时

使用阻塞 socket 最糟糕的事情之一可能就是当另一端突然宕机(没有执行 close)时会发生什么。你的 socket 很可能会挂起。TCP 是一种可靠的协议,它会等待很长时间才会放弃连接。如果你正在使用线程,整个线程基本上就死了。你对此束手无策。只要你没有做蠢事,比如在执行阻塞读取时持有锁,线程实际上并没有消耗太多资源。不要尝试杀死线程——线程比进程更高效的部分原因在于它们避免了与资源自动回收相关的开销。换句话说,如果你真的设法杀死了线程,你的整个进程很可能会被搞砸。

非阻塞 Socket

如果你已经理解了前面的内容,那么你已经掌握了使用 socket 的大部分机制。你仍然会以大致相同的方式使用相同的调用。只是,如果你做得正确,你的应用程序几乎会内外颠倒。

在 Python 中,你使用 socket.setblocking(False) 使其变为非阻塞。在 C 中,它更复杂(一方面,你需要选择 BSD 风格的 O_NONBLOCK 和几乎难以区分的 POSIX 风格的 O_NDELAY,这与 TCP_NODELAY 完全不同),但思路完全相同。你在创建 socket 后、使用它之前执行此操作。(实际上,如果你疯了,你可以来回切换。)

主要的机械差异在于 sendrecvconnectaccept 可以在没有做任何事情的情况下返回。你当然有多种选择。你可以检查返回码和错误码,并让自己发疯。如果你不相信我,试试看。你的应用程序会变得庞大、充满 bug 并消耗 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:第一个包含所有你可能想尝试读取的 socket;第二个包含所有你可能想尝试写入的 socket;最后一个(通常留空)包含你想要检查错误的 socket。你应该注意一个 socket 可以出现在多个列表中。select 调用是阻塞的,但你可以给它一个超时时间。这通常是明智的做法——给它一个相当长的超时时间(比如一分钟),除非你有充分的理由不这样做。

作为回报,你将得到三个列表。它们包含实际可读、可写和发生错误的 socket。每个列表都是你传入的相应列表的一个子集(可能为空)。

如果一个 socket 在输出的可读列表中,那么你可以*在这个行业中我们所能达到的*几乎肯定地确定对该 socket 执行 recv 将返回*一些东西*。可写列表也是同样的想法。你将能够发送*一些东西*。也许不是你想要的所有,但*一些东西*总比没有好。(实际上,任何相当健康的 socket 都会返回可写状态——它只是意味着出站网络缓冲区空间可用。)

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

实际上,即使是阻塞型 socket,select 也可能很有用。它是判断你是否会阻塞的一种方法——当缓冲区中有数据时,socket 会返回可读状态。然而,这仍然无助于解决判断对方是否完成或只是忙于其他事情的问题。

可移植性警告:在 Unix 上,select 对 socket 和文件都有效。不要在 Windows 上尝试这样做。在 Windows 上,select 仅适用于 socket。另请注意,在 C 语言中,许多更高级的 socket 选项在 Windows 上的实现方式不同。事实上,在 Windows 上,我通常会结合线程(运行得非常好)使用我的 socket。