Linux socket 编程,第二部分
在开始之前
IP socket 是在其上建立高级 Internet 协议的最低级的层:从 HTTP 到 SSL 到 POP3 到 Kerberos 再到 UDP-Time,每种 Internet 协议都建立在它的基础上。为了实现自定义的协议,或定制众所周知的协议的实现,程序员需要掌握基本的 socket 基础结构的工作知识。虽然本教程主要集中在 C 编程上,而且还使用 Python 作为例子中的代表性高级语言,不过类似的 API 在许多语言中都可用。
在本教程系列的 第一部分 中,David 向读者介绍了使用广为流传和跨平台的 Berkeley Sockets Interface 来编写自定义网络工具程序的基础。在本教程中,他将进一步阐述用户数据报协议(User Datagram Protocol,UDP),并继续讨论如何编写可扩展的 socket 服务器。
学习本教程需要具备最低限度的 C 语言知识,同时熟悉 Python 则更理想(主要是为了下面的第二部分)。然而,如果您对任何一种编程语言都不熟悉,那么您就应该付出一点额外的努力来学习一点编程语言;一种编程语言的大多数基本概念都可以同样应用到其它语言上,而且在诸如 Ruby、Perl、TCL 之类的大多数高级脚本语言中的称呼都相当类似。
尽管本教程介绍的是IP(Internet Protocol,Internet 协议)网络背后的基本概念,但预先熟悉某些网络协议和层的概念将会有所帮助(相关背景文献请参阅本教程末尾的 参考资料)。
理解网络层和协议
网络层

本小节和接下来的三个小节将扼要重述本教程 第一部分 中的讨论 ―― 如果已经阅读了第一部分,您可以直接跳到使用 Python 编写 UDP 应用程序 。
计算机网络由许多“网络层”组成,每个网络层对该层的数据提供不同的限制和/或保证。 每个网络层的协议一般具有它们自己的包、包头和布局格式。
传统的七个网络层(请参阅 参考资料 以找到指向这些层的讨论的链接)被划分为两组:高层和底层。socket 接口为网络的低层提供统一的 API(应用程序编程接口),并允许您在自己的 socket 应用程序中实现高层。而应用程序数据格式本身可能又构成进一步的层。
虽然 socket 接口在理论上允许访问 IP 之外的 协议系列,但是在实践中,socket 应用程序中使用的每个网络层都会使用 IP。对于本教程,我们仅研究 IPv4;未来 IPv6 也会变得很重要,不过它们的原理是相同的。在传输层,socket 支持两种特定的协议:传输控制协议(Transmission Control Protocol,TCP)和用户数据报协议(User Datagram Protocol,UDP)。
socket 不能用于访问较低(或较高)的网络层;例如,socket 应用程序不知道它是运行在以太网、令牌环网还是运行在拨号连接上。socket 伪层(pseudo-layer)也不知道关于像 NFS、HTTP、FTP 等这样的高级协议的任何信息(除非您自己编写一个实现那些高级协议的 socket 应用程序)。
在很多情况下,socket 接口并不是用于网络编程 API 的最佳选择。特别地,由于存在很多很优秀的库可以直接使用高层协议,您不必关心 socket 的细节;那些库会为您处理 socket 的细节。例如,虽然编写您自己的 SSH 客户机并没有什么错,但是对于仅只是为了让应用程序安全地传输数据来说,就没有必要做得这样复杂。低级层比 socket 所访问的层更适合归入设备驱动程序编程范畴。
正如上一小节所指出的,当您编写 socket 应用程序的时候,您可以在使用 TCP 还是使用 UDP 之间做出选择。它们都有各自的优点和缺点。
TCP 是流协议,而 UDP 是数据报协议。换句话说,TCP 在客户机和服务器之间建立持续的开放连接,在该连接的生命期内,字节可以通过该连接写出(并且保证顺序正确)。然而,通过 TCP 写出的字节没有内置的结构,所以需要高层协议在被传输的字节流内部分隔数据记录和字段。
另一方面,UDP 不需要在客户机和服务器之间建立连接,它只是在地址之间传输报文。UDP 的一个很好特性在于它的包是自分隔的(self-delimiting),也就是一个数据报都准确地指出它的开始和结束位置。然而,UDP 的一个可能的缺点在于,它不保证包将会按顺序到达,甚至根本就不保证。当然,建立在 UDP 之上的高层协议可能会提供握手和确认功能。
对于理解 TCP 和 UDP 之间的区别来说,一个有用的类比就是电话呼叫和邮寄信件之间的区别。在呼叫者用铃声通知接收者,并且接收者拿起听筒之前,电话呼叫不是活动的。只要没有一方挂断,该电话信道就保持活动,但是在通话期间,他们可以自由地想说多少就说多少。来自任何一方的谈话都按临时的顺序发生。另一方面,当你发一封信的时候,邮局在投递时既不对接收方是否存在作任何保证,也不对信件投递将花多长时间做出有力保证。接收方可能按与信件的发送顺序不同的顺序接收不同的信件,并且发送方也可能在他们发送信件是交替地接收邮件。与(理想的)邮政服务不同,无法送达的信件总是被送到死信办公室处理,而不再返回给发送者。
除了 TCP 和 UDP 协议以外,通信一方(客户机或者服务器)还需要知道的关于与之通信的对方机器的两件事情:IP 地址或者端口。IP 地址是一个 32 位的数据值,为了人们好记,一般用圆点分开的 4 组数字的形式来表示,比如:64.41.64.172
。端口是一个 16 位的数据值,通常被简单地表示为一个小于 65536 的数字。大多数情况下,该值介于 10 到 100 的范围内。一个 IP 地址获取 送到 某台机器的一个数据包,而一个端口让机器决定将该数据包交给哪个进程/服务(如果有的话)。这种解释略显简单,但基本思路是正确的。
上面的描述差不多是正确的,但是它遗漏了一些东西。在人类考虑一个 Internet 主机(对等方)的大多数时候,我们不是去记忆一个像 64.41.64.172
这样的数字,而是一个像 gnosis.cx
这样的名称。本教程的 第一部分 展示了如何使用 DNS 和本地查找来从域名找出 IP 地址。
使用 Python 编写 UDP 应用程序
与在本教程 第一部分 中一样,用于服务器和客户机的例子都使用了一个尽可能最简单的应用程序: 一个发送和接收完全相同的内容的应用程序。 事实上,许多机器为调试而运行一个“回显服务器”;这对于我们初始的客户机是很方便的,因为它可以在我们开始讨论服务器之前使用(假设您有一台在运行 echod
的服务器)。
在此我要感谢 Donahoo 和 Calvert 所著的 TCP/IP Sockets in C 一书(请参阅 参考资料)。本教程摘录了他们提供的几个例子。我推荐该书 ―― 但是不可否认,回显服务器和客户机在大多数 socket 编程介绍中都是首先要提到的。
本教程第一部分的读者已经详细看到了一个 TCP 回显客户机。因此让我们直接转到基于 UDP 的相似客户机吧。
我们将在稍后讨论用 C 编写的客户机和服务器。不过首先研究用 Python 编写的简单得多的版本要更容易一些,这样我们就能看到整体结构。在能够测试一个客户机 UDPecho
应用程序之前,我们需要做的第一件事情就是设法让服务器运行起来,以便客户机能够与之通信。事实上,Python 为我们提供了高级 SocketServer
模块,它允许我们只需最少的自定义工作就能编写 socket 服务器:
#!/usr/bin/env python "USAGE: %s <port>" from SocketServer import DatagramRequestHandler, UDPServer from sys import argv class EchoHandler(DatagramRequestHandler): def handle(self): print "Client connected:", self.client_address message = self.rfile.read() self.wfile.write(message) if len(argv) != 2: print __doc__ % argv[0] else: UDPServer(('',int(argv[1])), EchoHandler).serve_forever() |
各种专用的 SocketServer
类全都需要你提供适当的 .handle()
方法。但是对于 DatagramRequestHandler
,您可以从连接的客户机获得方便的伪文件 self.rfile
和 self.wfile
来分别执行读写。
编写一个 Python 客户机一般要首先编写基本的 socket
模块。幸运的是,通过高级起点编写几乎用于任何用途的客户机是如此容易。不过要注意,诸如 Twisted 之类的框架包括了用于此类任务的基类,因此几乎就用不着去思考。下面让我们考察一下基于 socket
的 UDP 回显客户机:
#!/usr/bin/env python "USAGE: %s <server> <word> <port>" from socket import * # import *, but we'll avoid name conflict from sys import argv, exit if len(argv) != 4: print __doc__ % argv[0] exit(0) sock = socket(AF_INET, SOCK_DGRAM) messout = argv[2] sock.sendto(messout, (argv[1], int(argv[3]))) messin, server = sock.recvfrom(255) if messin != messout: print "Failed to receive identical message" print "Received:", messin sock.close() |
如果您恰好回想起 第一部分 中的 TCP 回显客户机,就会注意到这里存在的一些区别。此例中创建的 socket 是 SOCK_DGRAM
类型的,而不是 SOCK_STREAM
类型的。不过更有趣的是 UDP 的无连接性质。与建立连接并重复调用 .send()
和 .recv()
方法直至传输完成不同,对于 UDP,我们仅使用一个 .sendto()
和一个 .recvfrom()
来发送和收取消息(一个数据报)。
由于不涉及连接,您需要传递目标地址作为 .sendto()
调用的一部分。在 Python 中,socket 对象跟踪消息实际所通过的临时 socket 编号。后面我们将看到,在 C 中您需要通过 sendto()
返回的一个变量来使用这个编号。
运行服务器和客户机很简单。服务器通过一个端口号来启动:
$ ./UDPechoserver.py 7 & [1] 23369 |
客户机接受三个参数: 服务器地址、要回显(echo)的字符串,以及端口。由于 Python 在它的标准模块中包装了比等价的 C 库更多的功能,您可以指定一个命名的地址,同样也可以指定一个 IP 地址。在 C 中您需要自己执行查找,或许首先要测试该参数看起来是像一个点分四组还是像一个域名:
$ ./UDPechoclient.py USAGE: ./UDPechoclient.py <server> <word> <port> $ ./UDPechoclient.py 127.0.0.1 foobar 7 Client connected: ('127.0.0.1', 51776) Received: foobar $ ./UDPechoclient.py localhost foobar 7 Client connected: ('127.0.0.1', 51777) Received: foobar |
在这个客户机会话中还有其他一些值得注意的有趣事情。当然,由于我是在同一个终端中运行服务器和客户机,因此两者的输出是散布在一起的。但是更有趣的是被回显的 client_address
。每个新的连接都会确定一个新的 socket 编号(它们可以复用,但这里的要点是你预先并不知道)。端口 7 纯粹用于识别消息发送请求,一个新的特殊 socket 用于实际的数据。
与使用 SocketServer
相比,使用 socket
模块来编写一个 Python UDP 服务器并不需要任何更多的代码行,但是代码编写风格更有强制性(实际上像 C)。
#!/usr/bin/env python "USAGE: %s <server> <word> <port>" from socket import * # import *, but we'll avoid name conflict from sys import argv if len(argv) != 2: print __doc__ % argv[0] else: sock = socket(AF_INET, SOCK_DGRAM) sock.bind(('',int(argv[1]))) while 1: # Run until cancelled message, client = sock.recvfrom(256) # <=256 byte datagram print "Client connected:", client sock.sendto(message, client) |
虽然其用法和行为与前面的 UDPechoserver.py
完全相同,但是这里是我们管理循环和客户机连接本身,而不是让一个类去管理它们。与以前一样,特殊 端口用于传输实际的消息 ―― 从 sock.recvfrom()
返回的 client
包含临时端口号:
$ ./UDPechoserver2.py 8 & [2] 23428 $ ./UDPechoclient.py localhost foobar 8 Client connected: ('127.0.0.1', 51779) Received: foobar |
使用 C 编写回显客户机
UDP 客户机的前几行与 TCP 客户机的对应行完全相同。我们主要是使用了几个 include 语句来包含 socket 函数,或其他基本的 I/O 函数。
#include <stdio.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #define BUFFSIZE 255 void Die(char *mess) { perror(mess); exit(1); } |
这里没有多少需要设置的东西。值得注意的是,我们分配的缓冲区大小要比 TCP 版本中的缓冲区大得多(但是在尺寸上仍然是有限的)。TCP 可以循环迭代未决的数据,并且在每个循环中要通过一个打开的 socket 多发送一点数据。对于这个 UDP 版本,我们想要一个足够大到可以容纳整个消息的的缓冲区,整个消息在单个数据报中发送(它可以少于 255 个字节,但是不可以大于 255 个字节)。这个版本还定义了一个很小的错误处理函数。
在 main()
函数的最开头,我们分配了两个 sockaddr_in
结构、一些用于包含字符串大小的整数,另一个用于 socket 句柄的 int 类型的变量,以及一个用于包含返回字符串的缓冲区。之后,我们检查了命令行参数看起来是否都是正确的。
int main(int argc, char *argv[]) { int sock; struct sockaddr_in echoserver; struct sockaddr_in echoclient; char buffer[BUFFSIZE]; unsigned int echolen, clientlen; int received = 0; if (argc != 4) { fprintf(stderr, "USAGE: %s <server_ip> <word> <port>\n", argv[0]); exit(1); } |
这里已经出现了与 Python 代码形成对比的地方。对于 C 客户机,您 必须 使用点分四组的 IP 地址。在 Python 中,所有 socket
模块函数处理幕后的名称解析。如果想要在 C 客户机种执行查找,您需要编写一个 DNS 函数 ―― 比如在本教程第一部分中介绍的那个函数。
事实上,检查作为服务器 IP 地址传入的 IP 地址是否真的看起来像点分四组,这并不是一种极端的想法。如果忘了传入命名的地址,您或许会接收到有点误导性的错误消息: “Mismatch in number of sent bytes: No route to host(发送的字节数不匹配,没有到达主机的路径)”。任何命名的地址实际上相当于未使用的或保留的 IP 地址(这当然无法通过简单的模式检查来排除)。
socket()
调用的参数决定了 socket 的类型: PF_INET
只是意味着它使用 IP(您总是会使用 IP);SOCK_DGRAM
和 IPPROTO_UDP
配合起来用于 UDP socket。在准备要回显(echo)的消息时,我们使用命令行参数来填充预期的服务器结构。
/* Create the UDP socket */ if ((sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) { Die("Failed to create socket"); } /* Construct the server sockaddr_in structure */ memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */ echoserver.sin_family = AF_INET; /* Internet/IP */ echoserver.sin_addr.s_addr = inet_addr(argv[1]); /* IP address */ echoserver.sin_port = htons(atoi(argv[3])); /* server port */ |
socket()
调用的返回值是一个 socket 句柄,它和文件句柄类似;特别是,如果 socket 创建失败,该调用将返回 -1 而不是返回一个正数句柄。支持函数 inet_addr()
和 htons()
(以及 atoi()
)用于将字符串参数转换为适当的数据结构。
就所做的工作而言,这个客户机要比本教程系列 第一部分 中介绍的相似 TCP 回显客户机简单一点。正如我们从 Python 版本中看到的,发送消息并不是基于首先建立连接。您只需使用 sendto()
来将消息发送到指定的地址,而不是在已建立的连接上使用 send()
。 当然,这需要两个额外的参数来指定预期的服务器地址。
/* Send the word to the server */ echolen = strlen(argv[2]); if (sendto(sock, argv[2], echolen, 0, (struct sockaddr *) &echoserver, sizeof(echoserver)) != echolen) { Die("Mismatch in number of sent bytes"); } |
这个调用中的错误检查通常确定到服务器的路径是否存在。如果错误地使用了命名的地址,则会引发一条错误消息,但是看起来有效但不可到达的地址也会引发错误消息。
收回数据的工作方式与在 TCP 回显客户机中相当相似。唯一的真正变化是对 recvfrom()
的调用替代了对 recv()
的 TCP 调用。
/* Receive the word back from the server */ fprintf(stdout, "Received: "); clientlen = sizeof(echoclient); if ((received = recvfrom(sock, buffer, BUFFSIZE, 0, (struct sockaddr *) &echoclient, &clientlen)) != echolen) { Die("Mismatch in number of received bytes"); } /* Check that client and server are using same socket */ if (echoserver.sin_addr.s_addr != echoclient.sin_addr.s_addr) { Die("Received a packet from an unexpected server"); } buffer[received] = '\0'; /* Assure null-terminated string */ fprintf(stdout, buffer); fprintf(stdout, "\n"); close(sock); exit(0); } |
结构 echoserver
已在对 sendto()
的调用期间使用一个 特殊 端口来配置好了;相应地,echoclient
结构通过对 recvfrom()
的调用得到了类似的填充。如果其他某个服务器或端口在我们等待接受回显时发送数据包,这样将允许我们比较两个地址。我们至少应该最低限度地谨防我们不感兴趣的无关数据包(为了确保完全肯定,也可以检查 .sin_port
成员)。
在这个过程的结尾,我们打印出发回的数据包,并关闭该 socket。
使用 C 编写 UDP 回显客户机
与 TCP 应用程序相比,UDP 客户机和服务器彼此更为相似。本质上,其中的每一个都主要由一些混合在一起的 sendto()
和 recvfrom()
调用组成。服务器的主要区别不过就是它通常将其主体放在一个无限循环中以保持提供服务。
下面让我们首先考察通常的 include 语句和错误处理函数:
#include <stdio.h> #include <sys/socket.h> #include <arpa/inet.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <netinet/in.h> #define BUFFSIZE 255 void Die(char *mess) { perror(mess); exit(1); } |
同样,UDP 回显服务器声明和使用情况消息中没有多少新的内容。我们需要一个用于服务器和客户机的 socket、一些将用于检验传输大小的变量,当然还需要用于读写消息的缓冲区。
int main(int argc, char *argv[]) { int sock; struct sockaddr_in echoserver; struct sockaddr_in echoclient; char buffer[BUFFSIZE]; unsigned int echolen, clientlen, serverlen; int received = 0; if (argc != 2) { fprintf(stderr, "USAGE: %s <port>\n", argv[0]); exit(1); } |
UDP 客户机和服务器之间的第一个真正区别在于服务器端需要绑定 socket。我们已经在 Python 的例子中看到了这点,这里的情况是相同的。服务器 socket 并不是传输消息所通过的实际 socket;相反,它充当一个 特殊 socket 的工厂,这个特殊 socket 是在我们很快将要看到的 recvfrom()
调用中配置的。
/* Create the UDP socket */ if ((sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) { Die("Failed to create socket"); } /* Construct the server sockaddr_in structure */ memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */ echoserver.sin_family = AF_INET; /* Internet/IP */ echoserver.sin_addr.s_addr = htonl(INADDR_ANY); /* Any IP address */ echoserver.sin_port = htons(atoi(argv[1])); /* server port */ /* Bind the socket */ serverlen = sizeof(echoserver); if (bind(sock, (struct sockaddr *) &echoserver, serverlen) < 0) { Die("Failed to bind server socket"); } |
读者还会注意到 echoserver
结构是以稍微不同的方式配置的。为了允许服务器托管的任何 IP 地址上的连接,我们对成员 .s_addr
使用了特殊常量 INADDR_ANY
。
UDP 服务器中的重大举措是它的主循环 ―― 虽然也不过如此。基本上,我们是在一个 recvfrom()
调用中永久地等待接收一条消息。此时,echoclient
结构将使用连接的 socket 的相关成员来填充。 然后我们在后续的 sendto()
调用中使用该结构。
/* Run until cancelled */ while (1) { /* Receive a message from the client */ clientlen = sizeof(echoclient); if ((received = recvfrom(sock, buffer, BUFFSIZE, 0, (struct sockaddr *) &echoclient, &clientlen)) < 0) { Die("Failed to receive message"); } fprintf(stderr, "Client connected: %s\n", inet_ntoa(echoclient.sin_addr)); /* Send the message back to client */ if (sendto(sock, buffer, received, 0, (struct sockaddr *) &echoclient, sizeof(echoclient)) != received) { Die("Mismatch in number of echo'd bytes"); } } } |
大功告成!我们可以不断地接收和发送消息,同时在此过程中向控制台报告连接情况。当然,正如我们将在下一节看到的,这种安排一次仅做一件事情,这对于处理许多客户机的服务器来说可能是一个问题(对这个简单的 echo 服务器来说或许不是问题,但是更复杂的情况可能会引入糟糕的延迟)。
可扩展的服务器
我们研究过的服务器(除了回显消息外,不做其他任何事情)能够极其快速地处理每个客户机请求。但是对于更一般的情况,我们可能希望服务器执行可能较长的操作,比如数据库查找、访问远程资源,或者执行复杂计算以便确定客户机的响应能力。我们的“一次做一件事情”的模型无法很好地扩展到处理多个客户机。
为了说明其中的要点,让我们考察一个稍微修改后的 Python 服务器,这个服务器需要花一些时间才能完成其任务。而且为了强调服务器正在处理请求,我们还在此过程中(无足轻重地)修改消息字符串:
#!/usr/bin/env python from socket import * from sys import argv def lengthy_action(sock, message, client_addr): from time import sleep print "Client connected:", client_addr sleep(5) sock.sendto(message.upper(), client_addr) sock = socket(AF_INET, SOCK_DGRAM) sock.bind(('',int(argv[1]))) while 1: # Run until cancelled message, client_addr = sock.recvfrom(256) lengthy_action(sock, message, client_addr) |
为了让服务器有一些工作可做,我们可以修改客户机以便发出多个请求(每个线程发送一个请求),这些请求需要尽可能快速地得到满足:
#!/usr/bin/env python from socket import * import sys, time from thread import start_new_thread, get_ident start = time.time() threads = {} sock = socket(AF_INET, SOCK_DGRAM) def request(n): sock.sendto("%s [%d]" % (sys.argv[2],n), (sys.argv[1], int(sys.argv[3]))) messin, server = sock.recvfrom(255) print "Received:", messin del threads[get_ident()] for n in range(20): id = start_new_thread(request, (n,)) threads[id] = None #print id, while threads: time.sleep(.1) sock.close() print "%.2f seconds" % (time.time()-start) |
针对我们新的“长操作”服务器,线程化的客户机将获得类似如下(有删减)的输出;特别要注意它所花的时间:
$ ./UDPechoclient2.py localhost "Hello world" 7 Received: HELLO WORLD [7] Received: HELLO WORLD [0] ... Received: HELLO WORLD [18] Received: HELLO WORLD [2] 103.96 seconds |
针对以前的服务器之一运行,这个客户机只会运行几秒钟时间(不过当然不会将返回字符串转换为大写);没有线程开销的版本针对以前的服务器运行得甚至更快。假设这个假想的服务器进程并不是纯粹受 CPU 约束的,那么我能应该能够比 100+ 的响应速度更快。 还要注意那些线程一般不是以它们被创建的顺序得到服务的。
我们设置“长操作”服务器的方式保证了它至少要花五秒钟的时间来给任何给定的请求提供服务。但是没有理由说多个线程不能在那同样的五秒钟内运行。同样,受 CPU 约束的进程明显不会通过线程化而运行的更快,但是在实际的服务器中,那五秒主要花在诸如针对另一台机器执行数据库查询等事情上。换句话说,我们应该能够并行地给多个客户机线程提供服务。
一种明显的方法就是使服务器线程化,就像使客户机线程化一样:
#!/usr/bin/env python from socket import * from sys import argv from thread import start_new_thread # ...definition of 'lengthy_action()' unchanged... sock = socket(AF_INET, SOCK_DGRAM) sock.bind(('',int(argv[1]))) while 1: # Run until cancelled message, client_addr = sock.recvfrom(256) start_new_thread(lengthy_action, (sock, message, client_addr)) |
在我的测试系统(与以前一样使用 localhost)上,这样将客户机运行时间减少到了大约 9 秒 ―― 其中 5 秒花在调用 sleep()
上,其余的 4 秒花在线程化和连接开销上(大致如此)。
在类 UNIX 系统上,分支甚至比线程化更容易。进程通常要比线程“重”,但是在诸如 Linux、FreeBSD 和 Darwin 这样的流行 Posix 系统上,进程创建仍然是相当高效的。
使用 Python,我们“长操作”服务器版本可以像下面这样简单:
#!/usr/bin/env python from socket import * from sys import argv, exit from os import fork def lengthy_action(sock, message, client_addr): from time import sleep print "Client connected:", client_addr sleep(5) sock.sendto(message.upper(), client_addr) exit() sock = socket(AF_INET, SOCK_DGRAM) sock.bind(('',int(argv[1]))) while 1: # Run until cancelled message, client_addr = sock.recvfrom(256) if fork(): lengthy_action(sock, message, client_addr) |
在我的测试系统上,我实际上发现这个分支版本要比线程化的版本 快 几秒。作为行为方面的少许区别,在向一组客户机线程提供服务之后,while
循环中的主进程转到了后台,虽然服务器是在前台启动的。然而,对于从后台启动服务器的通常情况,这个区别是不相关的。
另一种称为 异步 或 非阻塞 socket 的技术甚至可能比线程化或分支方法更有效率。异步编程背后的概念是将执行保持在单个线程内,但是要轮询每个打开的 socket,以确定它是否有更多的数据在等待读入或写出。然而,非阻塞 socket 实际上仅对受 I/O 约束的进程有用。我们使用 sleep()
创建的受 CPU 约束的服务器模拟就在一定程度上遗漏了这个要点。此外,非阻塞 socket 对 TCP 连接比对 UDP 连接更有意义,因为前者保持一个可能仍然具有未决数据的打开连接。
概而言之,异步对等方(客户机 或 服务器)的结构是一个轮询循环 ―― 通常使用函数 select()
或它的某个高级包装,比如 Python 的 asyncore
。在每次经过循环时,您都要检查所有打开的 socket,以确定哪些当前是可读的,以及哪些当前是可写的。这检查起来很快,并且您可以简单地忽略当前没有为 I/O 操作做好准备的任何 socket。这种 socket 编程风格避免了与线程或进程相关联的任何开销。
为了模拟低带宽连接,我们可以创建这样一个客户端,它在发送数据时引入人为的延时,并且逐字节地发出消息。为了模拟许多这样的连接,我们可以创建多个连接线程(每个都是慢速的)。一般来说,这个客户机与我们在上面看到的 DPechoclient2.py
类似,只不过是 TCP 版本:
#!/usr/bin/env python from socket import * import sys, time from thread import start_new_thread, get_ident threads = {} start = time.time() def request(n, mess): sock = socket(AF_INET, SOCK_STREAM) sock.connect((sys.argv[1], int(sys.argv[3]))) messlen, received = len(mess), 0 for c in mess: sock.send(c) time.sleep(.1) data = "" while received < messlen: data += sock.recv(1) time.sleep(.1) received += 1 sock.close() print "Received:", data del threads[get_ident()] for n in range(20): message = "%s [%d]" % (sys.argv[2], n) id = start_new_thread(request, (n, message)) threads[id] = None while threads: time.sleep(.2) print "%.2f seconds" % (time.time()-start) |
我们需要一个“传统的”服务器来测试上面的慢速客户机。本质上,下面的代码与本教程 第一部分 中介绍的第二个(低级)Python 服务器完全相同。唯一的真正区别在于最大连接数提高到了 20。
#!/usr/bin/env python from socket import * import sys def handleClient(sock): data = sock.recv(32) while data: sock.sendall(data) data = sock.recv(32) newsock.close() if __name__=='__main__': sock = socket(AF_INET, SOCK_STREAM) sock.bind(('',int(sys.argv[1]))) sock.listen(20) while 1: # Run until cancelled newsock, client_addr = sock.accept() print "Client connected:", client_addr handleClient(newsock) |
下面让我们针对“一次做一件事情”的服务器运行“慢速连接”客户机(与前面一样,输出有删减):
$ ./echoclient2.py localhost "Hello world" 7 Received: Hello world [0] Received: Hello world [1] Received: Hello world [5] ... Received: Hello world [16] 37.07 seconds |
与 UDP 压力测试客户机一样,线程不一定以它们被启动的顺序连接。然而最值得注意的是,为 20 个线程提供服务所花的时间基本上是在通过 socket 逐个地写出字节时引入的所有延时之和。这里没有什么是并行化的,因为我们需要等待每个单独的 socket 连接完成其任务。
现在我们已经准备好查看函数 select()
如何能够用来避免我们刚才引入的那种延时(或者由于确实很慢的连接而频繁产生的那种延时)。我们已在几个小节之前讨论了一般概念;下面让我们考察详细的代码:
#!/usr/bin/env python from socket import * import sys, time from select import select if __name__=='__main__': while 1: sock = socket(AF_INET, SOCK_STREAM) sock.bind(('',int(sys.argv[1]))) print "Ready..." data = {} sock.listen(20) for _ in range(20): newsock, client_addr = sock.accept() print "Client connected:", client_addr data[newsock] = "" last_activity = time.time() while 1: read, write, err = select(data.keys(), data.keys(), []) if time.time() - last_activity > 5: for s in read: s.shutdown(2) break for s in read: data[s] = s.recv(32) for s in write: if data[s]: last_activity = time.time() s.send(data[s]) data[s] = "" |
这个服务器是易碎的,因为它总是在从那些客户机连接之中 select()
之前,等待准确的 20 个客户机连接。但是它仍然说明了使用密集的轮训循环,以及仅当数据在特定的 socket 上可用时才读/写数据的的基本概念。select()
的返回值分别是可读、可写和错误的 socket 列表的三元组。这其中的每一种类型都是根据需要在循环中处理的。
顺便说一句,使用这种异步服务器允许“慢速连接”客户机在大约 6 秒的时间内完成全部 20 个连接,而不是需要 37 秒(至少在我的测试系统上是这样)。
为更可扩展的服务器提供的例子全都使用了 Python。的确,Python 库的品质意味着不会存在比用 C 编写的相似服务器显著更慢的服务器。而对于本教程,相对简洁的陈述是很重要的。
在介绍上面的服务器时,我坚持使用了 Python 中相对低级的功能。 像 asyncore
或 SocketServer
这样一些高级模块 ―― 或者甚至是 threading
而不是 thread
―― 都可能提供更“具 Python 性质”的技术。然而,我使用的这些低级功能在结构上仍然相当接近您在 C 中要编写的相同内容。Python 的动态类型化和简洁的语法仍然节省了一些代码行,但是 C 程序员应该能够使用我的例子作为类似的 C 服务器的基本框架。
结束语和参考资料
本教程中介绍的服务器和客户机很简单,但是它们展示了用 C 和 Python 编写 UDP socket 应用程序所必需的每一方面。更高级的客户机或服务器本质上不过是来回地传输更有趣的数据;socket 层的代码和本文中的这些例子并没有什么不同。
执行线程化、分支和异步 socket 处理的一般要点可类似地应用于更高级的服务器。您的服务器和客户机本身可能会做更多的事情,但是针对可扩展性的策略始终是这三种方法之一(或者它们的组合)。
请告诉我们本教程对您是否有帮助,以及我们如何能够做得更好。我们还想知道您希望看到的其他教程。
关于本教程内容的问题,请通过电子邮件 mertz@gnosis.cx 联系作者 David Mertz。
Michael J. Donahoo 和 Kenneth L. Calvert 所著的 TCP/IP Sockets in C 一书(Morgan-Kaufmann,2001 年)很好地介绍了如何用 C 进行 socket 编程。例子和更多信息可在 作者主页 上找到。
“UNIX 系统支持组”文档 Network Layers 解释了低级网络层的功能。
在 Wikipedia 了解关于 Berkeley socket 和 TCP/IP 协议族 的更多信息。
您还会找到 本教程第一部分 的“参考资料”小节中列出的丰富的有用链接,第一部分介绍了基础的网络、层和协议概念,并初步接触了回显 服务器。
本教程中的代码例子是用 Python 和 C 编写的,但是很容易转换为其他语言。
我们在本教程中使用的是 IPv4,但是 IPv6 最终会取代它。同样可以在 Wikipedia 了解关于它的更多信息。
诸如 Twisted 这样的 Python 框架为 socket 编程之类的事情提供了基类。
David 还编写了一个关于“Network programming with Twisted”的系列。
这两个教程中讨论的 socket 是 单播(unicast) socket,它们更广泛地使用在常见的 Internet 协议中。然而,也存在所谓的 多播(multicast) socket,它将一个传输与许多接收者联系起来。 Brad Huntting 和 David 编写了一系列的文章,描述了围绕 IP 多播存在的问题和技术:
- An Introduction to Multicasting Strategies
- Multicasting Strategies: Protocols And Topologies
- Multicasting Strategies: Understanding Reliability
要了解关于 Linux 的书籍的广泛选择,请访问 Developer Bookstore 的 Linux 部分。
David Mertz 是一位作家、程序员和教师,他总是在努力改善与读者(以及教程学员)的沟通。他欢迎读者的任何意见;请通过电子邮件 mertz@gnosis.cx 直接与他联系。
David 还编著了 Text Processing in Python 一书,读者可从 http://gnosis.cx/TPiP/ 在线阅读它。