自学内容网 自学内容网

高级IO(结尾总结)

 五种IO模型

阻塞IO:  

        在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式

这也是最常见的IO模型,阻塞流程按上图所示

非阻塞IO:

        如果内核还未准备好数据报,也不会阻塞而是直接返回,并且返回EWOULDBLOCK错误码 

非阻塞IO往往需要循环的去尝试读取文件描述符,这个过程称为轮询,但这种循环方式对CPU来说是较大的浪费,在特定场景下使用. 

  

信号驱动IO:

        内核将数据准备好的时候,会使用SIGIO信号通知应用程序进行IO操作. 

IO多路转接: 

        从流程图上看跟阻塞IO类似,实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态

TIP:这就相当于一个钓鱼佬,带了很多把鱼竿,同时等待多把鱼竿看看哪个上钩就收哪个.. )

异步IO:

由内核在数据拷贝完成时,同时 

 

 小结: 

  • 任何IO过程中,都包含了两个步骤,一个是等待,一个是拷贝。
  • 在实际应用场景中,往往等待的时间都远高于拷贝的时间,所以让IO更高效,最核心的办法就是让等待的时间尽量少 

高级IO的重要概念 

同步通信 VS 异步通信: 

  • 同步即是在发出一个调用时,在没有得到结果前,该调用就不返回,但是一旦调用返回了,就得到了返回值
  • 异步则是相反,调用在发出之后,这个调用就直接返回的,所以没有返回结果。当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或者通过回调函数处理这个调用 

tips:这里的同步跟进程间同步是八竿子打不着的

阻塞VS非阻塞:

阻塞和非阻塞关注的时程序在等待调用结果(消息,返回值)时的状态 

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回
  • 非阻塞调用指在不能立即得到结果之前,该调用不会阻塞当前线程

其他的高级IO:非阻塞IO,记录锁,系统V流机制,IO多路转接,readv和writev函数以及存储映射IO(mmap)

这里我们讨论的是IO多路转接 


非阻塞IO 

fcntl:

一个文件描述符,默认都是阻塞IO

函数原型如下:

#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

根据传入的cmd的不同,后面追加的参数也不相同.

fcntl函数有5种功能:

  • 复制一个现有的描述符(cmd = F_DUPFD) 
  • 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD)
  • 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)
  • 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)
  • 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)

这里我们使用第三个功能,设置文件状态标记,就可以将一个文件描述符设置为非阻塞 

实现函数SetNoBlock

基于fcntl,实现一个setnoblock函数,将文件描述符设置为非阻塞

void SetNoBlock(int fd) {
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
  • 使用F_GETFL将当前文件描述符的属性取出来(这是一个位图) 
  • 然后再F_SETFL将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK参数
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>

void SetNoBlock(int fd)
{
    int fl = fcntl(fd,F_GETFL);
    if(!fl){
        perror("fcntl");
        return ;
    }
    fcntl(fd,F_SETFL, fl | O_NONBLOCK);
}

int main()
{
    SetNoBlock(0);
    while(true){
        char buf[1024] = {0};
        ssize_t read_size = read(0,buf,sizeof(buf)-1);
        if(!read_size) {
            perror("read"); 
            continue;
        }
        sleep(2);
        printf("input:%s\n",buf);
    }
    return 0;
}

I/O多路转接之select 

初始select

系统提供select函数来实现多路复用输入/输出模型

  • select系统调用是用来让我们来同时监视多个文件描述符的状态变化的
  • 程序一样会停在select这里等待,知道被监视的文件描述符有一个或多个发生状态改

select函数原型 

#include<sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, 
struct timeval *timeout);
  • 参数nfds是需要监视的最大的文件描述符值+1;
  • rdset,wrset,exset分别对应于需要检测的可读文件描述符集合,可写文件描述符集合,及异常文件描述符集合
  • 参数timeout为结构timeval,用来设置select() 等待时间 

参数timeout的取值:

  • NULL:表示select() 没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件
  • 0:仅检测描述符集合的状态,然后立即返回,并不会等待外部事件的发生
  • 特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回

关于fd_set结构

从结构上看就是一个整形数组,更严格的说是一个位图,使用位图中对应的位来表述要检视的文件描述符 

提供一组操作fd_set的接口,来比较方便的操作位图。

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位

 关于timeval结构

timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则翻书返回,返回值为0

 

函数返回值: 

  •  执行成功则返回文件描述词状态已改变的个数
  • 如果返回0代表在描述词状态改变前已经超过timeout时间,没有返回
  • 当有错误发生时则返回-1,错误原因存到errno,此时参数readfds,writefds,exceptfds和timeout的值变为不可预测

 错误值可能为:

  • EBADF 文件描述词为无效的或该文件已关闭
  • EINTR 此调用被信号中断
  • EINVAL 参数n为负值
  • ENOMEM 核心内存不足

理解select执行的过程: 

① 执行fd_set set;FD_ZERO(&set);则现在用位表示是0000 0000

②若fd = 5执行FD_SET(fd,&set)后set变为0001 0000

③若再加入fd = 2,fd = 1 则set变成0001 0011

④执行select(6,&set,0,0,0)阻塞等待

⑤若fd = 1,fd = 2上都发生可读时间,则select返回,此时set变成0000,0011

注意:这时候没有事件发生的fd = 5就会被清空


 I/O多路转接之poll

poll函数原型:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
    // pollfd结构
    struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events */
    short revents; /* returned events */
};
  •  fds是一个poll函数监听的结构列表,每一个元素中包含了三个部分:
    • 文件描述符
    • 监听的事件集合
    • 返回的事件集合
  • nfds表示fds数组的长度
  • timeout表示poll函数的超时时间单位(ms)

events和revents的取值:

  • 返回结果: 
  • 返回值小于0,表示出错
  • 返回值等于0,表示poll函数超时等待
  • 返回值大于0,表示poll由于监听的文件描述符就绪而返回 

socket的就绪条件和上面select的一样 

对比selsect

优点 :

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

  •  pollfd结构包含了要检视的event和发生的event,不再使用select“参数-值”传递方式,接口更加方便
  • poll并没有最大数量限制(但是数量过大后性能也是会下降)

缺点: 

  • 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符 
  • 每次调用poll都需要把大量的pollfd结构从用户动态拷贝到内核态中
  • 同时连接的大量客户端子昂一个时候可能只有很少的处于就绪状态,因此随着随着监视的描述符增长,其效率会线性下降(毕竟每次都要遍历查询--)

使用poll监控标准输入示例:

#include <poll.h>
#include <unistd.h>
#include <stdio.h>
int main()
{
    struct pollfd poll_fd;
    poll_fd.fd = 0;
    poll_fd.events = POLLIN;
    for (;;)
    {
        int ret = poll(&poll_fd, 1, 1000);
        if (ret < 0)
        {
            perror("poll");
            continue;
        }
        if (ret == 0)
        {
            printf("poll timeout\n");
            continue;
        }
        if (poll_fd.revents == POLLIN)
        {
            char buf[1024] = {0};
            read(0, buf, sizeof(buf) - 1);
            printf("stdin:%s", buf);
        }
    }
    return 0;
}


I/O多路转接之epoll 

epoll初识:

按照man手册的说法:是为了处理大批量句柄而做了改进的poll 

它几乎几倍了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法

epoll的相关系统调用:

epoll_create: 

int epoll_create(int size);

创建一个epoll的句柄

  • 自lunux2.6.8,size参数是被忽略的
  • 用完之后一定要close关闭 

epoll_ctl:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll的事件注册函数

  • 他不同select()是在监听的时候告诉内核要监听什么事件,而是在这里先注册要监听的事件类型
  • 第一个参数epfd是epoll_create后返回的epoll句柄
  • 第二个参数op表示动作,用三个宏来表示
  • 第三个参数fd就是需要监听的fd
  • 第四个参数是告诉内核需要监听什么事

第二个参数op的取值--三个宏

  • EPOLL_CTL_ADD:注册新的fd到epfd中
  • EPOLL_CTL_MOD:修改已经注册的fd的监听事件
  • EPOLL_CTL_DEL:从epfd中删除一个fd

struct epoll_event的结构如下:

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

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

epoll_wait: 

int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)
  •  参数events是分配好的epoll_event结构体数组
  • epoll将会把发生的事件复制到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会帮我们在用户态分配内存)
  • maxevents告诉内核这个events有多大,这个大小不能大于创建epoll_create时的size
  • 参数timeout是超时时间(毫秒,0会立即返回,-1是永久阻塞)
  • 如果函数调用成功,返回对应I/O上已经准备好的文件描述符数目,如0表示已超时,返回小于0代表已经失败

Epoll工作原理: 

  • 当某一进程调用epoll_create方法时,Linux内核会创建一个evenpoll结构体,这个结构体中有两个成员和epoll的使用方式密切相关 

下面就是那两个重要成员:

  • 红黑树的根节点
  • 以及双向链表的指针
struct eventpoll{
    ....
    /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
    struct rb_root rbr;
    /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
    struct list_head rdlist;
    ....
};
  • 每一个epoll 对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
  • 这些事件都会挂载在红黑树中,因此重复添加的时间就可以通过红黑树高效的识别出来(lgn)
  • 而所有添加到epoll中的事件都会与设备(网卡)驱动建立回调关系,也就是当相应的事件发生时会调用这个方法
  • 这个回调方法在内核中叫ep_poll_callback,他会将发生的事件添加到rdlist双链表中
  • 在epoll中对于每一个事件都会建立一个epitem结构体
struct epitem{
    struct rb_node rbn;//红黑树节点
    struct list_head rdllink;//双向链表节点
    struct epoll_filefd ffd; //事件句柄信息
    struct eventpoll *ep; //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}
  • 当调用epoll_wait检查是否有时间发生的时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
  • 如果rdlist不为空,则把发生的时间复制到用户态,同时将事件数量返回给用户,这个操作的时间复杂度是O(1)  

总结使用epoll三部曲:

  • 调用epoll_create创建一个epoll句柄
  • 调用epoll_ctl,将要监控的文件描述符及事件进行注册
  • 调用epoll_wait,等待文件描述符就绪

epoll的优点和select的缺点对应 :

  • 接口使用方便,虽然被拆分成三个函数,但是反而更高效且方便。不需要每次循环都设置关注的文件描述符,也做到了输入输出参数分离开
  • 数据拷贝轻量,只在合适的时候调用EPOLL_CTL_ADD将文件描述符拷贝到内核中,这个操作并不频繁(而select/poll每次都需要循环拷贝) 
  • 事件回调机制:避免使用遍历,而是使用回调函数的方式,将就绪的文件描述符加入到就绪队列中,epoll_wait直接返回就绪队列就知道哪些文件描述符就绪。这个操作时间复杂度O(1),即使文件描述符数目很多,效率也不会收到影响
  • 没有数量上限制:文件描述符无上限

epoll的工作方式: 

epoll有2种工作方式,水平触发(LT),边缘触发(ET) 

 举个例子:

  • 我们将一个tcp socket添加到epoll描述符
  • 这时候socket另一端被写入了2KB的数据
  • 调用epoll_wait,并且他会返回。说明已经准备好了读取操作
  • 然后调用read,只读取了1KB的数据
  • 继续调用epoll_wait读取数据...

水平触发的Level Triggered 工作模式 

epoll默认状态下就是LT工作模式

  • 当epoll检测到socket上事件就绪的时候,可以不立即机型处理,或只处理一部分
  • 如上面例子,由于只读取了1K数据,缓冲区中还剩下1K数据,在第二次调用epoll_wait时,epoll_wait仍然会立刻返回并通知socket读事件就绪
  • 知道缓冲区上所有的数据都被处理完,epoll_wait才不会立即返回
  • 支持阻塞读写和非阻塞读写   

边缘触发Edge Triggered工作模式

如果在第一步将socket添加到epoll描述符中使用了EPOLLET标志,epoll进入ET工作模式

  • 当epoll检测到socket上事件就绪时,必须 立刻处理
  • 如上面的例子,虽然只读了1K的数据,缓冲区还剩1K的数据,在第二次调用epoll_wait的时候epoll_wait就不会再返回了
  • 也就是说ET模式下,文件描述符上的事件就绪后,只有一次处理机会
  • ET的性能比LT的更高(epoll_wait返回的次数少了很多),Nginx默认采用ET模式使用epoll
  • 只支持非阻塞的读写

select和poll其实也是工作在LT模式下,epoll即可以支持LT 也可以支持 ET

 对比LT和ET:

LT是epoll的默认行为,使用ET能够减少epoll触发的次数,但是代价就是要求程序员一次响应就绪过程中就要把所有的数据都处理好

相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到
每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
另一方面, ET 的代码复杂程度更高了

 

理解ET模式和非阻塞文件描述符:

 假设一个场景:服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 则不会发送第二个10k请求.

正常流程应该这样:

此时如果服务端的代码是阻塞式的read,并且一次只read 1K的数据的话(read不能保证一次就读出所有的数据,参考man手册可能被信号打断),剩下的9k数据就会呆在缓冲区

此时由于epoll是ET模式,并不会认为文件描述符就绪。epoll_wait就不会再次返回。剩下的9K会一直在缓冲区中,直到下一次客户端再给服务器写数据,epoll_wait才能返回

但是:

  • 服务器值读到1K个数据,要读完10K才会给客户端响应数据
  • 客户端要读到服务器的响应才会发送下一个请求(僵住了)
  • 客户端发送下一个请求,epoll_wait才会返回,才能读缓冲区剩余的数据 

所以,为了解决上述问题(阻塞read不能一下把完整的请求读完),于是就可以使用非阻塞轮询方式来读缓冲区,保证一定能把完整的请求都读出来。

而LT就没这个问题,如果LT下缓冲区没读完,就能够放epoll_wait返回的文件描述符读事件就绪,让read下次还会读取 

epoll的使用场景 :

 epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反

  • 对于多连接,且多连接中只有一部分比较活跃的时,比较适合使用epoll 

例如:一个需要处理上完个客户端的服务器--互联网APP的入口服务器,这样就很适合epoll

如果只是系统内部,服务器和服务器之间进行通信,只有少数的几个连接,这种情况下用epoll就并不合适(大炮打蚊子),具体要根据需求和场景特点来决定使用哪种IO模型

epoll中的惊群问题: 

背景:在Linux 下使用epoll编写socket的服务端程序,并且使用了多线程/进程来epoll_wait监听socket。

在多线程或者多进程的环境下,部分为了提高程序的稳定性,往往会让多个线程或者多个进程同时在epoll_wait监听socket描述符。当一个新的连接请求到达时,操作系统并不知道选择哪个线程或者进程来处理此事件,就会将其中的几个线程或进程唤醒,然而实际上却只有一个线程或进程能够成功处理accept事件,其他的线程或进程都将失败,且errno错误码是EAGAIN。

这种现象称为惊群效应,会带来资源的小号和性能的影响,那么该如何解决该问题:

多线程环境:

让其中的一个线程单独epoll_wait监听socket,当有新的连接请求到达,用该线程来调用accept事件来建立新连接,之后的数据的读写操作则交给其他工作线程去处理,这样就可以避免多线程下epoll_wait惊群效应

多进程环境:

目前很多的开源软件,例如lighttpd,nginx等都才用了master/worders的模式提高软件的吞吐和并发能力,在nginx中还采用了负载均衡技术,在某个子进程的处理能力达到一定负载的时候,由其他负载轻的子进程负责epoll_wait调用。

lighttpd的解决思路就是无视惊群效应,仍然采用master/workers模式,每个子进程仍然在自己监听的socket上调用epoll_wait,当有新的连接请求发生,操作系统唤醒部分子进程来处理,只有一个子进程能够成功处理此事件,其他被惊醒的子进程捕捉EAGAIN错误,然后无视掉

nginx的解决思路:在同一时刻,永远都只有一个子进程在监视socket上的epoll_wait,它的做法就是创建一个全局的phread_mutex_t,在子进程进行epoll_wait前,先获取锁。也就是我们说的对临界资源的加锁,就能保证同一时刻只有一个进程能够执行epoll_wait就能够避免惊群效应

并且在代码实现上,nginx是子进程在负载在一定范围下才会去争取锁,也就是说如果一个线程的epoll_wait连接的数量达到一定数值(nginx是达到7/8时)就不会去争取锁,它的目标就是好好招待现在已经accept的事件请求.


总结: 

本章讲述了五种IO模型,并且展开讲述了IO多路转接,在Linux下使用IO转接有主要有select、poll、epoll三种当中最广泛好用的当属epoll,epoll支持ET以及LT(默认LT),但也不能无脑使用epoll。

epoll其网络模式是事件驱动,事件驱动的本质还是IO事件,应用程序在多个IO句柄之间快速切换,实现异步IO。事件驱动的服务器最适合就是IO密集型的工作,例如反向代理,在客户端和web服务端中间起到一个数据中转的作用,基本上就是纯纯IO并不涉及复杂技术,所以用epoll这种事件驱动,单进程单线程就能搞定。

至于那些cpu密集型的服务,比如跑图形图像,科学计算,或者数据库读写这样的,就不好使用单进程单线程了,还是开多线程好,这样之间互不影响(cpu密集型的处理速度一般都不会快,即使很快但是有那么一点点卡顿,数量大了之后一样效率下降很多),可以说只要有阻塞的话,那么事件IO就没有优势,这时候不如开多进程/线程去跑


原文地址:https://blog.csdn.net/Obto_/article/details/135572656

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!