P36-P39:IO协程调度01-04
前面4节主要内容在协程调度的基础上,基于epoll
设计了IO协程调度,支持为socket句柄加读事件(EPOLLIN
)和写事件(EPOLLOUT
),并且支持删除事件、取消事件功能。IOManager
主要通过FdContext
结构体存储文件描述符fd
、注册的事件event
,执行任务cb/fiber
,其中fd
和event
用于epoll_wait
,cb/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
会截取bitmap
前n
位进行监听。
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 */
};
poll
与select
工作原理相同,但要注意的是,当数据来时,poll
将revents
置位(POLLIN
等),然后poll
函数返回。仍然要遍历数组来看是哪个文件描述符来了,并且将revents
置为0,这样就能重复使用pollfd
。
poll
优点
- 解决了
select
的1024上限 - 解决了
select fd_set
不可重用,pollfd
可以通过重置revents
恢复如初
poll
缺点
- 每次调用
poll
都需要将pollfd
拷贝到内核态,有开销 - 不知道是哪个文件描述符的数据来了,所以要遍历,时间复杂度为O(n)
epoll
相对于select
和poll
来说,epoll
更加灵活,没有描述符限制。epoll
使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy
只需一次。
-
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)
-
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)))
-
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工作流程
-
epoll_create:在创建
epoll
句柄时,从slab
缓存中创建一个epollevent
对象,epollfd
本身并不存在一个真正的文件与之对应, 所以内核需要创建一个"虚拟"的文件, 并为之分配真正的struct file
结构, 而且有真正的fd
,而eventpoll
对象保存在struct file
结构的private_data
指针中。返回epollfd
的文件描述符。 -
epoll_ctl:将
epoll_event
结构拷贝到内核空间中并且判断加入的fd
的f_op->poll
是否支持poll
结构(epoll,poll,select
I/O多路复用必须支持poll
操作)。通过
epollfd
取得struct file
中private_data
获取eventpoll
,根据op
区分是添加,修改还是删除。在添加
fd
时,首先在eventpoll
结构中的红黑树查找是否已经存在了相对应的epitem
,没找到就支持插入操作,否则报重复的错误,并且会调用被监听的fd
的poll
方法查看是否有事件发生。当
poll
时,通过poll_wait
执行回调函数ep_ptable_queue_proc
,初始化等待队列成员时绑定ep_poll_callback
回调函数,然后将等待队列成员加入到等待队列头,等待队列头是由fd
驱动所拥有的。当数据来时,等待队列头会挨个通知等待队列成员,这样epoll
就知道数据来了,然后执行回调函数ep_poll_callback
将准备好的epitem
加入到rdllist
。最后会将
epitem
放到eventpoll
的红黑树中,如果此时已经有数据来了,谁在epoll_wait
就唤醒谁。 -
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()
中调用被监听fd
的poll
方法拿到准备好的事件,若与我们监听的事件相同,那么就将数据从内核将拷贝到用户空间中。若设置了为边缘触发模式,则不会将当前epitem
放回到rdllist
中,也就是说,只有再次调用epoll_wait
时,通过本函数的poll
步骤,当信号来时调用了ep_poll_pollback()
才会将epitem
重新放回到rldlist
中;若设置了水平触发模式,则不管有没有有效事件就放回到rdllist
中去。
有点懵
三、Epoll工作模式
LT模式
当epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait
时,会再次响应应用程序并通知此事件。
LT是缺省的工作方式,并且同时支持block socket
和no-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优点
- 监视的描述符数量不受限制
- 它所支持的
fd
上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat/proc/sys/fs/file-max
察看,一般来说这个数目和系统内存关系很大
- IO的效率不会随着监视fd的数量的增长而下降
epoll
不同于select
和poll
轮询的方式,而是通过每个fd
定义的回调函数来实现的。只有就绪的fd
才会执行回调函数ep_poll_callback()
。ep_poll_callback()
的调用时机是由被监听的fd
的具体实现, 比如socket
或者某个设备驱动来决定的,因为等待队列头是他们持有的,epoll
和当前进程只是单纯的等待。
- 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);