假设你是一个工厂管理员,你将管理你工厂中108间工位的108个工人干活。这些工人只会在你说要干什么的时候才会干活,而且他们干每个工作前必须要你亲自去授权,否则他们没法完成工作。假设他们干活速度是比较慢的,而你很快就能转完所有工位。你将会如何管理这个工厂呢?
首要的,你必须给工人派活,这一步是必须的。因此,开始时需要跑到所有工位告诉他们干什么并授权。但你不知道他们什么时候完成啊。如果仅盯着一个人看,那后边如果有人先完成了,就闲着了,对于一个高效率工厂来说,这是不太好的。如果一直在每个工位转来转去,很显然这样也太累了。而且自己要做的其他工作也没时间做了。
你想到了一个绝妙的方法,你给每个工位装上了一个开关,工人做完工作会按下开关,此时你办公室的灯就会亮了。这样灯不亮的时候你就可以一直在办公室了。但这样还有点问题。每次灯亮,都要转108个工位,还是太麻烦了,有没有更快的方法?有的,把每个工位编号,当工位开关按下,你就会收到哪个工位按下完成开关,这样一来,每次你就可以精准的派发任务了。
上面这样的管理方式,与Linux中最常用的epoll管理fd的方式十分类似。
前置条件
上面的例子是很不严谨的。因为所有的条件仅仅做了简单的描述。而在程序中,必须要确定好很多事情的边界。
首先是被管理者,在程序中,显然这个角色就是文件描述符fd。但貌似并不是所有的fd都需要被管理。
先简单考虑两种fd,一种是打开的磁盘文件对应的fd,一种是socket打开的网络fd。
很显然,这两种fd是存在较大差异的。最主要的一点就是是不是有数据。对于磁盘文件,无论你什么时候读写,他都在那,因此你可以直接读写。但对于socket,读的时候需要对方有发来的数据,写的时候需要自己缓冲区不满,也就是对方收完了数据。
也可以这么理解,磁盘是时刻准备着的,想什么时候读就什么时候。但socket连接不是,必须等对方可读可写,才能进行操作。
显然,socket fd才是我们真正关心的。epoll中也支持socket fd的一系列操作,但对于普通读写,他是不支持的。如果用epoll_ctl注册普通方d,会产生EPERM错误。其实普通文件fd读写时也是有一段时间CPU空闲的,有兴趣读者可以自行查阅资料理解。
io多路复用
网上很多介绍各种模型的,Reactor就是一个例子。但这里并不准备展开讲那些总结好的模型,一方面他们的本质是一样的,另一方面直接复述大量概念会较难理解。
io多路复用就是先前我们例子的计算机版。我们的程序不用再累死累活的监视创建的大量fd,仅仅通过那个“灯”--epoll fd即可管理大量的socket等支持的fd。
他解决了什么窘境?假设我们4个线程创建了40000个socket fd,我们只能阻塞等待到4个(每个线程一个)fd上。所以仅仅靠现有的东西想管理这么多fd是不可能的。epoll就是解决的关键。只需要把socket fd添加到epoll fd关注表,我们阻塞在epoll fd上,epoll会把自己关注的表中有事件的fd告诉我们。这样一来,我们只需要监听一个epoll fd的状态就好了。一个epoll fd 管理了40000个fd,因此也就叫io多路复用。
实际应用
重要api
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *_Nullable event);
// epoll_event
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
};
typedef union epoll_data epoll_data_t;观察epoll_ctl函数。我们在注册fd时可以在epoll_data中通过结构体等方法传入我们想存的值。这个值还是比较关键的,稍后再做讨论。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);当阻塞在epoll_wait被唤醒时,在第二个参数中的epoll_event数组讲告诉我们哪些fd就绪了。函数返回值反应这个数组的长度。
流程
在实际应用过程中,实际上基本流程就像下边那样。
1.创建epoll fd,通过epoll_ctl注册我们关注的fd与事件。在epoll_data中,我们可以添加我们想要数据,这里我们加入我们关注事件的回调函数(需要注意的是,这些关注的fd都应该为非阻塞的)。
2.所有事件注册完毕后,程序阻塞在epoll_wait上,等待唤醒。
3.当有就绪的fd时,线程被唤醒,同时我们得到了就绪的fd与对应就绪的事件。我们通过步骤1中加入的回调函数,进行read,write以及业务等操作。循环处理所有就绪fd后,重新阻塞在epoll_wait,重复2,3。
扩展
上面所有流程,已经大体上概括了各种库和框架的核心调度方式。对上述过程的其他工程化修改总结,产生了像Reactor,Proactor模型,在本文中不再进行介绍。
当前的流程可以概括为回调模型,即当某个fd可读或可写时,通过回调函数来执行对应逻辑。这个模型缺点还是很明显的,比如一个需求需要10次io等待,那这个需求就要分到10个回调函数中,每一个io事件都要注册回调,代码就会十分分散,不易阅读。因此在此之上,有产生了协程框架来解决这些问题,不过会有一些性能上的损耗,同时也没能解决调试时栈不完整的问题。
除了socket fd可以用epoll管理外,Linux中还提供了event fd,通过event fd可以完成一些其他操作。如:当线程a有一个函数任务想让b线程运行,此时如果线程epoll中关注着一个event fd,那么a就可以向这个event fd写入数据就可以唤醒b了。(当然如果你动手能力强,也可用一个socket fd模拟,不过没人会这么干)
总结
本文主要面向初学者,仅仅是对epoll的简单介绍,很多实践中重要的点并没有提到。有兴趣的读者可以自行去进一步学习。如有文中描述错误或有更好的建议,还请提醒我,我会尽快修改。
广告:能为我的项目点点star吗,ദ്ദിᵔ.˛.ᵔ₎✧