套接字编程 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()
实际上,此循环有 3 种通用工作方式 - 分派一个线程来处理 clientsocket
,创建一个新进程来处理 clientsocket
,或者重构此应用程序以使用非阻塞套接字,并在我们的“服务器”套接字和任何活动 clientsocket
之间使用 select
进行多路复用。稍后会详细介绍。现在需要理解的重要一点是:这是“服务器”套接字所做的全部事情。它不发送任何数据。它不接收任何数据。它只是产生“客户端”套接字。每个 clientsocket
都是为了响应一些其他“客户端”套接字对我们绑定的主机和端口执行 connect()
而创建的。一旦我们创建了 clientsocket
,我们就会回到监听更多连接。两个“客户端”可以自由聊天 - 它们使用一些动态分配的端口,这些端口将在对话结束后回收。
IPC¶
如果您需要在同一台机器上的两个进程之间进行快速 IPC,则应研究管道或共享内存。如果您决定使用 AF_INET 套接字,请将“服务器”套接字绑定到 'localhost'
。在大多数平台上,这将绕过几层网络代码,并且速度会快得多。
另请参阅
multiprocessing
将跨平台 IPC 集成到更高级别的 API 中。
使用套接字¶
首先要注意的是,Web 浏览器的“客户端”套接字和 Web 服务器的“客户端”套接字是相同的。也就是说,这是一个“对等”对话。或者换句话说,作为设计者,您必须决定对话的礼仪规则。通常,connect
连接的套接字通过发送请求或注册开始对话。但这是一个设计决策 - 而不是套接字的规则。
现在有两种动词可用于通信。您可以使用 send
和 recv
,或者可以将客户端套接字转换为类似文件的野兽并使用 read
和 write
。后者是 Java 提供其套接字的方式。我在这里不打算讨论它,除非警告您需要在套接字上使用 flush
。这些是缓冲的“文件”,一个常见的错误是 write
某些内容,然后 read
以获取回复。如果没有 flush
,您可能会永远等待回复,因为请求可能仍然在您的输出缓冲区中。
现在我们遇到了套接字的主要障碍 - send
和 recv
在网络缓冲区上运行。它们不一定处理您交给它们(或期望它们)的所有字节,因为它们的主要重点是处理网络缓冲区。通常,当相关的网络缓冲区被填充 (send
) 或清空 (recv
) 时,它们会返回。然后,它们会告诉您它们处理了多少字节。您有责任再次调用它们,直到您的消息被完全处理。
当 recv
返回 0 字节时,表示另一侧已关闭(或正在关闭)连接。您将不会在此连接上收到任何更多数据。永远。您可能能够成功发送数据;稍后我会详细介绍。
像 HTTP 这样的协议仅使用套接字进行一次传输。客户端发送请求,然后读取回复。就是这样。套接字被丢弃。这意味着客户端可以通过接收 0 字节来检测回复的结尾。
但是,如果您计划重复使用套接字进行进一步传输,则需要意识到套接字上没有 EOT。我再说一遍:如果套接字 send
或 recv
在处理 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 语言中,情况也好不到哪里去,只是如果消息中嵌入了 \0
,就不能使用 strlen
了。)
最简单的改进是使消息的第一个字符成为消息类型的指示符,并让类型决定长度。现在您有两个 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 个字节。当然,这与固定长度的消息不太匹配。决策,决策。
断开连接¶
严格来说,您应该在 close
套接字之前对其使用 shutdown
。shutdown
是对另一端套接字的建议。根据您传递的参数,它可以表示“我不会再发送任何东西,但我仍然会监听”,或者“我不监听了,赶紧滚蛋!”。但是,大多数套接字库都已经习惯了程序员忽略使用此礼节,因此通常 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
完全不同),但它是完全相同的想法。您在创建套接字后但在使用套接字之前执行此操作。(实际上,如果您疯了,您可以来回切换。)
主要机制区别在于 send
、recv
、connect
和 accept
可能会在没有执行任何操作的情况下返回。您(当然)有许多选择。您可以检查返回代码和错误代码,并且通常会把自己逼疯。如果您不相信我,请尝试一下。您的应用程序将变得庞大、有错误并占用 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 上,我通常将线程(效果非常好)与我的套接字一起使用。