sylar高性能服务器-日志(P36-P42)内容记录

P36-P39:IO协程调度01-04

​ 前面4节主要内容在协程调度的基础上,基于epoll设计了IO协程调度,支持为socket句柄加读事件(EPOLLIN)和写事件(EPOLLOUT),并且支持删除事件、取消事件功能。IOManager主要通过FdContext结构体存储文件描述符fd、注册的事件event,执行任务cb/fiber,其中fdevent用于epoll_waitcb/fiber用于执行任务。

​ 在学习几节内容之前,了解一下epoll的相关概念理解代码会轻松一些,网上都有很多资料,这里我直接引用一位博主概括的笔记(epoll),写得很不错。

一、Epoll

I/O模式

​ 在IO操作时,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,第一阶段: 数据会先被拷贝到操作系统内核的缓冲区中,第二阶段: 然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

阻塞I/O

​ 默认情况下,所有的socket都是被阻塞的,也就是阻塞I/O。将会导致两个阶段的阻塞

  • 等待数据
  • 从内核拷贝数据到用户空间
非阻塞I/O

​ 如果将socket设置为non-blocking,当内核还没有准备好数据,就不会阻塞用户进程,而是立即返回一个error,可以通过系统调用获得数据,一旦内核的数据准备好了,并且又再次收到了用户进程的system call,就可以将数据拷贝到用户内存(在拷贝的过程中,进程也是会被block),然后返回。

异步I/O

​ 在两个阶段都不会被阻塞

  • 第一阶段:当用户进程发起read操作后,内核收到用户进程的system call会立刻返回,不会对用户进程产生任何的阻塞
  • 第二阶段:当内核准备好了数据,将数据拷贝到用户空间,当这一切都完成之后,内核才会给用户进程发送信号表示操作完成,所以第二阶段也不会被阻塞

只有异步I/O是真正的异步,其他的模式包括阻塞I/O、异步I/O、I/O多路复用都是同步I/O。对于真正的I/O操作,指的是第二阶段:当内核收到用户进程发来的system call,将数据拷贝到用户空间中,这一步骤只有异步I/O是非阻塞的,其他的I/O模式都会被阻塞。

I/O多路复用

概念:服务器要跟多个客户端建立连接,就需要处理大量的socket fd,通过单线程或单进程同时监测若干个文件描述符是否可以执行IO操作,这就是IO多路复用。

select
/*
    @param: n 最大文件描述符+1
    @param: readfds 读文件描述符
    @param writefds 写文件描述符
    @param exceptfds 异常文件描述符
    @param timeout 超时事件
*/
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

​ 当进程调用select时会被阻塞,fd_set的数据结构为bitmap,通过FD_SET方法将需要监听的文件描述符集合fdset对应的bitmap置为1(例如文件描述符集合为4,9,那么就将bitmap的第4位和第9位置为1),select会截取bitmapn位进行监听。

select会将需要关注的fd_set拷贝到内核态监听,当有数据来时,内核将有数据的fd_set置位(bitmap对应的文件描述符置位为相应的操作,读、写、异常),select返回。因为不知道是哪个文件描述符来数据了,所以再遍历fdset寻找就绪的文件描述符。

select的缺点

  • 在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
  • fd_set是不可重用的,每次需要使用FD_ZERO方法清空
  • 每次调用select都需要将fd_set拷贝到内核态,有开销
  • 不知道是哪个文件描述符的数据来了,所以要遍历,时间复杂度为O(n)
poll
/*
    param fds fd事件
    param nfds fd数量
    param timeout 超时时间
*/
int poll (struct pollfd *fds, unsigned int nfds, int timeout);

struct pollfd {
   
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */ 
};

pollselect工作原理相同,但要注意的是,当数据来时,pollrevents置位(POLLIN等),然后poll函数返回。仍然要遍历数组来看是哪个文件描述符来了,并且将revents置为0,这样就能重复使用pollfd

poll优点

  1. 解决了select的1024上限
  2. 解决了select fd_set不可重用,pollfd可以通过重置revents恢复如初

poll缺点

  1. 每次调用poll都需要将pollfd拷贝到内核态,有开销
  2. 不知道是哪个文件描述符的数据来了,所以要遍历,时间复杂度为O(n)
epoll

​ 相对于selectpoll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

  1. epoll_create

    //创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
    int epoll_create(int size)

    这个参数不同于``select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll`所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议(大于0就行)。

    当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

    通过源码得知,每创建一个epollfd, 内核就会分配一个eventpoll结构体与之对应,其中维护了一个RBTree来存放所有要监听的struct epitem(表示一个被监听的fd)

  2. epoll_ctl:从用户空间将epoll_event结构copy到内核空间

    /*
        @param epfd epoll_create()的返回值
        @param op 添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD事件
        @param event 告诉内核需要监听什么事
    */
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)struct epoll_event {
         
      __uint32_t events;  /* Epoll events */
      epoll_data_t data;  /* User data variable */
    };
    
    /* 
     * epoll事件关联数据的联合体
     * fd: 表示关联的文件描述符。
     * ptr:表示关联的指针。
     */
    typedef union epoll_data {
         
        void *ptr;
        int fd;
        __uint32_t u32;
        __uint64_t u64;
    } epoll_data_t;
    
    //events可以是以下几个宏的集合:
    EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
    EPOLLOUT:表示对应的文件描述符可以写;
    EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
    EPOLLERR:表示对应的文件描述符发生错误;
    EPOLLHUP:表示对应的文件描述符被挂断;
    EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
    EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
    

    使用copy_from_user从用户空间将epoll_event结构copy到内核空间。

    **if** (ep_op_has_event(op) && copy_from_user(&epds, **event**, **sizeof**(**struct** epoll_event)))

  3. epoll_wait

    /*
        @param epfd epoll_create() 返回的句柄
        @param events 分配好的 epoll_event 结构体数组,epoll 将会把发生的事件复制到 events 数组中
                      events不可以是空指针,内核只负责把数据复制到这个 events 数组中,不会去帮助我们在用户态中分配内存,但是内核会检查空间是否合法
        @param maxevents 表示本次可以返回的最大事件数目,通常 maxevents 参数与预分配的 events 数组的大小是相等的;
        @param timeout 表示在没有检测到事件发生时最多等待的时间(单位为毫秒)
                       如果 timeout 为 0,则表示 epoll_wait 在 rdllist 链表为空时,立刻返回,不会等待。
                       rdllist:所有已经ready的epitem(表示一个被监听的fd)都在这个链表里面
                       
    */
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
    
    

    收集在 epoll监控的事件中已经发生的事件,如果epoll中没有任何一个事件发生,则最多等待timeout毫秒后返回。epoll_wait的返回值表示当前发生的事件个数,如果返回 0,则表示本次调用中没有事件发生,如果返回 -1,则表示发生错误,需要检查errno判断错误类型。

    通过__put_user将数据从内核空间拷贝到用户空间

    if (__put_user(revents, &uevent->events) ||
        __put_user(epi->event.data, &uevent->data)) {
         
        list_add(&epi->rdllink, head);
        return eventcnt ? eventcnt : -EFAULT;
    }
    

二、Epoll工作流程

  1. epoll_create:在创建epoll句柄时,从slab缓存中创建一个epollevent对象,epollfd本身并不存在一个真正的文件与之对应, 所以内核需要创建一个"虚拟"的文件, 并为之分配真正的struct file结构, 而且有真正的fd,而eventpoll对象保存在struct file结构的private_data指针中。返回epollfd的文件描述符。

  2. epoll_ctl:将epoll_event结构拷贝到内核空间中并且判断加入的fdf_op->poll是否支持poll结构(epoll,poll,selectI/O多路复用必须支持poll操作)。

    通过epollfd取得struct fileprivate_data获取eventpoll,根据op区分是添加,修改还是删除。

    在添加fd时,首先在eventpoll结构中的红黑树查找是否已经存在了相对应的epitem,没找到就支持插入操作,否则报重复的错误,并且会调用被监听的fdpoll方法查看是否有事件发生。

    poll时,通过poll_wait执行回调函数ep_ptable_queue_proc,初始化等待队列成员时绑定ep_poll_callback回调函数,然后将等待队列成员加入到等待队列头,等待队列头是由fd驱动所拥有的。当数据来时,等待队列头会挨个通知等待队列成员,这样epoll就知道数据来了,然后执行回调函数ep_poll_callback将准备好的epitem加入到rdllist

    最后会将epitem放到eventpoll的红黑树中,如果此时已经有数据来了,谁在epoll_wait就唤醒谁。

  3. epoll_wait

    • epoll_wait()要将数据从内核copy到用户空间,内存需要用户空间自己提供,但内核会验证内存空间是否合法,然后执行ep_poll()
    • ep_poll()中当rdllist不为空时,使用等待队列把当前进程挂到epoll的等待队列头,无限循环 { 若rddllist有数据或者已经过了超时事件,又或者有信号来了,就跳出循环唤醒进程。无事件发生,就使用schedule_timeout睡觉,数据来时,调用ep_poll_callback()唤醒了epoll的等待队列头时,就不用睡眠了。},然后将当前进程从epoll的等待队列中移除。然后调用ep_send_events()
    • ep_send_events()中调用ep_scan_ready_list()
    • ep_scan_ready_list()中先将rdllist剪切到txlist中,执行ep_send_events_proc()txlist中的epitem处理,把未处理完的epitm重新加入到rdllist中。然后将ovflist加入到rddlist中,若仍有事件没有处理完,则唤醒epoll的等待队列头。
    • ep_send_events_proc()中调用被监听fdpoll方法拿到准备好的事件,若与我们监听的事件相同,那么就将数据从内核将拷贝到用户空间中。若设置了为边缘触发模式,则不会将当前epitem放回到rdllist中,也就是说,只有再次调用epoll_wait时,通过本函数的poll步骤,当信号来时调用了ep_poll_pollback()才会将epitem重新放回到rldlist中;若设置了水平触发模式,则不管有没有有效事件就放回到rdllist中去。

    有点懵

三、Epoll工作模式

LT模式

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

LT是缺省的工作方式,并且同时支持block socketno-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

ET模式

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

ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

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

ET模式下: 如果read返回0,那么说明已经接受所有数据 如果errno=EAGAIN,说明还有数据未接收,等待下一次通知 如果read返回-1,说明发生错误,停止处理

四、Epoll优点

  1. 监视的描述符数量不受限制
  • 它所支持的fd上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat/proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大
  1. IO的效率不会随着监视fd的数量的增长而下降
  • epoll不同于selectpoll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数ep_poll_callback()
  • ep_poll_callback()的调用时机是由被监听的fd的具体实现, 比如socket或者某个设备驱动来决定的,因为等待队列头是他们持有的,epoll和当前进程只是单纯的等待。
  1. epoll使用一个文件描述符管理多个描述符
  • 将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

五、Class IOManager

该类继承于Scheduler

事件类型

主要把socket的事件归类为读事件和写事件

enum Event {
    // 事件类型
    NONE = 0x0, // 无事件
    READ = 0x1, // EPOLLIN
    WRITE = 0x4 // EPOLLOUT
};
SOCKET事件上下文结构体
struct FdContext {
   
    typedef Mutex MutexType;
    // 事件类
    struct EventContext {
   
        Scheduler* scheduler = nullptr; // 事件执行的调度器
        Fiber::ptr fiber; // 事件协程
        std::function<void()> cb; // 事件的回调函数
    };

    // 获得事件上下文
    EventContext& getContext(Event event);
    // 重置事件上下文
    void resetContext(EventContext& ctx);
    // 触发事件
    void triggerEvent(Event event);

    int fd = 0; // 事件关联的句柄
    EventContext read; // 读事件
    EventContext write; // 写事件
    Event events = NONE; // 已注册的事件
    MutexType mutex;
};
成员变量
int m_epfd = 0; // epoll文件句柄
    int m_tickleFds[2]; // pipe文件句柄,其中fd[0]表示读端,fd[1] 表示写端
    std::atomic<size_t> m_pendingEventCount = {
   0}; // 等待执行的事件数量
    RWMutexType m_mutex; // 互斥锁
    std::vector<FdContext*> m_fdContexts; //socket事件上下文容器
构造函数
IOManager::IOManager(size_t threads, bool use_caller, const std::string& name) 
    : Scheduler(threads, use_caller, name) {
   
    // 创建一个epollfd
    m_epfd = epoll_create(5000);
    // 断言是否成功
    SYLAR_ASSERT(m_epfd > 0);
    // 创建管道用于进程通信
    int rt = pipe(m_tickleFds);
    SYLAR_ASSERT(!rt);
    // 创建事件并初始化
    epoll_event event;
    memset(&event, 0, sizeof(epoll_event));
    // 注册读事件,设置边缘触发模式
    event.events = EPOLLIN | EPOLLET;
    // 将fd关联到pipe的读端
    event.data.fd = m_tickleFds[0];
    // 对一个打开的文件描述符执行一系列控制操作
    // F_SETFL: 获取/设置文件状态标志
    // O_NONBLOCK: 使I/O变成非阻塞模式,在读取不到数据或是写入缓冲区已满会马上return,而不会阻塞等待。
    rt = fcntl(m_tickleFds[0], F_SETFL, O_NONBLOCK); // fcntl可以改变句柄的属性
    SYLAR_ASSERT(!rt);
    // 将pipe的读端注册到epoll
    rt = epoll_ctl(m_epfd, EPOLL_CTL_ADD, m_tickleFds[0], &event);
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

madkeyboard

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值