介绍
本文的目的是分享我多年来关于如何开发某种应用程序的一些想法,对于这种应用程序,术语“服务”只是一个无力的近似称呼。更准确地说,将写的与一大类程序有关,这些程序每秒处理大量离散的消息或请求。网络服务通常最适合此定义,但从某种意义上讲,实际上并非所有的程序都是服务。但是,由于“高性能请求处理程序”是很糟糕的标题,为简单起见,倒不如叫“服务”万事大吉。
尽管单个程序中多任务处理现在很普遍,但我不会写此类“轻度并行”应用程序。您用来阅读本文的浏览器可能会并行执行某些操作,但是如此低的并行度真的不会带来多少有趣的挑战。当请求处理的基础结构本身是整体性能的瓶颈时,就会出现有趣的挑战,因此改进基础结构实际上会提高性能。对于运行在具有千兆位内存的千兆赫处理器上的浏览器,通过 DSL 线路同时进行六路下载,基础结构成为瓶颈并不常见。此文关注的重点不是用吸管抿水的应用程序,而是从消防水管喝水的应用程序。在硬件功能的边缘,如何做才是真正重要的。
有些人不可避免地会对我的一些意见和建议持怀疑态度,或者认为他们有更好的方法。挺好,我不是想成为上帝的代言人;我发现这些方法对我来说很有用,不仅是它们对性能的影响,而且它们对以后调试或扩展代码的难度也有影响。效果因人而异。如果还有其他方法对您更好,那太好了,但是请注意,我在这里建议的几乎所有方法都曾作为其他方法的替代品而存在,而我曾经尝试过,只是其结果让人厌恶或恐惧。你最喜欢的想法可能会在其中某个故事中占据显著位置,如果让我现在就讲述出来,无辜的读者可能会无聊至死。您不想伤害他们,对吗?
本文的其余部分将围绕我称之为“性能糟糕的四骑士”展开:
译者注:天启四骑士,战争、瘟疫、饥荒和死亡。
- 数据拷贝
- 上下文切换
- 内存分配
- 锁竞争
最后还将有一个总括的章节,但这些是最大的性能杀手。如果您能够处理大多数请求而无需数据拷贝,无需上下文切换,无需经过内存分配器并且无需竞争锁,那么即使有一些次要问题,您也会拥有一个性能良好的服务。
数据拷贝
这可能是一个很短的章节,原因很简单:大多数人已经吸取了这个教训。人人都知道数据拷贝不好。很明显,对吧?实际上,显而易见可能是您在计算机职业生涯的很早就知道,仅仅是因为有人在几十年前就开始使用这个词了。我知道我的情况就是如此,但我离题了。如今,每门学校课程和每个非正式的指南都涵盖了它。甚至营销人员也发现“零拷贝”是一个很好的热门词汇。
尽管事后看来副本很糟糕,但似乎仍然有些让人错过的细微差别。其中最重要的是,数据拷贝经常是隐藏和伪装起来的。您真的知道您调用的驱动程序或库中的代码是否会进行数据拷贝吗?可能超出您的想象。猜猜PC上的“编程 I/O”是指什么。哈希函数是伪装、非隐藏副本的一个示例,该函数具有副本的所有内存访问开销,并且还涉及更多的计算。一旦指出散列实际上是“拷贝升级版”,显然应该避免使用散列,但我知道至少有一群才华横溢的人,他们必须用艰难的方式来解决这个问题。如果您真的想摆脱数据拷贝,不管是因为它真的会影响性能,还是因为你想把“零拷贝操作”写入黑客会议幻灯片里,您将需要跟踪许多真正属于数据拷贝但并未广而告之的内容。
避免数据拷贝行之有效的方法是使用间接寻址,并传递缓冲区描述符(或缓冲区描述符链),而不是仅仅使用缓冲区指针。每个描述符通常由以下内容组成:
- 整个缓冲区的指针和长度。
- 缓冲区的实际填充部分的指针和长度,或偏移量和长度。
- 指向列表中其他缓冲区描述符的前后指针。
- 引用计数。
现在,代码只需将适当的缓冲区描述符的引用计数加一,而不用拷贝一段数据以确保它留在内存中。在某些情况下,这种做法可以非常好地工作,包括典型的网络协议栈的运行方式,但也可能成为一个真正令人头痛的问题。一般来说,很容易在链的开始或结尾添加缓冲区,添加对整个缓冲区的引用以及释放整个链。在中间添加,逐块释放或引用部分缓冲区愈加困难。尝试拆分或合并缓冲区简直让人发疯。
不过,我实际上并不建议所有情况都使用这种方法。为什么不?因为每次要查看头字段时都必须遍历描述符链,这将成为极大的痛苦。确实有比数据拷贝更糟糕的事情。我发现最好的方法是识别程序中的大对象,例如数据块,确保这些大对象按上述方法单独进行分配,这样就不必拷贝它们,也不必过多地操心其他事情。
这就引出了我关于数据拷贝的最后一点:不要过分规避。我已经看到太多的代码通过做更糟糕的事情来避免数据拷贝,例如强制执行上下文切换或中断大型 I/O 请求。数据拷贝代价很高,当您寻找避免冗余操作的地方时,它是您应首先考虑的问题之一,但是收益递减。对代码进行梳理,然后将其复杂度提高一倍,仅仅是为了去掉最后几份数据副本,通常是在浪费本可以更好利用在其他地方的时间。
上下文切换
尽管每个人都认为数据拷贝很糟糕,但我却常常为这么多人完全忽略上下文切换对性能的影响而感到惊讶。根据我的经验,在高负载下,上下文切换实际上比数据副本要落后更多的“崩溃”。系统从一个线程到另一个线程所花费的时间,开始多于它在线程内实际执行有用工作所花费的时间。令人惊奇的是,在某种程度上,导致过度上下文切换的原因是显而易见的。上下文切换的第一大原因是活跃线程数多于处理器数。随着活跃线程与处理器的比率增加,上下文切换的数量也会增加——运气好的话会呈线性关系,但通常呈指数关系。这个非常简单的事实解释了为什么每个连接一个线程的多线程设计的伸缩性非常差。对于可伸缩系统来说,唯一可行的选择是限制活动线程的数量,使其(通常)小于或等于处理器的数量。这种方法的一种流行变体是永远只使用一个线程。尽管这种方法确实避免了上下文抖动,也避免了加锁,它也无法实现超过一个处理器的总吞吐量。因此,除非该程序无论如何都是非 CPU 密集型的(通常是网络I/O密集型的),否则它仍然不受重视。
“线程有度”的程序要做的第一件事就是弄清楚如何让一个线程同时处理多个连接。这通常意味着前端使用 select/poll、asynchronous I/O、信号或完成端口,后端是事件驱动的结构。哪种前端 API 最好,许多“宗教战争”已经打过,而且还在继续。Dan Kegel 的 C10K论文
是该领域很好的资料。就个人而言,我认为所有 select/poll 和 signal 形式都是丑陋的,因此偏爱 AIO 或完成端口,但实际上并不重要。除了 select(),其他都可以很好地工作,处理程序前端最外层不需要做太多的工作。
多线程事件驱动服务最简单概念模型是在其中心处有一个队列。一个或多个 “listener” 线程读取请求并将其放入队列,一个或多个 “worker” 线程将其从中移除并处理。从概念上讲,这是一个很好的模型,但是人们通常经常以这种方式实现他们的代码。为什么这样做是错的呢?因为上下文切换的第二大原因是将工作从一个线程转移到另一个线程。有些人甚至要求由原始(译者注:listener)线程发送请求的响应,使错误更严重 —— 导致每个请求需要两次上下文切换而非一次。使用“对称的”方法非常重要,在这种方法中,给定线程可以在不更改上下文的情况下,从 “listener” 成为 “worker”,再成为 “listener”。
通常,即使将来的一瞬间,也不可能知道有多少个线程处于活跃状态。毕竟,请求可能随时出现在任何连接上,也可能专用于各种维护任务的“后台”线程在那一刻唤醒。如果您不知道有多少线程处于活跃状态,该如何限制有多少活跃线程?以我的经验,最有效也是最简单的方法之一:使用老式的计数信号量,每个线程在执行“实际工作”时都必须持有该信号量。如果已经达到线程限制,则每个 listen 模式线程可能会在唤醒时可能会产生一个额外的上下文切换,然后阻塞在信号量上,但是一旦所有 listen 模式线程都以这种方式阻塞,它们就不会继续争用资源,直到一个现有线程“退出”,因此系统影响可以忽略不计。更重要的是,这种方法处理维护线程比大多数替代方法更优雅(大部分时间处于睡眠状态,因此不计入活跃线程数)。
一旦将请求处理分为两个阶段(listener 和 worker),并使用多个线程为这些阶段提供服务,就很自然地将处理进一步分为两个以上的阶段。在最简单的形式下,处理一个请求就变成了在一个方向上依次调用各个阶段,然后又在另一个方向上进行调用(对于应答)的问题。但是,事情会变得更加复杂。一个阶段可能代表 “fork”出来两条处理路径的两个互不相同的阶段,或者本身可能会在不调用其他阶段的情况下生成应答(例如,缓存的值)。因此,每个阶段都必须能够指定请求“下一步应该做什么”。由阶段的派发函数的返回值表示,有三种可能:
- 该请求需要传递到另一个阶段(返回值中包含指示阶段的ID或指针)。
- 请求已完成(特殊的“请求处理完毕”返回值)
- 请求被阻塞(特殊的“请求阻塞”返回值)。与前面的情况相同,只是请求没有被释放,稍后将由另一个线程继续执行。
请注意,在本模型中,请求的排队是在阶段内,而非阶段之间。避免了将请求不断放在后继阶段的队列中,然后立即调用该后继阶段,再次使请求出队的常见愚蠢做法。我称之为没事找事的队列、锁定活动。
将一个复杂的任务分成多个较小的通信部分的想法似乎很熟悉,那是因为它实际上已经很久远了。我的方法源于 1978 年 C.A.R. Hoare 提出的“Communicating Sequential Processes”概念,该概念又基于 Per Brinch Hansen 和 Matthew Conway 的思想,这些思想可以追溯到 1963 年 —— 我出生之前!但是,当 Hoare 创造术语 CSP 时,他的意思是抽象数学意义上的“进程”,并且 CSP 进程不必与同名的操作系统实体相关。在我看来,通过单 OS 线程内部类线程的协程以实现 CSP 的常见方法给用户带来了并发的所有麻烦,却又没有任何可伸缩性。
同一时期,Matt Welsh 的 SEDA 是一个朝着更明智的方向发展的阶段执行理念的例子。实际上,SEDA 是“正确完成服务架构”的一个很好的例子,它的一些特定的特征值得评论(尤其是那些与我上面概述的特征不同的地方)。
- SEDA 的“批处理”倾向于强调一次处理多个请求,而我的方法倾向于强调一次处理单个请求的多个阶段。
- 在我看来,SEDA 的一个显著缺陷是,它为每个阶段分配了一个单独的线程池,只在后台重新分配各个阶段的线程以响应负载。因此,上面提到的引起上下文切换的“1”和“2”原因仍然存在。
- 在学术研究项目的背景下,用 Java 实现 SEDA 可能说得通。但是,在现实世界中,这种选择可谓不恰当的。