本博文重要针对UNP一书中的第六章内容来聊聊I/O复用技巧和其在网络编程中的实现
1. I/O复用技巧
I/O多路复用是指内核一旦发明进程指定的一个或多个I/O条件预备就绪,它就通知该进程。I/O复用实用于以下场所:
(1) 当客户处置多个描写符(通常为交互式输入或网络套接字),必需实用I/O复用
(2) 当一个客户处置多个套接字时,这类情形很少见,但也可能涌现
(3) 当一个TCP服务器既要处置监听套接字,又要处置已衔接套接字,一般就要应用I/O复用
(4) 如果一个服务器既要实用TCP,又要实用UDP,一般就要应用I/O复用
(5) 如果一个服务器要处置多个服务或多个协定,一般就要应用I/O复用
与多线程和多进程技巧相比,I/O复用技巧的最大优势就是体系开消小,体系没必要创立进程/线程,也没必要保护这些进程/进程,从而大大减小了体系的开消。
2. I/O模型
Unix下常见的I/O模型有五种,分离是:阻塞式I/O,非阻塞式I/O,I/O复用,信号驱动式I/O和异步I/O。
Unix下对一个输入操作,通常包括两个不同的阶段:
(1) 期待数据预备好
(2) 从内核向进程复制数据
例如:对一次read函数操作来讲,数据先会被拷贝到操作体系内核的缓冲区去,然后才会从操作体系内核的缓冲区拷贝到运用程序的地址空间。
再比如对一次socket传播输来讲,首先期待网络上的数据达到,然后复制到内核的某个缓冲区,然后再把内核缓冲区的数据复制到进程缓冲区。
下面就以上述两个阶段来论述五种I/O模型。
2.1 阻塞式I/O模型
2.1.1 趣解模型
假定一个特定的场景,你的一个好朋友找你借钱,你身上没有充分的现金,因而,你要去银行取钱,银行人多,你只能在那里排队,在这段时光内,你不能分开队伍去干你自己的事情。时光都糟蹋在排队上面了。这就是典范的阻塞式I/O模型。
2.1.2 网络模型
默许情形下,所有的套接字都时阻塞的,以数据报套接字为例
如上图,我们把recvfrom函数视为体系调用,进程调用recvform函数后就阻塞于此,期待数据报的达到,一直到内核把数据报预备好后,就将数据从内核复制到用户进程,随后用户进程再对这些数据进行处置。
这类模型的利益就是,能够及时取得数据,没有延迟,但是就像上面趣解模型中讲到,对用户来讲,这段时光一直要处于等到状况,不能去做其他的事情,在性状方面付出了代价。
2.2 非阻塞式I/O模型
2.2.1 趣解模型
还是去取钱的例子,假定你没法忍耐一直在那里排队,而是去旁边的商场走走,然后隔一段时光回来看看还有在排队没,有的话再持续去走走,直到有一次你回来看到没有人排队了为止。这就是非阻塞式I/O模型。
2.2.2 网络模型
进程把一个套接字设置成非阻塞是在通知内核:当所要求的I/O操作非得把本进程投入眠眠能力完成时,不要把本进程投入眠眠,而是返回一个毛病。
如上图所示,前三次讯问都返回一个毛病,即内核没有数据报预备好,到第四次调用recvform函数时,数据被预备好了,它被复制到运用进程缓存区,因而recvform胜利返回,运用进程随后处置数据。
这类模型相对阻塞式来讲,
长处在于:运用进程没必要阻塞在recvfrom调用中,而是可以去处置其他事情
缺陷在于:如趣解模型中所说,你来回跑银行带来了很大的延时,可能在你来回的路上叫到了你的号。在网络模型中便可以表示在义务完成的响应延迟增大了,隔一段时光轮询一次recvform,数据报可能在两次轮询之间的任意时光内预备好,这将会致使整体数据吞吐量的下降。
2.3 I/O复用模型
2.3.1 趣解模型
现在,银行都会按一个显示屏,上面会显示轮到几号客户了。这个时候,你就不用每次都去跑进去看还有排队没,而是远远的看看显示屏上轮到你没有,如果显示了你的名字,你就去取钱就好了。这就是I/O复用模型。
2.3.2 网络模型
有了I/O复用技巧,我们可以调用select或poll函数,阻塞在这两个体系调用中的某一个之上,而不是阻塞在真实的I/O体系调用上。
如上图所示,进程受阻于select调用,期待可能多个套接字中的任一个变成可读。当select返回套接字可读这一条件时,运用进程就调用recvfrom把所读的数据报复制到运用进程缓冲区。
进程阻塞在select,如果进程还有其他的义务的话便可以体现到I/O复用技巧的利益,那个义务先返回可读条件,就去履行哪一个义务。从单一的期待变成多个义务的同时代待。
这类模型较之前的模型来讲,可以没必要屡次轮询内核,而是等到内核的通知。
2.4 信号驱动式I/O模型
2.4.1 趣解模型
你还是不满意银行的服务,虽然没必要排队,但你在商场逛的也不放心啊,你还是要盯着显示屏,深怕没有看到显示屏上面你的名字,因而,银行也退出了全新的服务,你去银行取钱的时候,银行目前人多不能及时处置你的业务,而是叫你留下手机号,等到空闲的时候就短信通知你可以去取钱了。这就是信号驱动式I/O模型。
2.4.2 网络模型
我们可以用信号,让内核在描写符就绪时发送SIGIO信号告知我们。
如上图所示,进程树立SIGIO的信号处置程序(就要趣解模型中的留下手机号),并通过sigaction体系调用安装一个信号处置函数,该体系调用将立即返回,进程持续工作,知道数据报预备好后,内核发生一个SIGIO信号,告知运用进程和预备好,因而就在信号处置程序中调用recvfrom读取数据报,并通知主重复数据已预备好待处置,也能够立即通知主重复让他读取数据报。
这类模型的利益就是,在数据报没有预备好的期间,运用进程没必要阻塞,持续履行主重复,只要期待来自负号处置函数的通知便可。
2.5 异步I/O模型
2.5.1 趣解模型
你细细的想了想自己取钱时为了甚么,不过时借给你的朋友,银行都退出了网上银行服务,你只须要知道你的好朋友的银行卡号,然后在网银中申请转账,银行后台会给你处置,然后把钱打到你朋友的账户下面,等这些都处置好后,银行会给你发一条短信,告知你转账胜利,这个时候你便可以够跟你的好朋友说,钱已打给你了。这就是异步I/O模型,取钱借钱的繁杂事就交给银行后台给你处置吧。
2.5.2 网络模型
POSIX规范中供给一些函数,这些函数的工作机制是:告知内核启动某个操作,并让内核在全部操作完成后通知我们。
如上图所示,我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描写符,缓冲区指针,缓冲区大小和文件偏移,并告知内核完玉成部操作后通知我们。
不同于信号驱动式I/O模型,信号是在数据已复制到进程缓冲区才发生的。
2.6 各种I/O模型的比拟
以一张图来解释五种I/O操作的差别:
同步I/O操作:致使要求进程阻塞,直到I/O操作完成
异步I/O操作:不致使进程阻塞
可知,前四种都属于同步I/O操作慢体系都会阻塞与recvfrom操作,而异步I/O不会。
3. select函数
select函数用于I/O复用,该函数许可进程指导内核期待多个事件中的任何一个发生,并只在有一个或多个事件发生或阅历一段指定的事件才唤醒它。
3.1 函数原型
它的函数原型时:
int select(int maxfdp1, fd_set *readset, fd_set *writeset , fd_set *exceptset , const struct timeval *timeout);
对timeout参数:
(1) timeout==NULL,表示要永久期待下去,直到有一个描写符预备好I/O时才返回
(2) *timeout的值为0,表示不期待,检讨描写符就立即返回,这称为轮询。
(2) *timeout的值不为0,表示期待一段固定的时光,再有一个描写符预备好I/O时返回,但是不能超过由该参数制订的时光。
对readset,writeset和exceptset三个参数:
这三个描写符解释了可读,可写和处于异常条件的描写符聚集
对描写集fd_set构造,供给了以下四个操作函数
#include <sys/select.h>
int FD_ISSET(int fd,fd_set *fdset); //设定描写集中的某个描写符
void FD_CLR(int fd,fd_set *fdset);//关掉描写集中的某个描写符
void FD_SET(int fd,fd_set *fdset);//打开描写集中的某个描写符
void FD_ZERO(fd_set *fdset);//消除聚集内所有元素
对maxfdp1参数:
指定待测试的描写符个数,它的值时待测试的最大描写符编号加1,即从上面三个描写符集中的最大描写符编号加1。
对返回值:
select返回值有三种情形:
(1) 返回值为-1时,表示出错,如果在指定的描写符一个都没有预备好时捕捉一个信号,则返回-1
(2) 返回0,表示没有描写符预备好,指定的时光就超过了。
(3) 返回正数,表示已预备好的描写符个数,在这类情形下,三个描写符集中照旧打开的位对应于已预备好的描写符
3.2 应用select函数修正的str_cli函数
#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);//socket描写符
maxfdp1 = max(fileno(fp), sockfd) + 1;//最大描写符编号+1
Select(maxfdp1, &rset, NULL, NULL, NULL);//调用select,阻塞于此
//如果返回的套接字可读,就用readline读入回射文本
if (FD_ISSET(sockfd, &rset)) { /* socket is readable */
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}
//如果尺度输入可读,就先用fgets读入一行文本
if (FD_ISSET(fileno(fp), &rset)) { /* input is readable */
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
Writen(sockfd, sendline, strlen(sendline));
}
}
}
3.3 批量输入
在上一节提到的str_cli版本中,依然存在一个问题。假定客户在尺度输入中批量输入数据,在输入完最后一个数据后,碰到了EOF,str_cli返回到main函数,main函数随后终止。但是,在这个进程中,尺度输入的EOF终止符其实不意味着我们也同时完成了从套接字的读入,可能仍有要求在去往服务器的路上,或仍有应对在返回客户的路上。
缘由就处在于此:
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
当碰到EOF终止符的时候,str_cli函数选择了立即返回,而此时,我们更须要的是找到一个条件来断定套接字的读取是不是完成。
3.4 shutdown函数
shutdown函数供给了关闭TCP衔接其中一半的办法,也正是为懂得决上一小节发明的问题。
假定在尺度输入碰到EOF终止符时,我们只关闭发送这一端,也就是给服务器发送一个FIN,告知它我们已完成了数据发送,但是依然坚持套接字描写打开以便读取。
这点跟close函数有点像,但是斟酌到close函数有以下两个限制:
(1) close把描写符的援用计数减1,仅在该计数变成0时才关闭该套接字。但是应用shutdown可以不管援用计数就激起TCP的正常衔接终止序列
(2) close终止读和写两个方向的数据传送。shutdown只是关闭单方向的读或写。
其函数原型以下:
int shutdown(int sockfd , int howto);//若胜利则返回0,若出错返回-1
关于该函数的第二个参数howto:
(1) SHUT_RD 关闭衔接的读这一半,套接字中不再有数据可吸收,而且套接字吸收缓冲区中的现有数据都被抛弃
(2) SHUT_WR 关闭衔接的写这一半,对TCP套接字来讲,这称为半关闭,当前留在套接字发送缓冲区的数据将被发送,后跟TCP正常的衔接终止序列。
(3) SHUT_RDWR 关闭读半部和写半部,这与调用shutdown两次等效。
3.5 str_cli函数再修正版
#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);//关闭select描写符集中的尺度输入描写符
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) {//若读取的字节数为0
stdineof = 1;//表明套接字读取数据完成
Shutdown(sockfd, SHUT_WR); /* send FIN *///关闭读这一半
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
}
}
4. TCP回射服务器程序(采取select函数)
在【unix网络编程第三版】浏览笔记(四):TCP客户/服务器实例中我们采取fork生成子进程来处置每一个客户的需求。
如今,有了select函数,就没必要创立那末多子进程了,避免了为每一个客户创立一个子进程的所有开消,本节就将其改写成任意个客户的单进程版本。
select函数的描写符集中须要存储每一个客户的衔接套接字。因而我们很容易想到用采取一个数组client[FD_SETSIZE]来保留所有已衔接的套接字。
每次有新客户衔接的时候,就在client数组中找到第一个可用项来保留该衔接套接字。
具体解释见代码注释:
#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; //初始化maxfd,在传入select函数时须要+1
maxi = -1; //记载client数组中最后一个非-1数所占的序号
for (i = 0; i < FD_SETSIZE; i++)
client[i] = -1; //初始化client数组,为-1表示该项可用
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
for ( ; ; ) {
rset = allset; //初始化描写符集
nready = Select(maxfd+1, &rset, NULL, NULL, NULL);//注意此处为最大描写符编号+1,返回已预备好的描写符个数
if (FD_ISSET(listenfd, &rset)) { //检测到有新客户衔接
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);//衔接新客户,取得已衔接套接字
#ifdef NOTDEF
printf("new client: %s, port %d\n",
Inet_ntop(AF_INET, &cliaddr.sin_addr, 4, NULL),
ntohs(cliaddr.sin_port));
#endif
for (i = 0; i < FD_SETSIZE; i++)
if (client[i] < 0) {//找到第一个可用项
client[i] = connfd; //存储套接字描写符
break;
}
if (i == FD_SETSIZE)//限制最大衔接个数
err_quit("too many clients");
FD_SET(connfd, &allset); /* add new descriptor to set */
if (connfd > maxfd)
maxfd = connfd; //重置maxfd为最大描写符编号+1
if (i > maxi)
maxi = i; //client数组中最后一个描写符所占的序号
if (--nready <= 0)
continue; //没有已衔接套接字了
}
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; //没有已衔接套接字了
}
}
}
}
5. pselect函数
pselect函数由POSIX创造,是select的变种。
#include <sys/select.h>
int pselect(int maxfdp1,fd_set *restrict readfds,fd_set *restrict writefds,fd_set *restrict exceptfds,const struct timespec *restrict tsptr,const sigset_t *restrict sigmask);
相对select函数,pselect函数有以下几点不同:
(1) pselect应用timespec构造,新构造的tv_nsec指定纳秒数,而原构造里的tv_usec指定奥妙级
(2) pselect增长了第六个参数:一个指向信号掩码的指针。该参数许可程序先制止递交某些信号,再测试由这些当前被制止的信号处置函数设置的全局变量,然后调用pselect,告知它重新设置信号掩码。
(3)pselect的超时值设为了const,保证了调用pselect不会修正此值。
6. poll函数
poll函数的功效与select类似,不过在处置流装备时,它能够供给额外的信息。
#include <poll.h>
int poll(struct pollfd *fdarray,nfds_t nfds,int timeout);//若有就绪描写符就返回其数量,如超时则返回0,若出错就返回-1
对第一个参数:为指向一个构造数组第一个元素的指针,每一个数组元素都是一个pollfd构造,用于指定测试某个给定描写符fd的条件。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
要测试的条件由events成员指定,函数在相应的revents成员中返回该描写符的状况。
这里每一个描写符都有两个变量,一个为调用值,一个为返回成果,避免了应用值成果参数。
该构造中events和revents成员所用的常值以下表:
该表中,前四个处置输入,中间三个处置输出,最后三个处置异常。
就TCP/UDP而言,以下几种情形引发poll返回特定的revent
(1) 所有正规TCP数据和所有UDP数据都被以为时普通数据
(2) TCP的带外数据被以为时优先级带数据
(3) 当TCP衔接的读半部关闭时,也被以为时普通数据,随后的读操作将返回0
(4) TCP衔接存在毛病既可以为是普通数据,也能够为时毛病,不管哪类情形,随后的读操作都会返回-1,并把errno设为适合的值
(5) 在监听套接字上有新的衔接可用既可以为时普通数据,也能够为时优先级数据。
(6) 非阻塞式connect的完成被以为是使相应套接字可写
对第二个参数nfds:表示构造数组中元素的个数
对第三个参数timeout:指定poll函数返回前期待多长时光。
timeout值 | 解释 |
---|---|
INFINT | 永久期待 |
0 | 立即返回,不阻塞进程 |
大于0 | 期待指定数量的毫秒数 |
在select函数中,FD_SETSIZE和每一个描写符集中最大描写符数量这些都触及到固定值。但是在poll函数中分配一个pollfd数组并把该数组中元素的数据通知内核成了调用者的义务,内核不再须要知道这些固定大小的数据类型。
7. TCP回射服务器再修正版
#include "unp.h"
#include <limits.h> /* for OPEN_MAX */
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++)//由调用者指定OPEN_MAX
client[i].fd = -1; //初始化为-1,表示可用
maxi = 0; //client数组中已用项的最大序号
for ( ; ; ) {
nready = Poll(client, maxi+1, INFTIM);
if (client[0].revents & POLLRDNORM) {//新客户衔接
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);//返回已衔接客户套接字
#ifdef NOTDEF
printf("new client: %s\n", Sock_ntop((SA *) &cliaddr, clilen));
#endif
for (i = 1; i < OPEN_MAX; i++)//与select不同,这里的最大值均由调用者指定
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++) { //检讨client数组中所有项
if ( (sockfd = client[i].fd) < 0)
continue;
//有些实现在一个衔接上吸收到RST时返回的时POLLERR事件,而其他实现返回的只是POLLRDNORM事件
if (client[i].revents & (POLLRDNORM | POLLERR)) {//查看返回的revents状况
if ( (n = read(sockfd, buf, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
//由用户来关闭该套接字
#ifdef NOTDEF
printf("client[%d] aborted connection\n", i);
#endif
Close(sockfd);
client[i].fd = -1;
} else
err_sys("read error");
} else if (n == 0) {
//由用户来关闭该套接字
#ifdef NOTDEF
printf("client[%d] closed connection\n", i);
#endif
Close(sockfd);
client[i].fd = -1;
} else
Writen(sockfd, buf, n);
if (--nready <= 0)
break; //没有已衔接套接字了
}
}
}
}