上一章中,TCP客户同时处理两个输入:标准输入和TCP套接字,我们遇到的问题是客户阻塞于标准输入上的fgets调用期间,服务器进程被杀死时,虽然服务器TCP正确地给客户TCP发送了一个FIN,但客户进程正阻塞于从标准输入读的过程,它将看不到这个EOF,直到从套接字读时(可能已经过了很长时间)才看到。客户进程需要内核一旦发现进程指定的一个或多个IO条件就绪(即输入已准备好被读取,或描述符能承接更多输出),就通知客户进程,这称为IO复用,可由select和poll函数支持,前者还有较新的POSIX变种函数pselect。
有些系统提供了更先进的让进程在一串事件上等待的机制,轮询设备(poll device)就是这样的机制之一,但不同厂家提供的方式不同。
IO复用使用场合:
1.客户处理多个描述符(通常是交互式输入和网络套接字)时。
2.客户同时处理多个套接字时。这种情况比较少见。
3.服务器既要处理监听套接字,又要处理已连接套接字时。
4.服务器既要处理TCP,又要处理UDP时。
5.服务器要处理多个服务或多个协议时(如inetd守护进程)时。
IO复用并非只限于网络编程。
Unix下的5种IO模型:
1.阻塞式IO。
2.非阻塞式IO。
3.IO复用(select、poll函数)。
4.信号驱动式IO(SIGIO)。
5.异步IO(POSIX的aio_系列函数)。
一个输入操作通常包含两个阶段:
1.等待数据准备好。
2.从内核向进程复制数据。
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达,当所等待分组到达时,它被复制到内核中的某个缓冲区;第二步是把数据从内核缓冲区复制到应用进程缓冲区。
默认所有套接字都是阻塞的,以数据报套接字为例,阻塞式IO模型如下图:
我们使用UDP(数据报)而非TCP作为例子的原因在于,对于UDP,数据准备好读取的概念简单,要么整个数据报已收到,要么还没有。对于TCP,诸如套接字低水位标记(low-water mark)等额外变量导致数据准备好的概念变得复杂。
我们把recvfrom函数视为系统调用,不论它如何实现,因为我们正在区分应用进程和内核。源自Berkeley的内核上recvfrom函数是系统调用,在System V内核上recvfrom函数是调用了系统调用getmsg的函数,无论如何,recvfrom函数都会运行在内核空间一段时间。
上图中,recvfrom函数直到数据报到达且被复制到应用进程的缓冲区中,或发生错误,才会返回,最常见的错误是系统调用被信号中断。进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的,recvfrom函数成功返回后,应用进程开始处理数据报。
进程可把一个套接字设置成非阻塞的,即通知内核,当所请求的IO操作要把本进程投入睡眠才能完成时,不要将本进程投入睡眠,而是返回一个错误:
上图中,前三次recvfrom调用没有数据可返回,因此内核立即返回一个EWOULDBLOCK错误。第四次调用recvfrom时已有一个数据报准备好,该数据报被复制到应用进程缓冲区,于是recvfrom函数成功返回,我们接着可以处理数据。
当一个应用进程像上图一样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询。应用进程持续轮询内核,以查看某个操作是否就绪,这么做往往会消耗大量CPU时间。
可用select或poll函数实现IO复用,阻塞在这两个系统调用上,而不是阻塞在真正的IO系统调用上:
如上图,我们阻塞于select调用,等待数据报套接字变为可读,当select函数返回套接字可读条件时,我们调用recvfrom把要读的数据报复制到应用进程缓冲区。
与阻塞式IO模型相比,IO复用模型优势在于可以等待多个描述符就绪。
与IO复用类似的另一种IO模型是在多线程中使用阻塞式IO,但它没有使用select函数阻塞在多个文件描述符上,而是使用多个线程(每个文件描述符一个线程),这样每个线程都能自由地调用诸如recvfrom函数之类的阻塞式IO系统调用了。
我们可以让内核在描述符就绪时发送SIGIO信号通知我们,称这种模型为信号驱动式IO:
上图过程需要我们先开启套接字的信号驱动式IO功能,并通过sigaction系统调用安装一个信号处理函数,此系统调用将立即返回,我们的进程不会在此被阻塞。当数据报准备好读取时,内核为该进程产生一个SIGIO信号,我们既可以在信号处理函数中调用recvfrom读取数据报,并通知主循环已准备好数据待处理,也可以立即通知主循环,让它读取数据报。
无论如何处理SIGIO信号,信号驱动式IO模型的优势在于等待数据报到达期间进程不被阻塞,主循环可以继续执行,只要等待来自信号处理函数的通知,既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
异步IO由POSIX规范定义,各种早期标准所定义的实时函数中存在的差异已取得一致,演变成了当前的POSIX规范。一般这些实时函数的工作机制是,告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。异步IO模型与信号驱动IO模型主要区别在于,信号驱动式IO是由内核通知我们何时启动一个IO操作,而异步IO模型是由内核通知我们IO操作何时完成。
上图中,我们调用aio_read函数(POSIX异步IO函数以aio_或lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与read函数相同的3个参数)、文件偏移(与lseek函数类似),并告诉内核当整个操作完成时如何通知我们。aio_read系统调用立即返回,且在等待IO完成期间,进程不被阻塞。本例中我们要求内核在操作完成时产生某个信号,该信号在数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动式IO模型。
POSIX对同\异步IO的定义:
1.同步IO操作导致请求进程阻塞,直到IO操作完成。
2.异步IO操作不导致请求进程阻塞。
根据POSIX对同\异步IO的定义,阻塞式IO模型、非阻塞式IO模型、IO复用模型、信号驱动式IO模型都是同步IO模型,因为其真正的IO操作将阻塞进程。只有异步IO模型与POSIX定义的异步IO相匹配。
select函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生,或经历一段时间后才唤醒它。例如,我们可以调用select,告知内核仅在下列情况发生时才返回:
1.集合{1,4,5}中任何描述符准备好读;
2.集合{2,7}中的任何描述符准备好写;
3.集合{1,4}中任何描述符有异常条件待处理;
4.已经历了10.2秒。
即我们调用select告知内核对哪些描述符(就写、读、异常条件)感兴趣以及等待多长时间。我们感兴趣的描述符不局限于套接字,任何描述符都能用于select函数。
源自Berkeley的实现已允许任何描述符的IO复用,SVR 3最初把IO复用限制于对应流设备的描述符,SVR 4去除了这个限制。
select函数的timeout参数告知内核等待所指定描述符中的任何一个就绪可花多长时间,timeval结构用于指定这段时间的秒数和微秒数:
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
timeout参数有三种可能:
1.空指针:永远等待,仅在有描述符准备好IO时才返回。
2.timeval结构中有一个值不为0:等待timeval结构指定的时间,或有描述符准备好IO时返回。
3.timeval结构中两个值均为0:不等待,检查描述符后立即返回,这称为轮询。
select函数在等待时会被捕获的信号中断,从信号处理函数返回时:源自Berkeley的内核不自动重启被中断的select函数,SVR 4可以自动重启被中断的select函数,条件是在安装信号处理函数时指定SA_RESTART标志,从可移植性角度考虑,如果我们捕获信号,那么必须做好select函数返回EINTR错误的准备。
尽管timeval结构允许我们指定一个微秒级的分辨率,但内核支持的真实分辨率往往更粗糙,例如,许多Unix内核把超时值向上舍入成10ms的倍数,另外还涉及调度延迟,即定时器时间到后,内核还需花一点时间调度相应进程运行。
如果timeout参数所指的timeval结构中的tv_sec成员值超过1亿秒,则有些系统的select还是将以EINVAL错误失败返回,这是一个非常大的超时值(超过3年),不大可能有用。就此指出:timeval结构能够表达select还是不支持的值。
timeout参数有const限定词,表示它不会被select函数修改,例如,如果我们指定一个10s的限定值,但在定时器超时前就返回了(可能是有一个或多个描述符就绪,也可能是得到EINTR错误),那么timeout参数所指的timeval结构不会被更新成该函数返回时剩余的秒数,如果我们需要这个值,则必须在调用select前获取系统时间,select函数返回后再获取系统时间,两者相减(健壮的程序需要考虑系统时间可能在这段时间内被管理员或ntpd之类的守护进程调整)。
有些Linux版本会在select函数返回时修改timeval结构,因此从移植性考虑,我们应该假设该timeval结构在select函数返回后已被修改,并在每次调用select函数前都初始化timeval结构。POSIX规定对该结构使用const限定词。
参数readset、writeset、exceptset指定我们要让内核测试读、写、异常条件的描述符。目前支持的异常条件为:
1.某个套接字上有带外数据到达。
2.某个已置为分组模式(packet mode)的伪终端存在可从其主端读取的控制状态信息。
select函数使用描述符集为readset、writeset、exceptset参数指定一个或多个描述符值,每个描述符集通常是一个整数数组,其中每个整数中的每一位对应一个描述符,假设使用32位整数,则该数组的第一个元素对应于描述符0~31,第二个元素对应描述符32~63,以此类推。描述符集的实现细节与应用程序无关,它们隐藏在数据类型fd_set和以下4个宏中:
我们分配一个fd_set类型的描述符集后,可以用以上宏设置或测试该集合中的每一位,也可用C语言赋值语句把它赋值成另一个描述符集。
以上我们讨论的每个描述符占整数数组中一位的方法仅仅是select函数的可能实现之一,但把描述符集中的每个描述符称为位是常见的,如打开读集合中表示监听描述符的位。
以下代码用于定义一个fd_set类型的变量,然后打开描述符1、4、5的对应位:
fd_set rset;
FD_ZERO(&rset); /* initialize the set: all bits off */
FD_SET(1, &rset); /* turn on bit for fd 1 */
FD_SET(4, &rset); /* turn on bit for fd 4 */
FD_SET(5, &rset); /* turn on bit for fd 5 */
描述符集需要初始化,因为作为自动变量(即局部作用域变量,在控制流进入变量作用域时系统自动为其分配存储空间,并在离开作用域时释放空间)分配的一个描述符集如果没有初始化,会发生不可预期的后果。
如果我们对select函数的readset、writeset、exceptset参数中的某一个条件不感兴趣,可将其设为空指针。如果这3个指针都为空,我们就有了一个比sleep函数更精确的定时器(sleep函数以秒为睡眠最小单位)。poll函数也提供类似功能。
maxfdp1参数指定待测试的描述符个数,它的值是待测试的最大描述符加1,描述符0、1、2···直到maxfdp1-1都将被测试,也就是说,即使我们只想监测描述符5、6,内核同时还会监测描述符0~4,因此,如果要监测描述符5时第一个参数填成5,那么内核将不会监测到我们想要监测的描述符5。maxfdp1是最大待测试描述符加1的原因在于,我们指定的是描述符个数而非最大值,而描述符是从0开始的。
头文件sys/select.h中定义的FD_SETSIZE常值是fd_set类型的变量中描述符总数,其值通常为1024(默认,为了避免单个进程消耗过多资源,进程只能打开1024个文件描述符),但很少有程序能用到这么多描述符。maxfdp1参数迫使我们计算出所关心的最大描述符并告知内核,存在该参数纯粹是为了效率原因,每个fd_set都有表示大量描述符的空间(典型值1024),但一个普通进程所用的数量却少得多,内核通过在进程与内核间只复制描述符集中的必要部分,从而不测试总为0的那些位来提高效率。
select函数会修改指针参数readset、writeset、exceptset所指向的描述符集,因此这三个参数都是值-结果参数,调用select函数时,我们指定所关心的描述符的值,select函数返回时,结果将指示哪些描述符已就绪。select函数返回后,我们用FD_ISSET宏测试fd_set数据类型中的描述符,描述符集内任何与未就绪描述符对应的位返回时均被清0,因此,每次重新调用select函数时,我们需要把所有描述符集内所关心的位置1。
select函数的返回值表示所有描述符集中已就绪的总位数,如果在任何描述符就绪前定时器超时,则返回0。返回-1表示出错,如被所捕获的信号中断时。
SVR 4的早期版本中,select函数有一个缺陷,如果返回时多个描述符集内的同一位为1(如某描述符既准备好读又准备好写的情况),那么在函数返回值中只计一次。当前版本已修正此缺陷。
满足以下条件之一时,一个套接字准备好读:
1.该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区当前的低水位标记的字节数大小。我们可以使用SO_RCVLOWAT套接字选项设置该套接字的低水位标记,对于TCP和UDP套接字,低水位标记默认值为1字节。
2.该连接的读半部关闭(接收到了FIN后),对这样的套接字的读操作将不阻塞并返回0(即返回EOF)。
3.该套接字是一个监听套接字且已完成的连接数不为0,对这样的套接字调用accept通常不会阻塞。
4.套接字上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1,同时把errno设为确切的错误条件。这些待处理错误也可以通过指定SO_ERROR套接字选项来调用getsockopt获取并清除。
满足以下条件之一时,一个套接字准备好写:
1.该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区当前的低水位标记大小,并且该套接字是已连接的(除非该套接字不需要连接,如UDP)。如果我们把这样的套接字设置成非阻塞,写操作将不阻塞并返回一个正值(由传输层接受的字节数)。我们可以用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记,对TCP和UDP来说,默认值通常为2048。
2.该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。
3.非阻塞调用connect时,当套接字成功建立连接或connect函数已经失败。
4.套接字上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1,同时把errno设置成确切的错误条件。这些待处理错误也可通过指定SO_ERROR套接字选项调用getsockopt获取并清除。
对以上描述符准备好写的讨论中,我们限定套接字是非阻塞的,这是由于,如果select函数告诉我们某套接字可写,假设该套接字的发送缓冲区有8192字节可用,当我们要写8193字节时,write函数将阻塞,等待最后1个字节的可用空间,因此我们需要将该描述符设为非阻塞以避免阻塞。但对于可读条件,由于对阻塞式套接字的读操作只要有数据可读总会返回。
如果一个套接字存在带外数据或仍处于带外标记,则它异常条件就绪。
以上读可读性、可写性、异常条件的定义取自内核的soreadable宏、sowriteable宏、soo_select函数。
当某个套接字上发生错误时,它将被select函数标记为既可读又可写。
接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select返回可读或可写条件前有多少数据可读或有多大空间可用于写。例如,除非至少有64个字节的数据,我们的应用进程才能有有效工作可做,我们就可以把接收低水位标记设为64,以防少于64字节的数据准备好读时select函数就唤醒我们。
任何UDP套接字只要其发送低水位标记小于等于发送缓冲区大小(总是有这种关系)就总是可写的,因为UDP套接字不需要连接。
最初设计select函数时,操作系统通常对每个进程可用的最大描述符数设置了上限(4.2 BSD的限制为31),select函数就使用了相同的上限值。但当今Unix版本允许每个进程使用事实上无限数目的描述符(往往仅受限于内存总量和管理性限制),这对select函数会产生影响。
许多select函数实现有类似下面的声明,它取自4.4 BSD的sys/types.h头文件:
这使我们想到是否可以在包括该头文件前把FD_SETSIZE定义为更大的值以增加select函数所用描述符集的大小,但这样是行不通的,因为内核中使用的描述符集使用内核的FD_SETSIZE定义作为上限使用,因此增大描述符集大小的唯一方法是先增加FD_SETSIZE的值,再重新编译内核,不重新编译内核而重新改变其值是不够的。
有些厂家正在将select函数的实现修改为允许进程将FD_SETSIZE定义为比默认值更大的某个值,BSD/OS已改变了内核以允许更大的描述符集,并定义了4个新的FD_xxx宏用于动态分配并操纵这样的描述符集,但从可移植性考虑,小心使用大描述符集。
使用select函数重写str_cli函数,这样服务器进程一终止,客户就能马上得到通知,早先的版本问题在于:当套接字上发生某些事件时,客户可能阻塞于fgets调用。新版本改为阻塞于select调用,或是等待标准输入可读,或是等待套接字可读。
客户的套接字上处理如下:
1.如果对端TCP发送数据,那么该套接字变为可读,且read函数返回一个大于0的值(即读入数据的字节数)。
2.如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,且read函数返回0(EOF)。
3.如果对端TCP发送一个RST(对端主机崩溃并重新启动,对我们前一个报文段的回复),那么该套接字变为可读,且read函数返回-1,errno中含有确切错误码。
使用select函数的str_cli函数实现:
void str_cli(FILE *fp, int sockfd) {
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);
for (; ; ) {
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL); // 我们将写集合指针、异常集合指针设为空指针,同时将最后一个参数时间限制设为空指针,因为我们希望本调用阻塞到某个描述符就绪为止
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if (Readline(sockfd, recvline, MAXLINE) == 0) {
err_quit("str_cli: server terminated prematurely");
}
Fputs(recvline, stdout);
}
// 不要在这个if前加else,否则当两个描述符都可读时,只会从套接字描述符读,然后再调用select,才发现标准输入可读
// 但这么做不会导致客户不能工作,只是降低了效率
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if (Fgets(sendline, MAXLINE, fp) == NULL) {
return; /* all done */
}
Writen(sockfd, sendline, strlen(sendline));
}
}
}
但以上函数仍然不正确,在最初版本中,它以停-等方式工作,这对交互式使用是合适的:发送一行文本给服务器,然后等待应答,等待时间是往返时间RTT加上服务器处理的时间(对简单的回射服务器而言,处理时间几乎为0),如果知道了客户与服务器之间的RTT,我们就可估计出回射固定数目的行所需时间。
ping程序可用于测量RTT,ping测量所用的是长度为84字节的IP数据报。如果有一个2000行的文件,平均每行49字节,加上IP首部的20字节和TCP首部的20字节,那么每行对应的分组大小为89字节,基本与ping分组的大小一致。我们通过执行ping命令,得到两个主机之间平均30次的RTT为175ms,因此可以估算出这2000行文本的客户处理时间大约为2000*0.175秒(350秒),实际上真实时间为354秒,与我们的估计很接近。
如果我们把客户和服务器之间的网络作为全双工管道来考虑,请求是从客户向服务器发送,应答从服务器向客户发送的:
客户在时刻0发出请求,我们假设RTT为8个时间单位,其应答在时刻4发出并在时刻7接收到,我们还假设没有服务器处理时间且请求大小与应答大小相同。上图只展示了客户与服务器之间的数据分组,忽略了同样穿越网络的TCP确认。
既然一个分组从管道的一端发出到到达管道的另一端存在延迟,而管道是全双工的,就上例而言,我们仅使用了管道的1/8。这种停-等方式对于交互式输入是合适的,但如果我们把标准输入重定向到文件,从而以批量方式运行,但这样做会发现我们从服务器收到的内容少于发往服务器的内容,对于回射服务器而言,它们理应相等。
在批量方式下,客户以网络可接受的最快速度持续发送请求,这会导致时刻7时管道充满:
这里我们假设发出第一个请求后,立即发出下一个。我们还假设客户能够以网络可以接受的速度持续发送请求,且能够以网络可提供给它的速度处理应答。
以上我们忽略了涉及TCP批量处理数据流时的许多问题,如限制数据在一个全新的或空闲的连接上的发送速率的慢启动算法、返回的ACK。
我们假设批量处理的数据只有9行,最后一行在时刻8发出,写完这个请求后,我们不能立即关闭连接,因为管道中还有其他请求和应答。问题在于我们对EOF的处理:收到EOF后,str_cli函数会返回到main函数,而main函数随后终止,在批量方式下,标准输入中的EOF并不意味着我们同时也完成了从套接字的读入。
我们需要一种关闭TCP连接其中一半的方法,即,我们想给服务器发送一个FIN,告诉它我们已经完成了数据发送,但仍保持套接字描述符打开以便读取。
一般,为提升性能而引入缓冲机制增加了网络应用程序的复杂性。考虑有多个来自标准输入的文本行可用的情况,select函数将使用fgets函数读取输入,这使得所有可用的文本行被读入stdio所用的缓冲区,但fgets函数只返回stdio缓冲区里的第一行,其余输入行仍在stdio缓冲区中。之后把fgets函数返回的单行输入写给服务器,随后select函数被再次调用以等待新工作,而不管stdio缓冲区中还有额外的输入行待消费,其原因在于select函数不知道stdio使用了缓冲区,它只是从read系统调用的角度指出是否有数据可读,而不是从fgets之类的函数角度考虑,因此,混合使用stdio和select函数被认为是非常容易出错的,这样做时需要极其小心。
同样的问题存在于readline调用中,这次select函数不可见的数据不是隐藏在stdio缓冲区中,而是隐藏在readline函数自己的缓冲区中,我们同时提供了一个可以看到readline函数缓冲区的函数,因此可能的解决方法之一是修改代码,调用select前使用查看缓冲区的函数,以查看是否存在已经读入而未消费的数据。但为了处理readline函数的缓冲区中可能的不完整输入行和可能有一个或多个完整输入行(我们可直接消费)这两种情况,引入的复杂性会迅速增长到难以控制的地步。
终止网络连接的通常方法是调用close函数,但它有两个限制:
1.close函数把描述符的引用计数-1,仅在计数变为0时才关闭套接字,shutdown函数不管引用计数就激发TCP的正常连接终止序列。
2.close函数终止读和写两个方向上的数据传送,shutdown函数可关闭一个方向上的数据传送。TCP是全双工的,有时需要告知对端我们已完成了数据发送,即使对端仍有数据要发送给我们。
howto参数的值:
1.SHUT_RD:关闭连接的读这一半,套接字接收缓冲区中的现有数据被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown后,由该套接字接收的来自对端的任何数据都被确认,然后丢弃这些数据。默认,写入到一个路由套接字的所有数据都作为输入回送给主机上的所有路由套接字,有些程序把第二个参数指定为SHUT_RD来调用shutdown以防止环回复制,防止环回复制的另一个方法是关闭SO_USELOOPBACK套接字选项。
2.SHUT_WR:关闭连接的写这一半,对于TCP套接字,这称为半关闭,当前留在套接字发送缓冲区中的数据将被发送,后跟TCP的正常连接终止序列。进程不能再对这样的套接字调用任何写函数。
3.SHUT_RDWR:连接的读半部和写半部都被关闭,等效于用以上两个参数调用两次shuwdown。
以上3个howto参数值由POSIX规范定义,它的典型值为0(SHUT_RD)、1(SHUT_WR)、2(SHUT_RDWR)。
str_cli函数的改进后的正确版本,使用了shutdown函数正确地处理批量输入,且废弃了以文本行为中心的代码,转而对缓冲区操作:
void str_cli(FILE *fp, int sockfd) {
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;
stdineof = 0; // 只要该标志为0,我们就在主循环内用select函数检查标准输入的可读性
FD_ZERO(&rset);
for (; ; ) {
if (stdineof == 0) {
FD_SET(fileno(fp), &rset);
}
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
// 当我们在套接字上读到EOF时,如果我们已在标准输入上遇到EOF,那就是正常终止,否则就是服务器过早终止
if (stdineof == 1) {
return; /* normal termination */
} else {
err_quit("strcli: server terminated prematurely");
}
}
Write(fileno(stdout), buf, n);
}
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if ((n = Read(fileno(fp), buf, MAXLINE)) == 0) {
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /* send FIN */
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
}
}
以上程序客户大部分时间花在阻塞于select调用中,一旦待处理错误被设置成ETIMEDOUT,select函数就立即返回套接字的可读条件。
我们可以用select函数把回射服务器改成处理多个客户的单进程程序,而不是为每个客户派生一个子进程。以下是服务器在第一个客户建立连接前的状态:
如上图,服务器有单个监听描述符,用一个圆点表示。
服务器只维护一个读描述符集:
假设服务器是在前台启动的,则描述符0、1、2分别被设置为标准输入、标准输出、标准错误输出,因此监听套接字的第一个可用描述符是3,。上图还有一个名为client的整型数组,它包含每个客户的已连接套接字描述符,该数组的所有元素都被初始化为-1。上图中描述符集中唯一的非0项表示的是监听套接字,因此select函数的第一个参数为4。
当第一个客户与服务器建立连接时,监听描述符变为可读,我们的服务器于是调用accept。本例中,accept函数返回的新已连接描述符将是4:
我们的服务器在client数组中记住每个新的已连接描述符,并把它加到描述符集中去:
稍后,第二个客户与服务器建立连接:
新的已连接描述符是5,将其存入服务器的client数组中:
假设第一个客户终止它的连接,该客户的TCP发送一个FIN,使得服务器中的描述符4变为可读。当服务器读这个已连接套接字时,read函数返回0,我们于是关闭该套接字并相应地更新数据结构:把client[0]的值置为-1,把描述符集中描述符4的位设为0:,但maxfd的值没有变:
总之,当有客户到达时,我们在client数组中的第一个可用项中记录其已连接套接字的描述符,我们还要把这个已连接描述符加到读描述符集中。变量maxi是client数组当前使用项的最大下标,变量maxfd加1后是select函数的第一个参数值。本服务器所能处理的最大客户数限制是以下两个值的较小者:FD_SETSIZE和内核允许本进程打开的最大描述符数。
单进程版本的服务器程序:
#include "unp.h"
int main(int argc, char **argv) {
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
socklen_t clilen;
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
maxfd = listenfd; /* initialize */
maxi = -1; /* index into client[] array */
for (i = 0; i < FD_SETSIZE; ++i) {
client[i] = -1; /* -1 indicates available entry */
}
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for (; ; ) {
rset = allset; /* structure assignment */
// 阻塞于select函数,等待新客户连接的建立、数据、FIN、RST的到达
nready = Select(maxfd + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &rset)) { /* new client connection */
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *)&cliaddr, &clilen);
for (i = 0; i < FD_SETSIZE; ++i) {
if (client[i] < 0) {
client[i] = connfd; /* save descriptor */
break;
}
}
if (i == FD_SETSIZE) {
err_quit("too many clients");
}
FD_SET(connfd, &allset); /* add new descriptor to set */
if (connfd > maxfd) {
maxfd = connfd; /* for select */
}
if (i > maxi) {
maxi = i; /* max index in client[] array */
}
if (--nready <= 0) {
continue; /* no more readable descriptors */
}
}
for (i = 0; i <= maxi; ++i) { /* check all clients for data */
if ((sockfd = client[i]) < 0) {
continue;
}
if (FD_ISSET(sockfd, &rset)) {
// 如果客户发来RST触发的可读条件,那么read函数会出错,包裹函数Read会直接终止进程,这样的服务器过于脆弱
// 但如果使用的是一个子进程服务一个客户的方式,那么终止行为不会影响到父进程和其他子进程
if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
/* connection closed by client */
Close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -i;
// 我们不减少maxi的值,但在此处可以减少它
} else {
Writen(sockfd, buf, n);
}
if (--nready <= 0) {
break; /* no more readable descriptors */
}
}
}
}
}
以上服务器代码比多进程版本的复杂,但它避免了为每个客户创建一个新进程的开销。
以上服务器代码如果将读客户数据的read函数换成readline函数就会有问题,如果有一个恶意客户连接到服务器,发送1个字节数据(不是换行符)后进入睡眠,服务器调用readline时,在readline函数内部,会先调用一次read函数,由于readline函数只有在读到换行符或EOF时才会返回,因此服务器会一直阻塞在第二次read调用,此时服务器不能再为其他客户提供服务,不论是接受新的客户连接还是读取现有客户数据,直到恶意客户发出换行符或终止为止。
这里的一个基本概念是:服务器在处理多个客户时,绝对不能阻塞于只与单个客户相关的某个函数调用,否则可能导致服务器被挂起,拒绝为所有客户提供服务,这就是拒绝服务型攻击,即对服务器做些工作,导致服务器不能为其他合法客户提供服务。解决办法有:使用非阻塞式IO、让每个客户由单独控制线程提供服务(如创建一个子进程或一个线程来服务每个客户)、对IO操作设置超时。
pselect函数由POSIX发明:
pselect函数相比于select函数有两个变化:
1.pselect函数使用timespec结构,而非timeval结构,timespec结构是POSIX的另一个发明:
这两个结构的区别在于第二个成员,timespec结构的该成员tv_nsec指定纳秒数,而旧结构的tv_usec指定微秒数。另外,timeval结构的第一个成员是有符号的长整数,而timespec结构的第一个成员类型为time_t,timeval结构里的有符号长整数也应该改为time_t,但为了防止破坏已有代码,没有做这样的修改,而新的timespec结构可以将第一个成员类型定义为time_t。
2.pselect函数增加了第6个参数,一个指向信号掩码的指针,该参数允许程序做以下步骤:先禁止某些信号的递送,然后检查被禁止信号的信号处理函数中设置的全局变量,然后调用pselect,pselect函数会重置信号掩码,以允许刚刚被禁止递送的信号的递送。它的用处如下:如果某程序的SIGINT信号处理函数仅仅设置全局变量intr_flag并返回,如果我们的进程在SIGINT信号递送时正阻塞于select函数,那么从信号处理函数返回将导致select函数返回EINTR错误,以上描述的代码:
if (intr_flag) {
handle_intr(); /* handle the signal */
}
if ((nready = select( ... )) < 0) {
if (errno == EINTR) {
if (intr_flag) {
handle_intr();
}
}
}
以上代码有一个问题,如果在测试intr_flag后,调用select前有信号发生,如果select函数一直阻塞,那么该信号会一直不被处理,有了pselect函数后,我们可以按以下方式可靠地编写上例代码:
sigset_t newmask, oldmask, zeromask;
sigemptyset(&zeromask);
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);
sigprocmask(SIG_BLOCK, &newmask, &oldmask); /* block SIGINT */
if (intr_flag) {
handle_intr(); /* handle the signal */
}
if ((nready = pselect( ... , &zeromask)) < 0) {
if (errno == EINTR) {
if (intr_flag) {
handle_intr();
}
}
}
以上代码中,在测试intr_flag变量前,先阻塞SIGINT,当pselect函数被调用时,pselect函数先以空集替代进程的信号掩码,然后再检查描述符是否准备好,并可能进入睡眠,然后当pselect函数返回时,进程的信号掩码又被重置为调用pselect之前的值(即SIGINT又被阻塞)。
poll函数起源于SVR 3,最初局限于流设备,SVR 4取消了这种限制,允许poll工作在任何描述符上,poll函数与select函数类似,但在处理流设备时,poll函数能提供额外的信息。
poll函数的参数fdarray是指向一个结构数组第一个元素的指针,每个数组元素都是一个pollfd结构,用于指定对于给定描述符测试哪些条件:
要测试的条件由events成员指定,函数在相应的revents成员中返回该描述符哪些条件出现了,这样避免使用值-结果参数,select函数的中间3个参数都是值-结果参数。下图是pollfd.events和pollfd.revents的可能取值:
上图分为3部分,第1部分是处理输入的4个常值,第2部分是处理输出的3个常值,第3部分是处理错误的3个常值。
poll识别3类数据:普通(normal)、优先级带(priority band)、高优先级(high priority),这些术语出自基于流的实现。
POLLIN被定义为POLLRDNORM和POLLRDBAND的逻辑或。POLLIN自SVR 3就存在,早于SVR 4中的优先级带,为了向后兼容,保留了POLLIN常值(虽然能被POLLRDNORM和POLLRDBAND用逻辑或替代)。类似地,POLLOUT等同于POLLWRNORM,前者早于后者。
就TCP和UDP,以下条件引起poll函数返回特定的revent,但POSIX在poll函数的定义中规定,多种情况会返回相同的条件:
1.所有正常TCP和UDP数据被认为是普通数据。
2.TCP的带外数据被认为是优先级带数据。
3.当TCP连接的读半部关闭(如收到了一个来自对端的FIN),也被认为是普通数据,随后的读操作将返回0。
4.TCP连接存在错误既可认为是普通数据,也可认为是错误(POLLERR),随后的读操作将返回-1,并把errno设为合适的值。这可用于处理收到RST或发生超时等条件。
5.在监听套接字上有新连接可用既可认为是普通数据,也可认为是优先级数据,大多实现视之为普通数据。
6.非阻塞式connect的完成被认为是使相应套接字可写(可对监听套接字调用accept获取已连接套接字)。
fdarray参数数组中元素的个数由nfds参数指定。历史上这个参数曾被定义为unsigned long,有些过分大了,定义为unsigned int可能就够了。Unix 98为该参数定义了名为nfds_t的新数据类型。
timeout参数指定poll函数返回前等待多长时间,它的单位是毫秒,以下是该参数的取值含义:
INFTIM常值被定义为一个负值。如果系统不能提供毫秒级精度的定时器,该值就向上舍入到最接近的支持值。与select函数一样,给poll函数指定的任何超时值都受限于实际系统实现的时钟分辨率(通常是10ms)。
POSIX规范要求在头文件poll.h中定义INFTIM,但许多系统仍将它定义在头文件sys/stropts.h中。
发生错误时,poll函数返回-1;若定时器到时前没有任何描述符准备就绪,poll函数返回0;否则poll函数返回就绪描述符的个数,即revents成员值非0的描述符个数。
如果我们不再关心某个特定描述符,可以把与它对应的pollfd结构的fd成员设为一个负值,poll函数将忽略这样的pollfd结构的events成员,并在返回时将它的revents成员值置为0。
poll函数不会有select函数的最大描述符数目的问题,因为分配pollfd结构的数组并把数组中元素数通知内核就成了调用者的责任。
POSIX规范对select函数和poll函数都有规范,但从可移植性考虑,支持select函数的系统更多。另外POSIX还定义了pselect函数,它能够处理信号阻塞并提供了更高的时间分辨率,而POSIX没有为poll函数定义类似的东西。
用poll函数代替select函数重写TCP回射服务器程序,使用select函数的版本中,我们需要分配一个client数组和一个名为rset的描述符集,改用poll函数后,我们只需分配一个pollfd结构的数组维护客户信息。当某项未用,我们将pollfd结构数组中对应元素的fd成员设为-1,否则设为描述符值:
#include "unp.h"
#include <limits.h> /* for OPEN_MAX */
// 有些系统已经删去了OPEN_MAX的定义,这里简单重新定义了该宏
#ifndef OPEN_MAX
#define OPEN_MAX 1024
#endif
int main(int argc, char **argv) {
int i, maxi, listenfd, connfd, sockfd;
int nready;
ssize_t n;
char buf[MAXLINE];
socklen_t clilen;
// 我们声明在pollfd结构数组中存在OPEN_MAX个元素(该值运行时是不变的)
// 确定一个进程在某一时刻能打开的最大描述符数量的方法之一是以参数_SC_OPEN_MAX调用POSIX函数sysconf(运行时限制值),然后动态分配一个合适大小的数组
// 但sysconf函数可能返回该值不确定,这意味着我们需要猜一个值,这里我们就用了POSIX的OPEN_MAX常值
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
Listen(listenfd, LISTENQ);
client[0].fd = listenfd;
client[0].events = POLLRDNORM;
for (i = 1; i < OPEN_MAX; ++i) {
client[i].fd = -1; /* -1 indicated available entry */
}
maxi = 0; /* max index into client[] array */
for (; ; ) {
nready = Poll(client, maxi + 1, INFTIM);
if (client[0].revents & POLLRDNORM) { /* new client connection */
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *)&cliaddr, &clilen);
for (i = 1; i < OPEN_MAX; ++i) {
if (client[i].fd < 0) {
client[i].fd = connfd; /* save descriptor */
break;
}
}
if (i == OPEN_MAX) {
err_quit("too many clients");
}
client[i].events = POLLRDNORM;
if (i > maxi) {
maxi = i; /* max index in client[] array */
}
if (--nready <= 0) {
continue; /* no more readable descriptors */
}
}
for (i = 1; i <= maxi; ++i) { /* check all clients for data */
if ((sockfd = client[i].fd) < 0) {
continue;
}
// 我们此处还检查了POLLERR事件,该事件并没有设置到events成员中
// 检查POLLERR的原因在于,有些实现在一个连接上收到RST时返回POLLERR事件,而有些实现返回POLLRDNORM事件
// 无论返回什么,我们都调用read,有错误发生时,read函数会返回这个错误
if (client[i].revents & (POLLRDNORM | POLLERR)) {
if ((n = read(sockfd, buf, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
/* connection reset by client */
Close(sockfd);
client[i].fd = -1;
} else {
err_sys("read error");
}
} else if (n == 0) {
/* connection closed by client */
Close(sockfd);
client[i].fd = -1;
} else {
Writen(sockfd, buf, n);
}
if (--nready <= 0) {
break; /* no more readable descriptors */
}
}
}
}
}
以上代码中,我们使用了MAX_OPEN常值作为打开描述符的最大数量,如果我们想使用内核允许的最大的打开描述符数量,我们可以使用getrlimit函数,获取RLIMIT_NOFILE资源的当前值,然后调用setrlimit把当前的软限制rlim_cur设为硬限制rlim_max的值,如Solaris 2.5上最大打开描述符数量的软限制是64,但任何进程都能将其增长到默认的硬限制1024。getrlimit和setrlimit函数不属于POSIX.1,但它们在UNIX 98中是必需的。
如果描述符集是一个整数数组,怎样使用赋值语句将其赋值给另一描述符集?可以将这个整数数组包含在一个结构中,而C允许使用等号赋值结构。