所谓IO,就是内存读写的问题,只不过读写的目标不同,可能向磁盘读写,也可能向远程计算机读写,而在linux系统中,所有的外设都可以抽象为文件,所以在linux中IO问题等同于磁盘文件IO。在计算机中,用户态程序只能访问自己的内存区域,如若想访问磁盘文件内容必须通过操作系统为应用程序提供的统一接口:系统调用。所以真正的从磁盘中读取数据或写入数据的过程是由操作系统内核完成的,用户程序仅仅负责调用系统调用。针对IO过程中应用程序与系统内核之间工作方式的不同,通常分为五种IO方式。

  •     阻塞IO
  •     非阻塞IO
  •     IO多路复用
  •     信号驱动IO(SIGIO)
  •     异步IO

  注意!这五种IO方式是用户程序与操作系统内核之间来说的,主体是用户程序,客体是内核。

  站在用户程序的角度上来讲,用户程序调用了“读”的系统调用后,进行的操作有两步:

    1、等待读取的数据就绪;

    2、将数据从系统内核内存区域拷贝到用户程序的内存区域。

  对于Socket编程来说,第一步一般是等待数据从网络上传到本地,由内核程序存入内核的内存区域;第二步就是用户程序从内核内存区将数据拷贝至自己的内存数据区。

  明白这些之后就可以谈谈什么是阻塞IO

一、阻塞IO

  用户进程在调用IO系统调用后,进入Block状态(进程生命周期中的“阻塞”态),直到请求读取的数据读取完成,即IO操作完成,再由Block状态变为Runnable(包括Running和Ready)状态。

  进程处于阻塞态时会让出CPU,不会占用CPU资源,操作系统默认的IO方式就是阻塞IO,绝大部分程序也采用阻塞IO。java的IO如果不加设置或者不使用AIO,都是阻塞IO。

  此过程图解:

  java核心学习(三十二) 网络编程---五种IO方式以及select以及epoll方法的理解

 

 

    解释此图:一个进程调用 recvfrom ,然后系统调用并不返回知道有数据报到达本地系统,然后系统将数据拷贝到进程的缓存中。 (如果系统调用收到一个中断信号,则它的调用会被中断)我们称这个进程在调用recvfrom一直到从recvfrom返回这段时间是阻塞的。当recvfrom正常返回时,我们的进程继续它的操作。

二、非阻塞IO

  非阻塞IO就是用户进程调用IO系统调用,如果被读取的数据没有准备好,则立即返回,进程不阻塞,继续执行下面的代码。所以非阻塞IO为了确保可以IO到数据,一般会采用轮询的方式,不断进行非阻塞IO的系统调用,直到数据就绪,再由操作系统执行完IO数据拷贝过程,然后正常返回。

  对应到Socket的非阻塞IO,图示如下:

    java核心学习(三十二) 网络编程---五种IO方式以及select以及epoll方法的理解

 

    再上图中,我们开始对 recvfrom 的三次调用(当然实际的情况即此都有可能),因为系统还没有接收到网络数据,所以内核马上返回一个 EWOULDBLOCK的错误。
第四次我们调用 recvfrom 函数,一个数据报已经到达了,内核将它拷贝到我们的应用程序的缓冲区中,然后 recvfrom 正常返回,我们就可以对接收到的数据进行处理了。
当一个应用程序使用了非阻塞模式的套接字,它需要使用一个循环来不听的测试是否一个文件描述符有数据可读(称做 polling(轮询))。应用程序不停的 polling 内核来检查是否 I/O操作已经就绪。一般情况下,这将是一个极浪费 CPU资源的操作,但是在大量短连接请求的http服务器中,由于并发请求数很高,所以几乎每一次recvfrom调用都有数据拷贝,所以也不会十分浪费CPU资源。

    由上图还可以很清晰的看出,非阻塞IO指的是数据还未准备好时,进程不阻塞,而内核在进行真正的IO过程时,进程还是阻塞着的。

三、IO多路复用(针对批量操作时,IO多路复用非常有优势)

  IO多路复用方式由select()系统调用或poll()或epoll()来实现(下面会说道它们的区别),本质上是将访问数据是否就绪的函数与读取数据的函数分开。

    当程序使用阻塞式的IO多路复用的方式进行IO时,先调用select()(poll()、epoll()),进程进入阻塞,当数据就绪后,该系统调用返回可用的文件描述符(文件句柄fd),然后再通过recvfrom函数进行拷贝数据的操作。

    注意,这里使用了两个系统调用,而上面两种IO方式是使用一个系统调用recvfrom,如我们开始对 recvfrom 的三次调用果系统内只有这一个线程,调用select后调用recvfrom,完成了IO操作。但是一般网络服务器都是多线程工作,select调用完成后,该进程的这个recvfrom函数可能不会被马上调用,所以当recvfrom被调用的时候不能够保证fd还处于可用状态,由此整个进程会被阻塞,这个进程的所有线程一起阻塞,所以多线程环境下一般不会使用阻塞IO搭配IO多路复用。惊群现象,就是一个典型场景,多个进程或者线程通过 select 或者 epoll 监听一个 listen socket,当有一个新连接完成三次握手之后,所有进程都会通过 select 或者 epoll 被唤醒,但是最终只有一个进程或者线程 accept 到这个新连接,若是采用了阻塞 I/O,没有accept 到连接的进程或者线程就 block 住了。

    还有在边缘触发环境下,最好用非阻塞IO。举个例子,假定经过长时间的沉默后,现在来了100个字节,这时无论边缘触发和条件触发都会产生一个read ready notification。大家常用的select就是属于条件触发这一类,长期关注socket写事件会出现CPU 100%的毛病。

    多路复用的高级之处在于:它能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

    java核心学习(三十二) 网络编程---五种IO方式以及select以及epoll方法的理解

 

IO 多路技术一般在下面这些情况中被使用:
1、当一个客户端需要同时处理多个文件描述符的输入输出操作的时候(一般来说是标准的输入输出和网络套接字),I/O 多路复用技术将会有机会得到使用。
2、当程序需要同时进行多个套接字的操作的时候。
3、如果一个 TCP 服务器程序同时处理正在侦听网络连接的套接字和已经连接好的套接字。
4、如果一个服务器程序同时使用 TCP 和 UDP 协议。
5、如果一个服务器同时使用多种服务并且每种服务可能使用不同的协议(比如 inetd就是这样的)。

四、信号驱动IO

  java核心学习(三十二) 网络编程---五种IO方式以及select以及epoll方法的理解

 

  再经过上面的讨论之后,直接看图已经很直观了,博主多此了解较少,以后详细学习了再补充。

五、异步IO

  当我们运行在异步 I/O 模式下时,我们如果想进行 I/O 操作,只需要告诉内核我们要进行 I/O 操作,然后内核会马上返回。具体的 I/O 和数据的拷贝全部由内核来完成,我们的程序可以继续向下执行。当内核完成所有的 I/O 操作和数据拷贝后,内核将通知我们的程序。
  异步 I/O 和 信号驱动I/O的区别是:
    1、信号驱动 I/O 模式下,内核在操作可以被操作的时候通知给我们的应用程序发送SIGIO 消息。
    2、异步 I/O 模式下,内核在所有的操作都已经被内核操作结束之后才会通知我们的应用程序。

  java核心学习(三十二) 网络编程---五种IO方式以及select以及epoll方法的理解

 

六、Epoll模型
   Linux 2.6内核中提高网络I/O性能的新方法-epoll I/O多路复用技术在比较多的TCP网络服务器中有使用,即比较多的用到select函数。

  1、为什么select落后
  首先,在Linux内核中,select所用到的FD_SET是有限的,即内核中有个参数__FD_SETSIZE定义了每个FD_SET的句柄个数,在 我用的2.6.15-25-386内核中,该值是1024,搜索内核源代码得到:

include/linux/posix_types.h:
#define __FD_SETSIZE 1024


  也就是说,如果想要同时检测1025个句柄的可读状态是不可能用select实现的。或者 同时检测1025个句柄的可写状态也是不可能的。其次,内核中实现 select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄,显然,select函数执行时间与FD_SET中的句柄个数有一个比例关系,即 select要检测的句柄数越多就会越费时。当然,在前文中我并没有提及poll方法,事实上用select的朋友一定也试过poll,我个人觉得 select和poll大同小异,个人偏好于用select而已。

  2、内核中提高I/O性能的新方法epoll
  epoll是什么?按照man手册的说法:是为处理大批量句柄而作了改进的poll。要使用epoll只需要这三个系统调 用:epoll_create(2), epoll_ctl(2), epoll_wait(2)。
当然,这不是2.6内核才有的,它是在 2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)

  从理论上说,AIO似乎是最高效的,你的IO操作可以立即返回,然后等待os告诉你IO操作完成。但是一直以来,如何实现就没有一个完美的方 案。最著名的windows完成端口实现的AIO,实际上也是内部用线程池实现的罢了,最后的结果是IO有个线程池,你应用也需要一个线程池…… 很多文档其实已经指出了这带来的线程context-switch带来的代价。在linux 平台上,关于网络AIO一直是改动最多的地方,2.4的年代就有很多AIO内核patch,最著名的应该算是SGI那个。但是一直到2.6内核发布,网络 模块的AIO一直没有进入稳定内核版本(大部分都是使用用户线程模拟方法,在使用了NPTL的linux上面其实和windows的完成端口基本上差不多 了)。2.6内核所支持的AIO特指磁盘的AIO—支持io_submit(),io_getevents()以及对Direct IO的支持(就是绕过VFS系统buffer直接写硬盘,对于流服务器在内存平稳性上有相当帮助)。
  所以,剩下的select模型基本上就是我们在linux上面的唯一选择,其实,如果加上no-block socket的配置,可以完成一个”伪”AIO的实现,只不过推动力在于你而不是os而已。不过传统的select/poll函数有着一些无法忍受的缺 点,所以改进一直是2.4-2.5开发版本内核的任务,包括/dev/poll,realtime signal等等。最终,Davide Libenzi开发的epoll进入2.6内核成为正式的解决方案

  3、epoll的优点
    <1>支持一个进程打开大数 目的socket描述符(FD)
    select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

    <2>IO 效率不随FD数目增加而线性下降
    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行 操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。

    <3>使用mmap加速内核 与用户空间的消息传递。
    这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。

    <4>内核微调
    这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑 linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时 期动态调整这个内存pool(skb_head_pool)的大小— 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手 的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网 卡驱动架构。

  4、epoll的工作模式
  令人高兴的是,2.6内核的epoll比其2.5开发版本的/dev/epoll简洁了许多,所以,大部分情况下,强大的东西往往是简单的。唯一有点麻烦 是epoll有2种工作方式:LT和ET。
    LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你 的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.
    ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述 符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致 了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。
    epoll只有epoll_create,epoll_ctl,epoll_wait 3个系统调用,具体用法请参考http://www.xmailserver.org/linux-patches/nio-improve.html ,在http://www.kegel.com/rn/也有一个完整的例子,大家一看就知道如何使用了

  5、 epoll的使用方法
  首先通过create_epoll(int maxfds)来创建一个epoll的句柄,其中maxfds为你epoll所支持的最大句柄数。这个函数会返回一个新的epoll句柄,之后的所有操作 将通过这个句柄来进行操作。在用完之后,记得用close()来关闭这个创建出来的epoll句柄。之后在你的网络主循环里面,每一帧的调用 epoll_wait(int epfd, epoll_event events, int max events, int timeout)来查询所有的网络接口,看哪一个可以读,哪一个可以写了。基本的语法为:

nfds = epoll_wait(kdpfd, events, maxevents, -1);


  其中kdpfd为用epoll_create创建之后的句柄,events是一个 epoll_event*的指针,当epoll_wait这个函数操作成功之后,epoll_events里面将储存所有的读写事件。 max_events是当前需要监听的所有socket句柄数。最后一个timeout是 epoll_wait的超时,为0的时候表示马上返回,为-1的时候表示一直等下去,直到有事件范围,为任意正整数的时候表示等这么长的时间,如果一直没 有事件,则范围。一般如果网络主循环是单独的线程的话,可以用-1来等,这样可以保证一些效率,如果是和主逻辑在同一个线程的话,则可以用0来保证主循环 的效率。

  Epoll模型主要负责对大量并发用户的请求进行及时处理,完成服务器与客户端的数据交互。其具体的实现步骤如下:
    (a) 使用epoll_create()函数创建文件描述,设定将可管理的最大socket描述符数目。
    (b) 创建与epoll关联的接收线程,应用程序可以创建多个接收线程来处理epoll上的读通知事件,线程的数量依赖于程序的具体需要。
    (c) 创建一个侦听socket描述符ListenSock;将该描述符设定为非阻塞模式,调用Listen()函数在套接字上侦听有无新的连接请求,在 epoll_event结构中设置要处理的事件类型EPOLLIN,工作方式为 epoll_ET,以提高工作效率,同时使用epoll_ctl()注册事件,最后启动网络监视线程。
    (d) 网络监视线程启动循环,epoll_wait()等待epoll事件发生。
    (e) 如果epoll事件表明有新的连接请求,则调用accept()函数,将用户socket描述符添加到epoll_data联合体,同时设定该描述符为非 阻塞,并在epoll_event结构中设置要处理的事件类型为读和写,工作方式为epoll_ET.
    (f) 如果epoll事件表明socket描述符上有数据可读,则将该socket描述符加入可读队列,通知接收线程读入数据,并将接收到的数据放入到接收数据 的链表中,经逻辑处理后,将反馈的数据包放入到发送数据链表中,等待由发送线程发送。
  C++语言: epoll使用方法1:

//epoll_wait范围之后应该是一个循环,遍利所有的事件:
for (n = 0; n < nfds; ++n) 
{
    if (events[n].data.fd == listener) 
    {
        //如果是主socket的事件的话,则表示有新连接进入了,进行新连接的处理。
        client = accept (listener, (struct sockaddr *) &local, &addrlen); 
        if (client < 0) 
        { 
            perror ("accept"); 
            continue; 
        } 
        setnonblocking (client); 
        // 将新连接置于非阻塞模式 
        ev.events = EPOLLIN | EPOLLET; 
        // 并且将新连接也加入EPOLL的监听队列。 
        //注意,这里的参数EPOLLIN | EPOLLET并没有设置对写socket的监听, 
        //如果有写操作的话,这个时候epoll是不会返回事件的, 
        //如果要对写操作也监听的话,应该是EPOLLIN | EPOLLOUT | EPOLLET
        ev.data.fd = client; 
        if (epoll_ctl (kdpfd, EPOLL_CTL_ADD, client, &ev) < 0) 
        { 
            /*设置好event之后,将这个新的event通过epoll_ctl加入到epoll的监听队列里面,这里用EPOLL_CTL_ADD来加一个新的epoll事件,通过EPOLL_CTL_DEL来减少一个epoll事件,通过EPOLL_CTL_MOD来改变一个事件的监听方式. */ 
            fprintf (stderr, "epoll set insertion error:fd=%d", client); 
            return -1; 
        } 
    } 
    else 
    // 如果不是主socket的事件的话,则代表是一个用户socket的事件, 
    do_use_fd (events[n].data.fd); 
    //则来处理这个用户socket的事情,比如说read(fd,xxx)之类的,或者一些其他的处理。 
} 

  对,epoll的操作就这么简单,总共不过4个 API:epoll_create, epoll_ctl, epoll_wait和close。 以前公司的服务器都是使用HTTP连接,但是这样的话,在手机目前的网络情况下不但显得速度较慢,而且不稳定。虽然使用SOCKET之后,对于用户的费用可能会增加(由于是用了CMNET而非CMWAP),但是,秉着用户体验至上的原则, 相信大家还是能够接受的。 这次的服务器设计中,最重要的一个突破,是使用了EPOLL模型, 虽然对之也是一知半解,但是既然在各大PC网游中已经经过了如此严酷的考验,相信他不会让我们失望,使用后的结果,确实也是表现相当不错。在这里,我还是 主要大致介绍一下这个模型的结构。

  6、Linux下EPOll编程实例 EPOLL模型似乎只有一种格式,所以大家只要参考我下面的代码, 就能够对EPOLL有所了解了,代码的解释都已经在注释中:

    C++语言:

 while (TRUE) 
{ 
    int nfds = epoll_wait (m_epoll_fd, m_events, MAX_EVENTS, EPOLL_TIME_OUT); //等待EPOLL时间的发生,相当于监听, 
    //至于相关的端口,需要在初始化EPOLL的时候绑定。 
    if (nfds <= 0) 
        continue; 
    m_bOnTimeChecking = FALSE; 
    G_CurTime = time (NULL); 
    for (int i = 0; i < nfds; i++) 
    { 
        try 
        { 
            if (m_events[i].data.fd == m_listen_http_fd) //如果新监测到一个HTTP用户连接到绑定的HTTP端口, 
            //建立新的连接。由于我们新采用了SOCKET连接,所以基本没用。 
            { 
                OnAcceptHttpEpoll (); 
            } 
            else if (m_events[i].data.fd == m_listen_sock_fd) //如果新监测到一个SOCKET用户连接到了绑定的SOCKET端口, 
            //建立新的连接。 
            { 
                OnAcceptSockEpoll (); 
            } 
            else if (m_events[i].events & EPOLLIN) //如果是已经连接的用户,并且收到数据,那么进行读入。 
            { 
                OnReadEpoll (i); 
            } 
            OnWriteEpoll (i); //查看当前的活动连接是否有需要写出的数据。 
        } 
        catch (int) 
        { 
            PRINTF ("CATCH捕获错误\n"); 
            continue; 
        } 
    } 
    m_bOnTimeChecking = TRUE; 
    OnTimer (); //进行一些定时的操作,主要就是删除一些短线用户等。 
} 

 

 

  epoll优势

    <1>支持一个进程打开大数 目的socket描述符(FD)
    <2>IO 效率不随FD数目增加而线性下降
    <3>使用mmap加速内核 与用户空间的消息传递。
    <4>内核微调
    工作模式 LT ET 在通知次数上区别(重复直到IO操作/只通知一次)

分类:

技术点:

相关文章: