首先他们都属于IO复用模型,I/O多路复用模型就是通过一种机制,一个线程可以监视多个文件描述符,一旦有一个或多个fd有事件发生就通知应用程序,然后进行相应fd的读写等操作。在linux或者unix操作系统中,一切皆文件,socket也不例外,socket会生成的对应的文件描述符。

1、什么是IO多路复用

「定义」

  • IO多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。

    2、为什么有IO多路复用机制?

    没有IO多路复用机制时,有BIO、NIO两种实现方式,但有一些并发性能问题

    select

    select(…) 实现原理

    该函数准许进程指示内核等待多个事件中的任何一个发送,并只在有一个或多个事件发生或经历一段指定的时间后才唤醒。函数原型如下: ```c

    include

    include

int select( int maxfdp1, fd_set readset, fd_set writeset, fd_set exceptset, struct timeval timeout )

  1. 返回值:就绪文件描述符的数目,超时返回0,出错返回-1<br />**函数参数介绍如下:**<br />(1)第一个参数maxfdp1指定待检查的文件描述符个数,它的值是待检查的最大文件描述符加1(因此把该参数命名为maxfdp1),描述字012...maxfdp1-1均将被检查。<br />因为文件描述符是从0开始的。<br />(2)中间的三个参数readsetwritesetexceptset指定我们要让内核检查读、写和异常条件的文件描述符。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
  2. ```c
  3. void FD_ZERO(fd_set *fdset); //清空集合
  4. void FD_SET(int fd, fd_set *fdset); //将一个给定的文件描述符加入集合之中
  5. void FD_CLR(int fd, fd_set *fdset); //将一个给定的文件描述符从集合中删除
  6. int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写

(3)timeout告知内核等待所指定文件描述符中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。

  1. struct timeval{
  2. long tv_sec; //seconds
  3. long tv_usec; //microseconds
  4. };

这个参数有三种可能:
(1)永远等待下去:仅在有一个文件描述符准备好I/O时才返回。为此,把该参数设置为空指针NULL。
(2)等待一段固定时间:在有一个文件描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
(3)根本不等待:检查描述字后立即返回,这称为轮询。为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。

select函数的调用过程

(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数pollwait
(3)遍历所有fd
  调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是
pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。

每次调用kernel 的 select函数,都会涉及到用户态/内核态的切换还需要传递需要检查的socket集合,其实就是需要检查的fd(文件描述符)集合。
操作系统这个select函数被调用以后,首先会去fd集合中去检查内存中socket套接字的状态,这个时间复杂度是O(N)的,然后检查完一遍之后,如果有就绪状态的socket,那么就会直接返回,不会阻塞当前线程。否则的话,那个就说明当前指定fd集合对应的socket没有就绪状态,那么就需要阻塞当前调用线程了,直到有某个socket有数据之后,才唤醒线程。
select(…) 对监听socket有1024的大小限制
这个是因为fd集合这个结构是一个bitmap位图的结构,这个位图结构就是一个长的二进制数,类似0101这种,这个bitmap默认长度是1024个bit,想要修改长度非常麻烦,需要重新编译操作系统内核。
处于某种性能考虑,select函数做了两件事
第一件事,跑到就绪状态的socket对应的fd文件中设置一个标记mask,表示这个fd对应的socket就绪了。
第二件事,返回select函数,对应的也就是唤醒java线程,站在java层面,他会收到一个int结果值,表示有几个socket处于就绪状态。所以接下来会是一个O(N)的系统调用,检查fd集合中每一个socket的就绪状态,其实就是检查文件系统中指定socket的文件描述符的状态,涉及到用户态和内核态的来回切换,如果bitmap再大,就非常耗费性能。还有就是系统调用涉及到参数的数据拷贝,如果数据太庞大,他也不利于系统的调用速度。

select睡眠和唤醒过程

如果select (…) 遍遍历 O(N) 去检查未发现就绪的socket ,则将用户进程插入到该设备驱动对应资源的等待队列中,等待设备驱动唤醒。

 select巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。

select睡眠过程

  select会循环遍历它所监测的fd_ set内的所有文件描述符对应的驱动程序的poll函数。
  驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。 
  当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。

select唤醒过程

后续某个socket就绪后,通过设备驱动唤醒。
唤醒该进程的过程通常是在所监测文件的设备驱动内实现的。
驱动程序维护了针对自身资源读写的等待队列。当设备驱动发现自身资源变为可读写并且有进程睡眠在该资源的等待队列上时,就会唤醒这个资源等待队列上的进程。
详细过程如下:
假设我们客户端往当前服务器发送了数据,数据通过网线到网卡,网卡再到DMA硬件的这种方式直接将数据写到内存里面,然后整个过程,CPU是不参与的

  • 当传输完成以后,它就会触发网络数据传输完毕的中断程序,这个中断程序它会把cpu正在执行的进程给顶掉,然后cpu就会执行咱这个中断程序的逻辑
    • 对应的逻辑是:根据内存中的数据包,然后分析出来数据包是哪个socket的数据,
    • 同时tcp/ip它又是保证传输的时候是有端口号的,然后根据端口号就能找到对用的socket实例,找到socket实例以后,就会把数据导入到socket读缓冲里面
    • 导入完成以后,它就开始去检查socket等待队列,看是不是有等待者,如果有等待者的话,就会把等待者移动到工作队列里面去,中断程序到这一步就执行完了
    • 这样咱们的进程就又回到了工作队列,又有机会获取到cpu时间片了
  • 然后当前进程执行的select函数再次检查,就会发现这个就绪的socket了,就会给就绪的socket的fd文件描述符打标记,然后select函数就执行完了,返回到java层面就涉及到内核态和用户态的转换,后面的事情就是轮询检查每一个socket的fd是否被打了标记,然后就是处理被打了标记的socket就ok了

    操作系统调度

  • cpu同一时刻,它只能运行一个进程,操作系统做主要的任务就是系统调度,就是有n个进程,然后让这n个进程在cpu上切换进行

  • 未挂起的进程都在工作队列内,都有机会获取到cpu的执行权
  • 挂起的进程就会从这个工作队列里移除出去,反映到咱们java层面就是线程阻塞了
  • linux系统线程其实就是轻量级进程

    操作系统中断

  • 比如说,咱们用键盘打字,如果cpu正执行其他程序,一直不释放,那咱这个打字就也没法打了

  • 咱们都知道,不是这样的情况,因为就是有系统中断的存在,当按下一个键以后会给主板发送一个电流信号,主板感知到以后,它就会触发这个cpu中断、
  • 所以中断 其实就是让cpu给正在执行的进程先保留程序上下文,然后避让出cpu,给中断程序绕道
  • 中断程序就会拿到cpu的执行权限,进行相应代码的执行,比如说键盘的中断程序,就会执行输出的逻辑。

    select的缺点

  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
  3. select支持的文件描述符数量太小了,默认是1024

    poll

    poll() 和 select()区别

    select 用的是bitmap ,它表示需要检查的socket集合
    poll 使用的是 链表结构,表示需要检查的socket集合(主要是为了解决socket监听长度超过1024的socket的限制)


    poll本质和select没有区别,但其采用链表存储,解决了select最大连接数存在限制的问题,但其也是采用遍历的方式来判断是否有设备就绪,所以效率比较低,另外一个问题是大量的fd数组在用户空间和内核空间之间来回复制传递,也浪费了不少性能。
    epoll和kqueue是更先进的IO复用模型,其也没有最大连接数的限制(1G内存,可以打开约10万左右的连接),并且仅仅使用一个文件描述符,就可以管理多个文件描述符,并且将用户关系的文件描述符的事件存放到内核的一个事件表中(底层采用的是mmap的方式),这样在用户空间和内核空间的copy只需一次。另外这种模型里面,采用了类似事件驱动的回调机制或者叫通知机制,在注册fd时加入特定的状态,一旦fd就绪就会主动通知内核。这样以来就避免了前面说的无脑遍历socket的方法,这种模式下仅仅是活跃的socket连接才会主动通知内核,所以直接将时间复杂度降为O(1)。

最后来聊聊windows的iocp的异步IO模型,目前很少有支持asynchronous I/O的系统,即使windows上的iocp非常出色,但由于其系统本身的局限性和微软的之前的闭源策略,导致主流市场大部分用的还是unix系统,与mac系统的kqueue和linux系统的epoll相比,iocp做到了真正的纯异步io的概念,即在io操作的第二阶段也不阻塞应用程序,但性能好坏,其实取决于copy数据的大小,如果数据包本来就很小,其实这种优化无足轻重,而kqueue与epoll已经做得很优秀了,所以这可能也是unix或者mac系统至今都没有实现纯异步的io模型主要原因。

epoll

select 和 poll 的共有缺陷

  1. 第一个缺陷: select 和 poll 函数,这两系统函数每次调用都需要我们提供给它所有的需要监听的socket文件描述符集合,而且主线程是死循环调用select/poll函数的,这里面涉及到用户空间数据到内核空间拷贝的过程
    • 咱们需要监听的socket集合,数据变化非常小
    • 每次就一到两个socket_fd需要更改,但是没有办法,因为select和poll函数,只是一个很单纯的函数
    • 它在kernel层面,不会保留任何的数据信息,所以说每次调用都进行了数据拷贝
  2. 第二个缺陷: select 和 poll 函数它的返回值都是int整型值,只能代表有几个socket就绪或者有错误了,它没办法表示具体是哪个socket就绪了
    • 这就导致了程序被唤醒以后,还需要新的一轮系统调用去检查哪个socket是就绪状态的,然后再进行socket数据处理逻辑,这里走了不少弯路(同时还存在用户态和内核态的切换,这样缺陷就更严重了)

epoll 就是为了解决这两个问题

epoll与select、epoll对比优化:

  1. 相比于select,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。因为在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。
  2. 相对于select和poll来说,epoll更加灵活,没有描述符限制。
  3. epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll是怎么优化select问题的

1,每次发生事件它不需要循环遍历所有文件描述符,它把发生变化的文件描述符单独集中到了就绪列表中。
2,仅向操作系统传递1次监视对象信息,监视范围或内容发生变化时只通知发生变化的事项。

epoll (…) 实现原理

  epoll操作过程需要三个接口,分别如下:

  1. int epoll_create(int size);
  2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  3. int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout);

  首先要调用epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。
  epoll_ctl可以操作上面建立的epoll,例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。
  epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。

epoll_create函数

  1. /**
  2. * @brief 该函数生成一个epoll专用的文件描述符。它其实是在内核申请一空间,用来存放你想关注的socket fd上是否发生以及发生了什么事件。
  3. * * @param size size就是你在这个epoll fd上能关注的最大socket fd数
  4. * * @return 返回生成的文件描述符
  5. */
  6. int epoll_create(int size);

创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值。
  需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
[

](https://blog.csdn.net/lixungogogo/article/details/52226479)

epoll_ctl函数

  1. /**
  2. * @brief 该函数用于控制某个epoll文件描述符上的事件,可以注册事件,修改事件,删除事件。
  3. * * @param epfd 由 epoll_create 生成的epoll专用的文件描述符
  4. * @param op 要进行的操作例如注册事件,
  5. EPOLL_CTL_ADD 注册新的fd到epfd中;
  6. EPOLL_CTL_MOD 修改已经注册的fd的监听事件
  7. EPOLL_CTL_DEL 从epfd中删除一个fd
  8. * @param fd 关联的文件描述符
  9. * @param event 指向epoll_event的指针
  10. * * @return 0 succ
  11. * -1 fail
  12. */
  13. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件epoll的事件注册函数.
  它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
  第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:

  1. EPOLL_CTL_ADD:注册新的fdepfd中;
  2. EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
  3. EPOLL_CTL_DEL:从epfd中删除一个fd

  第三个参数是需要监听的fd。
  第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:

  1. typedef union epoll_data {
  2. void *ptr;
  3. int fd;
  4. __uint32_t u32;
  5. __uint64_t u64;
  6. } epoll_data_t;
  7. struct epoll_event {
  8. __uint32_t events;
  9. /* Epoll events */
  10. epoll_data_t data;
  11. /* User data variable */
  12. };

events可以是以下几个宏的集合:

  1. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
  2. EPOLLOUT:表示对应的文件描述符可以写;
  3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  4. EPOLLERR:表示对应的文件描述符发生错误;
  5. EPOLLHUP:表示对应的文件描述符被挂断;
  6. EPOLLET EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  7. EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,
  8. 需要再次把这个socket加入到EPOLL队列里

例:

  1. struct epoll_event ev;
  2. //设置与要处理的事件相关的文件描述符
  3. ev.data.fd=listenfd;
  4. //设置要处理的事件类型
  5. ev.events=EPOLLIN|EPOLLET;
  6. //注册epoll事件
  7. epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);

epoll_wait函数

  1. /**
  2. * @brief 该函数用于轮询I/O事件的发生
  3. * * @param epfd 由epoll_create 生成的epoll专用的文件描述符
  4. * @param events 用于回传代处理事件的数组
  5. * @param maxevents 每次能处理的事件数
  6. * @param timeout 等待I/O事件发生的超时值;-1相当于阻塞,0相当于非阻塞。一般用-1即可
  7. * * @return >=0 返回发生事件数
  8. * -1 错误
  9. */
  10. int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout);

  等待事件的产生,类似于select()调用。
  参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epollcreate()时的size。
  参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
  epoll_wait范围之后应该是一个循环,遍历所有的事件。
  我们调用epoll
wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epollctl中拿到了要监控的句柄列表。
  所以,实际上在你调用epoll
create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。
  在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。

epoll实现机制

  epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket。
  这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。
  这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层。
  简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。
  epoll的高效就在于,当我们调用epoll ctl往里塞入百万个句柄时,epoll wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。
  这是由于我们在调用epoll create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件.
  当epoll wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。
  而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。
  那么,这个准备就绪list链表是怎么维护的呢?
  当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。
  所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。
  如此,一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
  执行epoll
create时,创建了红黑树和就绪链表,执行epoll_ ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

条件触发和边缘触发

工作模式
  epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:

LT模式:
  当epoll_ wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:
  当epoll_ wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

  ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
  epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
  那么ET模式是怎么做到的呢?

ET模式的原理
  当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表。
  最后,epoll
wait检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。
  所以,非ET的句柄,只要它上面还有事件,epoll_ wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会次次从epoll_wait返回的。

epoll优点

  1. 支持一个进程打开大数目的socket描述符(FD)

  select最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是1024/2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

  1. IO效率不随FD数目增加而线性下降

  传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在Linux内核。

  1. 使用mmap加速内核与用户空间的消息传递。

  这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核与用户空间mmap同一块内存实现的。

IOCP理解与应用

扯远点。首先传统服务器的网络IO流程如下: 接到一个客户端连接->创建一个线程负责这个连接的IO操作->持续对新线程进行数据处理->全部数据处理完毕->终止线程。 但是这样的设计代价是:

  • 1:每个连接创建一个线程,将导致过多的线程。
    • 2:维护线程所消耗的堆栈内存过大。
      • 3:操作系统创建和销毁线程过大。
        • 4:线程之间切换的上下文代价过大。

此时我们可以考虑使用线程池解决其中3和4的问题。这种传统的服务器网络结构称之为会话模型。 后来我们为防止大量线程的维护,创建了I/O模型,它被希望要求可以: 1:允许一个线程在不同时刻给多个客户端进行服务。 2:允许一个客户端在不同时间被多个线程服务。
这样做的话,我们的线程则会大幅度减少,这就要求以下两点: 1:客户端状态的分离,之前会话模式我们可以通过线程状态得知客户端状态,但现在客户端状态要通过其他方式获取。 2:I/O请求的分离。一个线程不再服务于一个客户端会话,则要求客户端对这个线程提交I/O处理请求。
那么就产生了这样一个模式,分为三部分:

  • 1:会话状态管理模块。它负责接收到一个客户端连接,就创建一个会话状态。
    • 2:当会话状态发生改变,例如断掉连接,接收到网络消息,就发送一个I/O请求给 I/O工作模块进行处理。
      • 3:I/O工作模块接收到一个I/O请求后,从线程池里唤醒一个工作线程,让该工作线程处理这个I/O请求,处理完毕后,该工作线程继续挂起。

上面的做法,则将网络连接 和I/O工作线程分离为三个部分,相互通讯仅依靠 I/O请求。此时可知有以下一些建议:

  • 1:在进行I/O请求处理的工作线程是被唤醒的工作线程,一个CPU对应一个的话,可以最大化利用CPU。所以 活跃线程的个数 建议等于 硬件CPU个数。
    • 2:工作线程我们开始创建了线程池,免除创建和销毁线程的代价。因为线程是对I/O进行操作的,且一一对应,那么当I/O全部并行时,工作线程必须满足I/O并行操作需求,所以 线程池内最大工作线程个数 建议大于或者等于 I/O并行个数。
      • 3:但是我们可知CPU个数又限制了活跃的线程个数,那么线程池过大意义很低,所以按常规建议 线程池大小 等于 CPU个数*2 左右为佳。例如,8核服务器建议创建16个工作线程的线程池。 上面描述的依然是I/O模型并非IOCP,那么IOCP是什么呢,全称 IO完成端口。

它是一种WIN32的网络I/O模型,既包括了网络连接部分,也负责了部分的I/O操作功能,用于方便我们控制有并发性的网络I/O操作。它有如下特点:

  • 1:它是一个WIN32内核对象,所以无法运行于Linux.
    • 2:它自己负责维护了工作线程池,同时也负责了I/O通道的内存池。
      • 3:它自己实现了线程的管理以及I/O请求通知,最小化的做到了线程的上下文切换。
        • 4:它自己实现了线程的优化调度,提高了CPU和内存缓冲的使用率。

使用IOCP的基本步骤很简单:

  • 1:创建IOCP对象,由它负责管理多个Socket和I/O请求。CreateIoCompletionPort需要将IOCP对象和IOCP句柄绑定。
    • 2:创建一个工作线程池,以便Socket发送I/O请求给IOCP对象后,由这些工作线程进行I/O操作。注意,创建这些线程的时候,将这些线程绑定到IOCP上。
      • 3:创建一个监听的socket。
        • 4:轮询,当接收到了新的连接后,将socket和完成端口进行关联并且投递给IOCP一个I/O请求。注意:将Socket和IOCP进行关联的函数和创建IOCP的函数一样,都是CreateIoCompletionPort,不过注意传参必然是不同的。
          • 5:因为是异步的,我们可以去做其他,等待IOCP将I/O操作完成会回馈我们一个消息,我们再进行处理。
          • 其中需要知道的是:I/O请求被放在一个I/O请求队列里面,对,是队列,LIFO机制。当一个设备处理完I/O请求后,将会将这个完成后的I/O请求丢回IOCP的I/O完成队列。
          • 我们应用程序则需要在GetQueuedCompletionStatus去询问IOCP,该I/O请求是否完成。
          • 其中有一些特殊的事情要说明一下,我们有时有需要人工的去投递一些I/O请求,则需要使用PostQueuedCompletionStatus函数向IOCP投递一个I/O请求到它的请求队列中。

            IOCP和Epoll之间的异同

            异: 1:IOCP是WINDOWS系统下使用。Epoll是Linux系统下使用。
            2:IOCP是IO操作完毕之后,通过Get函数获得一个完成的事件通知。 Epoll是当你希望进行一个IO操作时,向Epoll查询是否可读或者可写,若处于可读或可写状态后,Epoll会通过epoll_wait进行通知。
            3:IOCP封装了异步的消息事件的通知机制,同时封装了部分IO操作。但Epoll仅仅封装了一个异步事件的通知机制,并不负责IO读写操作。Epoll保持了事件通知和IO操作间的独立性,更加简单灵活。
            4:基于上面的描述,我们可以知道Epoll不负责IO操作,所以它只告诉你当前可读可写了,并且将协议读写缓冲填充,由用户去读写控制,此时我们可以做出额外的许多操作。IOCP则直接将IO通道里的读写操作都做完了才通知用户,当IO通道里发生了堵塞等状况我们是无法控制的。
            同: 1:它们都是异步的事件驱动的网络模型。 2:它们都可以向底层进行指针数据传递,当返回事件时,除可通知事件类型外,还可以通知事件相关数据。

            IO设计模式

            从上面的几种io机制可以看出来,不同的平台实现的io模型可能都不一样,实际上不管哪一种模型,这中间都可以抽象一层API出来,提供一致的接口,目的是为了更好的支持跨平台编程语言的调用,屏蔽操作系统的差异性。这其中广为人知的有C++的ACE,Libevent这些,他们都是跨平台的,而且他们自动选择最优的I/O复用机制,用户只需调用接口即可。IO模型的抽象,总得来说有两种设计模式,分别是Reactor and Proactor模式,这里不在细说。在Java里面,io版本经历了bio,nio,aio的演变,这个我在上篇文章已经介绍过,其实对应io模型,分别是阻塞io,非阻塞io,异步io,这里需要注意的是异步io仅仅在windows上支持,在linux上还是基于epoll的实现的,并非纯异步。

            总结

            本篇文章结合了io的五种模型,分析了各个主流操作系统的io实现机制并对比了其优缺点,编程语言的io接口,其实是依赖底层的操作系统的实现,为了兼容不同平台的io调用,这里面出现了两种关于高性能io的设计模式,分别是Reactor and Proactor,其都是采用多路复用的思想,来设计抽象IO接口,这个我们在下篇文章会介绍。

            1.1、select机制

            select函数有一个参数为fd_set,表示监控的描述符集合,数据结构为long类型数组,每一个数组元素都与一个打开的文件句柄相关联,文件句柄可以是socket描述符、文件描述符等;当调用select函数之后,内核根据IO的状态更新fd_set的值,由此来通知调用select函数的应用程序那个IO就绪了。从流程上看select和同步阻塞IO差不多,而且select还额外多了select操作,但是select的优势是可以同时监控多个IO操作,而同步阻塞IO想要做到同时监控多个IO操作必须采用多线程的方式。很明显从线程消耗上来说,selec更合适一个线程同时和多个IO操作交互的场景。
            select的问题:
            1、每次调用select,都需要将fd_set从用户空间复制到内核空间,当fd_set比较大时,复制消耗较大
            2、每次调用select,内核都需要遍历一次fd_set,当fd_set比较大时,遍历消耗较大
            3、内核对于fd_set作了大小限制,最大值为1024或2048
            4、当描述符数量较多,而活跃的较少时性能浪费较大,甚至还不如同步阻塞IO模型

            1.2、poll机制

            poll机制的实现逻辑和select基本上一致,只是没有对描述符的数量做限制,存储描述符的数据结构为链表结构poll相当于是select的改良吧,但是也只是在同时监控的描述符数量上改良了,其他实现逻辑并没有变,所以还是会有select遇到的问题select和poll都只适合文件描述符数量和活跃数都多时,如果文件描述符数量较多而活跃的较少时并不适合。

            1.3、epoll机制

            select和poll机制最大的问题是每次调用函数都需要将文件描述符集合从用户空间复制到内核空间,另外每次都需要线性遍历所有的文件描述符判断状态。而epoll的实现方式是通过注册事件回调通知的方式实现,另外通过一个文件描述符来管控多个文件描述符,同样也没有文件描述符数量的上限。epoll的实现流程为通过调用内核的epoll_ctl函数注册需要监听的文件描述符以及对应的事件,内核一旦发现该文件描述符状态就绪,就会将所有的已经就绪的文件描述符保存到ready集合中,应用程序通过函数epoll_wait函数不停从ready集合中获取已经就绪的文件描述符即可,所以epoll不需要对所有的文件描述符进行遍历,而只需要对已经就绪的文件描述符进行遍历即可。
            当然如果文件描述符全部是活跃状态的,那么epoll机制的性能可能还没有select和poll的高,但是大多数情况下都是文件描述符数量较多,而活跃数较少的场景。

另外select、poll和epoll处理IO事件时都默认是水平触发,也就是每次查下文件描述符状态都是获取到所有就绪的文件描述符,如果对于就绪的文件描述符不进行处理,那么每次调用时都会返回该文件描述符;
而epoll除了支持水平触发之外,还支持边缘触发模式,边缘触发模式是每次只会返回上一次调用之后到目前为止的就绪文件描述符,也就是说一个文件描述符就绪事件只会通过epoll_wait方法返回一次,所以需要应用程序立即处理,如果不处理那么就需要等到下一次文件描述符就绪。边缘触发模式相比于水平触发模式来说大量减少了就绪事件触发的次数,所以效率更高,但是需要应用程序缓存事件就绪状态并且立即处理,否则可能会丢失数据。

总结select、poll、epoll的对比

select poll epoll
获取FD状态的方式 线性遍历所有FD 线性遍历所有的FD 注册FD就绪的事件,回调通知
FD数量限制 1024或2048 无上限 无上限
FD存储数据结构 数组 链表 红黑树
IO效率 线性遍历,时间复杂度o(n) 线性遍历,时间复杂度o(n) 遍历已经就绪的事件集合,时间复杂度o(1)
FD复制到内核 每次调用都需要复制一次 每次调用都需要复制一次 epoll_ctl注册时复制一次,epoll_wait不需要复制
IO触发模式 仅支持水平触发 仅支持水平触发 支持水平触发和边缘触发