65.9K
CodeProject 正在变化。 阅读更多。
Home

旅鼠软件技术

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (34投票s)

2020年2月10日

GPL3

23分钟阅读

viewsIcon

99197

downloadIcon

341

我们是否即将走向悬崖?

引言

在计算机行业中一些常见的技术,在服务器和其他重要应用中是不可取的。这些技术有时被使用,因为它们需要较少的努力。或者,它们只是在使用特定语言、平台或框架时做某事的默认方式。因此,健壮服务核心及其相关文章的核心目的是提供通常更优选但不容易获得或不为人知的替代方案。如果您是第一次阅读有关RSC的文章,请花几分钟阅读这篇前言

免责声明

本文贬斥的这些技术有其合理用途,甚至指出了其中一些。但这些技术被严重滥用,对其缺点考虑甚少。当然,这取决于您正在做什么。如果您正在为个人使用构建东西,并且其质量不是那么重要,那就随便做吧。如果您正在为客户端或桌面编写软件,本文中提出的一些问题将不适用。本文主要是从设计需要高可用、可靠、高效和可伸缩的服务器软件的角度编写的。但即使您不是在开发服务器软件,本文也应该提醒您许多常用技术的缺点,并让您了解其他选项。

文章最后讨论了一些C++主题,因为尽管C++有其缺点,但我会选择它来构建具有上述特性的系统。然而,文章的其余部分讨论的是不特定于C++的通用设计原则。

服务器设计糟糕列表1

线程

问题不在于线程本身,而在于它们的**数量**。以下是一些典型示例:

  • 每用户一线程每TCP accept()一线程:愚蠢至极
  • 每请求一线程:白痴行为
  • 每对象一线程:荒谬绝伦

一个拥有数千个线程的系统,其性能将远不能令人满意。线程创建和调度需要时间,它们的栈会消耗大量内存,除非其大小经过精心设计,而对于一个无脑生成线程的系统来说,这通常不会发生。我们有一个小任务要做?让我们fork一个线程,调用join,让它完成工作。在C++11引入<thread>之前,这已经很流行了,但<thread>并没有对此起到任何抑制作用。我认为<thread>除了用于玩具系统之外,没有什么用处,尽管它可以作为基类,在其上添加许多其他功能。

即使撇开这些“每XX一线程”的设计不谈,有些系统也过度使用线程,因为这是它们唯一的封装机制。它们不是非常面向对象,也缺乏任何类似于应用框架的东西。所以每个开发者都通过编写一个新线程来执行新功能,从而创建自己的小世界。

编写新线程的主要原因应该是避免使现有线程的线程循环复杂化。线程循环应该易于理解,并且线程不应试图处理各种类型的工作,这会迫使它多任务并对其进行优先级排序,实际上是充当调度程序本身。

以下是一些避免过多线程的方法:

  • 考虑在当前线程中完成工作,而不是生成新线程。
  • 使线程成为守护进程(在重启之前持续存在,执行特定类型的工作)。
  • 要串行执行大量工作,请将其放入由另一个线程服务的工作队列中。如果一个工作项指向应该处理它的对象,并且对象不执行阻塞操作,那么一个线程足以前端所有这些对象。
  • 为了并行执行大量工作——通过为每个请求创建线程并不能更快完成——让同一个线程向其他处理器发送请求并处理它们的回复。一个线程可以处理所有这些,除非它使用阻塞发送,这将在后面受到彻底谴责。
  • 将阻塞操作卸载到专门处理它们的线程,例如前端`cin`的线程、前端`cout`的线程、将数据流式传输到文件的线程以及处理网络I/O的线程。为了提高吞吐量,线程池通常适用于这些(例如,写入磁盘的线程池,或每个IP端口和协议组合的专用线程)。

调度

我的文章健壮C++:P和V的危害感叹抢占式和优先级调度是大多数操作系统的实际标准。通过看似随机地启动上下文切换,这些调度策略创建了多线程应用程序必须用互斥锁保护的临界区。

如果您查看那篇文章,您会发现它收到了相当多的差评。大多数人没有发表评论,但有一条评论指出信号量是普遍接受的,并且不同的解决方案不可能是一种改进。这条评论应该简单地说“tl;dr”,因为很早的时候,文章就指出信号量是不可或缺的,但就像goto一样,应用程序应该很少使用它们。

假设我们没有调度器,并且我们召开会议讨论如何实现一个调度器。哪个小组会设计出以下方案:

  • 尽可能多地创建临界区;
  • 这样做,增加了与满足产品规格无关的人为复杂性;
  • 强迫开发者为“线程安全”而烦恼;
  • 通过互斥锁分配、竞争甚至死锁来降低系统性能;以及
  • 滋生容易犯错但难以重现和调试的错误?

显然,这样的设计只能出自一个受虐狂主导的群体。所以我的文章推荐了这些替代方案:

  • 协作式调度取代抢占式调度。每个线程在准备好进行上下文切换时都会让出。它通常在完成一个逻辑工作单元后,在其线程循环中这样做。如果每个逻辑工作单元都运行到完成,那么该代码中就不可能存在临界区。
  • 比例调度取代优先级调度。将每个线程分配给一个派系——一组执行相关工作的线程——并为每个派系分配一部分CPU时间。一个派系可能比另一个派系获得更多的时间,但每个派系都有重要的工作要做,因此即使系统负载很重,也能获得一些时间。除非您在后台运行SETI处理,否则您不太可能拥有优先级高的线程会饿死的工作琐碎的线程。

情况并非如此简单,因此文章讨论了如何处理不完全符合此方案的场景。尽管如此,许多系统如果采用这些调度策略,将会受益匪浅。

对称多处理

正如抢占式和优先级调度会尽可能多地创建临界区一样,在共享内存的CPU上运行可执行文件的副本也会如此。要在这样的多核平台上使用协作式和比例调度,请通过为每个核心提供私有内存段来沙盒化每个核心,并构建一个将每个核心视为独立节点的分布式系统。这避免了通常困扰多核系统的临界区、互斥锁竞争和缓存冲突。而且,由于您的应用程序将是真正的分布式,它将超越单个平台上可用的CPU数量进行扩展。

内存管理

尽管许多应用程序仅使用堆来分配新对象,但这在需要长时间运行的系统中存在几个主要缺点,并且每个缺点都可能导致崩溃。

  1. 持续分配和释放各种大小的内存块可能导致碎片化,最终导致分配失败。为避免这种情况,堆管理器必须合并相邻的空闲区域并使用最佳匹配策略,这两者都会增加开销。
  2. 一个 bug 可能会导致软件组件分配所有可用内存。
  3. 内存泄漏最终可能导致堆内存不足。

健壮C++:对象池描述了一种解决这些缺点的方法。当系统初始化时,它会为系统类层次结构中的每个主要子树创建一个对象池。每个池的大小由一个配置参数决定,该参数指定池中块(用于未来对象)的数量。一个基类,其所有派生类都共享其池,会重载operator newoperator delete以使用池而不是堆。这提供了一些针对内存吞噬者的保护,并消除了不断增加的碎片化。

这种设计还可以从内存泄漏中恢复,因为池反映了系统的对象模型。因此,对象池审计(一个线程)可以标记所有块为孤立块,告诉每个池声明其正在使用的块,然后恢复任何孤立块。这是垃圾回收,但作为**后台**活动,而不是前台活动。系统不必像Java或C#那样花费那么多时间进行垃圾回收,并且垃圾回收器不必担心在有很多工作要做时冻结系统。应用程序仍然期望删除对象,因此恢复孤立块会突出一个需要查找和修复的错误。

这种设计的好处是我更喜欢C++而非家长式语言的原因之一,后者排除了这种设计。它依赖于放置`new`功能,该功能也可用于在其他情况下提高性能。如果我能够分配对象,那么期望我释放它们是合理的!而且,垃圾回收语言无论如何也不是内存泄漏的万能药,因为当引用未被清除时,仍然可能发生内存泄漏。

回调

一些框架普遍使用回调来实现观察者模式。订阅者向发布者注册,当感兴趣的事件发生时,发布者通过调用订阅者提供的回调函数来通知订阅者。

回调是高效的,并且有其合法用途。然而,它们也有值得考虑的缺点:

  1. 如果回调由于使用错误的指针等原因导致异常,那么发布者的线程而非订阅者的线程将面临风险。
  2. 如果回调执行大量工作,它可能会损害系统的工程设计。它的工作可能以错误的优先级运行,或者,如果使用比例调度,则在错误的派系中运行。
  3. 如果订阅者是在提供事件路由的框架中实现的,则回调事件不会通过该框架。订阅者直接接收它,绕过了框架以及当事件以通常方式通过它路由时它提供的功能。

当任何这些缺点成为问题时,应使用**消息**取代回调。发布者可以发送消息,或者如果订阅者是异构的且需要不同的消息,则其回调可以这样做。

同步消息

同步消息是指应用程序内联发送的消息,之后它会阻塞以等待响应。这通常作为远程过程调用 (RPC) 实现,使其看起来就像应用程序正在调用一个返回结果的函数。因此,为了简洁起见,我们将其称为**SRPC**(同步RPC)。

由于多种原因,SRPC 是软件中最糟糕的东西之一:

  1. 如果应用程序正在直接与用户交互,SRPC 期间显示屏上会出现一个旋转的轮子或沙漏。如果 SRPC 的接收方未能响应,用户除了看着这个图标旋转直到 SRPC 超时之外,什么也做不了,此时他的耐心也已耗尽。在大多数情况下,用户只能通过强制关闭应用程序来使旋转图标消失。这简直令人发指。
  2. 如果应用程序在服务器中运行,SRPC 会阻塞正在运行的线程,导致上下文切换的开销。更糟糕的是,需要一个线程池,以便在其他线程被 SRPC 阻塞时,有一个线程可以为用户服务。这是前面提到的愚蠢的“每请求一线程”设计的一个例子。
  3. 应用程序无法并行执行工作,因为SRPC会导致其按顺序执行。
  4. 如果在 SRPC 期间应用程序还可以从其他来源接收输入,那么这些输入会排队等待,而不是及时处理。
  5. 类似于前面提到的回调,SRPC的响应或超时在应用程序函数内部到达,绕过了通常提供事件路由的任何框架。
  6. 应用程序不需要将 SRPC 包含在其状态机中。如果它专门使用 SRPC,它甚至除了其线程栈和指令指针之外,根本就没有状态机!这使得很难将其需求追溯到其实现,也难以全面了解其行为。
  7. 在某些情况下,SRPC会**明确命名**消息的目标函数。这在事务涉及的两个软件组件之间产生了不希望的耦合。
  8. SRPC 看起来像一个函数调用,所以很容易在 SRPC 期间不经意地持有互斥锁,从而阻塞所有其他互斥锁的用户,直到 SRPC 完成。可悲的是,我见过一些系统在多个地方发生这种情况。

精心设计的软件通过使用状态机和异步消息来避免这些缺点。然而,这通常需要更多的开发时间。状态机必须仔细设计,以便它对每个可能的状态-事件组合都有一个事件处理程序。这在 SRPC 中不太受关注,因为在 SRPC 期间,除了回复或超时之外的事件只是简单地排队。

状态机的一个潜在严重问题是其状态-事件空间的爆炸。这通常发生在应用程序由一组代表同一用户运行的状态机实现时。当组中的状态机交换消息以处理外部输入时,它们处于瞬态,因此在完成处理当前输入之前不愿意接受另一个外部输入。在此期间,它们可以使用优先级高于外部消息的异步消息,而不是使用 SRPC。由于优先级消息首先处理,因此组可以在接受另一个外部消息之前达到稳定状态。唯一的限制是组中的状态机必须都在同一处理器中运行,但出于性能原因,这也是可取的。

分布式

透明化分布式

修改单处理器应用程序使其分布式,并以对应用程序透明的方式进行,这不是一种实际的软件设计技术。相反,它是由那些没有严肃系统经验的人吹捧的东西。“透明”的解决方案通常涉及添加SRPC,而SRPC已经被揭露为非常糟糕。

分布式引入了处理器间消息,由于多种原因,这**不能**是透明的:

  1. 另一个处理器可能无法响应消息,因为:(a) 无法访问;(b) 停止服务;(c) 发生异常;(d) 由于过载控制策略而丢弃了消息。因此,消息的发送方必须处理超时。
  2. 处理器间消息的接收方响应可能很慢,这会降低应用程序的响应时间,并可能影响其工程规则。
  3. 如前一节末尾所讨论的,处理器间消息的发送方在等待响应时会进入新状态,因此它必须考虑在此状态下可能到达的其他消息。
  4. 如果需要独立升级每个处理器的软件的能力,则它们之间的协议必须保持向后兼容。

任何“透明”分布的说法都是欺诈性的。

分布式方法

分布式应用程序的动机通常是通过增加处理能力来提高吞吐量。那么问题是如何对应用程序进行分区。将一些组件(功能)移动到其他处理器的策略称为**异构分布式**,但这通常是一个糟糕的选择,原因如下:

  1. 它很少能显著提高可伸缩性。以前通过过程调用实现的功能,现在需要处理器间消息,这会带来更高的开销。这种开销可能会显著削减通过卸载应用程序一部分而节省的处理时间。
  2. 它无助于提高生存能力,而这本来可以是分布式的好处,甚至是**动机**。如果运行应用程序一部分的任何处理器出现故障,整个应用程序都将不可用。
  3. 它会导致凝聚力差,因为应用程序的状态现在分布在多个处理器上。因此,处理器间消息可能需要交换大量信息。如果处理器间协议需要保持向后兼容性,这可能会变得特别具有挑战性。
  4. 它通常会导致构建不同的软件负载,每个负载处理可以分配给特定处理器的应用程序子集。这可能会成为显著的管理开销。

出于这些原因,**同构分布式**是更优选的。这种方法不是将**功能**移动到其他处理器,而是将**用户**移动到其他处理器。这就是可伸缩网络的构建方式,因此可以在**系统内部**使用相同的方法。这被称为**递归设计**,它不会遇到上述缺点:

  1. 系统以网络扩展的方式进行扩展。
  2. 如果处理器宕机,只会影响该处理器服务的用户。
  3. 内聚性得以保持,因为应用程序的每个实例仍然在单个处理器内运行。如果应用程序是多用户的,以前支持跨网络协作的相同协议仍然可以使用。仅当所有用户以前都必须由同一处理器提供服务时,才需要新的处理器间协议。
  4. 每个处理器运行相同的软件负载。

然而,在某些情况下,异构分布是合适的:

  1. **难以或昂贵地分布式**。一个大型的、频繁更新的数据库就是很好的例子。它将保持集中,而使用它的应用程序则分布式。
  2. **纵向,层间**。尽管在层**内**同构分布式是优选的,但在可以分离上层和下层功能时,异构分布式通常是优选的。当网络以这种方式设计时,它定义了一个协议标准,以便来自不同供应商的上层和下层组件可以互操作。下层组件可能面临严格的实时要求,而上层组件可能只面临软实时要求。将面临不同压力的组件组合起来是具有挑战性的,因此将它们分离可能更为谨慎。

最终结果是**分层分布式**:层**内**同构(对等),但上层和下层**之间**异构。

用户空间

许多操作系统都提供用户空间,这意味着每个进程都在一个私有内存段中运行。默认情况下,进程无法访问其自身段之外的内存;这样做会导致异常。

用户空间是在单个系统上运行不相关应用程序的好方法。进程之间的防火墙可以防止一个应用程序中的错误损坏另一个应用程序中的数据。

然而,将大型应用程序的组件运行在单独的用户空间中通常是一个糟糕的设计决策,主要原因在于它引入的性能开销:

  1. 进程间的上下文切换比线程间的上下文切换昂贵得多。
  2. 带有用户空间的系统也将内核与应用程序隔离。进入和退出内核模式以访问内核函数是另一项开销。
  3. 如果一个进程需要访问另一个进程中的数据,它必须使用消息来完成。曾经的函数调用变成了可能昂贵1000倍的东西。

用户空间唯一的优点是,一个组件不会导致另一个组件崩溃。但是,如果一个组件设计不良,则没有什么可以阻止它损坏自己的数据。而且,由于所有组件都在应用程序中发挥作用,一个组件的崩溃最终会导致应用程序不可用。

通过消息访问数据的成本如此之高,以至于多个进程所需的数据最终会迁移到所有进程都可以访问的共享段中。然而,这重新引入了用户空间本应消除的许多风险。

保护易受攻击的共享段而又不显著损害性能的愿望可以导致一个顿悟:**写保护**共享段,只是不要**读保护**它!只要仍能封装组件,就没有理由阻止应用程序的组件能够**读取**彼此的数据,而这种设计并不排除这一点。

写保护数据不得经常更改,因为每次更新都涉及取消保护和重新保护共享段,其成本与进程间上下文切换相似。幸运的是,配置数据和大型数据库等通常满足此标准。这些数据对应用程序至关重要,重新加载它们耗时,因此写保护它们是有益的,并且不会增加不必要的开销。

既然我们已经到了这一步,我们不妨取消用户空间,让应用程序在一个进程中运行。用户空间对提高生存能力的作用微乎其微。它们只会拖慢系统,没有任何实际用途。

用户空间适用的一个场景是在单个系统上运行通常在单独节点中运行的可执行文件。这可以降低将应用程序部署到相对较小的用户群的成本。

有关写保护数据及其产生的快速重启策略的更多详细信息,请参阅健壮C++:初始化和重启

动态链接

动态链接是指在应用程序需要时才加载软件。最著名的例子可能是Windows上的DLL(动态链接库)实现。

动态链接的优点是多个应用程序可以共享一个库实例,而不是单独包含冗余副本。但是,当系统专门用于特定目的时,这样做并无益处。预先已知需要该库,因此应用程序应作为单个静态链接可执行文件交付。这有几个优点:

  1. 它避免了与共享库相关的样板代码(例如,`dllexport`,`dllimport`)。
  2. 当系统初始化后,它就完全准备就绪了。加载器在载入共享库时,它不会深呼吸。这符合服务器在初始化期间应尽可能多地构建系统的通用设计原则,以便在投入服务时为客户端请求提供可预测的延迟。
  3. 它避免了**依赖地狱**的危险,即当其中一个共享库与软件测试所用的库不同时,软件崩溃或行为不正确。

专用系统的软件应专门使用静态库。

C++

尽管 C++ 是实现健壮软件的绝佳选择,但以下是一些在典型编码标准中不会出现的指导原则。

预处理器

预处理器的许多用法可以追溯到编译器优化能力远不如现在的时代。现在它们优化得如此之好,以至于很难调试编译器积极优化代码的发布版本。许多优化使代码变得如此模糊,以至于在调试实时系统中,我建议禁用它们大部分。

在 C 语言中,预处理器还被用来实现 C++ 可以用模板更好地实现的功能。事实上,直到我看到它们被描述为“强化版宏”时,我才真正理解模板。

在现代 C++ 中,使用预处理器功能的理由远不如其初期。但旧习惯难以改变。预处理器的一些方面,如 `#include`,无法避免,但那些迫使读者弄清楚预处理器将发出什么的代码应该被禁止。

具体来说,我将预处理器的使用限制在以下几点:

  • #include 指令
  • #ifndef 用于 #include 保护
  • #ifdef 用于包含或排除,几乎总是指一个**完整文件**,该文件是特定于平台的目标文件。
  • #define 用于映射到空字符串的伪关键字(当 `for` 语句的一部分为空时,我使用 `NO_OP` 而不是裸分号,其理由与 `[[fallthrough]]` 相同)。

仅此而已,以下所有内容都应被视为令人憎恶:

  • #define 用于不映射到空字符串的符号
  • #undef
  • # 运算符(字符串化)
  • ## 运算符(连接)

main()

在一个大型系统中,`main` 很容易变成一个包含万物的“垃圾场”。《健壮C++:初始化与重启》描述了如何以结构化、分层的方式初始化系统。

异常

出于多种原因,异常应该非常谨慎地使用:

  • 当一个函数可以抛出异常时,它的调用者不仅要检查它的返回值,还要决定是否使用`try`和`catch`。如果一个**传递地**调用的函数**也**可以抛出异常,而它的调用者没有捕获,情况很快就会变得难以控制。那些称异常为“地狱般的`goto`”的人可能就是在这种系统上工作的。
  • 异常增加了开销,既包括可执行文件的大小,也包括处理异常所需的时间。
  • 除非在抛出异常的地方设置断点后可以重现问题,否则基本的`exception`对于调试目的毫无用处。更好的方法——也是在**实时**系统中捕获调试信息的唯一方法——是通过实现一个在栈被展开**之前**捕获栈的子类来**派生**自`exception`。这**肯定**会使抛出异常变得昂贵。

当异常发生时,它应该被视为需要修复的bug。健壮C++:安全网描述了如何将POSIX信号和Windows结构化异常转换为C++异常,以便基类`Thread`可以捕获它们并从`SIGSEGV`(错误的指针)等情况中恢复,而不是允许应用程序退出。

例如,应用程序函数应该检查参数以避免引发异常。几乎总是,它们应该返回一个表示失败的值,而不是抛出异常。但是当错误非常严重以至于正在执行的工作应该中止时,异常是合适的,因为目标是返回到函数调用栈的某个位置并恢复。禁止使用异常的编码标准在这种情况下是愚蠢地忽视了它们的优雅。

如果系统想要在通常行为是中止的情况下恢复,它将不得不处理异常或类似的东西。那么问题就来了,应用程序何时可以使用它们?答案不是**从不**,而是**很少**。

noexcept

将函数标记为 `noexcept` 以便编译器可以“优化”代码,是最近玷污 C++ 世界的癖好之一。我预测 `noexcept` 最终会像渡渡鸟、`register`、`inline` 的原始含义以及如果一项值得称赞的 C++20 提案成功的话,`volatile` 2 一样被淘汰。

如果我们可以捕获`SIGSEGV`异常并防止中止,那么将任何“平凡的”非虚函数标记为`noexcept`是危险的。原因是,如果传递给函数的`this`指针是错误的,函数将通过使用它而导致中止。我们正在捕获POSIX信号,所以我们也可以捕获由此产生的`SIGABRT`,但是栈是否会被正确展开(通过删除局部对象)取决于编译器,所以您可以看到其中的风险。

一些编译器坚持即使是非平凡的析构函数也要标记`noexcept`。尽管它们对此一无所知,但您必须服从。但在自愿标记任何东西为`noexcept`之前,请仔细考虑。C++静态分析工具中描述的工具因此建议删除`noexcept`,除非这会导致兼容编译器中的错误。

本节已经提到了在非虚函数上使用 `noexcept` 的风险。当一个**虚**函数是 `noexcept` 时,它的**重写**也必须是 `noexcept`,这可能有些武断。所以最终,`noexcept` 应该只在别无选择时使用,即在重写一个被标记为 `noexcept` 的外部函数时。

历史

  • 2020年4月29日:新增动态链接一节
  • 2020年3月6日:新增对称多处理一节
  • 2020年2月19日:微小改动
  • 2020年2月10日:初始版本

注释

1 本节的标题是为了致敬经典文章The Windows Sockets Lame List

2 C++20 弃用了一部分 `volatile` 的用法。

© . All rights reserved.