目录
Linux Socket编程 第一部分
1. 在开始之前
1.1. 关于这本教程
IP socket 是在其上建立高级 Internet 协议的最低级的层:从 HTTP 到 SSL 到 POP3 到 Kerberos 再到 UDP-Time,每种 Internet 协议都建立在它的基础上。为了实现自定义的协议,或定制众所周知的协议的实现,程序员需要掌握基本的 socket 基础结构的工作知识。虽然本教程主要集中在 C 编程上,而且还使用 Python 作为例子中的代表性高级语言,不过类似的 API 在许多语言中都可用。
本教程将向您介绍使用跨平台的 Berkeley Sockets Interface 编写自定义的网络工具程序的基本知识。几乎所有 Linux 和其他基于 UNIX 的操作系统上的网络工具都依赖这个接口。
学习本教程需要具备最低限度的 C 语言知识,同时熟悉 Python 则更理想(主要是为了下面的第二部分)。然而,如果您对任何一种编程语言都不熟悉,那么您就应该付出一点额外的努力来学习一点编程语言;一种编程语言的大多数基本概念都可以同样应用到其它语言上,而且在诸如 Ruby、Perl、TCL 之类的大多数高级脚本语言中的称呼都相当类似。
尽管本教程介绍的是IP (Internet Protocol,Internet 协议)网络背后的基本概念,但预先熟悉某些网络协议和层的概念将会有所帮助(相关背景文献请参阅本教成末尾的 参考资料 )。
1.2. 关于作者
David Mertz 是一位作家、程序员和教师,他总是在努力改善与读者(以及教程学员)的沟通。他欢迎读者的任何意见;请把您的意见直接发到 。
David 还编著了 Text Processing in Python 一书, 读者可以从 http://gnosis.cx/TPiP/ 在线阅读它。
2. 了解IP网络和网络层
2.1. 网络是什么?
我们通常所称的计算机网络由许多 网络层组成的(请参阅 参考资料 了解详细解释这些概念的有用参考资料)。其中的每个网络层都提供关于该层的数据的不同限制和/或保证。每个网络层的协议一般都有它们自己的包、包头和布局格式。
传统的七个网络层被分为两组:高层和低层。 Socket 接口对网络的低层提供了统一的 API (应用程序接口),并且允许您在 socket 应用程序内部实现高层协议。而且,应用程序的数据格式本身可以组成进一步的层。例如 SOAP 就建立在 XML 之上,并且 ebXML 本身可以利用 SOAP。无论如何,超过第 4 层的任何内容都超出了本教程的内容范围。
2.2. Socket 是做什么的?
虽然 socket 接口理论上还允许访问除 IP 以外的协议系列,然而在实际上,socket应用程序中使用的每个网络层都将使用 IP。对于本教程来说,我们仅介绍 IPv4;将来 IPv6 也会变得很重要,但是它们在原理是相同的。在传输层,socket 支持两个特殊协议:TCP (transmission control protocol,传输控制协议) 和 UDP (user datagram protocol,用户数据报协议)。
Socket不能用来访问较低(或较高)的网络层。例如,socket 应用程序不知道它是运行在以太网、令牌环网还是拨号连接上。Socket 的伪层(pseudo-layer)也不知道高层协议(比如 NFS、HTTP、FTP等)的任何情况(除非您自己编写一个 socket 应用程序来实现那些高层协议)。
在很多情况下,socket接口并不是用于网络编程 API 的最佳选择。特别地,由于存在很多很优秀的库可以直接使用高层协议,您不必关心 socket 的细节;那些库会为您处理 socket 的细节。例如,虽然编写您自己的 SSH 客户机并没有什么错,但是对于仅只是为了让应用程序安全地传输数据来说,就没有必要做得这样复杂。低级层比 socket 所访问的层更适合归入设备驱动程序编程领域。
2.3. IP、TCP 和 UDP
正如上一小节所指出的,当您编写 socket 应用程序的时候,您可以在使用 TCP 还是使用 UDP 之间做出选择。它们都有各自的优点和缺点。
TCP 是流协议,而UDP是数据报协议。换句话说,TCP 在客户机和服务器之间建立持续的开放连接,在该连接的生命期内,字节可以通过该连接写出(并且保证顺序正确)。然而,通过 TCP 写出的字节没有内置的结构,所以需要高层协议在被传输的字节流内部分隔数据记录和字段。
另一方面,UDP 不需要在客户机和服务器之间建立连接,它只是在地址之间传输报文。UDP 的一个很好特性在于它的包是自分隔的(self-delimiting),也就是一个数据报都准确地指出它的开始和结束位置。然而,UDP 的一个可能的缺点在于,它不保证包将会按顺序到达,甚至根本就不保证。当然,建立在 UDP 之上的高层协议可能会提供握手和确认功能。
对于理解 TCP 和 UDP 之间的区别来说,一个有用的类比就是电话呼叫和邮寄信件之间的区别。在呼叫者用铃声通知接收者,并且接收者拿起听筒之前,电话呼叫不是活动的。只要没有一方挂断,该电话信道就保持活动,但是在通话期间,他们可以自由地想说多少就说多少。来自任何一方的谈话都按临时的顺序发生。另一方面,当你发一封信的时候,邮局在投递时既不对接收方是否存在作任何保证,也不对信件投递将花多长时间做出有力保证。接收方可能按与信件的发送顺序不同的顺序接收不同的信件,并且发送方也可能在他们发送信件是交替地接收邮件。与(理想的)邮政服务不同,无法送达的信件总是被送到死信办公室处理,而不再返回给发送者。
2.4. 对等方、端口、名称和地址
除了 TCP 和 UDP 协议以外,通信一方(客户机或者服务器)还需要知道的关于与之通信的对方机器的两件事情:IP 地址或者端口。IP 地址是一个 32 位的数据值,为了人们好记,一般用圆点分开的 4 组数字的形式来表示,比如:64.41.64.172。端口是一个 16 位的数据值,通常被简单地表示为一个小于 65536 的数字。大多数情况下,该值介于 10 到 100 的范围内。一个 IP 地址获取送到某台机器的一个数据包,而一个端口让机器决定将该数据包交给哪个进程/服务(如果有的话)。这种解释略显简单,但基本思路是正确的。
上面的描述几乎都是正确的,但它也遗漏了一些东西。大多数时候,当人们考虑 Internet 主机(对等方)时,我们都不会记忆诸如 64.41.64.172这样的数字,而是记忆诸如 gnosis.cx 这样的名称。为了找到与某个特定主机名称相关联的 IP 地址,一般都使用域名服务器(DNS),但是有时会首先使用本地查找(经常是通过 /etc/hosts 的内容)。对于本教程,我们将一般地假设有一个 IP 地址可用,不过下一小节将讨论编写名称/地址查找代码。
2.5. 主机名称解析
命令行实用程序 nslookup 可以被用来根据符号名称查找主机 IP地址。实际上,许多常见的实用程序,比如 ping 或者网络配置工具,也会顺便做同样的事情。但是以编程方式做这样的事情很简单。
在 Python 或者其他非常高级的脚本语言中,编写一个查找主机 IP 地址的实用程序是微不足道的事情:
这里的窍门是使用相同 gethostbyname()) 函数的包装版本,该函数也可以在 C 中找到。它的用法非常简单:
$ ./nslookup.py gnosis.cx 64.41.64.172
在 C 中,标准库调用 gethostbyname() 用于名称查找。下面是 nslookup 的一个简单的命令行工具实现;要改编它以用于大型应用程序是一件简单的事情。当然,使用 C 要比使用 Python 稍微复杂一点。
1 /* Bare nslookup utility (w/ minimal error checking) */
2 #include <stdio.h> /* stderr, stdout */
3 #include <netdb.h> /* hostent struct, gethostbyname() */
4 #include <arpa/inet.h> /* inet_ntoa() to format IP address */
5 #include <netinet/in.h> /* in_addr structure */
6
7 int main(int argc, char **argv) {
8 struct hostent *host; /* host information */
9 struct in_addr h_addr; /* Internet address */
10 if (argc != 2) {
11 fprintf(stderr, "USAGE: nslookup <inet_address>\n");
12 exit(1);
13 }
14 if ((host = gethostbyname(argv[1])) == NULL) {
15 fprintf(stderr, "(mini) nslookup failed on '%s'\n", argv[1]);
16 exit(1);
17 }
18 h_addr.s_addr = *((unsigned long *) host->h_addr_list[0]);
19 fprintf(stdout, "%s\n", inet_ntoa(h_addr));
20 exit(0);
21 }
注意,gethostbyname() 的返回值是一个 hostent 结构,它描述该名称的主机。该结构的成员 host->h_addr_list 包含一个地址表,其中的每一项都是一个按照“网络字节顺序”排列的 32 位值;换句话说,字节顺序可能是也可能不是机器的本机顺序。为了将这个 32 位值转换成圆点隔开的四组数字的形式,请使用 inet_ntoa() 函数。
3. 使用C编写客户机应用程序
3.1. 编写 socket 客户机的步骤
我的客户机和服务器例子都将使用尽可能最简单的应用程序:发送数据和接收回完全相同的数据。事实上,很多机器出于调试目的而运行“回显服务器”;这对我们的最初客户机来说是很方便的,因为它可以在我们开始讲述服务器部分之前被使用(假设您有一台运行着 echo 的机器)。
我想感谢 Donahoo 和 Calvert 编写的 TCP/IP Sockets in C》一书(参阅 参考资料)。我已经改编了他们提供的几个例子。我推荐这本书,但是不可否认,echo 服务器/客户机很快就会i在介绍 socket 编程的大多数书籍中出现。
编写客户机应用程序所涉及的步骤在 TCP 和 UDP 之间稍微有些区别。对于二者来说,您首先都要创建一个 socket;单对 TCP 来说,下一步是建立一个到服务器的连接;向该服务器发送一些数据;然后再将这些数据接收回来;或许发送和接收会在短时间内交替;最后,在 TCP 的情况下,您要关闭连接。
3.2. TCP 回显客户机(客户机设置)
首先,我们来看一个 TCP 客户机。在本教程系列的第二部分,我们将做一些调整,用 UDP 来(粗略地)做同样的事情。我们首先来看前面几行:一些 include 语句,以及创建 socket 的语句。
这里没有太多的设置,只是分配了特定的缓冲区大小,它限定了每个过程中回显的数据量(但如果必要的话,我们可以循环通过多个过程)。我们还定义了一个小的错误函数。
3.3. TCP 回显客户机(创建 socket)
socket()调用的参数决定了 socket 的类型:PF_INET 只是意味着它使用 IP(您将总是使用它); SOCK_STREAM 和 IPPROTO_TCP 配合用于创建 TCP socket。
1 int main(int argc, char *argv[]) {
2 int sock;
3 struct sockaddr_in echoserver;
4 char buffer[BUFFSIZE];
5 unsigned int echolen;
6 int received = 0;
7
8 if (argc != 4) {
9 fprintf(stderr, "USAGE: TCPecho <server_ip> <word> <port>\n");
10 exit(1);
11 }
12 /* Create the TCP socket */
13 if ((sock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
14 Die("Failed to create socket");
15 }
说返回的值是一个 socket 句柄,它类似于文件句柄。特别地,如果 socket 创建失败,它将返回 -1 而不是正数形式的句柄。
3.4. TCP 回显客户机(建立连接)
现在我们已经创建了一个 socket 句柄,还需要建立与服务器的连接。连接需要有一个描述服务器的 sockaddr 结构。特别地,我们需要使用echoserver.sin_addr.s_addr 和 echoserver.sin_port 来指定要连接的服务器和端口。我们正在使用 IP 地址这一事实是通过echoserver.sin_family 来指定的,但它总是被设置为 AF_INET。
1 /* Construct the server sockaddr_in structure */
2 memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
3 echoserver.sin_family = AF_INET; /* Internet/IP */
4 echoserver.sin_addr.s_addr = inet_addr(argv[1]); /* IP address */
5 echoserver.sin_port = htons(atoi(argv[3])); /* server port */
6 /* Establish connection */
7 if (connect(sock, (struct sockaddr *) &echoserver,
8 sizeof(echoserver)) < 0) {
9 Die("Failed to connect with server");
10 }
与创建 socket 类似,在尝试建立连接时,如果失败,则返回-1,否则 socket 现在就准备好发送或接收数据了。有关端口号的参考资料请参阅参考资料 。
3.5. TCP回显客户机(发送/接收数据)
现在连接已经建立起来,我们准备好可以发送和接收数据了。send() 调用接受套接字句柄本身、要发送的字符串、所发送的字符串的长度(用于验证)和一个标记作为参数。一般情况下,表记的默认值为 0。send() 调用的返回值是成功发送的字节的数目。
1 /* Send the word to the server */
2 echolen = strlen(argv[2]);
3 if (send(sock, argv[2], echolen, 0) != echolen) {
4 Die("Mismatch in number of sent bytes");
5 }
6 /* Receive the word back from the server */
7 fprintf(stdout, "Received: ");
8 while (received < echolen) {
9 int bytes = 0;
10 if ((bytes = recv(sock, buffer, BUFFSIZE-1, 0)) < 1) {
11 Die("Failed to receive bytes from server");
12 }
13 received += bytes;
14 buffer[bytes] = '\0'; /* Assure null terminated string */
15 fprintf(stdout, buffer);
16 }
rcv() 调用不保证会获得某个特定调用中传输的每个字节。在接收到某些字节之前,它只是处于阻塞状态。所以我们让循环一直进行,直到收回所发送的全部字节。很明显,不同的协议可能决定以不同的方式(或许是字节流中的分隔符)决定何时终止接收字节。
3.6. TCP 回显客户机(包装)
对 send() 和 recv() 的调用在默认的情况下都是阻塞的,但是通过改变套接字的选项以允许非阻塞的套接字是可能的。然而,本教程不会介绍创建非阻塞套接字的细节,也不介绍在生产服务器中使用的诸如分支、线程或者一般异步处理(建立在非阻塞套接字基础上)之类的细节。这些问题将在本教程的第二部分介绍。
在这个过程的末尾,我们希望在套接字上调用 close() ,这很像我们对文件句柄所做的那样:
4. 使用C编写服务器应用程序
4.1. 编写套接字服务器的步骤
套接字服务器比客户机稍微复杂一点,这主要是因为服务器通常需要能够处理多个客户机请求。服务器基本上包括两个方面:处理每一个已建立的连接,以及要建立的连接。
在我们的例子中,以及在大多数情况下,都可以将特定连接的处理划分为支持函数,这看起来有点像 TCP 客户机所做的事情。我们将这个函数命名为 HandleClient()。
对新连接的监听与客户机有一点不同,其诀窍在于,最初创建并绑定到某个地址或端口的套接字并不是实际连接的套接字。这个最初的套接字的作用更像一个套接字工厂,它根据需要产生新的已连接的套接字。这种安排在支持派生的、线程化的或异步的分派处理程序(使用 select())函数)方面具有优势;不过对于这个入门级的教程,我们将仅按同步的顺序处理未决的已连接套接字。
4.2. TCP 回显服务器(应用程序设置)
我们的回显服务器与客户机非常类似,都以几个 #include 语句开始,并且定义了一些常量和错误处理函数:
常量 BUFFSIZE 限定了每次循环所发送的数据量。常量 MAXPENDING 限定了在某一时间将要排队等候的连接的数量(在我们的简单的服务器中,一次仅提供一个连接服务)。函数 Die() 与客户机中的相同。
4.3. TCP 回显服务器(连接处理程序)
用于回显连接的处理器程序很简单。它所做的工作就是接收任何可用的初始字节,然后循环发回数据并接收更多的数据。对于短的(特别是小于 BUFFSIZE) 的)回显字符串和典型的连接,while 循环只会执行一次。但是底层的套接字接口 (以及 TCP/IP) 不对字节流将如何在 recv() 调用之间划分做任何保证。
1 void HandleClient(int sock) {
2 char buffer[BUFFSIZE];
3 int received = -1;
4 /* Receive message */
5 if ((received = recv(sock, buffer, BUFFSIZE, 0)) < 0) {
6 Die("Failed to receive initial bytes from client");
7 }
8 /* Send bytes and check for more incoming data in loop */
9 while (received > 0) {
10 /* Send back received data */
11 if (send(sock, buffer, received, 0) != received) {
12 Die("Failed to send bytes to client");
13 }
14 /* Check for more data */
15 if ((received = recv(sock, buffer, BUFFSIZE, 0)) < 0) {
16 Die("Failed to receive additional bytes from client");
17 }
18 }
19 close(sock);
20 }
传入处理函数的套接字是已经连接到发出请求的客户机的套接字。一旦完成所有数据的回显,就应该关闭这个套接字。父服务器套接字被保留下来,以便产生新的子套接字,就像刚刚被关闭那个套接字一样。
4.4. TCP 回显服务器(配置服务器套接字)
就像前面所介绍的,创建套接字的目的对服务器和对客户机稍有不同。服务器创建套接字的语法与客户机相同,但结构 echoserver 是用服务器自己的信息而不是用它想与之连接的对等方的信息来建立的。您通常需要使用特殊常量 INADDR_ANY ,以支持接收服务器提供的任何 IP 地址上的请求;原则上,在诸如这样的多重主机服务器中,您可以相应地指定一个特定的 IP 地址。
1 int main(int argc, char *argv[]) {
2 int serversock, clientsock;
3 struct sockaddr_in echoserver, echoclient;
4
5 if (argc != 2) {
6 fprintf(stderr, "USAGE: echoserver <port>\n");
7 exit(1);
8 }
9 /* Create the TCP socket */
10 if ((serversock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
11 Die("Failed to create socket");
12 }
13 /* Construct the server sockaddr_in structure */
14 memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
15 echoserver.sin_family = AF_INET; /* Internet/IP */
16 echoserver.sin_addr.s_addr = htonl(INADDR_ANY); /* Incoming addr */
17 echoserver.sin_port = htons(atoi(argv[1])); /* server port */
注意,无论是IP地址还是端口,它们都要被转换为用于 sockaddr_in 结构的网络字节顺序。转换回本机字节顺序的逆向函数是 ntohs() 和 ntohl()。这些函数在某些平台上不可用,但是为跨平台兼容性而使用它们是明智的。
4.5. TCP 回显服务器(绑定和监听)
虽然客户机应用程序 connect() 到某个服务器的 IP 地址和端口,但是服务器却 bind() 到它自己的地址和端口。
一旦帮定了服务器套接字,它就准备好可以 listen() 了。与大多数套接字函数一样,如果出现问题,bind() 和 listen() 函数都返回 -1。一旦服务器套接字开始监听,它就准备 accept() 客户机连接,充当每个连接上的套接字的工厂。
4.6. TCP 回显服务器(套接字工厂)
为客户机连接创建新的套接字是服务器的一个难题。函数 accept() 做两件重要的事情:返回新的套接字的套接字指针;填充指向echoclient(在我们的例子中) 的 sockaddr_in 结构。
1 /* Run until cancelled */
2 while (1) {
3 unsigned int clientlen = sizeof(echoclient);
4 /* Wait for client connection */
5 if ((clientsock = accept(serversock, (struct sockaddr *) &echoclient,
6 &clientlen)) < 0) {
7 Die("Failed to accept client connection");
8 }
9 fprintf(stdout, "Client connected: %s\n",
10 inet_ntoa(echoclient.sin_addr));
11 HandleClient(clientsock);
12 }
13 }
我们可以看到 echoclient 中已填充的结构,它调用访问客户机 IP 地址的 fprintf()。客户机套接字指针被传递给 HandleClient(),我们在本节的开头看到了这点。
5. 使用Python编写套接字应用程序
5.1. 套接字和 SocketServer 模块
Python 的标准模块的 socket 提供了可从 C scoket 中找到的几乎完全相同的功能。不过该接口通常更加灵活,主要是因为它有动态类型化的优点。此外,它还使用了面向对象的风格。例如,一旦您创建一个套接字对象,那么诸如 .bind()、 .connect() 和 .send() 之类的方法都是该对象的方法,而不是在某个套接字上执行操作的全局函数。
在相比于 socket 的更高层次上,模块 SocketServer 提供了用于编写服务器的框架。这仍然是相对低级的,还有可用于为更高级的协议提供服务的更高级接口。比如 SimpleHTTPServer、 DocXMLRPCServer 和 CGIHTTPServer。
5.2. 使用 Python 编写的 TCP 回显客户机
让我们来看一下这个完整的客户机,然后做一些解释:
1 #!/usr/bin/env python
2 "USAGE: echoclient.py <server> <word> <port>"
3 from socket import * # import *, but we'll avoid name conflict
4 import sys
5
6 if len(sys.argv) != 4:
7 print __doc__
8 sys.exit(0)
9 sock = socket(AF_INET, SOCK_STREAM)
10 sock.connect((sys.argv[1], int(sys.argv[3])))
11 message = sys.argv[2]
12 messlen, received = sock.send(message), 0
13 if messlen != len(message):
14 print "Failed to send complete message"
15 print "Received: ",
16 while received < messlen:
17 data = sock.recv(32)
18 sys.stdout.write(data)
19 received += len(data)
20 print
21 sock.close()
初看起来,我们似乎从 C 版本中省去了一些错误捕捉代码。但是由于 Python 为我们在用 C 编写的客户机中检查的每种情形都给出了描述性的错误,我们可以让内置的异常(exception)为我们做这些工作。当然,如果我们希望准确描述以前的错误,那就不得不围绕这些调用向这个 socket 对象的方法添加几个 try/except 子句。
虽然这个 Python 客户机比较短,但在某种程度上是功能强大的。特别地,我们馈送给 .connect() 调用的地址既可以是用圆点隔开的四段数字式的IP地址,也可以是是符号名称,而不需要额外的查找工作。例如:
$ ./echoclient 192.168.2.103 foobar 7 Received: foobar $ ./echoclient.py fury.gnosis.lan foobar 7 Received: foobar
我们还可以在 .send()和 .sendall() 之间做出选择。前者一次发送尽可能多的字节数,后者发送整个报文(如果不能发送就会引发一个异常)。对于这样的客户机,我们要说明的是,如果没有发送整个报文,那么就取回实际发送的准确字节数量。
5.3. 使用 Python 编写的 TCP 回显服务器(SocketServer)
使用 Python 编写 TCP 回显服务器的最简单方法是使用 SocketServer 模块。使用这个模块式是如此容易,以致它几乎就像是在欺骗一样。在后面的几个小节中,我们将介绍遵循 C 实现的低级版本,不过现在让我们来看看使用它究竟有多简单:
1 #!/usr/bin/env python
2 "USAGE: echoserver.py <port>"
3 from SocketServer import BaseRequestHandler, TCPServer
4 import sys, socket
5
6 class EchoHandler(BaseRequestHandler):
7 def handle(self):
8 print "Client connected:", self.client_address
9 self.request.sendall(self.request.recv(2**16))
10 self.request.close()
11
12 if len(sys.argv) != 2:
13 print __doc__
14 else:
15 TCPServer(('',int(sys.argv[1])), EchoHandler).serve_forever()
唯一需要我们提供的就是具有一个 .handle() 方法的 SocketServer.BaseRequestHandler 的一个孩子。self 实例具有一些有用的属性,比如 .client_address 和 .request,后者本身是一个已连接的套接字对象。
5.4. 使用 Python 编写的 TCP 回显服务器(套接字)
如果我们希望采用“避易就难”的实现方式,并且希望获得更精细的控制,我们可以使用 Python 来编写几乎跟使用 C 所编写的完全一样(不过具有更少的代码行)的回显服务器:
1 #!/usr/bin/env python
2 "USAGE: echoclient.py <server> <word> <port>"
3 from socket import * # import *, but we'll avoid name conflict
4 import sys
5
6 def handleClient(sock):
7 data = sock.recv(32)
8 while data:
9 sock.sendall(data)
10 data = sock.recv(32)
11 sock.close()
12
13 if len(sys.argv) != 2:
14 print __doc__
15 else:
16 sock = socket(AF_INET, SOCK_STREAM)
17 sock.bind(('',int(sys.argv[1])))
18 sock.listen(5)
19 while 1: # Run until cancelled
20 newsock, client_addr = sock.accept()
21 print "Client connected:", client_addr
22 handleClient(newsock)
实在地说,这种“避易就难”的方式仍然不是很难。但是就像在 C 实现中一样,我们使用 .listen() 制造了新的已连接的套接字,并且调用了每个这样的连接的处理程序。
6. 结束语与参考资料
6.1. 结束语
在本教程中介绍的服务器和客户机很简单,但是它们展示了编写 TCP 套接字应用程序的每个基本要素。如果所传输的数据更复杂,或者应用程序中的对等方(客户机和服务器)之间的交互更高深,那就是另外的应用程序编程问题了。即使这样,所交换的数据仍然遵循 connect() 和 bind() 然后再 send() 和 recv() 的模式。
本教程没有谈及的一件事情是 UDP 套接字的使用,虽然我们在本教程开头的摘要中提到了。比起 UDP,TCP 使用得更普遍,不过同时理解 UDP 套接字以作为你编写应用程序的选择也是很重要的。在本教程的第二部分,我们将考察 UDP,同时也会介绍使用 Python 实现套接字应用程序,此外还会介绍一些其他的中间主题。
6.2. 参考资料
关于使用 C 进行套接字编程的一本优秀入门读物是 Michael J. Donahoo 和 Kenneth L. Calvert 所著的TCP/IP Sockets in C 一书 (Morgan-Kaufmann, 2001)。例子和更多的信息可在该书的作者页面找到。
UNIX 系统支持组的文档 Network Layers 解释了较低网络层的功能。
传输控制协议(TCP)在 RFC 793 中介绍。
用户数据报协议(UDP)是 RFC 768 的主题。
读者可以在 IANA(Internet Assigned Numbers Authority ) Web 站点上找到广泛使用的端口分配的列表。
"Understanding Sockets in Unix, NT, and Java" (developerWorks)用 C 和 Java 示例源代码阐述了套接字的基本原理。
"运行时:编写套接字程序" (developerWorks) 比较了 Windows 和 Linux 上的套接字的性能。
AIX C 编程书籍 “Communications Programming Concepts”的 Sockets 部分深入地讨论了许多相关问题。
" AIX 5L Version 5.2 Technical Reference "的第 2 卷集中介绍了通信,当然包括大量关于套接字编程的内容。
Robocode 项目 (alphaWorks) 有一篇关于"Using Serialization with Sockets" 的文章,其中包括 Java 源代码和例子。
套接字、网络层次、UDP以及其他许多内容也在对话形式的 Beej's Guide to Network Programming 中进行了讨论。
您会发现 Gordon McMillan 的 Socket Programming HOWTO 和 Jim Frost 的 BSD Sockets: A Quick and Dirty Primer 也很有用。
在 developerWorks 的 Linux 专区可以找到为 Linux 开发者准备的更多参考资料。
6.3. 反馈
请告诉我们本教程是否对您有帮助,以及我们应该如何改进它。我们还想知道您希望我们提供关于其他哪些主题的教程。
关于本教程内容的问题,请通过 [email protected]与作者 David Mertz 联系。
Linux Socket编程 第二部分
1. 在开始之前
关于本教程 第 1 页(共2 页)
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 协议)网络背后的基本概念,但预先熟悉某些网络协议和层的概念将会有所帮助(相关背景文献请参阅本教程末尾的 参考资料)。
关于作者 第 2 页(共2 页)
David Mertz 是一位作家、程序员和教师,他总是在努力改善与读者(以及教程学员)的沟通。他欢迎读者的任何意见;请通过电子邮件 [email protected] 直接与他联系。
David 还编著了 Text Processing in Python 一书,读者可从 http://gnosis.cx/TPiP/ 在线阅读它。
2. 理解网络层和协议
网络是什么? 第 1 页(共4 页)
本小节和接下来的三个小节将扼要重述本教程 第一部分 中的讨论 ―― 如果已经阅读了第一部分,您可以直接跳到使用 Python 编写 UDP 应用程序。
计算机网络由许多“网络层”组成,每个网络层对该层的数据提供不同的限制和/或保证。 每个网络层的协议一般具有它们自己的包、包头和布局格式。
传统的七个网络层(请参阅 参考资料 以找到指向这些层的讨论的链接)被划分为两组:高层和底层。socket 接口为网络的低层提供统一的 API(应用程序编程接口),并允许您在自己的 socket 应用程序中实现高层。而应用程序数据格式本身可能又构成进一步的层。
socket 是做什么的? 第 2 页(共4 页)
虽然 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 所访问的层更适合归入设备驱动程序编程范畴。
IP、TCP 和 UDP 第 3 页(共4 页)
正如上一小节所指出的,当您编写 socket 应用程序的时候,您可以在使用 TCP 还是使用 UDP 之间做出选择。它们都有各自的优点和缺点。
TCP 是流协议,而 UDP 是数据报协议。换句话说,TCP 在客户机和服务器之间建立持续的开放连接,在该连接的生命期内,字节可以通过该连接写出(并且保证顺序正确)。然而,通过 TCP 写出的字节没有内置的结构,所以需要高层协议在被传输的字节流内部分隔数据记录和字段。
另一方面,UDP 不需要在客户机和服务器之间建立连接,它只是在地址之间传输报文。UDP 的一个很好特性在于它的包是自分隔的(self-delimiting),也就是一个数据报都准确地指出它的开始和结束位置。然而,UDP 的一个可能的缺点在于,它不保证包将会按顺序到达,甚至根本就不保证。当然,建立在 UDP 之上的高层协议可能会提供握手和确认功能。
对于理解 TCP 和 UDP 之间的区别来说,一个有用的类比就是电话呼叫和邮寄信件之间的区别。在呼叫者用铃声通知接收者,并且接收者拿起听筒之前,电话呼叫不是活动的。只要没有一方挂断,该电话信道就保持活动,但是在通话期间,他们可以自由地想说多少就说多少。来自任何一方的谈话都按临时的顺序发生。另一方面,当你发一封信的时候,邮局在投递时既不对接收方是否存在作任何保证,也不对信件投递将花多长时间做出有力保证。接收方可能按与信件的发送顺序不同的顺序接收不同的信件,并且发送方也可能在他们发送信件是交替地接收邮件。与(理想的)邮政服务不同,无法送达的信件总是被送到死信办公室处理,而不再返回给发送者。
对等方、端口、名称和地址 第 4 页(共4 页)
除了 TCP 和 UDP 协议以外,通信一方(客户机或者服务器)还需要知道的关于与之通信的对方机器的两件事情:IP 地址或者端口。IP 地址是一个 32 位的数据值,为了人们好记,一般用圆点分开的 4 组数字的形式来表示,比如:64.41.64.172。端口是一个 16 位的数据值,通常被简单地表示为一个小于 65536 的数字。大多数情况下,该值介于 10 到 100 的范围内。一个 IP 地址获取 送到 某台机器的一个数据包,而一个端口让机器决定将该数据包交给哪个进程/服务(如果有的话)。这种解释略显简单,但基本思路是正确的。
上面的描述差不多是正确的,但是它遗漏了一些东西。在人类考虑一个 Internet 主机(对等方)的大多数时候,我们不是去记忆一个像 64.41.64.172 这样的数字,而是一个像 gnosis.cx 这样的名称。本教程的 第一部分 展示了如何使用 DNS 和本地查找来从域名找出 IP 地址。
3. 使用Python编写UDP程序
编写 socket 应用程序的步骤 第 1 页(共5 页)
与在本教程 第一部分 中一样,用于服务器和客户机的例子都使用了一个尽可能最简单的应用程序: 一个发送和接收完全相同的内容的应用程序。 事实上,许多机器为调试而运行一个“回显服务器”;这对于我们初始的客户机是很方便的,因为它可以在我们开始讨论服务器之前使用(假设您有一台在运行 echod 的服务器)。
在此我要感谢 Donahoo 和 Calvert 所著的 TCP/IP Sockets in C 一书(请参阅 参考资料)。本教程摘录了他们提供的几个例子。我推荐该书 ―― 但是不可否认,回显服务器和客户机在大多数 socket 编程介绍中都是首先要提到的。
本教程第一部分的读者已经详细看到了一个 TCP 回显客户机。因此让我们直接转到基于 UDP 的相似客户机吧。
一个高级 Python 服务器 第 2 页(共5 页)
我们将在稍后讨论用 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)
print doc % argv[0]
UDPServer((,int(argv[1])), EchoHandler).serve_forever()
- def handle(self):
各种专用的 SocketServer 类全都需要你提供适当的 .handle()方法。但是对于 DatagramRequestHandler,您可以从连接的客户机获得方便的伪文件 self.rfile 和 self.wfile 来分别执行读写。
一个用 Python 编写的 UDP 回显客户机 第 3 页(共5 页)
编写一个 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)
- print "Failed to receive identical message"
如果您恰好回想起 第一部分 中的 TCP 回显客户机,就会注意到这里存在的一些区别。此例中创建的 socket 是 SOCK_DGRAM 类型的,而不是 SOCK_STREAM 类型的。不过更有趣的是 UDP 的无连接性质。与建立连接并重复调用 .send() 和 .recv() 方法直至传输完成不同,对于 UDP,我们仅使用一个 .sendto() 和一个 .recvfrom() 来发送和收取消息(一个数据报)。
由于不涉及连接,您需要传递目标地址作为 .sendto() 调用的一部分。在 Python 中,socket 对象跟踪消息实际所通过的临时 socket 编号。后面我们将看到,在 C 中您需要通过 sendto() 返回的一个变量来使用这个编号。
客户机和服务器实际应用 第 4 页(共5 页)
运行服务器和客户机很简单。服务器通过一个端口号来启动:
$ ./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 用于实际的数据。
一个低级 Python 服务器 第 5 页(共5 页)
与使用 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]
- 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
4. 使用C编写回显客户机
4.1. 客户机设置
UDP 客户机的前几行与 TCP 客户机的对应行完全相同。我们主要是使用了几个 include 语句来包含 socket 函数,或其他基本的 I/O 函数。
这里没有多少需要设置的东西。值得注意的是,我们分配的缓冲区大小要比 TCP 版本中的缓冲区大得多(但是在尺寸上仍然是有限的)。TCP 可以循环迭代未决的数据,并且在每个循环中要通过一个打开的 socket 多发送一点数据。对于这个 UDP 版本,我们想要一个足够大到可以容纳整个消息的的缓冲区,整个消息在单个数据报中发送(它可以少于 255 个字节,但是不可以大于 255 个字节)。这个版本还定义了一个很小的错误处理函数。
4.2. 声明和使用情况信息
在 main() 函数的最开头,我们分配了两个 sockaddr_in 结构、一些用于包含字符串大小的整数,另一个用于 socket 句柄的 int 类型的变量,以及一个用于包含返回字符串的缓冲区。之后,我们检查了命令行参数看起来是否都是正确的。
1 int main(int argc, char *argv[]) {
2 int sock;
3 struct sockaddr_in echoserver;
4 struct sockaddr_in echoclient;
5 char buffer[BUFFSIZE];
6 unsigned int echolen, clientlen;
7 int received = 0;
8
9 if (argc != 4) {
10 fprintf(stderr, "USAGE: %s <server_ip> <word> <port>\n", argv[0]);
11 exit(1);
12 }
这里已经出现了与 Python 代码形成对比的地方。对于 C 客户机,您 必须 使用点分四组的 IP 地址。在 Python 中,所有 socket 模块函数处理幕后的名称解析。如果想要在 C 客户机种执行查找,您需要编写一个 DNS 函数 ―― 比如在本教程第一部分中介绍的那个函数。
事实上,检查作为服务器 IP 地址传入的 IP 地址是否真的看起来像点分四组,这并不是一种极端的想法。如果忘了传入命名的地址,您或许会接收到有点误导性的错误消息: “Mismatch in number of sent bytes: No route to host(发送的字节数不匹配,没有到达主机的路径)”。任何命名的地址实际上相当于未使用的或保留的 IP 地址(这当然无法通过简单的模式检查来排除)。
4.3. 创建 socket 并配置服务器结构
socket() 调用的参数决定了 socket 的类型: PF_INET 只是意味着它使用 IP(您总是会使用 IP);SOCK_DGRAM 和 IPPROTO_UDP 配合起来用于 UDP socket。在准备要回显(echo)的消息时,我们使用命令行参数来填充预期的服务器结构。
1 /* Create the UDP socket */
2 if ((sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
3 Die("Failed to create socket");
4 }
5 /* Construct the server sockaddr_in structure */
6 memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
7 echoserver.sin_family = AF_INET; /* Internet/IP */
8 echoserver.sin_addr.s_addr = inet_addr(argv[1]); /* IP address */
9 echoserver.sin_port = htons(atoi(argv[3])); /* server port */
socket()调用的返回值是一个 socket 句柄,它和文件句柄类似;特别是,如果 socket 创建失败,该调用将返回 -1 而不是返回一个正数句柄。支持函数 inet_addr() 和 htons()(以及 atoi())用于将字符串参数转换为适当的数据结构。
4.4. 向服务器发送消息
就所做的工作而言,这个客户机要比本教程系列 第一部分 中介绍的相似 TCP 回显客户机简单一点。正如我们从 Python 版本中看到的,发送消息并不是基于首先建立连接。您只需使用 sendto() 来将消息发送到指定的地址,而不是在已建立的连接上使用 send()。 当然,这需要两个额外的参数来指定预期的服务器地址。
这个调用中的错误检查通常确定到服务器的路径是否存在。如果错误地使用了命名的地址,则会引发一条错误消息,但是看起来有效但不可到达的地址也会引发错误消息。
4.5. 从服务器收回消息
收回数据的工作方式与在 TCP 回显客户机中相当相似。唯一的真正变化是对 recvfrom()的调用替代了对 recv()的 TCP 调用。
1 /* Receive the word back from the server */
2 fprintf(stdout, "Received: ");
3 clientlen = sizeof(echoclient);
4 if ((received = recvfrom(sock, buffer, BUFFSIZE, 0,
5 (struct sockaddr *) &echoclient,
6 &clientlen)) != echolen) {
7 Die("Mismatch in number of received bytes");
8 }
9 /* Check that client and server are using same socket */
10 if (echoserver.sin_addr.s_addr != echoclient.sin_addr.s_addr) {
11 Die("Received a packet from an unexpected server");
12 }
13 buffer[received] = '\0'; /* Assure null-terminated string */
14 fprintf(stdout, buffer);
15 fprintf(stdout, "\n");
16 close(sock);
17 exit(0);
18 }
结构 echoserver 已在对 sendto() 的调用期间使用一个 特殊 端口来配置好了;相应地,echoclient 结构通过对 recvfrom() 的调用得到了类似的填充。如果其他某个服务器或端口在我们等待接受回显时发送数据包,这样将允许我们比较两个地址。我们至少应该最低限度地谨防我们不感兴趣的无关数据包(为了确保完全肯定,也可以检查 .sin_port 成员)。
在这个过程的结尾,我们打印出发回的数据包,并关闭该 socket。
5. 使用C编写UDP回显客户机
5.1. 服务器设置
与 TCP 应用程序相比,UDP 客户机和服务器彼此更为相似。本质上,其中的每一个都主要由一些混合在一起的 sendto() 和 recvfrom() 调用组成。服务器的主要区别不过就是它通常将其主体放在一个无限循环中以保持提供服务。
下面让我们首先考察通常的 include 语句和错误处理函数:
5.2. 声明和使用情况信息
同样,UDP 回显服务器声明和使用情况消息中没有多少新的内容。我们需要一个用于服务器和客户机的 socket、一些将用于检验传输大小的变量,当然还需要用于读写消息的缓冲区。
5.3. 创建、配置和绑定服务器 socket
UDP 客户机和服务器之间的第一个真正区别在于服务器端需要绑定 socket。我们已经在 Python 的例子中看到了这点,这里的情况是相同的。服务器 socket 并不是传输消息所通过的实际 socket;相反,它充当一个 特殊 socket 的工厂,这个特殊 socket 是在我们很快将要看到的 recvfrom()调用中配置的。
1 /* Create the UDP socket */
2 if ((sock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
3 Die("Failed to create socket");
4 }
5 /* Construct the server sockaddr_in structure */
6 memset(&echoserver, 0, sizeof(echoserver)); /* Clear struct */
7 echoserver.sin_family = AF_INET; /* Internet/IP */
8 echoserver.sin_addr.s_addr = htonl(INADDR_ANY); /* Any IP address */
9 echoserver.sin_port = htons(atoi(argv[1])); /* server port */
10
11 /* Bind the socket */
12 serverlen = sizeof(echoserver);
13 if (bind(sock, (struct sockaddr *) &echoserver, serverlen) < 0) {
14 Die("Failed to bind server socket");
15 }
读者还会注意到 echoserver 结构是以稍微不同的方式配置的。为了允许服务器托管的任何 IP 地址上的连接,我们对成员 .s_addr 使用了特殊常量 INADDR_ANY。
5.4. receive/send 循环
UDP 服务器中的重大举措是它的主循环 ―― 虽然也不过如此。基本上,我们是在一个 recvfrom() 调用中永久地等待接收一条消息。此时,echoclient 结构将使用连接的 socket 的相关成员来填充。 然后我们在后续的 sendto() 调用中使用该结构。
1 /* Run until cancelled */
2 while (1) {
3 /* Receive a message from the client */
4 clientlen = sizeof(echoclient);
5 if ((received = recvfrom(sock, buffer, BUFFSIZE, 0,
6 (struct sockaddr *) &echoclient,
7 &clientlen)) < 0) {
8 Die("Failed to receive message");
9 }
10 fprintf(stderr,
11 "Client connected: %s\n", inet_ntoa(echoclient.sin_addr));
12 /* Send the message back to client */
13 if (sendto(sock, buffer, received, 0,
14 (struct sockaddr *) &echoclient,
15 sizeof(echoclient)) != received) {
16 Die("Mismatch in number of echo'd bytes");
17 }
18 }
19 }
大功告成!我们可以不断地接收和发送消息,同时在此过程中向控制台报告连接情况。当然,正如我们将在下一节看到的,这种安排一次仅做一件事情,这对于处理许多客户机的服务器来说可能是一个问题(对这个简单的 echo 服务器来说或许不是问题,但是更复杂的情况可能会引入糟糕的延迟)。
6. 可扩展的服务器
6.1. 服务器工作中的复杂性
我们研究过的服务器(除了回显消息外,不做其他任何事情)能够极其快速地处理每个客户机请求。但是对于更一般的情况,我们可能希望服务器执行可能较长的操作,比如数据库查找、访问远程资源,或者执行复杂计算以便确定客户机的响应能力。我们的“一次做一件事情”的模型无法很好地扩展到处理多个客户机。
为了说明其中的要点,让我们考察一个稍微修改后的 Python 服务器,这个服务器需要花一些时间才能完成其任务。而且为了强调服务器正在处理请求,我们还在此过程中(无足轻重地)修改消息字符串:
1 #!/usr/bin/env python
2 from socket import *
3 from sys import argv
4
5 def lengthy_action(sock, message, client_addr):
6 from time import sleep
7 print "Client connected:", client_addr
8 sleep(5)
9 sock.sendto(message.upper(), client_addr)
10
11 sock = socket(AF_INET, SOCK_DGRAM)
12 sock.bind(('',int(argv[1])))
13 while 1: # Run until cancelled
14 message, client_addr = sock.recvfrom(256)
15 lengthy_action(sock, message, client_addr)
6.2. 服务器压力测试
为了让服务器有一些工作可做,我们可以修改客户机以便发出多个请求(每个线程发送一个请求),这些请求需要尽可能快速地得到满足:
1 #!/usr/bin/env python
2 from socket import *
3 import sys, time
4 from thread import start_new_thread, get_ident
5
6 start = time.time()
7 threads = {}
8 sock = socket(AF_INET, SOCK_DGRAM)
9
10 def request(n):
11 sock.sendto("%s [%d]" % (sys.argv[2],n),
12 (sys.argv[1], int(sys.argv[3])))
13 messin, server = sock.recvfrom(255)
14 print "Received:", messin
15 del threads[get_ident()]
16
17 for n in range(20):
18 id = start_new_thread(request, (n,))
19 threads[id] = None
20 #print id,
21 while threads: time.sleep(.1)
22 sock.close()
23 print "%.2f seconds" % (time.time()-start)
6.3. 服务器压力测试
为了让服务器有一些工作可做,我们可以修改客户机以便发出多个请求(每个线程发送一个请求),这些请求需要尽可能快速地得到满足:
1 #!/usr/bin/env python
2 from socket import *
3 import sys, time
4 from thread import start_new_thread, get_ident
5
6 start = time.time()
7 threads = {}
8 sock = socket(AF_INET, SOCK_DGRAM)
9
10 def request(n):
11 sock.sendto("%s [%d]" % (sys.argv[2],n),
12 (sys.argv[1], int(sys.argv[3])))
13 messin, server = sock.recvfrom(255)
14 print "Received:", messin
15 del threads[get_ident()]
16
17 for n in range(20):
18 id = start_new_thread(request, (n,))
19 threads[id] = None
20 #print id,
21 while threads: time.sleep(.1)
22 sock.close()
23 print "%.2f seconds" % (time.time()-start)
6.4. 线程化的服务器
我们设置“长操作”服务器的方式保证了它至少要花五秒钟的时间来给任何给定的请求提供服务。但是没有理由说多个线程不能在那同样的五秒钟内运行。同样,受 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 秒花在线程化和连接开销上(大致如此)。
6.5. 分支服务器
在类 UNIX 系统上,分支甚至比线程化更容易。进程通常要比线程“重”,但是在诸如 Linux、FreeBSD 和 Darwin 这样的流行 Posix 系统上,进程创建仍然是相当高效的。
使用 Python,我们“长操作”服务器版本可以像下面这样简单:
1 #!/usr/bin/env python
2 from socket import *
3 from sys import argv, exit
4 from os import fork
5
6 def lengthy_action(sock, message, client_addr):
7 from time import sleep
8 print "Client connected:", client_addr
9 sleep(5)
10 sock.sendto(message.upper(), client_addr)
11 exit()
12
13 sock = socket(AF_INET, SOCK_DGRAM)
14 sock.bind(('',int(argv[1])))
15 while 1: # Run until cancelled
16 message, client_addr = sock.recvfrom(256)
17 if fork():
18 lengthy_action(sock, message, client_addr)
在我的测试系统上,我实际上发现这个分支版本要比线程化的版本 快 几秒。作为行为方面的少许区别,在向一组客户机线程提供服务之后,while 循环中的主进程转到了后台,虽然服务器是在前台启动的。然而,对于从后台启动服务器的通常情况,这个区别是不相关的。
6.6. 异步服务器
另一种称为 异步 或 非阻塞 socket 的技术甚至可能比线程化或分支方法更有效率。异步编程背后的概念是将执行保持在单个线程内,但是要轮询每个打开的 socket,以确定它是否有更多的数据在等待读入或写出。然而,非阻塞 socket 实际上仅对受 I/O 约束的进程有用。我们使用 sleep() 创建的受 CPU 约束的服务器模拟就在一定程度上遗漏了这个要点。此外,非阻塞 socket 对 TCP 连接比对 UDP 连接更有意义,因为前者保持一个可能仍然具有未决数据的打开连接。
概而言之,异步对等方(客户机 或 服务器)的结构是一个轮询循环 ―― 通常使用函数 select() 或它的某个高级包装,比如 Python 的 asyncore。在每次经过循环时,您都要检查所有打开的 socket,以确定哪些当前是可读的,以及哪些当前是可写的。这检查起来很快,并且您可以简单地忽略当前没有为 I/O 操作做好准备的任何 socket。这种 socket 编程风格避免了与线程或进程相关联的任何开销。
6.7. 具有慢速 socket 连接的客户机
为了模拟低带宽连接,我们可以创建这样一个客户端,它在发送数据时引入人为的延时,并且逐字节地发出消息。为了模拟许多这样的连接,我们可以创建多个连接线程(每个都是慢速的)。一般来说,这个客户机与我们在上面看到的 DPechoclient2.py 类似,只不过是 TCP 版本:
1 #!/usr/bin/env python
2 from socket import *
3 import sys, time
4 from thread import start_new_thread, get_ident
5
6 threads = {}
7 start = time.time()
8
9 def request(n, mess):
10 sock = socket(AF_INET, SOCK_STREAM)
11 sock.connect((sys.argv[1], int(sys.argv[3])))
12 messlen, received = len(mess), 0
13 for c in mess:
14 sock.send(c)
15 time.sleep(.1)
16 data = ""
17 while received < messlen:
18 data += sock.recv(1)
19 time.sleep(.1)
20 received += 1
21 sock.close()
22 print "Received:", data
23 del threads[get_ident()]
24
25 for n in range(20):
26 message = "%s [%d]" % (sys.argv[2], n)
27 id = start_new_thread(request, (n, message))
28 threads[id] = None
29 while threads:
30 time.sleep(.2)
31 print "%.2f seconds" % (time.time()-start)
6.8. 具有慢速 socket 连接的客户机,继续
我们需要一个“传统的”服务器来测试上面的慢速客户机。本质上,下面的代码与本教程 第一部分 中介绍的第二个(低级)Python 服务器完全相同。唯一的真正区别在于最大连接数提高到了 20。
1 #!/usr/bin/env python
2 from socket import *
3 import sys
4
5 def handleClient(sock):
6 data = sock.recv(32)
7 while data:
8 sock.sendall(data)
9 data = sock.recv(32)
10 newsock.close()
11
12 if __name__=='__main__':
13 sock = socket(AF_INET, SOCK_STREAM)
14 sock.bind(('',int(sys.argv[1])))
15 sock.listen(20)
16 while 1: # Run until cancelled
17 newsock, client_addr = sock.accept()
18 print "Client connected:", client_addr
19 handleClient(newsock)
6.9. 具有慢速 socket 连接的客户机,总结
下面让我们针对“一次做一件事情”的服务器运行“慢速连接”客户机(与前面一样,输出有删减):
$ ./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 连接完成其任务。
6.10. 使用 select() 来多路复用 socket
现在我们已经准备好查看函数 select() 如何能够用来避免我们刚才引入的那种延时(或者由于确实很慢的连接而频繁产生的那种延时)。我们已在几个小节之前讨论了一般概念;下面让我们考察详细的代码:
1 #!/usr/bin/env python
2 from socket import *
3 import sys, time
4 from select import select
5
6 if __name__=='__main__':
7 while 1:
8 sock = socket(AF_INET, SOCK_STREAM)
9 sock.bind(('',int(sys.argv[1])))
10 print "Ready..."
11 data = {}
12 sock.listen(20)
13 for _ in range(20):
14 newsock, client_addr = sock.accept()
15 print "Client connected:", client_addr
16 data[newsock] = ""
17 last_activity = time.time()
18 while 1:
19 read, write, err = select(data.keys(), data.keys(), [])
20 if time.time() - last_activity > 5:
21 for s in read: s.shutdown(2)
22 break
23 for s in read:
24 data[s] = s.recv(32)
25 for s in write:
26 if data[s]:
27 last_activity = time.time()
28 s.send(data[s])
29 data[s] = ""
这个服务器是易碎的,因为它总是在从那些客户机连接之中 select() 之前,等待准确的 20 个客户机连接。但是它仍然说明了使用密集的轮训循环,以及仅当数据在特定的 socket 上可用时才读/写数据的的基本概念。select() 的返回值分别是可读、可写和错误的 socket 列表的三元组。这其中的每一种类型都是根据需要在循环中处理的。
顺便说一句,使用这种异步服务器允许“慢速连接”客户机在大约 6 秒的时间内完成全部 20 个连接,而不是需要 37 秒(至少在我的测试系统上是这样)。
6.11. 用 C 编写的可扩展服务器
为更可扩展的服务器提供的例子全都使用了 Python。的确,Python 库的品质意味着不会存在比用 C 编写的相似服务器显著更慢的服务器。而对于本教程,相对简洁的陈述是很重要的。
在介绍上面的服务器时,我坚持使用了 Python 中相对低级的功能。 像 asyncore 或 SocketServer 这样一些高级模块 ―― 或者甚至是 threading 而不是 thread ―― 都可能提供更“具 Python 性质”的技术。然而,我使用的这些低级功能在结构上仍然相当接近您在 C 中要编写的相同内容。Python 的动态类型化和简洁的语法仍然节省了一些代码行,但是 C 程序员应该能够使用我的例子作为类似的 C 服务器的基本框架。
7. 结束语和参考资料
7.1. 结束语
本教程中介绍的服务器和客户机很简单,但是它们展示了用 C 和 Python 编写 UDP socket 应用程序所必需的每一方面。更高级的客户机或服务器本质上不过是来回地传输更有趣的数据;socket 层的代码和本文中的这些例子并没有什么不同。
执行线程化、分支和异步 socket 处理的一般要点可类似地应用于更高级的服务器。您的服务器和客户机本身可能会做更多的事情,但是针对可扩展性的策略始终是这三种方法之一(或者它们的组合)。
7.2. 参考资料
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 还编写了一个关于“使用 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 部分。
7.3. 反馈
请告诉我们本教程对您是否有帮助,以及我们如何能够做得更好。我们还想知道您希望看到的其他教程。
关于本教程内容的问题,请通过电子邮件 [email protected] 联系作者 David Mertz。