Linux五大网络IO模型图解

用户空间与内核空间

  对于一个应用程序即一个操作系统进程来说,它既有内核空间(与其他进程共享),也有用户空间(进程私有),它们都是处于虚拟地址空间中。用户进程是无法访问内核空间的,它只能访问用户空间,通过用户空间去内核空间复制数据,然后进行处理。为了避免用户应用导致冲突甚至内核崩溃,所以进程的寻址空间就划分为两部分:内核空间、用户空间。

  用户空间:只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
  内核空间:可以执行特权命令(Ring0),调用一切系统资源
  写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备; 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

  

阻塞io(同步io)

  发起请求就一直等待,直到数据返回。全程阻塞在第一阶段 用户进程尝试读取数据时,此时数据尚未到达,内核需要等待数据,此时用户进程处于阻塞状态;第二阶段 数据拷贝到内核缓冲区后 此时已就绪,然后将数据拷贝到用户进程缓冲区,拷贝的过程中用户进程也是阻塞状态。(好比你去商场试衣间,里面有人,那你就一直在门外等着)

非阻塞io(同步io)

  不管有没有数据都返回,没有就隔一段时间再来请求,如此循环。复制数据时阻塞在第一阶段 用户进程尝试读取数据时,此时数据尚未到达,就返回异常给用户进程,用户进程拿到error后,就再次重复尝试,直到数据就绪;第二阶段 数据拷贝到内核缓冲区后 此时已就绪,然后将数据拷贝到用户进程缓冲区,拷贝的过程中用户进程也是阻塞状态。(好比你要喝水,水还没烧开,你就隔段时间去看一下饮水机,直到水烧开为止,水烧开了就等在那里接水喝)

  可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案: 

  如果调用recvfrom时,恰好没有数据,阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
  如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

  

io多路复用(同步io)

  文件描述符(File Descriptor):简称FD,是一个从0 开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
  IO多路复用:是利用单个线程来同时监听多个FD,并在某个FD可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。检查FD就绪时阻塞复制数据时阻塞。它的实现有三种方式   select   poll   epoll。

   

select

// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
/* fd_set 记录要监听的fd集合,及其对应状态 */ typedef struct {     // fds_bits是long类型数组,长度为 1024/32 = 32     // 共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪     __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];     // ... } fd_set;
// select函数,用于监听fd_set,也就是多个fd的集合 int select(     int nfds, // 要监视的fd_set的最大fd + 1     fd_set *readfds, // 要监听读事件的fd集合     fd_set *writefds,// 要监听写事件的fd集合     fd_set *exceptfds, // // 要监听异常事件的fd集合 // 超时时间,null-用不超时;0-不阻塞等待;大于0-固定等待时间     struct timeval *timeout );

  可以看到 select 函数 里面有5个属性。ndfs 用来遍历fd集合的,当遍历到最大值就说明集合遍历完了;timeout 超时时间;fd_set 读/写/异常 三种集合,可以看到他是一个长度为32的数组,每个元素是32bit,每个数组1024bit 也就是每种fd的监听上限个数是1024个。

  1 建立连接时创建 fd_set ,然后用户进程调用 select 函数。2 内核进程在 timeout 前遍历 fd_set 等待数据就绪或超时,数据准备好后 就从内核空间 缓冲区 拷贝到 用户空间 缓冲区,并返回就绪的df个数。3 用户进程遍历整个 fd_set 找到就绪的 fd 。

  

poll

// pollfd 中的事件类型
#define POLLIN     //可读事件
#define POLLOUT    //可写事件
#define POLLERR    //错误事件
#define POLLNVAL   //fd未打开

// pollfd结构
struct pollfd {
    int fd;           /* 要监听的fd  */
    short int events; /* 要监听的事件类型:读、写、异常 */
    short int revents;/* 实际发生的事件类型 */
};
// poll函数 int poll(     struct pollfd *fds, // pollfd数组,可以自定义大小     nfds_t nfds, // 数组元素个数     int timeout // 超时时间 );

  poll 相比于 select 做了几点优化。首先 poll 函数的 fds 数组大小可以自定义,理论上无限大。(由于需要遍历整个数组找到就绪的 fd ,所以他不可能特别的大);其次所有的 fd 事件都封装在 pollfd 类型里面,包括 fd句柄 监听的事件 实际发生的事件。

epoll

struct eventpoll {
    //...
    struct rb_root  rbr; // 一颗红黑树,记录要监听的FD
    struct list_head rdlist;// 一个链表,记录就绪的FD
    //...
};

// 1.创建一个epoll实例,内部是event poll,返回对应的句柄epfd
int epoll_create(int size);

// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
    int epfd,  // epoll实例的句柄
    int op,    // 要执行的操作,包括:ADD、MOD、DEL
    int fd,    // 要监听的FD
    struct epoll_event *event // 要监听的事件类型:读、写、异常等
);

// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
    int epfd,                   // epoll实例的句柄
    struct epoll_event *events, // 空event数组,用于接收就绪的FD
    int maxevents,              // events数组的最大长度
    int timeout   // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);

  1 建立连接时创建 fd_set ,然后用户进程调用 epoll_create 函数,创建 eventpoll 对象,里面通过 红黑树 记录要监听的 fd,还有一个 链表 记录就绪的 fd。2 调用 epoll_ctl 函数往红黑树中添加要监听的 fd ,并且为每一个 fd 函数添加 callback 函数,当 callback 触发时,就把相应的 fd 添加到 就绪链表中(返回具体的 fd 和 fd 数量,而不是像 select/poll 那样只返回个数量)。3 调用 epoll_wait 等待并检查链表是否为空,然后对具体的 fd 进行操作,同时将 fd 从链表中摘除。(多个进程监听同一个 eventpoll 时,调用epoll_wait 监听链表,当任意一个fd就绪时,所有的进程都会被通知到,这种就是惊群现象。)

  1 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高
  2 每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间
  3 利用ep_poll_callback机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

  

事件通知机制

  当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:LevelTriggered:简称LT,也叫做水平触发,默认是这种。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。

LT 举个栗子:
  1. 假设一个客户端socket对应的FD已经注册到了epoll实例中
  2. 客户端socket发送了2kb的数据
  3. 服务端调用epoll_wait,得到通知说FD就绪
  4. 服务端从FD读取了1kb数据 
  5. 回到步骤3(再次调用epoll_wait,形成循环)


如果是 LT 模式,则事件通知频率较高,需要重复进行通知 完成对整个数据流操作,影响性能。


==============

ET 流程的话,它仅通知一次,效率高,我们只需要对FD操作一次,在第四步 读取1kb之后就不
管了。所以我们需要手动修改策略,要么每次操作fd后 如果有剩余数据,则把fd放入链表,循
环操作;要么一次性读取整个fd。

信号驱动io(同步io)

  事先发出一个请求,当有数据后会返回一个标识回调,这时你可以去请求数据。复制数据时阻塞。信号驱动IO是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,会发出SIGIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。用户进程收到SIGIO信号就调用recvfrom读取内核空间缓存区数据。(好比银行排号,当叫到你的时候,你就可以去处理业务了)。

  

异步io

  发出请求就返回,剩下的事情会异步自动完成,不需要做任何处理。(好比有事秘书干,自己啥也不用管)  

  用户进程调用aio_read,创建信号回调函数,然后内核等待数据就绪,用户进程无需阻塞,可以做任何事情。当内核数据就绪,就把数据从内核数据拷贝到用户空间。拷贝完成,内核递交信号触发aio_read中的回调函数,用户进程处理数据

  

总结

  

posted @ 2019-01-19 16:44  吴磊的  阅读(10273)  评论(0编辑  收藏  举报
//生成目录索引列表