虽然套接字无处不在,但却是被误解最严重的技术之一。本文会介绍这项技术,但不会关注太多细节。因此要实际把代码跑起来,还需要填上一些坑。这些坑的数量不少,不过本文不会涉及到,但是我希望,本文能帮助读者对这项技术的背景有足够的了解,从而正确地使用套接字。
套接字
现在使用的套接字中,99%都是INET套接字,这里只讨论这种套接字。一般情况下,使用流套接字能获取更好的性能。我将尝试介绍套接字的前世今生,并在处理阻塞和非阻塞套接上提供一些建议。我会先介绍阻塞套接字。
理解套接字的一部分麻烦在于,根据不同的上下文,“socket”可能会有不同的意思。首先,我们来区分一下“客户端”套接字(一个会话的端点)和一个“服务器”套接字(更像是一个总机操作员)。客户端应用程序(比如浏览器)仅使用“客户端”套接字;服务端程序使用“客户端”和“服务器”套接字。
历史
在众多的进程通信方式(IPC)中,套接字是最流行的。给定平台上,可能存在着速度更快的IPC,但是对于跨平台进程通信,套接字是唯一的选择。
套接字是在BSD-Unix系统上最先发布的。随着因特网的普及,BSD套接字称为了事实上的网络套接字标准。使用这套标准,世界上任意两台机器之间的通信都变得非常方便。
创建套接字
当用户点击这个页面的链接时,浏览器大致会执行下面的操作:
# 创建流套接字
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 连接服务器的80端口
s.connect(("www.mcmillan-inc.com", 80))
当connect
操作完成时,套接字s
可用于发送请求来获得该页面的文本。s
读取到回复后奖被销毁。客户端套接字一般只用于一次交换(或一组顺序交换)。
web服务器端会稍微复杂一些。首先,web服务器创建一个服务器套接字:
# 创建流套接字
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 绑定主机和端口
serversocket.bind((socket.gethostname(), 80))
socket.listen(5)
需要注意几件事情:
- 我们使用
socket.gethostname()
,让套接字可以被外界访问到。如果使用s.bind(('localhost', 80))
或者s.bind(('127.0.0.1', 80))
,那么该套接字只能在这个机器上被访问到。s.bind(('', 80))
会让套接字通过该机器所拥有的所有地址访问到。 - 数字比较小的端口号通常保留用于“知名”服务(HTTP,SNMP等)。在测试的时候可以使用数字位数更多的端口号。
-
listen
的参数告诉套接字库,在连接数量到达5个之后再拒绝其他连接。如果其他的代码写得正确,这个数量应该足够了。
现在我们有了一个监听80端口的服务器套接字,下面进入web服务器的主循环:
while 1:
# 接受外部请求
(clientsocket, address) = serversocket.accept()
ct = client_thread(clientsocket)
ct.run()
主循环中一般有三种实现方式:
- 分配子线程来处理
clientsocket
。 - 创建新进程来处理
clientsocket
- 用非阻塞套接字重构代码,使用
select
复用服务器套接字和活动的clientsocket
套接字。
需要重点理解的是,这是服务端套接字所做的全部工作。这个套接字不会发送/接收任何数据,它只是用来创建客户端套接字。当其他客户端套接字调用connect()
方法时,clientsocket
被创建。一旦clientsocket
被创建,系统会回到循环监听更多的连接。那两个客户端套接字就可以自由交换信息了--它们使用动态分配的端口,这些端口在会话结束时将被回收。
IPC
要想在同一台机器上的两个进程之间实现快速的通信,应该考虑使用系统提供的任何形式的共享内存。基于共享内存和锁或信号量的简单协议是迄今为止最快的技术。
如果依然决定使用套接字,可以将服务器套接字绑定到localhost
。在大多数平台上,这会让通信在网络分层上走一些捷径,能带来更快的速度。
使用套接字
首先需要注意的是,浏览器使用的客户端套接字和web服务器使用的客户端套接字是相同的。也就是说,这是一个“点对点”对话。通常情况下,调用connect
的套接字通过发送请求开始一次对话。但是这是设计上的决策,而不是套接字的规则。
通信会使用两个方法。可以使用send
和recv
,或者使用read
和write
。后者是java的实现。需要在套接字上使用flush
。套接字都是缓存的“文件”,一个常见的错误是调用write
写入文件,然后调用read
等待回复。如果不使用flush
,可能会处于永远等待回复的状况中,因为请求可能仍然处于输出缓存区中。
现在来谈谈使用套接字会遇到的主要的坑--在网络缓冲区上调用send
和recv
操作。它们不一定会处理传递给它们的所有字节,因为这两个方法会涉及到网络缓存。一般来说,当网络缓存被填满(send
)或者清空(recv
)时,套接字会返回已处理的字节数。开发者需要多次调用send
和recv
来确保信息被完全处理了。
当recv
返回0时,表示对方已关闭(或正在关闭)连接。在本次连接上不会再接收更多的数据,但是仍可能发送数据。这个稍后再谈。
像HTTP这样的协议的套接字只用于一次传输。客户端发送请求,然后读取回复。完成后套接字将被丢弃。这意味着客户端可以通过检测接收字节是否为0来判断回复是否结束。
但是,如果你打算重用该套接字,必须考虑到套接字上没有EOT标识。调用send
或者是recv
返回0字节,则连接已经断开。如果连接没有断开,则可能等待recv
,因为套接字不会告诉使用者还有没有未读消息。如果你仔细考虑,会意识到一个基本的事实:信息必须是固定长度,或者被分隔,或者本身包含了长度,或者通过关闭连接来结束通信。选择权在使用者手中。
假设你并不想结束连接,最简单的解决方案是发送固定长度的消息:
class MySocket:
def __init__(self, socke=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 < MSLEN:
sent = self.sock.send(msg[totalsent:])
if sent == 0:
raise RuntimeError("socket connect 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 == '':
raise RuntimeError("socket connection broken")
chunks.append(chunk)
bytes_recd = bytes_recd + len(chunk)
return ''.join(chunks)
这里的发送代码几乎能用于任何消息传递方案--如果传递字符串,Python可以使用len()
来决定长度。接收代码相对而言要复杂一些。
最简单的改进是用消息的第一个字符指示消息类型,由类型来决定消息的长度。现在有两个recv
方法,一个用于接收首字段并得到消息的长度,第二个用于在循环中接收剩余的信息。如果决定分隔消息,可以以任意大小的块来接收消息(通常使用4096或8192),并在消息中扫描分隔符。
另一个需要注意的是,如果会话协议允许发送多个消息,而你调用recv
来获取任意长度的消息,则可能会将下个消息的开始也读取到。你需要将读到的消息放在一边,直到下次使用。
在消息头部加入长度前缀(比如前5个数字字符)会更加复杂,因为可能不会在recv
中获取所有5个字符。在高网络负载的情况下,除非有两个recv
循环--一个确定长度一个获取数据,否则会发生问题。你会发现send
不是通过一次操作将所有数据发送出去的。你一定会踩到这个坑!
二进制数据
完全可以通过套接字发送二进制数据。主要的问题在于,并不是所有的机器都使用与二进制数据相同的格式。比如,摩托罗拉芯片使用16位的十六进制字节0001来表示数字1。但是英特尔和DEC芯片用0100来表示数字1。socket库提供方法来转换16位和32位整数--ntohl
,htonl
,ntohs
,htons
,其中“n”表示network
,“h”表示host
,“s”表示short
,“l”表示“long”。在网络顺序是主机顺序的情况下,这些操作什么都不做,但是当机器的使用相反字节顺序时,这些方法会适当地交换字节顺序。
在32位机器上,二进制数据的ASCII表示通畅小于二进制表示。这是因为二进制表示中有大量0和1。字符“0”占用两个字节,而其二进制表示占用4个字节。
断开连接
严格来说,应该在调用close
方法前调用shutdown
。shutdown
会向另一端的套接字发送关闭信息。根据传入的参数,这个消息可以是“我不会再发送消息,但是依然会监听”,或者“我不再监听”。然而,因为大多数的程序员都不使用shutdown()
,因此大多数的socket库实现中,close
通常等同于shutdown(); close()
。所以在大多数场景中,并不需要调用shutdown
。
类HTTP协议中有一种有效使用shutdown
的方式。客户端发送请求后调用shutdown(1)
。这会告诉服务器“该客户已经完成发送,但是仍然能接收信息”,服务器可以通过接收0字节检测“EOF”,来判断请求是否已完成。然后服务端发送回复。
Python做的更多,当一个套接字被垃圾回收时,在需要的情况下会调用close
方法。但是开发者不应该依赖这种策略。如果你的套接字在没有调用close
的情况下消失,则另一端的套接字会认为你这边只是慢,从而无限期挂起。
当套接字断开
连接的另一方在没有调用close
的情况下断开,会导致你的套接挂起,这可能是使用阻塞套接字最糟糕的状况。SOCKSTREAM是一个可靠的协议,它会等待很长时间才会放弃连接。如果使用线程处理套接字,那整个线程可以被认为已经挂掉了。开发者什么也不能做。如果在线程中没有写愚蠢的代码,比如在加锁之后进行阻塞读,那该线程也不会消耗太多资源。不要尝试杀死该线程--线程比进程更高效的一个原因是它们避免了资源自动回收相关的开销。换句话说,如果杀掉一个线程,主进程是很有可能挂掉的。
非阻塞套接字
如果理解了之前的内容,你已经对套接字的使用机制有一个大概的了解。
在Python中,可以使用socket.setblocking(0)
将套接字设置为非阻塞。C语言中创建非阻塞套接字更麻烦(比如,需要在BSD风格的O_NONBLOCK
和几乎没有区别的O_NDELAY
中选择,而O_NDELAY
和TCP_NODELAY
又完全不一样),但是思想是一致的。
和阻塞套接字主要的差异在于,非阻塞套接字在调用send
,recv
,connect
,accept
后会立即返回。之后的处理有很多选择,比如检查返回码和错误码,不过这样做会让开发者抓狂。如果不相信的话,可以尝试一下。你的应用程序会越来越大,越来越消耗CPU。所以让我们直接跳过让人头疼的方法,并且使用正确的方式。
使用select
。
C语言调用select
代码有点复杂。而在Python中调用select
却相当简单。但是如果理解了Python中的select
,使用C也不会遇到什么问题:
read_to_read, ready_to_write, in_error = \
select.select(
potential_readers,
potential_writers,
potential_errs,
timeout
)
上面的代码向select
方法传入三个列表:第一个包含所有的读套接字;第二个包含所有的写套接字;第三个(通常为空)用于检测错误。注意,一个套接字可以进入一个以上的列表。select
调用是阻塞的,但是可以传入一个超时设置。这通常是一个明智的做法--设置一个合理的超时(比如一分钟)。
调用后会返回三个列表。它们包含实际可读,可写和错误的套接字。每个列表都是传入列表的一个子集。
如果一个套接字在可读列表中,几乎可以确定在该套接字上调用recv
会返回数据。位于可写列表中的套接字也一样。
服务器套接字应该放在potential_readers列表中,如果可读列表中有它,该套接字的recv
操作几乎一定会生效。用于connect
的套接字应该放在potential_writers列表中,如果可写列表中有它,可以确定它已经连接上了。
使用select
会遇到一个非常讨厌的问题:如果输入的列表中的套接字有一个非正常挂掉,那么select
调用会失败。要避免这样的问题,需要循环遍历列表中的所有套接字,然后执行select([sock], [], [], 0)
,直到找到出问题的套接字。
事实上,select
也可以用于阻塞套接字。这是检测套接字是否阻塞的一种办法--如果缓冲区有东西时,会返回可读套接字。但是,这仍然无法帮助确定另一端时完成还是只是忙于其他事情。
在Unix系统上,select
可同时用于文件和套接字。不过在Windows系统上,select
只能用于套接字。在C语言中使用套接时要注意,Windows和Unix系统在一些高级选项的实现方式是不同的。事实上,在Windows系统上,我都是用线程来处理套接字。
性能
毫无疑问,最快的套接字代码使用非阻塞套接字,并选择复用它们。你可以将一些会使局域网连接饱和的东西放在一起,而不会对CPU造成任何压力。麻烦的是,以这种方式写的应用程序不能做任何其他事情--它需要随时随地处理字节。
如果你的应用要处理更多业务的话,线程是最佳解决方案。不幸的是,类Unix系统中的线程支持在API和质量方面都有所不同。所以通常Unix解决方式是创建一个子进程来处理每个连接,这种方式带来的开销会很大。这也意味中,除非每个子进程完全独立,否则需要使用另一种形式的IPC,例如管道,共享内存或信号量,以便父进程和子进程之间进行通信。
最后,请记住,即使阻塞套接字比非阻塞套接字更慢,在很多情况下,阻塞套接字是“正确”的解决方案。毕竟,如果你的应用程序是通过套接字接收数据驱动的,那么将逻辑复杂化并没有太多意义。