当客户进程阻塞在某一个调用时,这时如果与服务器之前的连接丢失,容易导致客户忽略服务器发送过来的RST信号。因此,我们希望程序能有这样的能力,不去使用被阻塞的调用,当输入或数据准备好时我们直接去取,这个功能被称作I/O复用。
? 当客户处理多个套接字时,最好使用I/O复用
? 如果一个服务器即要处理监听套接口,又要处理连接套接口,则通常要使用到I/O复用
? 如果一个服务器即要处理TCP又要处理UDP,则通常要使用I/O复用
另外,I/O复用不只使用在网络编程中,在系统编程中也要使用。
一、 I/O模型
UNIX常用的是五种I/O模型,分别将会在下面列出。
通常,输入操作有两个阶段,1,等待数据准备好;2,从内核中拷贝出数据。由于数据准备好,尤其是网络数据,会被内核放在内核缓冲区,因此,需要从内核缓冲区拷贝到应用程序缓冲区。
阻塞I/O模型:
最常用的I/O模型为阻塞I/O模型。到目前为止使用的所有I/O都是阻塞的,默认所有的套接口都是阻塞的,调用在等待数据准备好的这段时间都是处理阻塞状态。
非阻塞I/O模型:
这种I/O模型是这样的,当某个调用需要阻塞时,我们要求内核不要阻塞进程,而是返回一个错误,如书中返回EWOULDBLOCK错误,直到数据准备好。
这种I/O模型要求程序一直不断的查询,即轮询的方式查询数据是否准备好,这对CPU时间是一个极大的浪费,因此这种I/O模型很少见,只有专门提供某功能的系统中才能见到。
I/O复用模型:
有了I/O复用,我们就可以使用select或poll,这是两个系统调用,我们在这两个调用中的某一个上阻塞而不是阻塞在真正的I/O调用,后面将详细阐述如何使用。
信号驱动I/O模型:
我们也可以使用信号,当数据准备好时,使用SIGIO信号通知我们,以便我们处理数据。
异步I/O模型:
这种I/O模型的主要区别在于,由内核拷贝数据,当拷贝完成后通知我们。这种模型还没有大量使用(截止书第二版发行时)。
二、 select函数
使用select函数,我们可以实现:
? 我们关心的某个套接字准备好
? 我们关心的某些套接字准备好
? 几秒钟时间已到
? 描述字中有异常条件需要处理
描述字不限接口,任何描述字都可以使用select函数来测试。
函数原型:
#include
#incldue
int select (int maxfdp1, fd_set * readset, fd_set * writeset, fd_set * exceptset, const struct timeval * timeout);
返回:准备好的描述字的正数目,0-超时, -1-出错
参数说明:
为了方便,从最后一个参数往前说明。
const struct timeval *timeout
指定一个时间结构,表明最大等待时间,在这个时间内,如果有数据准备好则返回,否则超时返回。
timeval是一个结构体,原型如下:
struct timeval{
long tv_sec; /*秒*/
long tv_usec; /*毫秒*/
}
有三种可能:
1. 等待固定时间。等待的时间为timeval结构体中时间值。
2. 永远等待下去。这时将最后一个参数(指针)设置为空,即NULL。
3. 根本不用等待。即轮询的方法,这里需要将结构体中的时间设置为0
注意,当select函数被中断时(即有信号数据返回)有可能返回EINT错误,且Berkeley的实现并不重启select。
中间三个参数,readset,writeset和exceptset用来指定套接口描述字,分别为读,写和异常条件。
现在只支持两个异常条件,暂不说明。
这三个变量的类型都是fd_set,这个变量用来使select函数操作一个或一组套接字,并且有一组宏可以对这个类型进行操作,如下例:
fd_set rset;
FD_ZERO(&rset); /*初始化set,将所有位关闭*/
FD_SET(1, &set); /*打开1号*/
FD_SET(4, &set); /*打开4号*/
FD_SET(5, &set); /*打开5号*/
对fd_set类型的集合的初始化是很重要的,如果使用了没有初始化的变量,将会引起不可预测的结果。
如果我们对某个条件不感兴趣,可以将对应项设置为NULL,如果我们将这三个都设置为NULL,则将会得到一个比sleep函数更精确的定时器,poll函数有相似的功能。
第一个参数maxfdp1指定被测试的描述字个数,它的值是被测试的最大描述字加1,如上例中打开了1,4,5,则这个变量为6。
三、 str_cli函数的修订版
这里我们重写了str_cli函数,使用select,使用select处理如下图所示的条件:
1. 如果对方发送数据,则TCP套接口变为可读,且read返回大于0(数据的字节数)
2. 如果对方TCP发送FIN,则套接字变为可读且read返回0,对方终止。
3. 如果对方发送RST(服务器崩溃并重启),套接字变为可读且read返回-1,errno中有明确的错误代码。
1
2 3 4 5 6 7
8 9 10 11 12 13
14 15 16 17 18
19 20 21 22 23 24 25 |
#include "unp.h"
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 (FD_ISSET(fileno(fp), &rset)) { /* input is readable */ if (Fgets(sendline, MAXLINE, fp) == NULL) return; /* all done */ Writen(sockfd, sendline, strlen(sendline)); } } } |
第8-13行:
在此我们仅需要一个描述字集以检查可读性,此集合由FD_ZERO初始化(8行),并使用FD_SET将两个描述符填入这个字符集,一个是代表标准IO的文件输入符fp,使用函数fileno将其转换成标准描述符,另一个是套接口sockfd.
select函数仅工作在描述字上。
第12行计算出两个套接字中的最大者,并将其值加1,调用select函数。
其它行则判断select是否条件满足,选择或读或写数据。
四、 批量输入
到目前为止,我们的str_cli函数仍是不准确的。
原因是,对于一个全双工连接,当客户发送完后,发送关闭连接,导致服务器回答途中的数据丢失,因此需要关闭一半的连接,这里需要调用shutdown函数。
shutdown函数原型如下:
#include
int shutdown(int sockfd, int howto);
返回:0 - 成功, -1 – 出错
第一个参数为要关闭的套接字。函数的主要行为取决于第二个参数。第二个参数有三个可能的取值:
SHUT_RD |
不再读取。缓冲区的数据都会发方进行确认,但数据本身将会被扔掉 |
SHUT_WR |
关闭连接写的这一半。在TCP中,这叫半关闭,当前缓冲区中的数据都将会被发送,且后跟正常结束序列。这时不管写计数是否大于0,进程不能再对套接字进行任何写操作。 |
SHUT_RDWR |
相当于上两项同时调用 |
五、 str_cli函数再次修订版
这个版本使用了select和shutdown,前者使我们能处理一端关闭的情况,后者能使我们处理批量输入的情况。
代码如下:
1
2 3 4 5 6 7 8
9 10 11 12
13 14 15 16 17
18 19 20 21 22
23 24
25 26 27 28 29 30 31
32 33 34 35 |
#include "unp.h"
void str_cli(FILE *fp, int sockfd) { int maxfdp1, stdineof; fd_set rset; char buf[MAXLINE]; int n;
stdineof = 0; 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) { if (stdineof == 1) return; /* normal termination */ else err_quit("str_cli: 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); } }/*for*/ } |
对这个函数的讨论还没有结束,在15.2中将开发一个非阻塞I/O版本,在23.3中将开发一个线程版本。
六、 TCP回射服务器修订版本
这里将更改服务器响应客户的方法,在原版本中,对每一个客户都fork一个新的进程,在新的版本中,将只用一个进程,使用select方法来响应多个客户的请求,并维护一个新的已连接套接字数组client[]。代码如下:
1
2 3 4 5 6 7 8 9 10 11
12
13 14 15 16
17
18
19 20 21 22 23 24
25 26 27
28 29 30
31 32 33 34 35 36 37
38 39 40 41 42
43 44 45
46 47 48 49 50 51 52 53 54 55 56
57 58 59 60 61 62 |
#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 */ 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 */ }/*fi FD_ISSET()*/
for (i = 0; i <= maxi; i++) { /* check all clients for data */ if ( (sockfd = client[i]) < 0) continue; if (FD_ISSET(sockfd, &rset)) { if ( (n = Read(sockfd, buf, MAXLINE)) == 0) { /*connection closed by client */ Close(sockfd); FD_CLR(sockfd, &allset); client[i] = -1; } else Writen(sockfd, buf, n);
if (--nready <= 0) break; /* no more readable descriptors */ }/*fi FD_ISSET()*/ }/*for i*/ }/*for ;;*/ } |
第12-24行:
服务器程序的基本步骤,socket,bind和listen,然后使用监听描述字对数据结构进行初始化
第26-27行:
阻塞于select。使用select等待某个事件的发生,或是客户连接的建立,或是数据,FIN,RST的到达。
第28-45行:
接收新连接。如果监听套接字变为可读,则表示建立了一个新的连接,使用accept更新数据结构。
第46-60行:
检查现有连接。对于每一个现有客户的连接,我们都检查其是否在select返回的描述字集合中,如果是就从客户读取一行并回馈到客户中,如果客户关闭连接,则readline返回0,则要更新相应的数据结构。
我们从不对maxi减1,但每次客户关闭连接时我们可以检查这种可能性。
本例避免了因fork新进程而造成的开销,是一个select的好例子,但它也有一个问题,这将在15.6节中叙述。
但本例最大的问题在于DOS攻击(拒绝服务式攻击),假设有一个恶意客户,先发了一个字节,这时select就会事件触发,服务器开始读取客户中可以读取的数据,但此客户发了一个字节后不再发送,服务器将阻塞于read调用直到客户再发一个换行符为止,这会引起服务器无法为其它客户提供服务,即DOS攻击。
防止这种攻击的方法为:
1. 使用非阻塞I/O模型,第15章
2. 为每个客户创建一个线程
3. 设置I/O超时
七、 poll函数
poll函数是select函数的一个替代方案,尤其是面向流设备时,现在的UNIX变种如LINUX都支持这个函数。原型如下:
#include
int poll(struct pollfd * fdarray, unsigned long nfds, int timeout);
返回:准备好描述字的个数,0为超时,-1表示出错
第一个参数是一个结构体pollfd指针,这个结构体定义如下:
struct pollfd{
int fd;
short events;
short revents;
}
这个结构体规定了测试描述符fd的一些条件。这个结构体这样设计,测试条件由成员events指定,测试结果由成员revents返回,在这点上要比select合理,select值使用值-结果传递。
常 量 |
能否作为 events输入 |
能否作为 revents果 |
解 释 |
POLLIN |
● |
● |
普通或优先级带数据可读 |
POLLRDNORM |
● |
● |
普通数据可读 |
POLLRDBAND |
● |
● |
优先级带数据可读 |
POLLPRI |
● |
● |
高优先级数据可读 |
POLLOUT |
● |
● |
普通数据可写 |
POLLWRNORM |
● |
● |
普通数据可写 |
POLLWRBAND |
● |
● |
优先级带数据可写 |
POLLERR |
|
● |
发生错误 |
POLLHUP |
|
● |
发生挂起 |
POLLVAL |
|
● |
描述字不是一个打开的文件 |
我们将此表分为三个部分,第一部分为处理输入的四个常值,第二部分是处理输出的三个常值,第三部分是处理错误的三个常值。
poll处理三个级别的数据,普通normal,优先级带priority band,高优先级high priority,这些都是出于流的实现。
对于TCP和UDP数据,由于Posix的poll函数实现,导致很多方法有可能返回相同的条件。有如下特点:
? 所有TCP数据和UDP数据都是普通数据
? TCP的带外数据被认为是优先级带数据
? TCP读的这一边数据关闭时(如收到FIN),也认为是普通数据,且后续操作都将返回0。
? TCP连接存在错误即可认为是普通数据,也可认为是错误POLLERR,无论哪种操作,都会返回-1,并将errno设置为适当的值。如收到RST等情况。
? 在监听套接口上的新连接即可被视为普通数据也可被视为优先级数据,大多数实现将其看做普通数据。
poll函数的第二个参数nfds用来指定第一个参数数组元素数量。
poll函数的第三个参数指定poll函数在返回前等待多长时间,单位为毫秒。下表为它的可能取值及意义。
timeout值 |
说 明 |
INFTIM |
永远等待 |
0 |
立即返回,不阻塞进程 |
>0 |
等待指定数据的毫秒数 |
INFTIM是一个负值。
Posix要求在poll.h中定义这个值,但大多数实现在sys/stropts.h中。
当发生错误时,poll函数返回-1,若在时间到之前没有任何描述符准备就绪,由返回0,否则返回就绪的描述符个数,即revents成员值非0的描述符个数。
如果我们不关心某个描述符,可以将其pollfd结构中的fd成员设置为一个负值,poll函数将忽略这样结构的pollfd的events成员,并将其revents成员值置为0。
相比select函数要指定FD_SETSIZE及设置fd_set之类的类型,而poll函数要简单的多。下一节是一个使用poll实现的回射服务器版本。
八、 TCP回射服务器程序(修订版)
这是一个使用poll函数实现的服务器版本,相比select函数,在select函数版本中,我们专门定义了一个client数组及一个中为rset的描述符集,改用poll后,只需要分配一个pollfd结构的数组来维护客户信息,而不必分配另外的数组。代码如下:
1 2
3 4 5 6 7 8 9 10 11 12
13
14 15 16 17
18
19
20 21 22 23 24
25 26 27
28 29
30 31 32 33 34 35 36
37 38 39
40 41 42
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include "unp.h" #include
int main(int argc, char **argv) { int i, maxi, listenfd, connfd, sockfd; int nready; ssize_t n; char buf[MAXLINE]; socklen_t clilen; 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 indicates 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; }/*fi*/ 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 */ }/*fi client[0]*/
for (i = 1; i <= maxi; i++) { /* check all clients for data */ if ( (sockfd = client[i].fd) < 0) continue; 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 */ }/*fi client[i]*/ }/*for i*/ }/* for ;;*/ } |
代码说明:
第11行:
定义一个client数组,数组最大值为OPEN_MAX,这个值标识服务器能接受的最大的客户数量,当然,这个值不太好确定,因此使用Posix的OPEN_MAX。
第20-43行:
这里的编程技巧在于,将数组client[0]的fd设置为监听套接字,将这个数组的其它值设置为-1,并将maxi设置0,这个变量表示数组当前使用的最大下标值。当有连接到来时, accept函数将会返回连接套接字,这时将会使用循环遍历client数组,找到第一个元素的fd为负值的,并将连接套接字赋值给这个fd,同时为其设置POLLRDNORM事件。最后更新maxi(如果i大于maxi时)。
第43-63行:
这里检查POLLRDNORM和POLLERR事件,其中我们并没有在event成员中设置第二个事件,因为它在条件成功时总是返回。我们检查POLLERR这个事件是因为在有的实现上RST返回一个POLLERR事件。无论哪种情形,都调用read函数,如果有错误发生,就会返回这个错误。当有一个连接被它的客户终止时,我们就把它的fd成员置为-1,在下次循环中可以重复利用。