概述
在第五章中我们看到了当客户端因为调用fgets而阻塞的时候,没有正确的处理服务器崩溃的情景,显然我们需要新的机制,允许进程获取预知能力,即在IO条件就绪(输入准备好读取,输出有足够的空间)的时候内核通知进程,从而避免进程阻塞,这种能力称之为I/O多路复用
I/O多路复用的使用场景:
- 客户处理多个描述符的时候
- 客户同时处理多个套接字
- TCP服务器如果需要同时处理监听套接字又要处理连接套接字的时候
- 如果既需要处理TCP又需要处理UDP的时候
I/O模型
总共来说,一个输入操作(就是外界数据输入)会包括
- 等待数据准备好(就是等待网络等传输数据到达)
- 从内核向进程复制
我们可以从这两个阶段,去区分我们有的数据类型
-
阻塞式I/O,这种I/O就是我们现在所使用的版本,它在这两个阶段都在阻塞等待
-
非阻塞式I/O,这种I/O不会阻塞等待在第一阶段,而是通过不断检查的方式,最后确定了有数据才会访问(就是不断询问我现在使用这个方法会不会让我阻塞,如果会那我就不运行)
轮询的方法会消耗大量的时间片,一般不会使用
-
I/O复用模型
I/O多路复用下,进程会阻塞在select上,当任意一个I/O条件就绪的时候,才会调用实际的系统调用
一种类似的解决方法是使用多线程阻塞I/O(每个文件描述符一个线程)
-
信号驱动式I/O
就是让描述符在I/O就绪的时候发送SIGIO信号,但是还是需要进程来处理“执行读取的系统调用”
-
异步I/O模型
和信号驱动的区别在于,异步I/O是让内核通知我们什么时候完成了这个I/O,而信号驱动是通知我们什么时候可以进行IO
显然我们可以有很多标准来划分这些I/O方式,一种比较常见的是同步/异步I/O,在这五种模型里面,只有异步I/O是异步I/O,因为其他 四种I/O模型,都需要阻塞调用真正的从内核复制数据的函数,只不过在等待数据上各有差别
select函数
函数定义
int select(int maxfdp1, fd_set * readset, fd_set * writeset,
fd_set * exceptset, struct timeval * timeout);//如果有就绪描述符就返回就绪描述符数量,超时返回0,出错返回-1
我们可以在select中感兴趣的读条件描述符集合(可读就返回),写条件描述符集合(可写就返回),异常描述符集合(出现异常就返回),以及超时
关于timeout
timeval数据结构允许我们指定秒数和微秒数
struct timeval
{
long tv_sec; /* seconds */
long tv_usec; /* and microseconds */
};
实现上,微秒的控制其实非常的弱,一般是向上舍入为10ms的倍数
我们可以实现三种操作:
- 如果我们传入空指针,那么select只有在至少一个描述符就绪才会返回,否则会一直等待
- 其他情况下,就会正常的等待以及超时
- 如果我们传入的timeval,tv_sec和tv_usec都设置为0,那么select无论是否有描述符就绪都会立刻返回(不阻塞)
一般来说,在第一第二种阻塞情形下,可能会被信号中断,对于一些系统而言,不会自动重启select,但是有些系统允许通过标记SA_RESTART来实现自动重启,为了可移植性,最好考虑收到EINTR错误的可能性
虽然posix对timeout设置成const,但是部分linux实现中仍然会修改,为了可移植性,最好每次使用timeval的时候都重新初始化
关于描述符集
unix中引入了描述符集的概念,fd_set是一个整数数组,然后整数的每一位都可以代表一个描述符,所以第一个元素可以表示0-31的描述符,第二个元素表示32-63的描述符,以此类推。
不过其实这些实现细节其实跟我们无关,我们完全可以用fd_set以及与其相关的四个函数来构造描述符集
void FD_ZERO(fd_set* fdset); //初始化(清空)
void FD_SET(int fd,fd_set* fdset); // 设置fd位
void FD_CLI(int fd,fd_set* fdset); // 清除fd位
int FD_ISSET(int fd,fd_set* fdset); // 查询fd位是否有描述符
应用于select中的时候,我们不感兴趣的描述符集可以设置为空,事实上,如果三个描述符集都为空,那么我们就得到一个比sleep更精确的定时器(sleep是以秒为单位)
关于maxfdp1
maxfdpl描述的是待测试的描述符个数,值应该等于”待测试的最大描述符“+1,比方说如果我们有描述符0,4,5
那么我们得到的maxfdpl就是6
这个参数的由来是为了提高效率,这样系统就可以少测试一些必然为0的位置
描述符集的值-结果性
描述符集是所谓的值结果参数,在传入参数的时候,我们指定了我们关心的描述符的值,函数返回的时候,传出已经就绪的描述符,然后我们用FD_ISSET来判断哪些我们关心的描述符就绪了
值得注意的是,这么一来,每次select完成之后,描述符集都被改写,所以如果需要再次调用select,就需要重新初始化(配置为1)
描述符就绪条件
对于读条件来说,下面任意一个条件符合就可以读
-
套接字接收缓冲区大小大于套接字接收缓冲区的低水位标记的当前大小,对该套接字的读取操作不会阻塞并且会返回大于0的值(返回准备好读取的值),我们可以用SO_RCVLOWAT的套接字选项来调整,一般udp和tcp默认是1
-
连接的读半部关闭(也就是说收到了FIN报文),对套接字读取不会阻塞并且返回0(表示eof)
-
套接字是监听套接字,且已完成的连接数不为0,对此accept一般不会阻塞(有例外)
-
套接字上有一个未处理的错误,对该套接字操作不会阻塞,会返回-1,同时errno设置成确切的错误,这些待处理错误可以通过SO_ERROR套接字选项调用getsockopt获取并清除
对于写条件来说,下面任意一个条件符合就可以写
- 该套接字发送缓冲区可用空间字节数大于或等于套接字发送缓冲区的低水位标记的当前大小,而且要么套接字已连接要么不需要连接(UDP),对这样的套接字,如果我们设置不阻塞,那么写操作将不阻塞并返回一个正值,我们可以用SO_SNDLOWAT的套接字选项来调整,默认为2048
- 套接字写半部关闭,写操作触发SIGPIPE信号
- 非阻塞的connect指向的套接字已建立连接或者connect已经失败
- 套接字上有一个未处理的错误,对该套接字操作不会阻塞,会返回-1,同时errno设置成确切的错误,这些待处理错误可以通过SO_ERROR套接字选项调用getsockopt获取并清除
我们需要注意的是,只要发生了错误,那么该套接字就会既可读又可写
我们可以用低水位来控制我们定义的可读可写,比方说如果我读取少于64字节的时候没有意义,那么我可以设置读取低水位为64
我们需要注意的是,select允许的maxfdp1其实是有限的,一般来说内核会编译一个常量为FD_SETSIZE,你并不能通过直接修改来实现,你还需要重新编译内核
使用select重构
我们利用这select可以将之前的str_cli改造成一个可以监听多个套接字的
void str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);//在这里初始化rset
for (;;)
{
FD_SET(fileno(fp), &rset);//配置两个套接字
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1; //注意,这里我们遵循“最大的套接字+1”原则
select(maxfdp1, &rset, NULL, NULL, NULL);//我们置空了timeout,表示阻塞等到至少一个符合条件
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));
}
}
}
但是我们这个版本仍然是存在问题的 ,需要考虑到,如果我们发生了重定向,那么很可能出现我们读取完文件之后,Fgets==null成立于是退出,但是此时socket中仍然有未发送的数据以及仍然在路上的应答数据。
这个问题之所以没有在非select版本下出现,是因为在前版本中,客户端是阻塞读取标准输入-》阻塞输出-》阻塞读取套接字-》阻塞写出标准输入-》阻塞读取标准输入,因此不会出现输入eof的时候仍然有未到达的输出。
但是在select中,标准输入的eof与套接字无关,我们因此需要改变return做法,改成关闭单向写出端口,但是仍然允许我们读取信息,我们因此需要引入shutdown方法。
另一个问题来自于stdio的不透明的缓冲区,这里面我们使用了fgets来获取一行,但是由于stdio会有缓冲区,实际上可能在缓冲区中有多行数据,而fgets只返回其中一行,然后select的时候就忽略了这些其实尚存在的数据,这本质上是因为select不知道缓冲区的存在,它只负责从read系统调用的角度考虑问题,因此我们一定要小心使用fgets和select的组合。
除了fgets引入的不透明的缓冲区的问题,我们注意到,我们也用了Readline来读取,这同样存在缓冲区问题,不过因为我们提供了访问缓冲区的接口,我们可以在select前面调用检索
shutdown函数
shutdown解决了close的什么问题呢?
- close只是将引用计数-1,并不能保证实现关闭,而shutdown 能确保无论是否计数为0都触发关闭序列(四次握手)。
- close会关闭读写双工通道,shutdown允许我们只关闭读或者写。
函数定义
int shutdown(int sockfd, int howto);
对于howto,我们有三个选项:
- SHUT_RD,关闭读通道,套接字的缓冲区数据全部丢失,也不再允许调用读函数,所有发送过来的数据都会得到确认(ACK)然后丢弃。
- SHUT_WR,关闭写通道,所有在发送缓冲区的数据都会被发送,然后紧跟着正常连接终止序列,不允许调用任何写函数。
- SHUT_RDWR,关闭读写通道,相当于两次调用shutdown,一次关闭写,一次关闭读一样。
重新改进版本
基于对上述两个问题的认知,我们改进并解决了上述的两个问题,我们解决的思路包括:1. 不再直接莽撞的return,而是Shutdown并标记标准输入行的结束 2. 避免使用所有带缓冲的读取函数,改成对缓冲区操作
void str_cli(FILE *fp, int sockfd)
{
int maxfdp1, fp_eof;
fd_set rset;
char buf[MAXLINE];
int n;
FD_ZERO(&rset);
fp_eof = 0;
for (;;)
{
if (fp_eof == 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))
{
if ((n = read(sockfd, buf, MAXLINE)) == 0)//注意这里我们不再用Readline,避免需要对缓冲进行过多的判断
{
if (fp_eof == 1)
return;
else
err_quit("str_cli:server terminated prematurely");
}
write(fileno(stdout), buf, n);//同样,我们放弃了fputs
}
if (FD_ISSET(fileno(fp), &rset))
{
if ((n = read(fileno(fp), buf, MAXLINE)) == 0)//这里我们放弃了Fgets来避免select不可见问题
{
fp_eof = 1;
Shutdown(sockfd, SHUT_WR);
FD_CLR(fileno(fp), &rset);
continue;
}
writen(sockfd, buf, n);
}
}
}
基于select改进server
我们不妨解释下,基于select改进server我们需要做什么
- 我们现在只有一个进程,我们需要一个fd_set可以监听所有的套接字(包括连接和监听)
- 我们同样需要一个fd数组去轮询是否该套接字有数据进来
#include "../unp.h"
int main(int argc, char **argv)
{
int listenfd, connfd, sockfd;
struct sockaddr_in servaddr, cliaddr;
int client[FD_SETSIZE];//这将会存储所有连接套接字,默认-1表示为空
int maxfd;
int maxi, i;//maxi表示当前的描述符最大到哪里了,i用来各种遍历
int n;
int nready; //表示select中有多少个准备好的
socklen_t clilen;
struct fd_set allset, rset; //之所以有两个,是因为,allset存储所有应该监听的套接字,rset则每次负责传入allset值以及获知反馈
char buf[MAXLINE];
//下面是常规的初始化servaddr
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
//常规的创建连接
listenfd = socket(AF_INET, SOCK_STREAM, 0);
Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(listenfd, 1024);
//对client数组置空
for (int i = 0; i < FD_SETSIZE; i++)
client[i] = -1;
maxfd = listenfd;//因为监听套接字是第一个要监听的套接字
maxi = -1;
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for (;;)
{
rset = allset;
nready = Select(maxfd + 1, &rset, NULL, NULL, NULL);//用nready来判断是否该轮已经没有套接字准备好了
if (FD_ISSET(listenfd, &rset))
{ //有新的连接
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] == -1)
{
client[i] = connfd;
break;
}
if (i == FD_SETSIZE)
err_quit("too many clients");
FD_SET(connfd, &allset);
if (connfd > maxfd)
maxfd = connfd;
if (i > maxi)
maxi = i;
if (--nready <= 0) //注意这里是c惯用法,先-1在判断是否<=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
Writen(sockfd, buf, n);
if (--nready <= 0)
break;
}
}
}
}
上述的服务器很好地实现了单进程I/O复用,但是会有一个潜在的问题,就是如果你还记得,在连接未被accept之前中止的话,berkerly的实现会丢弃掉该连接,试想一下,如果select发现有一个可行的连接,然后因为在处理其他事项,在这过程中,客户端发送RST中止因此服务器默认的丢掉了该套接字,结果调用accept的时候就阻塞等待一个不在的套接字了。 解决办法是将在select之后,监听套接字改成非阻塞套接字,然后accept的时候有意忽略若干错误
pselect函数
函数定义
int pselect(int maxfdp1, fd_set * readset, fd_set * writeset,
fd_set * exceptset, const struct timespec * timeout,
const sigset_t * sigmask)
如果观察分析pselect和select的区别,我们可以观察到
-
timeout改用了timespect结构体
struct timespec { time_t tv_sec; long tv_nsec;//注意之前是微秒数,现在是纳秒数 };
-
引入了mask来实现这么一件事情:就是原子性
很多信号的处理最后会归于修改某一个全局变量,比方说is_interrupt,我们很可能会希望在调用select陷入沉睡之前,先检查一下是否收到了interrupt信号,我们的代码会有点像
if (intr_flag){ handle();//被中断,处理 }else if ((nready=select(..))<0){//否则就阻塞select if (errno==EINTR){//如果在阻塞的时候接收到打断的指令,就检查intr_flag if (intr_flag) handle } }
遗憾的是,在检查intr_flag和调用select之间是有间隙的,如果这个时候收到了信号(也就是说intr_flag被改了),select是不知道的(因为没有运行),相当于信号就丢失了
显然我们需要实现的是:在测试intr_flag和调用select之间我们需要阻塞信号(避免说出现中间插入),我们有办法实现这个,但是我们又要在调用select阻塞的时候,信号不能屏蔽,否则就没有正常的接收了
这个时候pselect就派上用场了,pselect可以在运行的时候重新设置屏蔽信号,然后在结束运行的时候恢复
sigset_t newmask,oldmask,zeromask; sigemptyset(&zeromask); sigemptyset(&newmask); sigaddset(&newmask, SIGINT); sigprocmask(SIG_BLOCK, &newmask, &oldmask);//这里我们阻塞了信号SIGINT if (intr_flag) handle(); if ((nready=pselect(...,&zeromask))<0){//这里在调用的时候度鞥与没有阻塞, if (errno==EINTR){//如果在阻塞的时候接收到打断的指令,就检查intr_flag if (intr_flag) handle() } }
poll函数
函数定义
int poll(struct pollfd *fdarray, nfds_t nfds, int timeout)//超时为0,有则返回数目,出错误返回-1
//在设计上,pollfd避免使用值-结果参数,传递条件用events,返回结果用revents
struct pollfd {
int fd;
short events; // 表示监听事件
short revents; //表示特定事件可实现(这是返回值)
};
nfds跟我们之前提到的概念一致,可以认为就是最大描述符+1,timeout注意的是,如果是INFTIM常量表示一直等待,如果是0就不阻塞(立刻返回),否则就是设置为x 毫秒数的超时
events的指定方式和revents的测试方式都使用位运算
struct poll_fd demo;
demo.fd=specific_fd;
demo.events= POLLRDNORM|POLLERR;//监听POLLRDNORM与POLLERR
if (demo.revents&(POLLRDNORM|POLLERR)) //测试POLLRDNORM或者POLLERR
详细解释
总的来说,poll把文件描述符的读写都分的更详细,一般分为普通,优先级带,高优先级三种,我们常规用的比较多的是POLLRDNORM和POLLWRNORM,注意在读取的时候,我们不用声明监听POLLERR,POLLERR条件成立会自动返回
常数值 | 是否可以作为events取值 | 是否可以作为revents取值 | 什么意思? |
---|---|---|---|
POLLIN | 可以 | 可以 | 普通或优先级带数据可读(过时) |
POLLRDNORM | 可以 | 可以 | 普通数据可读 |
POLLRDBAND | 可以 | 可以 | 优先级带数据可读 |
POLLPRI | 可以 | 可以 | 高优先级数据可读 |
POLLOUT | 可以 | 可以 | 普通级数据可写(过时) |
POLLWRNORM | 可以 | 可以 | 普通级数据可写 |
POLLWRBAND | 可以 | 可以 | 优先级带数据可写 |
POLLERR | 可以 | 发生错误 | |
POLLHUP | 可以 | 发生挂起 | |
POLLNVAL | 可以 | 描述符不是一个文件 |
当我们需要读取数据的时候,我们首先需要理解我们监听的数据属于哪一个级别:
- 正规的TCP、UDP数据都是普通数据
- TCP的带外数据被认为是优先级带数据
- TCP的读半部关闭(收到一个FIN报文),是普通数据,接下来的读操作会返回0
- TCP出现错误既可能被认为是普通数据也可认为是错误(POLLERR),无论如何,接下来的读操作返回-1,并且设置errno,适合处理出现RST或者超时
- 监听套接字上的连接有些实现认为是优先级数据,大部分实现认为是普通数据
- 非阻塞式的connect的完成被认为是相应套接字可写
总的来说,在日常使用场景下,我们对于监听套接字可以用POLLRDNORM,对于连接套接字,我们也可以设置为POLLRDNORM,不过我们在用if判断的时候,要加上POLLERR,因为部分实现错误下会返回POLLERR
基于poll的server
#include "../unp.h"
int main(int argc, char **argv)
{
int maxi, i, listenfd, connfd, sockfd;
int nready; //表示select中有多少个准备好的
size_t n;
char buf[MAXLINE];
socklen_t clilen;
struct pollfd client[OPEN_MAX];//我们不再需要fd_set,OPEN_MAX是一个我们懒得估算所以指定的值
struct sockaddr_in servaddr, cliaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(8080);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(listenfd, 1024);
client[0].fd = listenfd;//设置第一个监听对象是监听套接字
client[0].events = POLLRDNORM;//POLLRDNORM可以用来判断connect了
for (i = 1; i < OPEN_MAX; i++)//这里要跳过0
client[i].fd = -1;
maxi = 0;
for ( ; ; )
{
nready = Poll(client, maxi + 1, INFTIM);//最大描述符+1的原则还是成立,INFTIM表示一直等待
if (client[0].revents & POLLRDNORM)//注意这里使用位运算判断是否收到新的连接
{
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
for (i = 1; i < OPEN_MAX; i++)
{
if (client[i].fd < 0)
{
client[i].fd = connfd;
break;
}
}
if (i == OPEN_MAX)
err_quit("too many clients");
client[i].events = POLLRDNORM;//设置新的套接字监听范围是普通数据可读
if (i > maxi)
maxi = i;
if (--nready <= 0)
continue;
}
for (i = 1; i <= maxi; i++)
{
if ((sockfd = client[i].fd) < 0)
continue;
if (client[i].revents & (POLLRDNORM | POLLERR))
{
if ((n = read(sockfd, buf, MAXLINE)) < 0)
{
if (errno == ECONNRESET)
{
close(client[i].fd);
client[i].fd = -1;
}
else
{
err_sys("read error");
}
}
else if (n == 0)
{
close(client[i].fd);
client[i].fd = -1;
}
else
{
Writen(sockfd, buf, n);
}
if (--nready <= 0)
break;
}
}
}
}