IO多路复用出现的场景是需要设计一个网络服务器,这个网络服务器可以供多个客户端同时连接并且能够处理这些连接传过来的请求
当应对并发的时候,我们一般想到的是利用多线程的程序,每个传上来的请求,都是一个线程,现在很多RPC的框架都是使用这种多线程的方式,但是多线程的方式存在一个很大的弊端,就是需要频繁的上下切换。cpu进行上下文切换的时候需要处理很多操作句柄,尤其上连接非常多的时候,这个上下文切换带来的代价会非常的高,因为多线程并不是最好的一种的解决方案,既然多线程的解决方案不行,我们就把思路转回到单线程
如何用单线程的方式来处理大量的客户端的连接呢?现在有个场景,现在客户端的连接有A、B、C、D、E五个连接,那A传上来消息的时候,服务器开始处理A传上来的消息,如果这个时候B传上来消息,这是服务器还在处理A传上来的消息,那B传上来的消息会不会丢弃呢?答案是不会的,原因是接收B传上来的消息的模块不是cpu,而是专门专注于IO的DMA控制器,因而这里使用单线程去处理网络连接并不会造成数据的丢失
在Linux系统中,一切都是文件,每一个网络连接在内核中都是文件描述符的形式描述符简称是fd,用单线程写一个网络服务器可以这么写(伪代码):
while(1){
for(fdx in (fdA ~ fdE)) {
if (fdx 有数据) {
读fdx; 处理数据
}
}
}
这个方式用C语言来写,性能也是不低的,但是它仍然做的不够好,原因是每次判断有数据还是没数据是由程序判断的,效率是比较低的。
下面看看select函数是怎么执行的
sockfd = socket(AT_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(20000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, 5);
for (i = 0; i < 5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
{
max = fds[i];
}
}
//---------------------------------------------------------------------------
while(1)
{
FD_ZERO(&rset);
for(i= 0; i < 5; i++)
{
FD_SET(fds[i], &rset);
}
puts("round again");
selct(max + 1, &rset, NULL, NULL, NULL);
for(i = 0; i < 5; i++)
{
if(FD_ISSET(fds[i], &rset))
{
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
虚线上面一部分是为了准备文件描述符的数组fds,先创建一个socket服务端,然后创建了5个文件描述符,也就是说socket可以接收5个客户端的连接,这5个文件描述符分别存到fds的第0个到第四个元素中,文件描述符就是一个数,这个数代表文件描述符的编号,这编号并不是按顺序的,而是随机的数字,然后找到这个数组中最大的值,存到max中
虚线下面一部分是一个while循环,先清空rset集合,即让rset集合不再包含任何文件描述符,这个rset是用来存储文件描述符的,rset具体的类型是一个bitmap,这个bitmap用来表征哪一个文件描述符是启用的,比如刚创建的5个文件描述符分别是3,4,6,7,9,在bitmap中存储的样子就是0001101101000...(...代表0),其中第三位、第四位、第六位、第七位、第九位是0,bitmap在select函数中默认1024位,这1024个位置会在fd需要被监听的位置被置为1,不要被监听的位置为0
select函数在执行过程中的流程
在程序中有一个bitmap,其中包含的数据是5个文件描述符,分别在对应的位置,这就是rset;由于程序是运行在用户态空间的,但是select函数在运行的时候,直接将用户态空间的rset拷贝到内核态,由内核来判断fd是否有数据来,内核判断的效率是比用户态高的;之前的伪代码中,通过while循环来判断fd是否有数据,用户态在判断的时候不断地询问内核,造成用户态和内核态不断地切换,性能会比较低,而select函数直接将rset拷贝到内核,内核就直接判断有些数据来。
如果没有数据来的话,内核会一直进行判断,程序呈堵塞状态,也就是说,select函数是一个堵塞函数,如果一直没有数据来的话,程序就是一直堵塞在调用select的位置。
有数据来的时候
内核会将有数据的fd置位(这里的FD置位中,FD指的其实是rset中对应的那一位,而不是真正的fds中的元素)
select函数会返回(程序不在堵塞了)
然后遍历整个fds数组,通过FD_ISSET判断rset中哪一个fd被置位了,被置位的那个fd的数据会被读出来,并且进行相应的处理,此处的puts函数代表业务处理。
关于select函数中的第一个参数---->max + 1,因为fd的数值对应着rset的位置,获取数值最大的一个fd,然后加1,比如最大的fd是9,加1后就是10,这样rset就会截取前十个元素为一个新数组,然后把需要监听的文件描述符拿出来
有数据的时候,可能是多个fd有数据,所以需要遍历五个文件描述符来判断有没有数据,并且做相应的处理
但是select有几个缺点:
bitmap默认的大小是1024,虽然可以调整它的大小,但是它仍然有上限的
rset(fd_set)在内核是会置位的,这说明rset在内核态被修改过了,当while循环下一次执行的时候,要重新给rset设置为一个空值,然后再进行置位,所以rset是不可重用的
rset虽然从用户态拷贝到了内核态,但是这个拷贝过程仍然有一个较大的开销
select函数获取到数据的时候,是不知道哪一个fd有数据,还需要进行一个for循环遍历,时间复杂度为O(n)
下面看看poll的代码
for (int i = 0; i < 5; i++)
{
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
//----------------------------------------------------------------------------------
while (1)
{
puts("round again");
poll(pollfds, 5, 5000);
for (int i = 0; i < 5; i++)
{
if (pollfds[i].revents & POLLIN)
{
pollfds[i].revents = 0;
memset(buffer, 0, MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
poll的工作原理和poll很像,传递的参数要少一些,poll函数的第一个参数pollfds是一个pollfd结构体数组
struct pollfd
{
int fd;
short events;
short revents;
}
第二参数代表数组中有5个元素,第三个参数代表5000ms的超时时间
poll的一切改进都是围绕pollfd结构体来进行的
在程序运行的时候,跟select函数一样,有5个pollfd组成的数组,拷贝到内核态,让内核帮忙监听pollfds中的数据
poll没有采用bitmap,采用了pollfd,就带来了一些好处
结构体pollfd中第一个字段保存fd,第二参数代表fd在意的事件,读数据的话在意POLLIN事件,写的话在意POLLOUT事件,两者都在意的话,两者与一下;第三个参数revents的初始值为0,在还未接收到数据的时候,poll函数处于堵塞状态,当有数据传来的时候,内核会把有数据的pollfd的revents置为1,然后返回,解除堵塞状态,然后通过遍历判断每个pollfd的revents是否为POLLIN,判断为true后,将revents重新置为0,执行其他代码后,进入下一次while循环,pollfd又恢复到了最初的模型,所以就可以重复使用。
poll解决了select使用bitmap出现的问题,利用pollfd实现了结构体重复使用
下面是epoll的方法
struct epoll_event events[5];
int epfd = epoll_create(10);
...
...
for (int i = 0; i < 5; i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof(client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
//-----------------------------------------------------------------------
while (1)
{
puts("round again");
nfds = epool_wait(epfd, events, 5, 10000);
for (int i = 0; i < nfds; i++)
{
memset(buffer, 0, MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
这是最新的一种多路复用函数,第一个重要的函数是epoll_create(10)创建一个epfd
epoll_create()返回引用新epoll实例的文件描述符,参数max_size标识这个监听的数目最大有多大,从Linux 2.6.8开始,max_size参数将被忽略,但必须大于零。
#include
epfd= epoll_creat(max_size);
epfd可以理解成一个白板
第二个重要的函数epoll_ctl,这个函数相当于在白板上写字,根据代码中的参数,表示在白板一个文件描述符fd和一个events事件(均包含在epoll_event中)
代码中循环了5次,也就是在白板中写了5次文件描述符fd和一个events事件,最后得到了一个写了这些数据的epfd
struct epoll_event
{
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
} __attribute__ ((__packed__));
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
在while循环中,有一个epoll_wait方法,这个方法的原理如下
epfd中保存五个fd和五个对应的events,在之前的select和poll方法,是将数据拷贝从用户态拷贝到内核态,但是在epoll_wait函数执行的过程中,用户态和内核态是共享epfd所在的这块内存的,也就是说epfd是在用户态和内核态进行共享的(因而解决了select方法和poll方法,用户态和内核态切换的开销),然后内核还是去判断哪一个fd有数据来
当没有数据来的时候,epoll函数跟select和poll一样,都是处于堵塞的
当有数据来的时候,select和poll都干了两件事,第一件事是打标、置位,第二件事是函数返回;在epoll中,置位同样是有的,当epfd中的fd有数据时(events表示监听读取数据事件),epfd中的fd会进行重排,将有数据的fd放到最前面的位置,假如第1个,第2个,第4个fd有数据,会将这3个fd重排放到第0位,第一位,第二位的位置;每当一个fd触发EPOLLIN时,会在触发事件的总数上自增+1,最后会得到一个总共触发了多少次EPOLLIN的返回值,比如有3个fd触发了EPOLLIN事件,就返回3,epoll_wait的返回值nfds就为3,之后对数组的前3为元素进行遍历就可以了
由于能确定最后有几个fd触发了EPOLLIN事件,所以遍历的次数就确定了,时间复杂度为O(1)
epoll的应用:redis、nginx、Java的NIO