I/O多路复用

题主是看redis相关书籍碰到了困惑,那就结合redis源码来回答题主这个问题。
redis源码地址:antirez/redis · GitHub

关于I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。

以select和tcp socket为例,所谓可读事件,具体的说是指以下事件:
1 socket内核接收缓冲区中的可用字节数大于或等于其低水位SO_RCVLOWAT;
2 socket通信的对方关闭了连接,这个时候在缓冲区里有个文件结束符EOF,此时读操作将返回0;
3 监听socket的backlog队列有已经完成三次握手的连接请求,可以调用accept;
4 socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。

所谓可写事件,则是指:
1 socket的内核发送缓冲区的可用字节数大于或等于其低水位SO_SNDLOWAIT;
2 socket的写端被关闭,继续写会收到SIGPIPE信号;
3 非阻塞模式下,connect返回之后,发起连接成功或失败;
4 socket上有未处理的错误,此时可以用getsockopt来读取和清除该错误。

Linux环境下,Redis数据库服务器大部分时间以单进程单线程模式运行(执行持久化BGSAVE任务时会开启子进程),网络部分属于Reactor模式,同步非阻塞模型,即非阻塞的socket文件描述符号加上监控这些描述符的I/O多路复用机制(在Linux下可以使用select/poll/epoll)。服务器运行时主要关注两大类型事件:文件事件和时间事件。文件事件指的是socket文件描述符的读写就绪情况,时间事件分为一次性定时器和周期性定时器。相比nginx和haproxy内置的高精度高性能定时器,redis的定时器机制并不那么先进复杂,它只用了一个链表来管理时间事件,而且目前链表也没有对各个事件的到点时间进行排序,也就是说,每次都要遍历链表检查每个事件是否需要到点执行。个人猜想是因为redis目前并没有太多的定时事件需要管理,redis以数据库服务器角色运行时,定时任务回调函数只有位于redis/src/redis.c下的serverCron函数,所有的定时任务都在这个函数下执行,也就是说,链表里面其实目前就一个节点元素,所以目前也无需实现高性能定时器。

Redis网络事件驱动模型代码:redis/src/目录下的ae.c, ae.h, ae_epoll.c, ae_evport.c, ae_select.c, ae_kqueue.c , ae_evport.c。其中ae.c/ae.h:头文件里定义了描述文件事件和事件时间的结构体, 即aeFileEvent和aeTimeEvent;事件驱动状态结构体aeEventLoop, 这个结构体只有一个名为eventloop的全局变量在整个服务器进程中;事件就绪回调函数指针aeFileProc和aeTimeProc;以及操作事件驱动模型的各种API(aeCreateEventLoop以及之后全部的函数声明)。ae_epoll.c, ae_select.c, ae_keque.c和ae_evport.c封装了select/epoll/kqueue等系统调用,Linux下当然不支持kqueue和evport。至于究竟选择哪一种I/O多路复用技术,在ae.c里有预处理控制,也就是说,这些源文件只有一个能最后被编译。优先选择epoll或者kqueue(FREEBSD和Mac OSX可用),其次是select。

redis事件驱动整体流程:redis服务器main函数位于文件redis/src/redis.c, 事件驱动入口函数位于main函数的倒数第三行:
aeMain(server.el); /* 实现代码位于ae.c */
这个函数调用aeProcessEvent进入事件循环,aeProcessEvent函数源码(同样位于ae.c源文件):
/* Process every pending time event, then every pending file event
 * (that may be registered by time event callbacks just processed).
 * Without special flags the function sleeps until some file event
 * fires, or when the next time event occurs (if any).
 *
 * If flags is 0, the function does nothing and returns.
 * if flags has AE_ALL_EVENTS set, all the kind of events are processed.
 * if flags has AE_FILE_EVENTS set, file events are processed.
 * if flags has AE_TIME_EVENTS set, time events are processed.
 * if flags has AE_DONT_WAIT set the function returns ASAP until all
 * the events that's possible to process without to wait are processed.
 *
 * The function returns the number of events processed. */
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
    int processed = 0, numevents;

    /* Nothing to do? return ASAP */
    if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;

    /* Note that we want call select() even if there are no
     * file events to process as long as we want to process time
     * events, in order to sleep until the next time event is ready
     * to fire. */
    if (eventLoop->maxfd != -1 ||
        ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
        int j;
        aeTimeEvent *shortest = NULL;
        struct timeval tv, *tvp;

        if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
            shortest = aeSearchNearestTimer(eventLoop);
        if (shortest) {
            long now_sec, now_ms;

            /* Calculate the time missing for the nearest
             * timer to fire. */
            aeGetTime(&now_sec, &now_ms);
            tvp = &tv;
            tvp->tv_sec = shortest->when_sec - now_sec;
            if (shortest->when_ms < now_ms) {
                tvp->tv_usec = ((shortest->when_ms+1000) - now_ms)*1000;
                tvp->tv_sec --;
            } else {
                tvp->tv_usec = (shortest->when_ms - now_ms)*1000;
            }
            if (tvp->tv_sec < 0) tvp->tv_sec = 0;
            if (tvp->tv_usec < 0) tvp->tv_usec = 0;
        } else {
            /* If we have to check for events but need to return
             * ASAP because of AE_DONT_WAIT we need to set the timeout
             * to zero */
            if (flags & AE_DONT_WAIT) {
                tv.tv_sec = tv.tv_usec = 0;
                tvp = &tv;
            } else {
                /* Otherwise we can block */
                tvp = NULL; /* wait forever */
            }
        }

        numevents = aeApiPoll(eventLoop, tvp);
        for (j = 0; j < numevents; j++) {
            aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
            int mask = eventLoop->fired[j].mask;
            int fd = eventLoop->fired[j].fd;
            int rfired = 0;

	    /* note the fe->mask & mask & ... code: maybe an already processed
             * event removed an element that fired and we still didn't
             * processed, so we check if the event is still valid. */
            if (fe->mask & mask & AE_READABLE) {
                rfired = 1;
                fe->rfileProc(eventLoop,fd,fe->clientData,mask);
            }
            if (fe->mask & mask & AE_WRITABLE) {
                if (!rfired || fe->wfileProc != fe->rfileProc)
                    fe->wfileProc(eventLoop,fd,fe->clientData,mask);
            }
            processed++;
        }
    }
    /* Check time events */
    if (flags & AE_TIME_EVENTS)
        processed += processTimeEvents(eventLoop);

    return processed; /* return the number of processed file/time events */
}
读完后可以看出,aeProcess先根据全局变量eventloop中的距离当前最近时间事件来设置事件驱动器aeApiPoll函数(其实就是select, epoll_wait, kevent等函数的时间参数)的超时参数,aeApiPoll函数的实现位于每一个I/O多路复用器的封装代码中(即ae_epoll.c, ae_evport.c, ae_select.c, ae_kqueue.c , ae_evport.c)。aeApiPoll函数执行后,将就绪文件事件返回到eventloop的fired成员中,然后依次处理就绪的文件事件,执行其回调函数。最后,检查定时任务链表(processTimeEvents函数), 执行时间任务。这就是redis服务器运行的大致主流程。

下面举一个例子,模拟一个tcp服务器处理30个客户socket。
假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:

1. 第一种选择: 按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡主,全班都会被耽误。
这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
2. 第二种选择:你 创建30个分身,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
3. 第三种选择,你 站在讲台上等,谁解答完谁举手。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。
这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用 非阻塞模式
这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是 事件驱动,所谓的reactor模式。

首先,要从你常用的IO操作谈起,比如read和write,通常IO操作都是阻塞I/O的,也就是说当你调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据。

&amp;lt;img src=&quot;https://i-blog.csdnimg.cn/blog_migrate/d440cb98e2e7a4b4776f9ba325b57336.png&quot; data-rawwidth=&quot;500&quot; data-rawheight=&quot;278&quot; class=&quot;origin_image zh-lightbox-thumb&quot; width=&quot;500&quot; data-original=&quot;https://pic2.zhimg.com/16ef4bcfbd8319535edeb45f597dfc61_r.png&quot;&amp;gt;

(图片来源:masterraghu.com/subjectl


这样,当服务器需要处理1000个连接的的时候,而且只有很少连接忙碌的,那么会需要1000个线程或进程来处理1000个连接,而1000个线程大部分是被阻塞起来的。由于CPU的核数或超线程数一般都不大,比如4,8,16,32,64,128,比如4个核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。这样是有问题的:
  1. 线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
  2. 线程的切换,或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。

那么,我们就要引入 非阻塞I/O的概念,非阻塞IO很简单,通过fcntl(POSIX)或ioctl(Unix)设为非阻塞模式,这时,当你调用read时,如果有数据收到,就返回数据,如果没有数据收到,就立刻返回一个错误,如EWOULDBLOCK。这样是不会阻塞线程了,但是你还是要不断的轮询来读取或写入。
&amp;lt;img src=&quot;https://i-blog.csdnimg.cn/blog_migrate/76ef014bda11acfcb7f0d5708c9c068c.png&quot; data-rawwidth=&quot;500&quot; data-rawheight=&quot;278&quot; class=&quot;origin_image zh-lightbox-thumb&quot; width=&quot;500&quot; data-original=&quot;https://pic4.zhimg.com/2cb0550b87ca28336d0411e58b45b013_r.png&quot;&amp;gt;

(图片来源:masterraghu.com/subjectl



于是,我们需要引入 IO多路复用的概念。多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。
&amp;lt;img src=&quot;https://i-blog.csdnimg.cn/blog_migrate/5c7bd1a0419f80c68a5b5128faf58cee.png&quot; data-rawwidth=&quot;500&quot; data-rawheight=&quot;272&quot; class=&quot;origin_image zh-lightbox-thumb&quot; width=&quot;500&quot; data-original=&quot;https://pic3.zhimg.com/9155e2307879cd7ce515e7a997b9d532_r.png&quot;&amp;gt;

(图片来源:masterraghu.com/subjectl

这样在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。

使用select函数的方式如下图所示:
&amp;lt;img src=&quot;https://i-blog.csdnimg.cn/blog_migrate/e278df225173274fbdacef6cd9ccdbc0.png&quot; data-rawwidth=&quot;475&quot; data-rawheight=&quot;467&quot; class=&quot;origin_image zh-lightbox-thumb&quot; width=&quot;475&quot; data-original=&quot;https://pic2.zhimg.com/bf52854bd1dc678de998b77aebaa2311_r.png&quot;&amp;gt;(图片来源: (图片来源: IBM Knowledge Center

I/O Multiplexing 的命名中 multiplexing 来自于通讯领域:在一个信道上传输多路信号或者数据流的技术[1]。

Linux 下的事件驱动的 I/O 编程之所以需要 multiplexing,是因为对 I/O ready 事件的通知是以一个监听集合为单位完成的。大家在用 select, poll, 和 epoll 时都是先创建这么一个集合,然后统一的 wait,一次 wait 可以获知多个 fd 的 I/O 事件。所以 multiplex 的是监听集合,并非 I/O 本身。实际发生操作的 I/O fd 都是可以并发读写互不干扰的,不存在 multiplexing 的问题。

参考链接:

1.zh.wikipedia.org/wiki/%

就是很多网络连接(多路),共(复)用少数几个(甚至是一个)线程。
连接很多的时候,不能每个连接一个线程,会耗尽系统内存的。线程也不能阻塞在任何一个连接上,等新的数据来,这样就不能及时响应其他连接发来的数据了;也不能用非阻塞方式,轮询所有的连接,这会浪费掉大量CPU时间;只能告诉系统,我对哪些连接感兴趣,有消息来的时候,通知我处理。
根据平台不同,可能会用到select/devpoll/epoll/kqueue/IOCP。要想了解更多,可以先上网查查它们。
指的是linux/unix/freebsd等系统下,调用select/poll/epoll/kqueue等函数来告知操作系统自己关心的描述符,让操作系统在这些描述符可读可写或异常时通知你。
可参考IO模型。

就是自己在用户态通过监听、轮询内核给出的低级IO事件来手动维护每一个IO流(和与之对应的上下文)的状态机,这样一个线程可以并发地交替地操纵多路IO流。其实内核本来也有IO状态机和并发机制(Blocking IO API + Threading),但是很多人怕线程多了context switch开销大,所以倾向于自己在用户态做

这个是编程世界里要性能不要可读性的经典案例

直白点说:
select/epoll函数就是多路复用器(multiplexing)

1) 早期的socket服务器是这样的:
ClientSocket s = accept(ServerSocket);  // 阻塞
   new Thread(s,function(){
         while( have data) {
            s.read(); // 阻塞
            s.send('xxxx');
         }
         s.close();
   });

核心是一个线程处理一个socket。

2) 现在的event-driven/proactor/reactor模式:
ClientSocket s = accept(ServerSocket);  // 阻塞
  waitting_set.add(s); 
  while(1) {
     set_have_data  ds = select(waitting_set); // 多路复用器,
                                               // 多路复用是说多个socket数据通路
                                               // 一个select就可以侦听
                                               // 就好像他们是一个数据通路一样。
     foreach(ds as item) {
          item.read();  // reactor这里不会阻塞,采用异步io,详情查linux aio
          item.send('xxx');
     }
      item.close();
  }

可以看到,第一,一个线程就完成了n个socket的读写,第二,多了select语句。

event-driven/proactor/reactor好处:
  1. 避免大量的线程创建工作,每个线程的创建工作都要消耗系统资源,比如每个线程至少要1M内存来维持其存在,32位的机器,不用thread pool,并发4千就挂了。
  2. 线程多了难免会更多的发生线程切换(context switch),线程切换要保存线程的stack,register,如果是进程切换,要清cpu的L1 cache,MMU的TLB,使得系统不必要的开销过大。
实现并发的技术中的一种,可以参考《深入理解计算机系统》第十二章
简单的说就是一个服务监听多个事件,
举个例子:你是一个话务员,你同时监听5个电话,所以当有任意一个电话时,你就得去拿起该电话,当然如果电话没有铃声,那么你就得一个个去查看,这个有没有,那个有没有,如果有铃声的话,那你就该喝茶喝茶,该完手机玩手机,有铃声了再去服务。。。。。。。
不知道说的明白不,但是最好还是先去查查资料吧,毕竟在这个答题框里回答的都很有限,而网上资源是无限的!
建议可以去查一下 epoll, selector 在linux的用法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值