摘要
几乎所有地方都用到了sockets,但它们可能是被严重误解的技术之一。本文是关于Sockets的概述。它并不是一篇教程——要让sockets运行起来,你仍旧需要做点工作。本文并没有涵盖全部的要点(而这样的要点有很多),但我希望它可以给你足够的背景知识,让你能像样地使用sockets。
Sockets
我只打算谈谈INET(比如IPv4)sockets,但是 99%使用中的sockets都是它。而且我只谈流(比如TCP)——除非你真的知道你在干什么(这样的话本HOWTO不适合你啦),使用流socket要比别的更稳定,性能更好。我将揭开socket是什么的神秘面纱、还有关于如何使用阻塞和非阻塞sockets的提示。但是,我会先从阻塞sockets开始谈起,在处理非阻塞sockets之前,你得知道它们(阻塞sockets)是如何工作的。
理解这些事情麻烦之一是,根据不同上下文,socket可以代表很多略有不同的东西。所以首先,咱们先区分一下“客户端”socket——会话的一个终端,和“服务器”socket,(服务器socket)更像一个接线员。客户端应用程序(比如说浏览器)只使用“客户端”sockets;web服务器在通信时使用“服务器”sockets和客户端sockets这两者。
历史
在各种各样的IPC里,sockets是目前最流行的。对于任意指定平台,可能有其他的IPC更快,但对跨平台通信而言,sockets是唯一的一个。
作为BSD风格的Unix的一部分,它们被创造于伯克利。它们像烈火般蔓延般在互联网上传播。因为——和INET的结合使得世界上任意机器通信变得难以置信地简单(至少跟其他方案比)。
创建一个socket
大体来讲,当你点击了把你带到这个页面的链接时,你的浏览器做了类似以下的事:
1 2 3 4 |
# 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)) |
连接完成后,请求页面的文本时就可以使用sockets发送请求了。接着它读取响应,然后销毁。对,就是销毁。客户端sockets一般只用来做一次数据交换(或一个少量的有序的数据交换)。
web服务器那边更复杂些。首先,web服务器创建一个”服务器socket”:
1 2 3 4 5 6 |
# 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等)。如果你随便玩玩,用一个更好的大数值吧(4位)。
最后,传给listen的参数告诉socket库,在拒绝外面的连接之前,我们想让它最多有5个连接请求(通常最多就这么大)队列。如果后面的代码写得正确,应该就够了。
现在我们有了「服务器」socket了,监听在80端口,我们可以进入web服务器主循环了:
1 2 3 4 5 6 7 |
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,或者重构本应用,使用非阻塞sockets,然后用select多路复用”服务器”socket和活动的clientsockets。以后再详细说这个。现在要理解一个要点:“服务器”socket只做这件事。它不发送任何数据。也不接收任何数据。它只生产客户端sockets。每当别的客户端socket使用connect()连接我们绑定的主机和端口时,都会生成一个clientsocket。一生成clientsocket,我们就返回去监听更多的连接。两个”客户端”总是可以通信—— 它们用的动态分配的端口会在通信结束时被回收掉。
进程间通信(IPC)
如果你需要在同一台机器上快速地进程间通信,你应该看一下管道或者共享内存。如果你确实想用AF_INET sockets
,那就把”服务器”socket绑定到’localhost’。在大多数平台上,这么做会绕过很多网络层,从而变快很多。
1 |
See also: The multiprocessing integrates cross-platform IPC into a higher-level API. |
参见:multiprocessing把跨平台进程间通信封装成了更高级别的API。
使用Socket
首先要注意到的事情是,web浏览器的”客户端”socket和web服务器的”客户端”socket是同一个东西。也就是说,这是一个对等网络(p2p)通信。或者换句话说,作为一个设计者,你必须决定通信的规则。通常,连接socket通过发送请求或登录来启动通信。但这是你设计的 —— 不是sockets的规定。
目前通信有两套动作可用。你可以使用send
和recv
,或者把客户端socket转成类似文件的东西,然后使用read
和write
。Java给它的socket提供的就是后一种方式。在这我不打算讲它,但是要提醒你,你需要对sockets使用flush。sockets带有是缓冲的”文件”,一个常见的错误就是写入了一些东西,然后去读取响应。但是如果没有flush,你可能要永远地等下去了,因为请求可能还在输出缓冲里。
现在咱们看看sockets的主要难点吧 —— 网络缓冲的send
和recv
操作。它们并不一定处理你传递给它们(或期望从其得到)的所有的字节,因为它们的注意力主要集中在处理网络缓冲。通常,当分配的网络缓冲被填充(send
)了或空(recv
)了,它们就会返回。告诉你处理了多少字节。当消息被完全处理后你需要再次调用它们。
当recv
返回0字节时,意味着另一端已经关闭(或者正在关闭)了连接。从这个连接上你再也接收不到数据了。但你可能可以成功发送数据;等下我会详细讲这点。
像HTTP这样的协议每次传输都只使用一个socket。客户端发送请求,然后读取回复。就这样。然后丢弃socket。就是说客户端接收到0字节就知道回复结束了。
但是,如果你打算在以后的传输中重用socket,你就得知道scoket里是没有EOT的。我再重复一遍:如果socket send
或recv
返回0字节,那这个连接就断开了。如果连接没有断开,就会永远的等下去,因为socket不会告诉你没有东西可读(目前)。现在如果你多想一下,你就会发现一个基本的事实:消息必须是固定长度的(呸),或带分隔符的(耸肩),或指示长度(好多啦),或者当连接关闭时结束。任你选择,(但有的方式比别的好)。
假设你不想关闭连接,最简单的解决方案是使用固定长度的消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
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()
来判断字符串长度(甚至当字符串包含字符时)。多数情况下接收代码比较复杂。(使用C的话,不会太坏,除了当消息包含时你不能使用strlen
)
最简单的改进是让消息的第一个字符表示消息类型,并让类型来决定长度。现在你需要进行两次recv
了 —— 首先(至少)要获取第一个字符,这样你就能知道长度了,然后循环获取剩下的。如果你打算使用分隔符,就得使用一个大小任意的块来接收数据,(4096或8192通常比较适合网络缓冲大小),然后从接收到的数据里搜索分隔符。
有个麻烦的事要注意:如果你的通信协议允许连续发送多个消息(不需要某种回复),然后传任意大的块给recv
,你可能会读到下一个消息的头。你必须把它先存起来,直到需要用到它的时候再使用。
在消息前加个表示它的长度(就是说,5个数字类型的字符)的前缀更复杂些,因为(信不信由你)一次recv
可能不能全部读够5字符。在测试时,你可能能侥幸避免;但在网络繁忙时,你的代码很快就会挂掉,除非你使用两个recv
循环 —— 第一个用来决定长度,第二个读取消息的数据部分。好“恶心”。同样“恶心”的是,你会发现send也不能一次性解决。尽管你已经读了本文,最后还会在这上面栽跟头的。
为了节省篇幅,塑造你的人格,(并且保持我自己的竞争地位),这些改进会作为练习留给读者解决。让我们继续。
二进制数据
完全可以通过socket发送二进制数据。主要的问题在于,不是所有的机器都使用相同的二进制格式。举个例子,摩托罗拉的芯片使用两个十六进制字节00 01来表示16位的整数1。然而,英特尔和DEC,字节就反过来了 —— 同样是1就用01 00表示。socket库必须调用ntohl
, htonl
, ntohs
, htons
把转换16和32位整数。这里的”n”表示网络,”h”表示主机,”s”表示短整型,”l”表示长整型。当网络字节序和主机字节序相同时,它们什么也不做,否则,它们就相应的交换字节。
对于现如今的32位机器,使用ascii表示二进制数据通常比二进制表示法要小。这是因为在数据传输的大量时间里,数据流的内容要么是0,要么是1。用字符表示“0”需要两个字节,而用二进制表示要4个字节。当然,这种情况对于固定长度的消息并不适用。所以在选择数据表示法时一定要好好考虑.
断开连接
严格来说,在关闭socket之前,你应该先shutdown它。shutdown就是给另一端的socket一个通知。它可以表示“我不会再发送数据啦,但我还在接收呢”,或者“我不在接收啦,解放啦!”,取决于传递给它的参数。然而,大多数socket库,对程序员忽略这个礼节已经很习惯了,通常close就跟先shutdown();
close()
一样。所以,在大多数情况下,一个明确的shutdown就不需要了。
有效的使用shutdown的一种方式是在类HTTP通信中。客户端发送一个请求,然后调用shutdown(1)。这样就告诉服务器“这个客户端已经发送结束了,但还在接收呢。”了。服务器可以通过接收到0字节来判断“EOF”。它(服务器)就可以认为它(客户端)已经完成了请求。然后服务器发送一个回复。如果成功发送完成后,客户端实际上仍然在接收。
Python把这个自动shutdown的传统更进一步,也就是说,当一个socket被垃圾回收时,如果需要,它会自动close。但依赖这个是个非常坏的习惯。如果socket没有调用close就消失了,另一端的socket就会一直挂起,它会认为你只是变慢了而已。当结束时请close掉socket。
当sockets挂掉的时候
可能使用阻塞socket最坏的事就是遇到另一端的socket挂了(没有调用close)。你的socket就很可能被挂起。TCP是可靠的协议,它会等待很久,直到放弃了这个连接。如果你是使用线程,整个线程就死了。你帮不了什么忙。只要你没有做什么蠢事,比如在阻塞读的时候锁,线程就不会消耗太多的资源。不要尝试去杀死线程——部分原因是,线程比进程高效,线程避免了分配自动回收的资源的开销。换句话说,如果你设法去结束线程,你的整个进程可能会被弄糟。
非阻塞socket
如果你已经理解了前面说的,你就知道了使用socket的原理。你还是以非常相似的方式去调用相同的函数。事实上,if you do it right, your app will be almost inside-out.
在Python里,要用socket.setblocking(0)
来设置非阻塞。在C里,更复杂了,(首先,你要从BSD风格的O_NONBLOCK
和几乎难以分辨的Posix风格的O_NDELAY
选择,O_NDELAY
跟TCP_NODELAY
完全不同),但它的原理一致。你要在创建socket之后,使用之前做这件事。(实际上,如果你已经抓狂了,你可以转回去再看看。)
主要的区别是,send
, recv
, connect
和accept
会在未完成前返回。你(当然)有很多选择。你可以检查返回值和错误码,一般这样做会让你抓狂的。不信你找个时间试试。你的应用会变得越来越臃肿,bug不断,还浪费CPU。所以,咱们跳过这个愚蠢的方案用正确的吧。
那就是用select。
在C里,使用select相当复杂。在Python里,它是块甜点,但它跟C版本的很像,如果你理解了Python里的select,在C里你也不会有太大困难:
1 2 3 4 5 6 |
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
会返回些东西。同理可证可写列表可以send
些东西。也许不能recv
或send
你想要的全部,但聊胜于无。(实际上,任何正常的socket将作为可写的socket被返回——这只表示出口网络缓冲空间是可用的。)
如果你有一个“服务器”socket,把它放入可能可读列表里。如果返回的可读列表中有它,accept
(几乎必然)可成功调用。如果你创建了一个连接别人的新的socket,把它放入可能可写列表里,如果它在可写列表里出现了,表示它已经连接上了。
实际上,select对于阻塞socket也很方便好用。它是判断是否阻塞的一种方式——socket会在缓冲里有数据时返回可读。然而,这并不能解决这个问题:判断另一端是否完成,或忙于处理别的事。
可移植警告:在Unix上,select能处理socket和file。在Windows上不要尝试这个。在Windows上,select只能处理socket。另外说下在C里,很多socket高级选项在Windows上是有区别的。事实上,在Windows上,我通常使用线程处理socket(它工作得非常,非常好)。