内核角落 - AEM:Linux 的可扩展和原生事件机制
在之前的一篇文章 [“Linux 的事件机制”,LJ,2003 年 7 月],我们介绍了 Linux 在电信领域采用原生和通用事件机制的必要性。许多旨在增强 Linux 功能的现有解决方案迄今为止都失败了。其他解决方案未能达到我们的满意程度,因为运营商级平台具有不同的实时性要求。为了取得成功,这种事件机制必须与主机操作系统紧密结合,并利用其功能以提供更好的性能。
在加拿大蒙特利尔的开放系统实验室(爱立信研究),我们于 2001 年启动了一个项目,以开发通用解决方案,即异步事件机制 (AEM)。 AEM 允许应用程序为某些特定事件定义和注册回调函数,并让操作系统在事件被激活时异步执行这些例程。
AEM 提供了一种事件驱动的开发方法。这是通过定义一个自然的用户界面来实现的,其中事件处理程序在其参数列表中包含内核直接发送的执行所需的所有数据。
AEM 的另一个动机是,基于多线程架构的复杂分布式应用程序已被证明难以开发和从一个平台移植到另一个平台,这归因于管理层。 AEM 的目标不仅是缩短软件的开发时间,还要简化源代码生成,以提高不同平台之间的可移植性并延长软件的生命周期。
该项目最大的挑战是设计和开发一个灵活的框架,以便可以在不重启系统的情况下,为正在运行的系统添加或更新新的事件处理实现。约束是在不重启系统的情况下执行系统维护。 AEM 的模块化架构提供了这种能力。
AEM 是对其他现有通知机制的补充解决方案。其最大的好处之一是可以混合事件驱动代码和其他顺序代码。
AEM 由一个核心模块和一组可加载内核模块组成,这些模块为应用程序提供一些特定的事件服务,包括软定时器和用于 TCP/IP 的异步套接字接口(图 1)。这种灵活的架构允许随意扩展 AEM 的功能。
模块可以实现什么功能没有限制,因为每个模块都向应用程序导出一系列独立的伪系统调用。 事实上,这允许两个不同的模块同时提供相同的功能。有趣的是,这提供了加载新模块以实现改进版本而不会破坏其他应用程序的可能性——它们继续使用旧版本。这种设计提供了根据应用程序的需要加载必要的 AEM 模块或在运行时升级模块的能力。

图 1. AEM 基于一个核心内核模块,该模块提供基本事件功能,以及一组独立的内核模块,这些模块为应用程序提供异步事件服务。
这种灵活性的条件是内核中的战略位置存在事件激活点(图 2)。每个激活点都是一个特定的 AEM 队列,用于激活事件。在以下部分中,我们将详细描述 AEM 的内部结构。
当程序执行的主流程在没有警告的情况下被中断以执行事件处理程序时,异步性的概念是一个主要问题。然后,输入请求在不知道先前输入状态的情况下被处理。这些请求由核心内核或中断处理程序直接传递,并且接收时不会假定任何类型的顺序。这种情况对于某些应用程序来说构成了一个问题,包括那些基于 TCP/IP 的应用程序,它们依赖于事务状态来继续进行。
AEM 是一个三层架构,由一组用于事件管理的伪系统调用、一个执行事件序列化并存储上下文信息的每个进程的 event_struct(以便执行用户回调)以及一个执行事件激活的每个事件的 job_struct 组成。
从 AEM 的角度来看,事件是系统刺激,它启动执行代理(事件处理程序)的创建。 event_struct 提供了支持,它是在事件注册期间初始化的结构,其中包含执行一个事件处理程序所需的上下文。一些主要字段是用户空间事件处理程序的地址、事件处理程序的构造函数和析构函数以及与其他事件的关系(事件列表、子事件和活动事件——见图 3)。
每个进程可以注册任意数量的事件。当检测到事件时,我们称之为已激活,并且用户定义的回调函数很快就会被执行。事件在内部以它们到达系统的相同顺序链接。然后,由每个处理程序构造函数正确管理数据,并保持每个进程的事件序列化,而无需假定任何到达顺序。
某些进程事件是活动的,并链接到活动事件列表。事件激活后,可以创建一个进程。这些事件称为克隆器,这些事件和创建的进程之间的关系在内部记录。图 3 中顶部进程注册的事件在其下方创建了两个新进程。它们仍然附加到此事件并保留自己的事件列表。
事件处理程序在事件注册期间使用,并且必须在用户级别实现。它们定义了自己的固定参数集,以便直接向用户空间进程提供事件数据完成。此操作由事件构造函数和析构函数完成,它们分别在处理程序被调用之前和之后执行。事件处理程序在与调用进程相同的上下文中执行。该机制是安全且可重入的;当前执行流程被保存,然后恢复到中断之前的状态。
在注册期间,每个事件都关联一个优先级,该优先级表示应用程序对接收通知的兴趣速度。 可以使用两个不同的优先级为同一事件注册两次。
其他实时通知机制,例如 POSIX 信号的实时扩展,在调度决策期间不考虑优先级。这很重要,因为它允许接收高优先级事件的进程在其他进程之前被调度。在 AEM 中,事件的发生会根据其优先级将事件处理程序推送到执行状态。在某种程度上,事件处理程序是一个进程,因为它具有执行上下文。当事件到达率很高时,动态更改进程优先级是一个真正的问题,因为优先级以相同的速率快速更新。我们通过引入使用事件优先级组合计算的动态软实时值来解决此问题。该值影响调度决策,但不影响 Linux 调度器,并为应用程序带来软实时响应能力。
作业是引入的新内核抽象,用于在通知进程之前服务事件。它不是进程,尽管两者都共享可执行实体的相同概念。作业执行的一个典型操作是将自身插入到等待队列中,并在那里停留直到某些东西唤醒它。此时,它会快速执行一些有用的工作,例如检查数据有效性或可用性,然后再激活用户事件,然后返回睡眠状态。作业还保证在它访问某些资源时,没有其他作业可以访问它。多个作业可以与一个进程关联,但每个事件只有一个作业。
内核和用户进程之间的这个抽象层是必要的。否则,很难确保在执行处理程序时,检查数据可用性或聚合同一事件的多次发生的一致性。如果出现问题,进程会浪费时间在用户空间中处理事件。是否连接多个通知是特定于事件的,并且应该在事件激活之前解决。
作业的通用实现将考虑软件中断,以便在事件发生时间和进程被通知时间之间具有较短的延迟。目标是代表进程执行,并提供与中断处理程序和内核线程相同的功能,而无需拖累完整的执行上下文。
实现了两种类型的作业,周期性作业和反应性作业。周期性作业以有规律的间隔执行,而反应性作业在接收到事件时零星地执行。作业由它们自己的离线调度程序调度。根据实时调度理论,两种类型的作业都可以由同一个调度程序管理(参见 Jeffay 等人的在线资源论文)。在我们的上下文中,作业是非抢占式任务。根据定义,作业没有特定的截止日期,尽管由于其低级性质,它们的执行时间应该隐含地受到限制。这种假设简化了实现。在我们的案例中,约束条件是反应性作业能够在两次调用之间以可忽略的时间间隔执行,以便满足流传输情况。
为了在发生零星事件时获得更好的吞吐量,我们对周期性作业和反应性作业的实现方式不同。作业调度程序和分派器处理周期性作业,而反应性作业出于性能原因自行更改状态。图 4 描述了作业状态演变以及用于从一个状态移动到另一个状态的函数。
一旦作业激活了相应的事件,进程就会异步执行,或者用户程序的当前执行流程会被重定向到代码中的其他位置。用户在注册时决定如何处理事件。
事件处理程序可以通过中断主线程的执行流程或创建新进程来处理事件并在并行执行它。在任何一种情况下,事件都被透明地和异步地管理,而无需显式轮询其发生。这种管理具有一些重要的意义,因为应用程序不必在需要之前保留和消耗系统资源。例如,我们为基准测试 AEM 适配了一个简单的 HTTP 服务器,它是完全单线程的,并且对于该类型的服务器具有相当好的性能。本文末尾对此进行了描述。
有时会出现需要创建新进程以响应事件的情况。不幸的是,动态创建进程会消耗资源;在此期间,新进程和父进程都无法处理新请求。在某些紧急情况下,可能没有剩余资源用于此目的,例如系统内存短缺。这是一个问题,因为紧急情况可能需要创建新进程以优雅地关闭系统或执行切换程序。
因此,我们引入了一个名为胶囊 (capsules) 的新概念。胶囊是一个已初始化的任务结构,并且是包含其他空闲胶囊的池的一部分。当进程想要创建新的执行上下文时,会从该池中链接出一个胶囊,并且仅使用当前进程参数中的一小部分对其进行初始化。
在事件注册期间,特定标志指示处理程序是否要在进程内部执行。没有参数或 0 表示处理程序将通过中断当前执行流程来执行。这些标志是
EFV_FORK:创建一个进程,其语义与 fork() 相同。
EVF_CAPSULE:从胶囊池创建一个进程。
EVF_NOCLDWAIT:此标志具有与信号 SIG_NOCLDWAIT 相同的语义。当子进程存在时,它会被重新父进程到胶囊管理器线程。
EVF_KEEPALIVE:通过进入内核循环(例如用户空间中的 while(1);)来防止进程/胶囊退出。
内存管理对于事件驱动的系统来说是一个主要问题,因为事件是通过执行直接从内核位于应用程序空间中的回调函数来处理的。最简单的解决方案是在进程注册事件时分配内存。但是,请考虑要注册大量事件的情况,或者需要重新启动进程、需要添加新事件或需要注销事件的情况。如果事件管理失败,系统完整性将变得不一致。操作系统内核自己管理此类资源并在进程请求时代表进程分配内存,则不太关键。
关于性能,必须能够在预分配池内部分配此内存,以防止内存碎片并保持软实时特性。即使通用内存分配器(例如 glibc 的 malloc())在通用目的方面提供了良好的性能,但它们也与此要求不符。
某些数据类型(例如整数)可以轻松传递到用户空间,但更复杂的数据类型(例如字符串)需要特定的实现。进程内存分配由 glibc 库管理。如果我们想从内核分配内存,这将变得很复杂,因为我们必须注意这个新地址是否正确位于或映射到进程空间中。代表用户进程高效且简单地分配内存是 Linux 内核当前缺少但需要的。
AEM 的 vmtable 正在填补内存分配领域的空白。它在用户进程内存池之上实现了二进制伙伴分配器的一种变体(在 Knuth 中介绍,参见资源)。这允许管理几乎所有类型的未计划大小的数据。在真正内存短缺的情况下,它可以退回到用户决策,即使用事件处理程序。如果发生不好的事情,此功能提供了依赖 glibc 作为最后手段的可能性。
AEM 的设计目的是在空闲块可用时在恒定时间内返回有效指针。这通常是电信应用程序的情况,这些应用程序最有可能为相同类型的应用程序接收相同大小的请求。我们还希望防止由短时间内大量不同大小的请求引起的内存碎片。
vmtable 还具有一个有趣的扩展:它可用于为设备驱动程序提供用户空间初始化程序。这可以使用指针来实现,该指针由 AEM 子系统从内核分配,然后通过回调函数传递给用户进程。然后在函数返回时将其返回给内核。回调函数不仅可以用作对事件的响应,还可以用作与内核安全通信的一种手段。
在这种场景中,每个用户进程都分配了一个称为 vmtable 的内存池。派生的进程和胶囊会自动继承 vmtable,具体取决于其父进程的 vmtable。实现了两种不同的策略
VMT_UZONE:分配在进程的堆段内完成。这提供了快速访问,但会消耗用户进程地址空间。
VMT_VZONE:分配在内核地址空间中完成,并映射到进程的地址空间内。这提供了最小的内存消耗,但由于页面错误处理,访问需要更多时间。
根据情况,这两种策略都具有一些优点和缺点。首选策略在启动应用程序时选择。图 5 说明了 vmtable 的架构布局。
在当前的实现中,物理页面由 vmtable 子系统显式分配。这样做是为了确保它们确实是连续分配的,以便内存池可以安全地用于 I/O 操作。未来的扩展是在网络性能的上下文中将 vmtable 用于直接 I/O。
vmtable 为 AEM 用户和模块开发人员导出一个简单易用的界面,隐藏了内核空间中内存分配的复杂性。 AEM 核心模块中实现了简单的例程,以透明的方式分配和释放内存。此接口极大地简化了模块的维护及其在不同 Linux 内核版本之间的适配。
我们进行了测试,以衡量 AEM 在两个远程进程之间的简单交换期间的行为。进行此测试是为了确保上下文切换事件处理程序所需的时间不会导致性能问题。最近,我们还进行了基准测试,以衡量 AEM 的可扩展性并测试作业和等待队列的内部实现,这代表了 AEM 的基本功能。
为了生成我们可以轻松比较的数字,我们决定使用现有的 Web 服务器并使用 AEM 接口对其进行适配。 AEMhttpd 是一个简单的单线程 HTTP 服务器(参见资源)。单线程服务器完全在主执行线程中运行。这意味着既不创建内核线程,也不创建用户线程来处理 HTTP 请求。使用这种类型的服务器进行的测量侧重于实现能力,而不是服务器本身的性能。
在图 6 所示的示例中,我们运行了 100 个活动事务。对于每个事务,我们增加了打开连接的数量,以增加套接字连接等待队列中的作业数量。在基于 select() 的标准服务器实现中,这将增加响应请求的时间,因为所有描述符都将按顺序扫描。使用 AEM,只有活动作业(即,数据就绪的套接字)才执行其相应的事件处理程序。这证明 AEM 提供了通用且可扩展的实现。
Linux 在工业界得到广泛应用,它正在成为企业级解决方案的首选操作系统。它也处于成为下一代基于 IP 的电信服务架构的首选操作系统的边缘。 Linux 功能已被广泛接受,但在内核级别提供增强功能将吸引需要可扩展性、性能和可靠性的下一代服务提供商。
AEM 是提供进程异步通知以及事件数据完成的可靠尝试。它还带来了一种事件驱动的方法,可以为应用程序开发启用安全的编程范例。此外,AEM 实现了一种机制,该机制侧重于通过导出简单的用户界面来提高应用程序的可靠性和可移植性。
Frédéric Rossi (Frederic.Rossi@ericsson.ca) 是加拿大蒙特利尔爱立信研究开放系统实验室的研究员。他是 AEM 的创建者和其开发背后的主要推动者。