io多路复用机制

一、什么是IO多路复用

文件描述符(fd) 表示的是对某个文件操作的句柄。当然socket套接字也算是fd。一般来说,想对fd进行读写操作,就要操作到fd,例如 read(),但read()本身是BIO,即阻塞IO,当对fd调用read()时,如果暂时没有数据输入到fd,那么read()将会处于阻塞状态,直到有数据输入,read()才会返回。

那么我们就可以想,如果现在有一个客户端连接进服务器,想要跟服务端通信,那么服务端就 对表示这个服务器的 sd(socket也能当作fd),调用read(),此时,若客户端有信息进入,read()返回,否则,read()会一直阻塞。

那么如果有两个客户端连接进来了,也想跟服务器通信,那怎么办呢?答案是开多一条线程,让另一个线程对第二个客户端调用read(),并阻塞到客户端有信息进入服务器为止。那这样就出现问题了,实际应用中,客户端不可能只有几个啊,可能有上万个客户端想要跟服务端通信,那么也要开上万个线程?那显然是不实际的。

所以解决方法就是 IO多路复用。IO多路复用一般有 select()、poll()、epoll()方式,他们都是对连接进服务端的 客户端socket就行监控,例如现在有100个 客户端socket,那么就监控这100个,如果这100个socket中有信息进入,则IO多路复用会返回,否则,就阻塞。即IO多路复用可以同时阻塞多个I/O操作,而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写(就是监听多个socket)。

正因为阻塞I/O只能阻塞一个I/O操作,而I/O复用模型能够阻塞多个I/O操作,所以才叫做多路复用。

二、select、poll、epoll

select 

每次会将一组的文件描述符一次性传入内核中,让内核自己去遍历。为什么要让内核自己去遍历呢?我们知道用户态请求内核的资源开销是比较高的,就相当于我们上面说的那种,在for循环里进行RPC调用一样,内核态用户态空间的反复切换比较浪费cpu资源,所以select的方式从用户态拷贝一组的fds数组到内核,内核自己遍历标记哪个已经就绪了,然后将文件描述符数组再拷贝回用户态,返回一个就绪的个数,用户态再对自己的这个数组进行遍历,找到可用的文件描述符进行处理。

所以在select整个过程中,要经过两次拷贝:一次是用户态向内核态的一次拷贝,一次是内核准备好数据之后,内核向用户的拷贝。两次遍历,一次遍历发生在内核,一次遍历发生在用户空间,以及一次系统调用,即用户态携带fds向内核的一次请求。相比较我们上面说的那种方式,用户态进行系统调用遍历,有n个io请求就要进行n次系统调用来说。select这种方式可以大幅度的减少系统调用的次数。

可以总结一下select的特点

  1. 每次select调用需要传入一个fds数组。
  2. 把遍历就绪操作放在了内核态,让内核自己去标记哪个文件描述符好了 。
  3. 只返回一个就绪个数给用户态,还需要用户态进行一次遍历 。

select 的缺点:

1. 内核/用户数据拷贝频繁,操作复杂。

在调用 select() 之前,需要手动在 程序中 维护一个包含要监控的文件描述符的 文件描述符集合 fd_set。把需要监听的文件描述符加到fd_set中。用户为了检测时间是否发生,还需要在用户程序手动维护一个数组,存储监控文件描述符。当内核事件发生,在将fd_set集合中没有事件发生的文件描述符清空,然后拷贝到用户区,和数组中的文件描述符进行比对。再调用select也是如此。每次调用,都需要来回拷贝。

2. 单个进程监控的文件描述符有限,通常为1024*8个文件描述符

3. 轮询时间效率低

select 检测时间是否发生的方式是通过轮询各个 文件描述符。当文件描述符的数量大的时候,轮询的效率很低,所以select 监控时的时间复杂度为O(n)。
 

2.2  poll

同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

int poll (struct pollfd *fds, unsigned int nfds, int timeout);
 

poll和select的总体过程都是一样的,区别在意select有一个最大只能监听1024个文件描述符的限制,而poll则去掉了1024大小的限制,select用的是BitsMap 结构,poll取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,不过还是会受到系统文件描述符限制。

不过这两个在本质上没有什么大的区别,也需要在用户态与内核态之间拷贝文件描述符数组。时间复杂度也没有什么变化

优缺点:

1. 相对于select,poll 没有监听文件描述符的数目上限。

2. 由于 poll 监听文件描述符的方式都是轮询,跟select 一样,所以 poll 在高并发下的表现也不是特别好。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

所以对于poll来说,select的大部分问题,poll都具有。拿select为例,加入我们的服务器需要支持100万的并发连接。则在FD_SETSIZE(最大fd连接数)为1024的情况下,我们需要开辟100个并发的进程才能实现并发连接。除了进程上下调度的时间消耗外。从内核到用户空间的无脑拷贝,数组轮询等,也是系统难以接受的。因此,基于select实现一个百万级别的并发访问是很难实现的。
————————————————
版权声明:本文为CSDN博主「ZJE_ANDY」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/u014453898/article/details/111859283

2.3 epoll 

我们上面说的select/poll的特点。但是我们仔细想一想,是不是还有可以优化的点,或者说还存在一些问题。

  1. 每次select调用需要传入一个fds数组。(问题:在并发量很高的时候,需要频繁执行用户到内核的复制,资源消耗量巨大)
  2. 把遍历就绪操作放在了内核态,让内核自己去标记哪个文件描述符好了 。(问题:能否改成事件驱动机制,哪个文件描述符就绪了,就把那个数组的文件描述符标记起来即可,避免内核的无效遍历)
  3. 只返回一个就绪个数给用户态,还需要用户态进行一次遍历 (问题:用户态的遍历也存在无效遍历的可能,可以优化成只返回已经就绪的文件描述符给用户态直接用,这样的话用户态就无需遍历了,可避免用户态的无效遍历)

新技术的出现必然是为了解决旧技术存在的问题,因此epoll的出现,也是为了解决我们上述说到的select/poll所存在的问题。

  1. epoll在内核中维护了一个文件描述符的集合(采用红黑树结构,可以高效的维护文件描述符,增删查一般时间复杂度是 O(logn)),用户态无需每次重新传入,只需要告诉内核修改的部分即可,可以大幅度减少内核和用户空间之间的数据拷贝的资源消耗
  2. epoll采用的是事件驱动机制,不需要通过轮询来找到对应的那个文件描述符。当某个文件描述符就绪的时候,会通过回调函数,把它放到一个就绪链表中记录起来,内核就不用遍历文件描述符了
  3. 内核只返回就绪的文件描述符集合,也就是上面提到的那个就绪链表,因为返回给用户态的肯定是已经就绪了的,所以用户态可以拿来即用,也无需做多余的遍历

总结一下epoll的过程,首先内核会维护一个所有待检测的文件描述符的红黑树,然后用户态每次只需传入有更改或者新增的那个部分的文件描述符,内核会在红黑树上维护好传过来的文件描述符,接着假如有一个文件描述符好了,会触发回调函数,将这个文件描述符添加到就绪链表中,当epoll_wait触发的时候就返回就绪链表给用户态直接使用。可以看到,epoll很好的解决了我们上面说到的那些select和poll所存在的问题,使得io效率在高并发环境下高了不少。 

epoll 特点:

没有最大并发连接的限制,能打开的fd上限远大于1024(1G的内存能监听约10万个端口)
采用回调的方式,效率提升。只有活跃可用的fd才会调用callback函数,也就是说 epoll 只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,epoll的效率就会远远高于select和poll。
内存拷贝。使用mmap()文件映射内存来加速与内核空间的消息传递,减少复制开销。

 

epoll 底层用到的数据结构(红黑树+双向链表)+结构体

eventpoll 结构体:

struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

同时在 内核cache里 建了个红黑树用于存储以后epoll_ctl传来的socket(表示客户端连接的fd)外

还会再建立一个就绪队列链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个就绪队列链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

当向系统中添加一个fd时,就创建对应的一个epitem结构体(即 fd 和 epitem结构体是一一对应的),这是内核管理epoll的基本数据结构:
 

struct epitem {
    struct rb_node rbn;          //用于主结构管理的红黑树
    struct list_head rdllink;    //事件就绪队列
    struct epitem *next;         //用于主结构体中的链表
    struct epoll_filefd ffd;     //这个结构体对应的被监听的文件描述符信息
    int nwait;                   //poll操作中事件的个数
    struct list_head pwqlist;    //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table
    struct eventpoll *ep;        //该项属于哪个主结构体(多个epitm从属于一个eventpoll)
    struct list_head fllink;     //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点
    struct epoll_event event;    //注册的感兴趣的事件,也就是用户空间的epoll_event
}

 那么在内核中维护的红黑树的结构就如下:

为什么会出现红黑树,因为 epoll_ctl 向epoll对象加入 fd 时,是要先搜索这个fd是否已经存在的,在大量fd 的情况下,红黑树的查询效率要比链表好。

总体而言:

epoll 支持两种事件触发模式,分别是边缘触发(*edge-triggered,ET*)水平触发(*level-triggered,LT*)

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

 

 

 Apache 使用了 select 模型,nginx 则使用 epoll 模型。在 php 中内置了 select 模型,对应的函数为 socket_select,多路复用是实现 http 服务器的基础

io阻塞代码

server.php
    $socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
        socket_set_option($socket,SOL_SOCKET,SO_REUSEADDR,true);
        socket_bind($socket,0,8888);
        socket_listen($socket);
        while(true){
            $conSock = socket_accept($socket);
            socket_getpeername($conSock,$ip,$port);
            echo 'ip:'.$ip.'...port:'.$port.'...connetted'.PHP_EOL;
            while(true){
                $recMsg = socket_read($conSock,1024);
                socket_write($conSock,strtoupper($recMsg), strlen ($recMsg));
                echo $recMsg ;
            }
        }

 

client.php
$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
socket_connect($socket,'192.168.113.136',8888);
while(true){
 fwrite( STDOUT ,'请输入内容:');
 $in =  fgets (STDIN);
 socket_write($socket,$in,strlen($in));
 echo socket_read($socket,1024);
}

这个时候是会阻塞的 IO阻塞模型只能是同一个时刻只能由一个客户端进行访问
 

socket_select() 函数 就是使用了 select 模型 实现io多路复用

socket_select 这个函数解决

//实现io多路复用 返回活跃的连接
socket_select ( array &$read , array &$write , array &$except , int $tv_sec [, int $tv_usec = 0 ] )
注意 :前三个参数都是引用传值。
$read 服务端监听的套接字资源,当它有变化(就是有新消息到或者有客户端连接/断开)时,socket_select函数才会返回,继续往下执行。
$write是监听是否有客户端写数据,传入NULL是不关心是否有写变化。
$except是$ sockets 里面要被排除的元素,传入NULL是”监听”全部。
第四个参数为null为阻塞, 为0位非阻塞, 为 >0 为等待时间
返回 活跃的链接数 当有连接 或数据操作时就会返回
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值