UDP卷1:第六章(I/O复用:select和poll函数)
最后更新于:2022-04-01 14:49:00
## 0. 概述
在第五章的TCP客户同时处理两个输入:标准输入和TCP套接字。我们遇到的问题是在客户阻塞于fgets调用期间,服务器进程会被杀死。服务器TCP虽然正确的给客户TCP发送了一个FIN,但是既然客户进程正阻塞于从标准输入读入的过程,它将看不到这个EOF,直到从套接字读时为止(可能经过很长时间)。这样的进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪(也就是说输入已准备好被读取,或者描述符已能承接更多的输出),它就通知进程。这个能力称为I/O复用,是由select和poll这两个函数支持。
I/O复用使用于以下场合:
1) 当客户处理多个描述符(通常是交互式输入和网络套接字)时,必须使用I/O复用。
2) 一个客户同时处理多个套接字是可能的,不过比较少见。
3) 如果一个TCP服务器既要处理监听套接字,又要处理已连接套接字,一般就要使用I/O复用。
4)如果一个服务器既要处理TCP,又要处理UDP,一般就要使用I/O复用。
5) 如果一个服务器要处理多个服务或者多个协议,一般就要使用I/O复用。
## 1. I/O模型
一个输入操作通常包括两个不同的阶段:
(1) 等待数据准备好
(2) 从内核向进程复制数据
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
### 1) 阻塞式I/O模型
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-06-20_57678b2fe2ff0.jpg)
进程调用recvfrom,其系统调用直到数据报到达且被复制到应用进程的缓冲区中或者发生错误才返回。我们说进程在从调用recvfrom开始到它返回的整段时间内饰被阻塞的。recvfrom成功返回后,应用进程开始处理数据报。
### 2) 非阻塞式I/O模型
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-06-20_57678b3008f57.jpg)
进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才能完成,不要把本进程投入睡眠,而是返回一个错误。
当一个应用进程像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮询。应用进程持续轮询内核,以查看某个操作是否就绪。这么做往往耗费大量CPU时间。
### 3) I/O复用模型
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-06-20_57678b302424b.jpg)
我们阻塞于select调用(而非阻塞于recvfrom处),等待数据报套接字变为可读。当select返回套接字可读这一条件时,我们调用recvfrom把所读数据报复制到应用进程缓冲区。使用select的优势在于我们可以等待多个描述符就绪。
### 4) 信号驱动式I/O模型
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-06-20_57678b303e674.jpg)
让内核在描述符就绪时发送SIGIO信号通知我们。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。
### 5) 异步I/O模型
![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-06-20_57678b305e567.jpg)
这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。
## 2. select函数
### 1) 基础知识
该函数允许进程指示内核等待多个事件中的任何一个发生,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒它。
~~~
#include <sys/select.h>
#include <sys/time.h>
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout );
返回:若有就绪描述符则为其数目,若超时则为0,若出错则为-1
~~~
作为一个例子,我们可以调用select,告知内核仅在下列情况发生时才返回:
(1)集合{1,4,5}中的任何描述符准备好读
(2)集合{2,7}中的任何描述符准备好写
(3)集合{1,4}中的任何描述符有异常条件待处理
(4)已经经历了10.2秒
timeout告知内核等待所指定描述符中的任何一个就绪可花多长时间。其timeval结构用于指定这段时间的秒数和微秒数:
~~~
struct timeval{
long tv_sec;
long tv_usec;
};
~~~
这个参数有以下三种可能:
1) 永远等待下去:仅在有一个描述符准备好I/O时才返回。为此,我们把这参数设置为空指针。
2) 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
3) 根本不等待:检查描述符后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0.
中间的三个参数readset,writeset和exceptset指定我们要让内核测试读,写和异常条件的描述符。
maxfdp1参数指定待测试的描述符个数,它的值是待测试的最大描述符加1.
关于fd_set结构体数据四个关键的宏:
~~~
void FD_ZERO( fd_set *fset );
void FD_SET( int fd, fd_set *fdset );
void FD_CLR( int fd, fd_set *fdset );
int FD_ISSET( int fd, fd_set *fset );
~~~
假设我们要将描述符1(对应于stdout,标准输出),4,5(分别对应socket中服务器socket描述符和客户端的一个socket描述符)放入select函数中,当任何一个写就绪时候就返回,那么我们大概可以这样写:
~~~
fd_set rset;
FD_ZERO( &rset );
FD_SET( 1, &rset );
FD_SET( 4, &rset );
FD_SET( 5, &rset );
select( maxfdp1, NULL, &rset, NULL,NULL);
~~~
描述符集的初始化非常重要,因为作为自动变量分配的一个描述符集如果没有初始化,那么可能发生不可预期的后果。
测试用力如下:
~~~
#include <stdio.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <time.h>
int main(int argc, char **argv)
{
fd_set rset;
FD_ZERO(&rset);
FD_SET(1, &rset);
FD_SET(4, &rset);
FD_SET(5, &rset);
return 0;
}
~~~
当我们调试程序,查看rset:
~~~
(gdb) p rset
$3 = {__fds_bits = {50, 0 <repeats 15 times>}}
(gdb) p rset.__fds_bits
$4 = {50, 0 <repeats 15 times>}
~~~
其中,50=110010,即第1,4,5位均被置为1.
### 2) 描述符就绪条件
(1)满足下列四个条件中的任何一个时,一个套接字准备好读(即可从描述符中读取数据)
a) 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据,即进程可以从缓冲区中读取数据)
b) 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(因为这时候服务器执行close套接字需要一段时间,而这段时间内,客户端可继续从服务器读取数据,只是读取的是EOF而已)
c) 该套接字是一个监听套接字且已完成的连接数不为0.(这样服务端才能执行accept函数,读取客户端发送过来的数据)
d) 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1,同时把errno设置成确切的错误条件。
(2)满足下列四个条件中的任何一个时,一个套接字准备好写(即可向描述符中写入数据)
a) 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或者该套接字已连接,或者该套接字不需要连接。
b) 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号。(就是如果服务器不启动,而客户端启动向服务器发送数据,则服务端向客户端发送RST,并且向客户端写入数据(相当于客户端读取数据),则产生SIGPIPE信号,进程强行终止)
c) 使用非阻塞式connect的套接字已建立连接,或者connect已经以失败告终。(只有成功connect,才能进行数据的写入)
d) 其上有一个套接字错误待处理。
注意:当某个套接字上发生错误时,它将由select标记为即可读又可写。
接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select返回可读或可写条件之前有多少数据可读或有多大空间可用于写。举例来说,如果我们知道除非至少存在64个字节的数据,否则我们的应用进程没有任何有效工作可做,那么可以把接收低水位标记设置为64,以防少于64个字节的数据准备好读时select唤醒我们。
### 3) 使用select的str_cli函数的实现
客户的套接字上的三个条件处理如下:
(1)如果对端TCP发送数据,那么该套接字变为可读,并且read返回一个大于0的值(即读入数据的字节数)
(2)如果对端TCP发送一个FIN(对端进程终止),那么该套接字变为可读,并且read返回0(EOF)。
(3)如果对端TCP发送一个RST(对端主机崩溃并重新启动),那么该套接字变为可读,并且read返回-1,而errno中含有确切的错误码。
~~~
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)){
if ( Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli:server terminated prematurely");
Fputs(recvline, stdout);
}
if ( FD_ISSET(fileno(fp), &rset)){
if ( Fgets(sendline, MAXLINE, fp) == NULL)
return;
Writen(sockfd, sendline, strlen(sendline));
}
}
}
~~~
### 4) 使用select版本的str_cli函数仍不正确,但问题出在哪里
如果我们批量输入的情况下,对标准输入中的EOF的处理:str_cli函数就此返回到main函数,而main函数随后终止。然而在批量方式下,标准输入中的EOF并不意味着我们同时也完成了从套接字的读入;可能仍有请求在去往服务器的路上,或者仍有应答在返回客户的路上。
(1)在fgets函数处返回单个输入行写给服务器,随后select再次被调用以等待新的工作,而不管stdio缓冲区中还有额外的输入行待消费。究其原因在于select不知道stdio使用了缓冲区---它只是从read系统调用的角度指出是否有数据可读,而不是从fgets之类调用的角度考虑。
(2)而在readline调用中,这回select不可见的数据不是隐藏在stdio缓冲区,而是隐藏在readline自己的缓冲区中。所以也可能导致程序终止时缓冲区中还有未读取的数据。
## 3. shutdown函数,poll函数以及TCP回射服务器程序的修订版
### 1) shutdown函数
终止网络连接的通常方法是调用close函数,不过close有两个限制,却可以使用shutdown来避免:
(1)close把描述符的引用计数减1,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止序列。
(2)close终止读和写两个方向的数据传送。这导致有些数据存于缓冲区内,并未被发送/接收成功。
~~~
#include <sys/socket.h>
int shutdown( int sockfd, int howto );
返回:若成功则为0,若出错则为-1
~~~
该函数依赖于howto参数的值:
SHUT_RD:关闭连接的读这一半----套接字中不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
SHUT_WR:关闭连接的写这一半----对于TCP套接字,这称为半关闭。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。
SHUT_RDWR:连接的读半部和写半部都关闭----这与调用shutdown两次等效:第一个调用指定SHUT_RD,第二次调用指定SHUT_WR.
### 2)str_cli函数的修订版
(1)我们使用read和write函数处理缓冲区而非文本,可以保证缓冲区的数据完全的读取。
(2)如果执行了err_quit函数,则说明服务器过早的终止。
(3)使用shutdown(sockfd,SHUT_WR)的作用是:终止写入,并且把缓冲区所有的数据全部发送出去
~~~
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);
/*read和write是对缓冲区进行操作*/
if ( FD_ISSET(sockfd,&rset)){
if ( ( n = Read(sockfd,buf,MAXLINE)) == 0){
if ( stdineof == 1 )
return;
else
err_quit("str_cli:server terminated prematurely");
}
Write(fileno(stdout),buf,n);
}
if ( FD_ISSET(fileno(fp), &rset)){
//说明数据已经从缓冲区中读取完毕,即全部数据都发送给进程
if ( (n = Read(fileno(fp), buf, MAXLINE)) == 0){
stdineof = 1;
Shutdown(sockfd,SHUT_WR);
FD_CLR(fileno(fp),&rset);
continue;
}
//因为执行了Shutdown(sockfd,SHUT_WR);说明所有存在缓冲区的数据,均被发送到了sockfd
Writen(sockfd, sendline, strlen(sendline));
}
}
}
~~~
### 3) TCP回射服务器程序(修订版)
服务端:
~~~
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <sys/select.h>
#define MAXLINE 1024
#define SA struct sockaddr
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(9877);
bind(listenfd, (SA *)&servaddr, sizeof(servaddr));
listen(listenfd, 5);
maxfd = listenfd;
maxi = -1;
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for ( ; ; ){
rset = allset;
nready = select(maxfd + 1, &rset, NULL, NULL, NULL);
if (FD_ISSET(listenfd, &rset)){
clilen = sizeof(cliaddr);
connfd = accept(listenfd, (SA *)&cliaddr, &clilen);
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0){
client[i] = connfd;
break;
}
if (i == FD_SETSIZE){
printf("too many clients\n");
exit(-1);
}
FD_SET(connfd, &allset);
if (connfd > maxfd)
maxfd = connfd;
if (i > maxi)
maxi = i;
if (--nready <= 0)
continue;
}
for (i = 0; i <= maxi; i++){
if ((sockfd = client[i]) < 0)
continue;
if (FD_ISSET(sockfd, &rset)){
if ((n = read(sockfd, buf, MAXLINE)) == 0){
close(sockfd);
FD_CLR(sockfd, &allset);
client[i] = -1;
} else
write(sockfd, buf, n);
if (--nready <= 0)
break;
}
}
}
}
~~~
客户端:
~~~
#include <stdio.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <fcntl.h>
#define MAXLINE 1024
#define SA struct sockaddr
void str_cli(FILE *fp, int sockfd);
int main(int argc, char **argv)
{
int sockfd[5], n;
struct sockaddr_in servaddr;
struct sockaddr_in cliaddr;
int i;
for (i = 0; i < 5; i++){
sockfd[i] = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(9877);
inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
connect(sockfd[i], (SA *)&servaddr, sizeof(servaddr));
}
str_cli(stdin, sockfd[0]);
return 0;
}
void str_cli(FILE *fp, int sockfd)
{
char sendline[MAXLINE], recvline[MAXLINE];
int n;
while (fgets(sendline, MAXLINE, fp) != NULL){
write(sockfd, sendline, strlen(sendline));
if (( n = read(sockfd, recvline, MAXLINE)) == 0){
printf("str_cli:server terminated prematurely\n");
return;
}
recvline[n] = '\0';
fputs(recvline, stdout);
}
}
~~~
程序输出:
服务端:
~~~
leichaojian@ThinkPad-T430i:~$ ./srv
~~~
客户端1:
~~~
leichaojian@ThinkPad-T430i:~$ ./cli 127.0.0.1
hello world
hello world
what
what
^C
~~~
客户端2:
~~~
leichaojian@ThinkPad-T430i:~$ ./cli 127.0.0.1
heihei
heihei
^C
~~~