第二部分
第一章
客户机设置
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() 调用的参数决定了 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。
第二章
服务器设置
与 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);
}
创建、配置和绑定服务器 socket
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。
receive/send 循环
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 服务器来说或许不是问题,但是更复杂的情况可能会引入糟糕的延迟)。
第三章
服务器工作中的复杂性
我们研究过的服务器(除了回显消息外,不做其他任何事情)能够极其快速地处理每个客户机请求。但是对于更一般的情况,我们可能希望服务器执行可能较长的操作,比如数据库查找、访问远程资源,或者执行复杂计算以便确定客户机的响应能力。我们的“一次做一件事情”的模型无法很好地扩展到处理多个客户机。
服务器压力测试
为了让服务器有一些工作可做,我们可以修改客户机以便发出多个请求(每个线程发送一个请求),这些请求需要尽可能快速地得到满足:
服务器压力测试,继续
针对以前的服务器之一运行,这个客户机只会运行几秒钟时间(不过当然不会将返回字符串转换为大写);没有线程开销的版本针对以前的服务器运行得甚至更快。假设这个假想的服务器进程并不是纯粹受 CPU 约束的,那么我能应该能够比 100+ 的响应速度更快。 还要注意那些线程一般不是以它们被创建的顺序得到服务的。
线程化的服务器
我们设置“长操作”服务器的方式保证了它至少要花五秒钟的时间来给任何给定的请求提供服务。但是没有理由说多个线程不能在那同样的五秒钟内运行。同样,受 CPU 约束的进程明显不会通过线程化而运行的更快,但是在实际的服务器中,那五秒主要花在诸如针对另一台机器执行数据库查询等事情上。换句话说,我们应该能够并行地给多个客户机线程提供服务。
一种明显的方法就是使服务器线程化,就像使客户机线程化一样:
在我的测试系统(与以前一样使用 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 编程风格避免了与线程或进程相关联的任何开销。
具有慢速 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)
具有慢速 socket 连接的客户机,继续
我们需要一个“传统的”服务器来测试上面的慢速客户机。本质上,下面的代码与本教程 第一部分 中介绍的第二个(低级)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)
具有慢速 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 连接完成其任务。
使用 select() 来多路复用 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 秒(至少在我的测试系统上是这样)。
用 C 编写的可扩展服务器
为更可扩展的服务器提供的例子全都使用了 Python。的确,Python 库的品质意味着不会存在比用 C 编写的相似服务器显著更慢的服务器。而对于本教程,相对简洁的陈述是很重要的。
在介绍上面的服务器时,我坚持使用了 Python 中相对低级的功能。 像 asyncore 或 SocketServer 这样一些高级模块 ―― 或者甚至是 threading 而不是 thread ―― 都可能提供更“具 Python 性质”的技术。然而,我使用的这些低级功能在结构上仍然相当接近您在 C 中要编写的相同内容。Python 的动态类型化和简洁的语法仍然节省了一些代码行,但是 C 程序员应该能够使用我的例子作为类似的 C 服务器的基本框架。