epoll和select

前两天看到一个推送,介绍epoll的原理的,我觉得是个挺好的例子,可以用来说明“错误的软件架构分析”是什么样的。但我这里不拉仇恨,不放那个链接,我只通过一个正面的描述说明一下,正确的软件架构分析应该是怎么做的。

很多工程师不能做好(已经存在的软件的)软件架构分析(或者建模),核心原因是求“正确”,他们希望他们的表述是没有错了,和被分析的对象完全一致。但架构分析不是这样的,架构是要找到那个软件“不变”的东西,宁愿和被分析对象不完全一致,也要保证首先突出“不变”的部分。但实际上,软件任何一个地方都是“可变”的,根本没有“不变”的部分。所以你看,我前面说的东西已经“不严谨”了。我追求的是“架构严谨”。所以,我其实不是追求“不变”的部分,我追求的是“最难变”的部分。不变-难变-较难变-容易变-自由,构成一个Pattern,你没有用某种力量去“推”它一下,它看起来就是一个平面,好像一块硬梆梆的石头,等你去推它一下,它内在的“Pattern”和“骨架”才露出来了,你才可以在这个骨架上建你后续的逻辑,那些后续的逻辑才是稳固的。

逻辑的稳固,是个动态的东西。我举个简单的例子帮助理解。比如你有一个硬件A,基于A做了一个软件B,B上面有一个用户程序C,那么这个过程中最难变的是谁?一般情况下,是A的设计,因为A的修改成本最高。所以,A的设计决定了B的接口。但这是初期,到了后期,假设我们有100个用户用了这个硬件,写了C1, C2, C3... C100个用户程序用了这个接口。这个时候,B的接口的控制要素就是当初定义的初期接口了。这个接口本来的控制要素是A,但后来,A的后续版本A1, A2, A3等反过来被它控制了。

这也是架构控制要追的“时机”,所谓圣人求难于其易,谋大于其细。本质也就是这个意思。普通人容易在B设计的初期无所谓,到后面想尽办法想解决问题,其实已经解决不了了。所以圣人尤难之,才终无难了。你只能求名,就会在初期放弃难,求进展,到难的时候当白莲花去攻关,但时机已经错过,你再努力也还是邀名,问题该解决不了,还是解决不了。

所以,逻辑的稳固,本身是个动态的过程,但这个动态具有一定的规律。它会顺着已有的逻辑在需求的作用下增强或者减弱,这是有一个连续的“因果”作用过程在驱动的。这个因果根本的驱动力是需求和竞争力。

需求和竞争力表现出来是feature。一个软件能被产品化,他会包含很多的细节的,部分细节逻辑为feature1服务,部分细节逻辑为feature2服务。如果我们删除对feature2的需求,和它相关的逻辑(和约束,下同)就都可以删除。但如果feature 1和feature2之间有复杂的绞连。这我们就做不到了,用户被迫在“同时选择f1和f2”和“重建f1,整体放弃整个f1/f2的绞连”之间做出选择。太多的绞连不能放弃,是整个软件最终被抛弃的原因。

我前面说那个epoll的介绍文档写得不好,就是因为它是平的,失去了“立体”和“重心”。用这种方法分析一个对象,后续的设计就会和无关紧要的东西产生密切的绞连。最终整个软件就会“死得快”。就算作为一种“学习”,这种学习的知识也是缺乏整理,无法有效利用的。

这其实是架构设计“不为天下先”这个策略的原因。每个设计,必然产生新的“约束”。加一个状态机管理,为了保证每次跃迁在执行上是原子的,就要加上锁的使用约束。加上锁的使用约束,会对线程的使用加上约束,对线程的使用加上约束,就对对外接口的提供产生约束,对对外接口产生约束就会对应用的业务模型产生约束,对业务模型产生约束就会对休眠过程产生约束,对休眠过程产生约束就会对业务迁移产生约束……约束越来越多,后面什么设计都不用加了。我们我们每次设计新的特性,增加新的逻辑链,都希望不产生新的约束,而是“复用”已有的约束,这就叫“不为天下先”。把设计做立体,目的就是希望当某个特性引入的约束太多的话,我们可以整体放弃它(这种放弃包括限制它的功能范围,出分支版本等),从而保护我们的整个软件的生存能力。不能放弃部分东西的个体,结果就是死。人类、生物为什么要发展出族群发展,个体死亡的演进模式?这个原理是一样的。没有放弃,要不没有发展,要不没有生存。


回到epoll和select的问题。我们要看这两个东西,不是看它的实现的,我们看的是它们在整个逻辑链中,已经建立的约束和带来的利益是什么,这才会看见它们的架构。

select包含两个接口select和pselect,两者只有一些细节上的差异,我们忽略这种小差异。从接口上说,select的核心是定义一组文件fd(简称fds),如果fds中某个fd的状态符合要求(比如变得可读,可写,或者有异常等),select就从等待中返回。

我们先不看内核怎么实现它的,我们至少可以先从逻辑上猜一下:这个东西要有一个基本的性能保障,靠任何一个fd的主动变化都能唤醒select这个系统调用引起的等待。它唯一的手段是让这个系统调用等在一个wait_queue上,然后让这些fd的backend在文件内容更新(这肯定是个IO线程的变动(注:这里的线程包括中断等任何异步执行的序列))的时候signal这个wait_queue。

这个设计可以带来的用户优势也是明显的:它的核心是用执行任务调度取代了线程调度。比如说吧,你有10个fd要监控处理。如果谁有事就要立即处理,你要不轮询,但轮询就会占用无效的轮询时间。如果你不想轮询,就要做同步等待,但同步等待要保证每个fd都能立即响应,你需要10个线程,每个等待在其中一个fd上面(否则前面的在等待,就无法处理后面的fd的消息)。

而线程是有成本的,如果你只有两个核,创建10个线程。这毫无意义。某个fd的信息处理了一半,时间片用完了,切换到另一个线程继续执行,这个切换对你毫无价值,只是增加成本。不如只创建两个线程,每个只处理一半的fds,处理完一个请求,再处理下一个。要做这种事,只有调度器能给你搞定,在用户态你自己是搞不定的。这样,你就需要一次等待在多个fd上,哪个fd就绪了,你就处理那一个,处理完了再考虑下一个。这样,所谓“多路复用”的select就有它的价值了,你没有任何其他手段可以做到这个。

我们看到了接口限制和利益考量,就可以对这个接口的演进有所判断,也可以知道我们应该在什么时候使用它了。其他的细节,都是枝叶而已。


再看看epoll,从接口表面的布置看,两者几乎是一样的,只是select用数组表示fds,epoll用另一个fd(epollfd)来表示fds。我简单想象一下:把原来select的接口大部分封装在epoll的接口上,不会太大的困难。比如把select变成自动创建一个epollfd,然后用epoll_ctl把数组中的fds设置进去,然后调epoll_wait()就可以了。

所以,两者本质是一个东西,区别仅仅是select的fds是每次等待都要设置进去,epoll的fds是一开始设置好,等待的时候不需要重新指定。这可以想像epoll在极端情形下会有三个优势:

  1. 如果我们固定等待一个数量很大的fd集合,每次select的成本会很高,因为要把这样一个列表每次拷贝到内核需要成本,而epoll只在初始化的时候需要处理
  2. 可以给每个fd设定要等待的类别,而不是分成三个组独立管理
  3. 由于每次fd更新了我们都要返回给用户态,要找到这个更新的fd,需要通过FD_ISSET扫描整个fds数组,这在fds数量很多的时候,也是不可忍受的成本

功能上的另一个比较明显的改进是epoll增加了边缘触发和电平触发的概念。对使用者的利益看起来是:可以让代码逻辑变得更规整,因为你没有把数据处理完,你可以回去继续epoll,让它继续走循环,这时如果其他fd就绪了,我们可以有机会优先处理那个fd。这样不会因为一个fd饿死所有其他的fd。但这个不是关键问题,因为它很容易在用户循环中通过增加一个“未完结fd cache”实现。

这样,我们基本上可以形成这样一种判断:epoll是select的升级版,一般情况下,或者fds特别多的情况下,应该首先用epoll。至于select,看你的平台支持情况,接口细节匹配情况再进行选择好了。


有了这些准备,我们才值得去看内核的实现上的不同。如果按前面这个判断,我会认为这两个接口应该复用同一个内核实现。但实际上不是,它们互相是独立的。一个实现在fs/select.c中,一个实现在fs/eventpoll.c中。但两者都是以fd的file->f_ops->poll函数为基础。因为poll回调是一个有大量实现者的接口,这些实现者就会成为这里的控制要素。分析它的接口我们就能看到内核可能的走向:

poll接口的语义要求是这样的:

unsigned int poll(struct file *file, poll_table *wait);

它要求每个实现者都主动调用下面这个函数:

static inline void poll_wait(struct file *file, wait_queue_head_t * wait_address, poll_table *wait);

它要求poll的实现者在这个函数中返回的时候,检查对应文件实体的状态,然后返回是可读可写还是错误。

其中wait_address由实现者自己定义,如果自己有数据了(比如硬件发出中断了),就signal这个wait_address,让它不要等待。

这个接口非常Tricky。你想象一下你来实现select或者epoll,你可以怎么用?

如果poll_wait就是在wait_address上等待,你wait就停在一个fd上了,你怎么玩其他fd的游戏?

我唯一能想到的用法是这样:把所有fd做成一个列表,先全部调一次所有fd的poll(),通过修改wait保证poll_wait()不会等待,只是收集它们的wait_address。把这些wait_address组织成一个完整的wait_queue。然后我才等在这个wait_queue上,之后,任何一个fd发了signal,我被释放了,我再去定位对应的fd,再调一次它的poll,更新它的状态,再从系统调用中返回。

这中间有很多优化策略,比如从signal快速定位fd,扫描poll的时候根据上次的结果决定是否需要调用它的poll,这些就是枝叶了。

这个逻辑,无论select还是eventpoll都越不过去,我们再确认一下具体的实现。判断实现上和我们前面的判断一致了,这样我们就完成了对这两个API的架构认知了。

编辑于 2019-05-08

文章被以下专栏收录