一:客户端
本章总结的服务器程序设计范式,使用同一个客户端程序进行测试。客户端运行在和服务器处于同一个子网上的两个不同主机上。每个客户端同时派生5个子进程,每个子进程在与服务器依次建立500次连接,每次连接请求4000个字节的数据。因此,每个客户端将与服务端建立2500个连接。总共有5000次连接,而且任意时刻服务端最多存在10个连接。使用的命令如下:
client 206.62.226.36 8888 5 500 4000
二:结论
上图测量的时间仅仅是用于进程控制所需的CPU时间,以迭代服务器为基准(原因见下一节),从其他服务器的实际CPU时间中减去迭代服务器的实际CPU时间,就得到相应服务器用于进程控制所需的CPU时间。因为迭代服务器没有进程控制。
上图描述了,在预先创建子进程池的设计范式中,闲置子进程过多对CPU时间的影响。
上图描述了,在预先创建子进程池和线程池的设计范式中,5000个连接在所有子进程或线程中的分布情况。
三:TCP迭代服务器
迭代TCP服务器总是在完全处理完某个客户的请求之后,才去处理下一个客户。因此这样的服务器程序比较少见。本章中用它作为测试基准,使用的测试命令如下:
client 206.62.226.36 8888 1 5000 4000
使用同样的TCP连接数5000,请求一样的字节数4000,但是同一时间只有一个连接,不进行进程控制,因而它的时间是最快的。
四:TCP并发服务器程序,每个客户一个子进程
传统上,并发服务器调用fork派生一个子进程来处理每个客户。这使得服务器能够同时为多个客户服务,每个进程一个客户。
绝大多数TCP服务器程序也按照这个范式编写。但是并发服务器的问题在于为每个客户现场fork一个子进程比较耗费CPU时间。在多年前的20世纪80年代后期,一个繁忙的服务器每天也就处理几百个亦或几千个客户,这点CPU时间是可以接受的。然而WEB应用的爆发式增长改变了人们的态度。繁忙的Web服务器每天测得TCP连接数以百万计,这还是就单个主机而言。
以后讲解的各种技术,都是在避免并发服务器为每个客户现场fork的做法,不过传统意义上的并发服务器依然相当普遍。
结论图1中的数据表明,传统意义的并发服务器所需CPU时间最多。
五:TCP预先派生子进程,accept无上锁保护
使用预先派生子进程的技术,不像传统意义上的并发服务器那样,为每个客户现场派生子进程,而是在启动阶段预先派生一定数量的子进程,当各个客户连接到达时,这些子进程立即就能为他们服务。
代码结构是:父进程启动完所有子进程之后,就进入睡眠。每个子进程中:
for(; ;)
{
connfd = accept(listenfd, cliaddr, &clilen);
web_child(connfd);
close(connfd);
}
在这种程序范式中,因为所有子进程和父进程共享同一个监听套接字。所以,当第一个客户连接到达时,所有子进程均被唤醒,因为所有子进程使用的监听套接字指向同一个socket结构。只有最先运行的那个子进程能获得客户连接,而其他子进程只能继续回复睡眠。
这种现象称为惊群(thundering herd)问题。因为尽管只有一个子进程将获得连接,所有其他子进程却都被唤醒了。尽管如此这段代码依然起作用,只是每仅有一个连接准备好时,却唤醒太多进程的做法会导致性能受损。结论图2的数据表明了这种问题。
结论图3给出了所有连接在子进程中的分布情况,可见当可用子进程阻塞在accept调用上时,内核调度算法把各个连接均匀地散布到各个子进程。
六:TCP预先派生子进程,accept使用上锁保护
有些系统不允许多个进程监听同一个套接字,在这些系统中,上述代码启动不久,某个子进程的accept可能就会返回EPROTO错误。
解决办法是让每个子进程在调用accept前后安置某种类型的锁,这样任意时刻只有一个子进程阻塞在accept调用中,其他子进程则阻塞在试图获取用于保护accept的锁上。子进程中的代码结构如下:
for(; ;)
{
my_lock_wait();
connfd = accept(listenfd, cliaddr, &clilen);
my_lock_release();
web_child(connfd);
close(connfd);
}
有多种方法可以提供上锁功能,可以使用fcntl函数记录锁,也可以使用线程互斥锁pthread_mutex_lock进行进程上的上锁。
根据结论图1,可见这种上锁增加了服务器的进程控制CPU时间,但是线程互斥锁还是要快于记录锁的。
根据结论图3,看见上锁时,操作系统还是均匀的把锁散步到等待线程中。
七:TCP预先派生子进程,传递描述符
这种设计是只让父进程调用accept,然后把所接受的已连接套接字“传递”给某个子进程。这么做绕过了为所有子进程的accept调用提供上锁保护的需求,不过需要从父进程到子进程的某种形式的描述符传递。这种技术会使代码多少有点复杂,因为父进程必须跟踪子进程的忙闲状态,以便给空闲子进程传递新的套接字。
子进程中的代码结构如下,子进程阻塞在read_fd调用中,等待父进程传递描述符,收到描述符之后,处理客户请求。子进程在处理完客户请求之后,会向管道中写入一个字节,以通知父进程本子进程可重用(闲置状态):
for ( ; ; )
{
if ( (n = Read_fd(sockfd[1], &c, 1, &connfd))== 0)
err_quit("read_fd returned0");
if (connfd < 0)
err_quit("no descriptorfrom read_fd");
web_child(connfd); /* process request */
Close(connfd);
Write(STDERR_FILENO, "", 1); /* tell parent we're ready again */
}
根据结论图1,可见本服务器慢于所有其他子进程池的设计。
八:TCP并发服务器,每个客户一个线程
使用线程代替子进程,客户连接到来时,现场创建一个线程处理客户请求,代码结构如下:
主线程:
for ( ; ; )
{
clilen = addrlen;
connfd = Accept(listenfd, cliaddr, &clilen);
Pthread_create(&tid, NULL, &doit, (void *) connfd);
}
子线程:
void * doit(void *arg)
{
void web_child(int);
Pthread_detach(pthread_self());
web_child((int) arg);
Close((int) arg);
return (NULL);
}
结论图1表明,这个简单的创建线程的版本快于所有预先派生子进程的版本。
九:TCP预先创建线程,每个线程各自accept
既然预先派生子进程快于为每个客户现场派生一个子进程,那么有理由相信预先创建一个线程池也快于为每个客户现场创建一个线程的做法。本节中的设计,是让每个线程各自调用accept,使用互斥锁加以保护,以保证任何时刻只有一个线程在调用accept。
结论图1表明,这样的设计确实快于每个客户现场一个线程的版创建线程池,事实上当前版本的服务器是所有版木之中最快的。
结论图3表明,所有连接也均匀的分布在各个线程上了,这种均衡性是由线程调度算法带来的。
十:TCP预先创建线程,主线程统一accept
主线程在创建一个线程池之后,只让主线程调用accept,并把每个客户连接传递给池中某个可用线程。
本设计范式的问题在于,主线程如何把一个已连接套接字传递给线程池中某个可用线程。
可以如前使用描述符传递,不过既然所有线程和所有描述符都在同一个进程之内,我们没有必要把一个描述符从一个线程传递到另一个线程。接收线程只需知道这个已连接套接字描述符的值,而描述符传递实际传递的井非这个值,而是对这个套接字的一个引用,因而将返回一个不同于原值的描述符。
所以,在多线程环境中,这实际上又是一个生产者消费者问题,主线程为一个生产者,所有子线程为多个消费者。
根据结论图1,可见这个版本的服务器慢于上一节中的互斥锁保护accept的版本,这是因为生产者和消费者之间的同步问题造成的。
摘自《Unix网络编程卷一:套接字联网API》第30章