C++ 低延迟应用构建指南(一)

原文:zh.annas-archive.org/md5/e3c367e5bf117e0b3804e1c041986096

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

对于构建实用的低延迟应用程序来说,C++的理论知识是不够的。C++编程语言功能丰富,因此对于超低延迟应用程序,决定使用哪些特性以及避免哪些特性可能很困难。本书深入探讨了 C++编程语言中可用的特性以及 C++编译器的技术细节,从低延迟性能优化的角度出发。

使用 C++进行低延迟交易系统开发是量化金融领域非常受欢迎的技能。从头开始设计和构建一个低延迟电子交易生态系统可能会令人望而却步,本书将详细介绍这一点。它从头开始构建了一个完整的低延迟交易生态系统,以便你可以通过跟随交易系统的开发和演变来学习。我们将通过逐步的示例和解释学习构建低延迟应用程序时的重要细节。

此外,测量和优化性能是所有低延迟系统的持续进化,本书也将涵盖这一内容。到本书结束时,你将非常了解低延迟交易系统以及专注于低延迟开发的 C++特性和技术。

本书面向的对象

本书面向希望学习如何构建低延迟系统的初学者或中级 C++开发者。本书也面向对学习 C++中的低延迟电子交易系统特别感兴趣的 C++开发者。

本书涵盖的内容

第一章介绍 C++中的低延迟应用程序开发,介绍了低延迟应用程序期望的行为和性能特征。它还讨论了 C++编程语言的哪些属性使其成为低延迟应用程序开发的优先语言。本章还讨论了不同业务领域中一些最重要的低延迟应用程序。

第二章设计一些常见的 C++低延迟应用程序,深入讨论了驱动实践中一些重要低延迟应用程序的技术细节。本章探讨了低延迟应用程序的细节,例如实时视频流、在线和离线游戏应用程序、物联网IoT)应用程序和低延迟电子交易。

第三章, 从低延迟应用的角度探索 C++概念,从低延迟应用开发的视角深入探讨了 C++编程语言的细节。它将讨论如何使用 C++设计和开发这些应用,以及最佳实践。它将讨论 C++编程语言本身的技术细节,并讨论哪些特性对于低延迟应用特别有帮助,以及尝试提升性能时应该避免哪些特性。本章还将深入探讨现代 C++编译器使用的所有现代编译器优化技术,以及 GCC 编译器支持的优化参数和标志。

第四章, 构建低延迟应用的 C++构建块,实现了许多低延迟应用中使用的 C++基本构建块。本章构建的第一个组件是一个线程库,用于支持低延迟应用中的多线程处理。第二个组件是一个内存池抽象,它避免了动态内存分配,而动态内存分配非常慢。然后,本章构建了一个低延迟的无锁队列,用于在线程之间传输通用数据,而不使用锁,因为锁对于许多低延迟应用来说太慢了。本章还构建了一个灵活且低延迟的日志框架。最后,本章将构建一个实用程序和类库,以支持 TCP 和 UDP 网络套接字操作。

第五章, 设计我们的交易生态系统,讨论了完整电子交易生态系统的理论、需求和设计,以及我们将在接下来的几章中从头开始使用 C++构建的所有组件。本章将描述交易交易所中匹配引擎的需求和设计,该引擎负责执行相互之间的订单。我们还将描述与市场参与者通信的订单服务器和市场数据发布者组件。我们还将讨论存在于交易客户端系统中的订单网关客户端和市场数据消费者组件的需求和设计,这些组件用于与交易所通信。本章最后将描述和设计用于在客户端系统中构建和运行不同交易算法的交易引擎框架。

第六章, 构建 C++匹配引擎,描述了交易所中匹配引擎组件设计的细节,该组件负责构建限价订单簿并执行客户订单之间的匹配。限价订单簿跟踪所有市场参与者发送的所有订单。然后,本章完全使用 C++实现了匹配引擎和限价订单簿组件,并包含了它们所需的所有功能。

第七章, 与市场参与者沟通,描述了交易所市场数据发布者和订单服务器组件设计的细节,这些组件负责发布市场数据更新并与交易客户端进行通信。然后,本章使用 C++完全实现了这两个组件,包括它们所需的所有功能。本章通过构建用于电子交易交易所的二进制文件来结束,将来自第六章第七章的组件联系起来。

第八章, 在 C++中处理市场数据和向交易所发送订单,描述了交易策略框架中市场数据消费者和限价订单簿设计的细节,这些组件负责消费市场数据更新并在客户端系统中构建订单簿。本章还将讨论交易客户端系统中用于与交易所通信并发送订单的订单网关。然后,本章使用 C++完全实现了这三个组件,包括所需的所有功能。

第九章, 构建 C++交易算法构建块,描述了交易策略框架及其子组件的设计,这些组件将被用来运行交易算法。我们将使用 C++实现整个框架,包括跟踪仓位、利润和损失的所有组件,计算交易特征/信号以构建智能,发送和管理市场中的实时订单,以及执行风险管理。

第十章, 构建 C++市场做市和流动性获取算法,完成了整个电子交易生态系统的 C++实现。本章在上一章构建的框架中构建了市场做市和流动性获取的交易算法。在我们实现交易算法之前,我们将讨论这些算法的交易行为、构建它们的动机以及这些策略如何旨在获利。本章还构建了构建最终交易客户端应用程序所需的交易引擎框架,将来自第八章第九章第十章的组件联系起来。本章通过运行一个完整的电子交易生态系统并理解不同交易所和交易客户端组件之间的交互来结束。

第十一章添加仪表和测量性能,创建了一个系统来以更高的粒度级别测量我们的低延迟组件的性能。我们还将添加一个系统来通过我们的系统对订单和市场数据事件进行时间戳,当它们从组件移动到组件时。本章最后通过性能测量系统重新运行我们的电子交易生态系统,生成性能数据。

第十二章分析和优化我们的 C++系统性能,首先分析并可视化上一章的性能数据。然后,它展示了可以用来优化我们的电子交易组件和整体生态系统的特定技巧和技术。它实现了某些性能优化想法,并对性能改进进行了基准测试。本章最后提出了一些可能的未来增强电子交易系统的方案,并实现并基准测试了其中一个想法。

为了充分利用本书

您至少需要具备 C++编程语言的入门级经验,并在 Linux 环境中编译、构建和运行 C++代码时有一定的舒适度。对低延迟应用和电子交易的了解是加分项,但不是必需的,因为所有相关的信息都将在本章中涵盖。

本书涵盖的软件/硬件操作系统要求
C++ 20Linux
GCC 11.3.0Linux

本书是在Linux 5.19.0-41-generic #42~22.04.1-Ubuntu x86_64 x86_64 x86_64 GNU/Linux操作系统上开发的。它使用CMake 3.23.2Ninja 1.10.2作为构建系统。然而,本书中展示的源代码预计可以在具有至少GCC 11.3.0编译器的所有 Linux 发行版上运行。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/ulPYN

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“–Werror参数将这些警告转换为错误,并将在编译成功之前强制开发人员检查并修复生成编译器警告的每个情况。”

代码块设置如下:

if(!a && !b) {}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

main:
.LFB1
    Movl    $100, %edi
    Call    _Z9factorialj

任何命令行输入或输出都按以下方式编写:

SpecificRuntimeExample::placeOrder()
SpecificCRTPExample::actualPlaceOrder()

小贴士或重要注意事项

它看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《使用 C++构建低延迟应用》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?您的电子书购买是否与您选择的设备不兼容?

别担心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接!二维码图片

https://packt.link/free-ebook/9781837639359

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。

第一部分:介绍 C++概念和探索重要低延迟应用

在本部分,我们将介绍低延迟应用以及使用 C++ 开发低延迟应用的正确方法。我们将讨论不同业务领域中一些常见低延迟应用的技术细节。我们还将讨论与低延迟应用开发相关的细节,以及 C++ 概念和技术如何融入其中。此外,我们还将编写一些 C++ 代码,从电子交易交换的角度实现我们之前介绍的不同低延迟组件。

本部分包含以下章节:

  • 第一章*,介绍 C++ 低延迟应用开发*

  • 第二章*,设计一些常见的 C++ 低延迟应用*

  • 第三章*,从低延迟应用的角度探索 C++ 概念*

  • 第四章*,构建低延迟应用的 C++ 基础*

第一章:介绍 C++中的低延迟应用开发

让我们以低延迟应用为起点,通过在本章中介绍它们来开启我们的旅程。在本章中,我们将首先了解对延迟敏感和对延迟关键的应用的行为和需求。我们将了解应用延迟对依赖快速和严格响应时间的业务产生的巨大商业影响。

我们还将讨论为什么 C++是低延迟应用开发中最受欢迎的编程语言之一。我们将用这本书的大部分篇幅从头开始构建一个完整的低延迟电子交易系统。因此,这将是一个很好的章节,让你了解使用 C++的动机以及它为什么是低延迟应用中最流行的语言。

我们还将介绍不同业务领域的一些重要低延迟应用。部分动机是让你明白,延迟确实在不同业务领域对响应时间敏感的使用案例中非常重要。另一个动机是识别这些应用在行为、期望、设计和实现方面的相似性。尽管它们解决不同的商业问题,但这些应用的低延迟需求通常建立在相似的技术设计和实现原则之上。

在本章中,我们将涵盖以下主题:

  • 理解对延迟敏感的应用的需求

  • 理解为什么 C++是首选编程语言

  • 介绍一些重要的低延迟应用

为了有效地构建超低延迟应用,我们首先应该理解我们将在这本书的其余部分中引用的术语和概念。我们还应该了解为什么 C++已经成为大多数低延迟应用开发的明确选择。始终牢记低延迟的商业影响也很重要,因为目标是构建低延迟应用以使业务的底线受益。本章讨论了这些想法,以便在我们深入本书其余部分的技术细节之前,你能建立一个良好的基础。

理解对延迟敏感的应用的需求

在本节中,我们将讨论一些概念,这些概念对于理解对延迟敏感的应用的哪些指标很重要。首先,让我们明确地定义延迟的含义和对延迟敏感的应用是什么。

延迟被定义为任务开始到任务完成之间的时间延迟。根据定义,任何处理或工作都会产生一些开销或延迟——也就是说,除非系统完全不工作,否则没有系统具有零延迟。这里的重要细节是,某些系统可能具有微不足道的毫秒分之一延迟,并且对额外微秒的容忍度可能很低。

低延迟应用是指那些尽可能快速执行任务并响应或返回结果的程序。这里的要点是,反应延迟是这类应用的重要标准,因为更高的延迟可能会降低性能,甚至使应用完全无用。另一方面,当这类应用以预期的低延迟运行时,它们可以击败竞争对手,以最大速度运行,实现最大吞吐量,或提高生产力和改善用户体验——具体取决于应用和业务。

低延迟可以被视为一个既定量又定性的术语。定量方面很明显,但定性方面可能并不一定明显。根据上下文,架构师和开发者可能在某些情况下愿意接受更高的延迟,但在某些情况下可能不愿意接受额外的微秒。例如,如果用户刷新网页或等待视频加载,几秒钟的延迟是可以接受的。然而,一旦视频加载并开始播放,就不再能够承受几秒钟的延迟来渲染或显示,而不会对用户体验产生负面影响。一个极端的例子是高速金融交易系统,其中额外的几微秒可能会在盈利公司和无法竞争的公司之间产生巨大的差异。

在以下小节中,我们将介绍适用于低延迟应用的一些术语。理解这些术语非常重要,这样我们才能继续讨论低延迟应用,因为我们将会频繁地引用这些概念。我们将讨论的概念和术语用于区分不同的延迟敏感型应用、延迟的测量以及这些应用的要求。

理解延迟敏感型与延迟关键型应用

延迟敏感型应用延迟关键型应用之间存在微妙但重要的区别。延迟敏感型应用是指,随着性能延迟的降低,它提高了业务影响或盈利能力。因此,系统可能在较高的性能延迟下仍然功能正常,甚至可能盈利,但如果降低延迟,则可能获得更高的盈利能力。这类应用的例子包括操作系统(OSes)、网络浏览器、数据库等。

另一方面,延迟关键的应用程序是指当性能延迟高于某个阈值时,会完全失败的应用程序。这里的要点是,虽然延迟敏感的应用程序在更高的延迟下可能会损失部分盈利能力,但延迟关键的应用程序在足够高的延迟下会完全失败。这类应用的例子包括交通控制系统、金融交易系统、自动驾驶汽车和一些医疗设备。

测量延迟

在本节中,我们将讨论不同的测量延迟的方法。这些方法之间的真正区别在于,我们考虑的处理任务的开始和结束是什么。另一种方法是我们测量的单位——时间是其中最常见的一个,但在某些情况下,如果涉及到指令级测量,也可以使用 CPU 时钟周期。接下来,我们将查看不同的测量方法,但首先,我们展示一个通用服务器-客户端系统的图,而不深入使用案例或传输协议的具体细节。这是因为测量延迟是通用的,适用于具有这种服务器-客户端设置的许多不同应用程序。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_1.1_B19434.jpg

图 1.1 – 具有不同跳数时间戳的通用服务器-客户端系统

我们在这里展示这个图是因为,在接下来的几个小节中,我们将定义并理解服务器到客户端以及返回服务器的往返路径上不同跳数之间的延迟。

首字节到达时间

首字节到达时间是指从发送者发送请求(或响应)的第一个字节到接收者接收第一个字节所经过的时间。这通常(但不一定)适用于具有数据传输操作且对延迟敏感的网络链路或系统。在图 1.1 中,首字节到达时间将是 https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Formula_1.1.pnghttps://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Formula_1.2.png 之间的差异

往返时间

往返时间RTT)是指数据包从一个进程传输到另一个进程所需的时间,以及响应数据包返回原始进程所需的时间。同样,这通常(但不一定)用于服务器和客户端进程之间的网络流量往返,但也可以用于一般情况下的两个进程之间的通信。

默认情况下,RTT 包括服务器进程读取、处理和响应发送者发送的请求所需的时间——也就是说,RTT 通常包括服务器处理时间。在电子交易的情况下,真正的 RTT 延迟基于三个组成部分:

  • 首先,交易所信息到达参与者所需的时间

  • 其次,算法分析信息和做出决策所需的时间

  • 最后,决策从达到交易所并经过撮合引擎处理所需的时间

我们将在本书的最后一节,分析和改进性能中进一步讨论这个问题。

交易计时

交易计时TTT)与 RTT 类似,是电子交易系统中最常用的术语。TTT 被定义为从数据包(通常是市场数据包)首次击中参与者的基础设施(交易服务器)到参与者完成处理该数据包并发送数据包(订单请求)到交易交易所的时间。因此,TTT 包括交易基础设施读取数据包、处理数据包、计算交易信号、根据该信号生成订单请求并将其发送出去所需的时间。发送出去通常意味着将数据写入网络套接字。我们将在本书的最后一节,分析和改进性能中重新审视这个主题,并对其进行更详细的探讨。在图 1.1中,TTT 将是https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Formula_1.2.pnghttps://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Formula_1.4.png之间的差异。

CPU 时钟周期

CPU 时钟周期基本上是 CPU 处理器可以完成的最低工作量增量。实际上,它们是驱动 CPU 处理器的振荡器两次脉冲之间的时间间隔。测量 CPU 时钟周期通常用于测量指令级别的延迟——即在处理器级别的极低级别。C++既是一种低级语言,也是一种高级语言;它允许你根据需要接近硬件,同时也提供了诸如类、模板等高级抽象。但通常,C++开发者不会花很多时间处理极低级别的或可能是汇编语言。这意味着编译后的机器代码可能并不完全符合 C++开发者的预期。此外,根据编译器版本、处理器架构等因素,可能还有更多差异的来源。因此,对于极高性能敏感的低延迟代码,工程师通常测量执行了多少条指令以及完成这些指令所需的 CPU 时钟周期数。这种级别的优化通常是可能达到的最高优化级别,与内核级别的优化并列。

现在我们已经看到了一些不同应用中测量延迟的不同方法,在下一节中,我们将探讨一些延迟汇总指标以及它们在不同场景下的重要性。

区分延迟指标

特定延迟指标相对于其他指标的重要性取决于应用和业务本身。例如,一个延迟关键的应用,如自动驾驶软件系统,比平均延迟更关心峰值延迟。低延迟电子交易系统通常比峰值延迟更关心平均延迟和更小的延迟方差。由于应用和消费者的性质,视频流和播放应用可能通常优先考虑高吞吐量而不是较低的延迟方差。

吞吐量与延迟

在我们查看这些指标本身之前,首先,我们需要清楚地理解两个术语之间的区别——吞吐量延迟——这两个术语非常相似,经常被互换使用,但不应如此。吞吐量定义为在特定时间内完成的工作量,而延迟是单个任务完成的速度。为了提高吞吐量,通常的方法是引入并行性并添加额外的计算、内存和网络资源。请注意,每个单独的任务可能不会以尽可能快的速度处理,但总体上,在一段时间后,将完成更多任务。这是因为,虽然每个任务单独处理时可能需要更长的时间,但并行性提高了任务集的吞吐量。另一方面,延迟是对每个单独的任务从开始到结束进行测量的,即使总体上执行的任务较少。

平均延迟

平均延迟基本上是系统的预期平均响应时间。它只是所有延迟测量观测值的平均值。这个指标包括大异常值,因此对于经历大范围性能延迟的系统来说可能是一个嘈杂的指标。

中值延迟

中值延迟通常是衡量系统预期响应时间的更好指标。由于它是延迟测量观测值的中间值,因此排除了大异常值的影响。因此,有时它比平均延迟指标更受欢迎。

峰值延迟

峰值延迟是对于系统来说一个重要的指标,因为单个大的异常性能可能会对系统产生灾难性的影响。峰值延迟的大值也可能显著影响系统的平均延迟指标。

延迟方差

对于需要尽可能确定性的延迟配置文件的系统,性能延迟的实际方差是一个重要的指标。这通常在预期的延迟相当可预测的情况下很重要。对于低延迟方差的系统,平均延迟、中值延迟和峰值延迟都预计会非常接近。

对延迟敏感的应用需求

在本节中,我们将正式描述对延迟敏感型应用程序的行为以及这些应用程序预期遵守的性能配置文件。显然,延迟敏感型应用程序需要低延迟性能,但在这里我们将尝试探索“低延迟”一词的细微差别,并讨论一些不同的看待方式。

正确性和鲁棒性

当我们思考延迟敏感型应用程序时,通常认为低延迟是这类应用程序最重要的单一方面。但在现实中,这类应用程序的一个巨大需求是正确性,我们指的是非常高的鲁棒性和容错性。直观上,这个想法应该完全合理;这些应用程序需要非常低的延迟才能成功,这应该告诉你这些应用程序也有非常高的吞吐量,需要处理大量的输入并产生大量的输出。因此,系统需要非常接近 100%的正确性,并且非常鲁棒,以便在业务领域取得成功。此外,随着应用程序在其生命周期中的增长和变化,正确性和鲁棒性要求也需要保持。

平均低延迟

这是思考延迟敏感型应用程序时最明显的要求。预期的反应或处理延迟需要尽可能低,以便应用程序或业务整体能够成功。在这里,我们关注平均和中值性能延迟,并希望它尽可能低。按设计,这意味着系统不能有太多的异常值或性能延迟的高峰。

峰值延迟上限

我们使用“峰值延迟上限”这个术语来指代必须为应用程序可能遇到的最大延迟设定一个明确的上限。这种行为对所有延迟敏感型应用程序都很重要,但对于延迟关键型应用程序来说尤为重要。即使在一般情况下,对于少数几个案例具有极高性能延迟的应用程序通常会破坏系统的性能。这实际上意味着应用程序需要处理任何输入、场景或事件序列,并在低延迟期内完成。当然,处理非常罕见和特定场景的性能可能远高于最可能的情况,但这里的重点是它不能是无界的或不可接受的。

可预测的延迟 - 低延迟变化

一些应用程序更喜欢预期的性能延迟是可预测的,即使这意味着如果平均延迟指标高于可能的情况,需要牺牲一点延迟。这实际上意味着此类应用程序将确保所有不同输入或事件的预期性能延迟尽可能小地变化。实现零延迟变化是不可能的,但可以在数据结构、算法、代码实现和设置方面做出一些选择,以尽可能最大限度地减少这种变化。

高吞吐量

如前所述,低延迟和吞吐量相关但并不相同。因此,有时一些需要尽可能高吞吐量的应用程序在设计实现上可能有所不同,以最大化吞吐量。关键是最大化吞吐量可能需要牺牲平均性能延迟或增加峰值延迟以实现这一点。

在本节中,我们介绍了适用于低延迟应用程序性能和这些指标业务影响的概念。当我们讨论我们构建的应用程序的性能时,我们将在本书的其余部分需要这些概念。接下来,我们将继续讨论,并探索用于低延迟应用程序开发的编程语言。我们将讨论支持低延迟应用程序的语言特性,并了解为什么 C++ 在开发和提高对延迟敏感的应用程序时位居榜首。

理解为什么 C++ 是首选编程语言

在低延迟应用程序方面,有几种高级语言选择——Java、Scala、Go 和 C++。在本节中,我们将讨论为什么 C++ 是低延迟应用程序中最受欢迎的语言之一。我们将讨论 C++ 语言的一些特性,这些特性支持高级语言结构以支持大型代码库。C++ 的强大之处在于它还提供了类似于 C 编程语言的非常低级别的访问权限,以支持非常高级的控制和优化。

编译型语言

C++ 是一种编译型语言,而不是解释型语言。编译型语言是一种编程语言,其中源代码被翻译成机器码二进制文件,该文件可以在特定架构上运行。编译型语言的例子有 C、C++、Erlang、Haskell、Rust 和 Go。编译型语言的替代品是解释型语言。解释型语言的不同之处在于程序是由解释器运行的,解释器逐行运行源代码并执行每个命令。解释型语言的例子包括 Ruby、Python 和 JavaScript。

解释性语言本质上比编译性语言慢,因为与编译性语言在编译时就将代码翻译成机器指令不同,这里的解释到机器指令是在运行时完成的。然而,随着即时编译技术的发展,解释性语言的性能并没有慢很多。对于编译性语言,代码在编译时已经为特定硬件预先构建,因此在运行时没有额外的解释步骤。由于 C++是一种编译性语言,它为开发者提供了对硬件的大量控制。这意味着有能力的开发者可以优化诸如内存管理、CPU 使用、缓存性能等方面。此外,由于编译性语言在编译时就已经转换为特定硬件的机器代码,因此它可以进行大量的优化。因此,一般来说,编译性语言,尤其是 C++,执行速度更快,效率更高。

更接近硬件——低级语言

与其他流行的编程语言,如 Python、Java 等相比,C++是低级语言,因此它与硬件非常接近。这在软件与运行其上的目标硬件紧密耦合,甚至需要低级支持的情况下特别有用。与硬件非常接近也意味着在用 C++构建系统时,存在显著的速度优势。特别是在低延迟应用,如高频交易HFT)中,几微秒的差距可能造成巨大的差异,C++通常是行业中的黄金标准。

我们将讨论一个例子,说明更接近硬件如何帮助 C++性能超过另一种语言,如 Java。C/C++指针是内存中对象的实际地址。因此,软件可以直接访问内存和内存中的对象,而无需额外的抽象,这些抽象会减慢速度。然而,这也意味着应用程序开发者通常必须显式地管理对象的创建、所有权、销毁和生命周期,而不是像 Python 或 Java 那样依赖编程语言为您管理这些事情。C++接近硬件的一个极端例子是,可以直接从 C++语句中调用汇编指令——我们将在后面的章节中看到这个例子。

资源的确定性使用

对于低延迟应用来说,高效使用资源至关重要。嵌入式应用(这些应用也常用于实时应用)在时间和内存资源上尤其有限。在像 Java 和 Python 这样的依赖自动垃圾回收的语言中,存在非确定性的因素——也就是说,垃圾回收器可能会在性能不可预测的情况下引入较大的延迟。此外,对于内存非常有限的系统,使用 C 和 C++这样的低级语言可以做一些特殊的事情,比如通过指针将数据放置在内存中的自定义部分或地址。在 C 和 C++这样的语言中,程序员负责显式创建、管理和释放内存资源,从而允许资源的确定性和高效使用。

速度和高效性能

C++比大多数其他编程语言都要快,原因我们已经讨论过了。它还提供了出色的并发和多线程支持。显然,这对于开发对延迟敏感甚至对延迟至关重要的低延迟应用来说,又是一个很好的特性。这样的需求也常常出现在服务器负载很重的应用中,如 Web 服务器、应用服务器、数据库服务器、交易服务器等。

C++的另一个优点是由于其编译时优化能力。C 和 C++支持宏或预处理器指令、constexpr指定符和模板元编程等功能。这些功能使我们能够将大量处理从运行时移动到编译时。基本上,这意味着我们通过在构建机器代码二进制时将大量处理移动到编译步骤,最小化了在关键代码路径上运行时的工作量。我们将在后续章节中详细讨论这些功能,当我们在构建一个完整的电子交易系统时,它们的益处将变得非常明显。

语言构造和特性

C++语言本身是灵活性和功能丰富的完美结合。它为开发者提供了很多自由度,他们可以利用它将应用调整到非常低的级别。然而,它也提供了很多高级抽象,可以用来构建非常大型、功能丰富、通用和可扩展的应用,同时在需要时仍能保持极低的延迟。在本节中,我们将探讨一些 C++特有的语言特性,这些特性使其处于独特的低级控制和高级抽象功能的位置。

可移植性

首先,C++高度可移植,可以构建适用于许多不同操作系统、平台、CPU 架构的应用程序。由于它不需要针对不同平台的不同运行时解释器,所需要做的就是编译时构建正确的二进制文件,这相对简单,最终部署的二进制文件可以在任何平台上运行。此外,我们之前已经讨论的一些其他特性(例如,在低内存和较弱的 CPU 架构上运行的能力,以及不需要垃圾回收的要求)使得它比其他一些高级语言更加可移植。

编译器优化

我们已经讨论过 C++是一种编译型语言,这使得它从本质上比解释型语言更快,因为它不会产生额外的运行时成本。由于开发者的完整源代码被编译成最终的执行二进制文件,编译器有机会全面分析所有对象和代码路径。这导致了在编译时实现非常高的优化水平的可能性。现代编译器与现代硬件紧密合作,生成一些令人惊讶的优化机器代码。这里的要点是,开发者可以专注于解决业务问题,并且假设 C++开发者是合格的,编译程序仍然可以非常优化,而不需要开发者投入大量的时间和精力。由于 C++还允许你直接内联汇编代码,这给了开发者更大的机会与编译器合作,生成高度优化的可执行文件。

静态类型

当谈到编程语言中的类型系统时,有两种选择——静态类型语言动态类型语言。静态类型语言在编译过程中对数据类型(整数、浮点数、双精度浮点数、结构体和类)以及这些类型之间的交互进行检查。动态类型语言在运行时执行这些类型检查。静态类型语言的例子有 C++和 Java,动态类型语言的例子有 Python、Perl 和 JavaScript。

静态类型语言的一个重大好处是,由于所有类型检查都是在编译时完成的,这给了我们机会在程序运行之前找到并消除许多错误。显然,仅类型检查本身不能找到所有可能的错误,但我们试图说明的是,静态类型语言在编译时发现与类型相关的错误和错误方面做得更好。这对于高度数值化的低延迟应用程序尤其如此。

静态类型语言的一个巨大好处,尤其是在低延迟应用方面,是由于类型检查是在编译时进行的,这为编译器提供了额外的机会,在编译时优化类型和类型交互。事实上,编译语言之所以运行得更快,很大程度上是因为静态类型检查系统与动态类型检查系统本身的差异。这也是为什么对于像 Python 这样的动态类型语言,高性能库如 NumPy 在创建数组和矩阵时需要类型的原因。

多范式

与其他一些语言不同,C++并不强迫开发者遵循特定的编程范式。它支持许多不同的编程范式,如单体、过程式、面向对象编程OOP)、泛型编程等。这使得它非常适合广泛的用途,因为它允许开发者以有利于最大优化和最低延迟的方式设计程序,而不是将编程范式强加给该应用。

C++本身就附带了一个大型的 C 和 C++库,它提供了大量的数据结构、算法和抽象,用于以下任务:

  • 网络编程

  • 动态内存管理

  • 数值操作

  • 错误和异常处理

  • 字符串操作

  • 常用算法

  • 输入/输出I/O)操作包括文件操作

  • 多线程支持

此外,庞大的 C++开发者社区已经构建并开源了许多库;我们将在以下小节中讨论其中一些最受欢迎的库。

标准模板库

标准模板库STL)是一个非常流行且广泛使用的模板化和仅包含头文件的库,它包含数据结构和容器、这些容器的迭代器和分配器,以及用于排序、搜索等任务的算法。

Boost

Boost是一个大型 C++库,它提供了对多线程、网络操作、图像处理、正则表达式regex)、线性代数、单元测试等方面的支持。

Asio

Asio异步输入/输出)是另一个广为人知且广泛使用的库,它有两个版本:非 Boost版本和作为 Boost 库一部分的版本。它提供了对多线程并发、实现和使用异步 I/O 模型的支持,并且可移植到所有主要平台。

GNU 科学库

GNU 科学库GSL)为各种数学概念和操作提供支持,如复数、矩阵和微积分,并管理其他函数。

活动模板库

Active Template LibraryATL)是一个模板丰富的 C++库,用于帮助编程组件对象模型COM)。它取代了之前的Microsoft Foundation ClassesMFC)库,并对其进行了改进。它由微软开发,是开源的,并且大量使用了重要的低延迟 C++特性,即奇特重复模板模式CRTP),我们也将在此书中深入探讨并大量使用它。它支持 COM 功能,如双接口、ActiveX 控件、连接点、可拆卸接口、COM 枚举接口等,还有更多。

Eigen

Eigen是一个用于数学和科学应用的强大 C++库。它提供了线性代数、数值方法和求解器、复数等数值类型、几何特征和操作等功能。

LAPACK

线性代数包LAPACK)是另一个专门用于线性代数、线性方程以及支持大矩阵例程的强大 C++库。它实现了许多功能,如求解联立线性方程、最小二乘法、特征值、奇异值分解SVD)以及更多应用。

OpenCV

Open Source Computer VisionOpenCV)是计算机图形和视觉相关应用中最知名的 C++库之一。它也适用于 Java 和 Python,并提供了许多用于人脸和物体识别、3D 模型、机器学习、深度学习等的算法。

mlpack

mlpack是一个超级快速、仅包含头文件的 C++库,用于广泛的各种机器学习模型及其相关的数学运算。它还支持 Go、Julia、R 和 Python 等其他语言。

QT

QT是构建跨平台图形程序时最流行的库之一。它支持 Windows、Linux、macOS,甚至 Android 和嵌入式系统等平台。它是开源的,用于构建 GUI 小部件。

Crypto++

**Crypto++**是一个免费的开源 C++库,用于支持密码学算法、操作和实用工具。它拥有许多密码学算法、随机数生成器、块加密、函数、公钥操作、秘密共享等,跨越 Linux、Windows、macOS、iOS 和 Android 等多个平台。

适合大型项目

在上一节中,我们讨论了 C++的设计和众多特性,使其非常适合低延迟应用。C++的另一个方面是,由于它为开发者提供的灵活性和允许构建的所有高级抽象,它实际上非常适合非常大的现实世界项目。像编译器、云处理和存储系统以及操作系统这样的大型项目就是出于这些原因用 C++编写的。我们将深入探讨这些以及其他许多试图在低延迟性能、功能丰富性和不同的业务案例之间取得平衡的应用,而且很多时候,C++是开发此类系统的完美选择。

成熟且庞大的社区支持

C 编程语言最初是在 1972 年创建的,然后 C++(最初被称为带类的 C)在 1983 年创建。C++是一种非常成熟的语言,并且被广泛嵌入到许多不同业务领域的许多应用中。一些例子包括 Unix 操作系统、Oracle MySQL、Linux 内核、Microsoft Office 和 Microsoft Visual Studio——这些都是在 C++中编写的。C++存在了 40 年意味着大多数软件问题都已经遇到,并且已经设计和实现了解决方案。C++也非常受欢迎,并且作为大多数计算机科学学位的一部分进行教授,此外,还有一个庞大的开发者工具、第三方组件、开源项目、库、手册、教程、书籍等库,专门针对它。总之,有大量的文档、示例和社区支持支持新的 C++开发者和新的 C++项目。

正在积极开发的语言

尽管 C++已经 40 岁了,但它仍然处于积极开发中。自从 1985 年第一个 C++版本商业发布以来,C++标准和语言已经经历了多次改进和增强。按时间顺序,发布了 C++ 98、C++ 03、C++ 0X、C++ 11、C++ 14、C++ 17 和 C++ 20,C++ 23 正在开发中。每个版本都带来了改进和新特性。因此,C++是一种强大的语言,并且随着时间的推移不断进化,添加现代特性。以下是一个展示 C++多年演变的图表:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_1.2_B19434.jpg

图 1.2 – C++的演变

考虑到 C++编程语言已经非常成熟,超快的速度,高级抽象与低级硬件访问和控制的完美结合,庞大的知识库,以及开发者社区以及最佳实践、库和工具,C++是低延迟应用开发的明显选择。

在本节中,我们探讨了为低延迟应用开发选择 C++ 编程语言的原因。我们讨论了使其成为这些应用极佳选择的各项特性、功能、库和社区支持。C++ 深度嵌入到大多数具有严格性能要求的程序中,这并不令人惊讶。在下一节中,我们将探讨不同商业领域的许多不同低延迟应用,目标是理解这些应用共享的相似之处。

介绍一些重要的低延迟应用

在本节中,我们将探讨不同商业领域的一些常见低延迟应用,以便我们熟悉不同类型的低延迟应用以及延迟如何在它们的性能中扮演重要角色。此外,讨论这些应用将揭示这些应用在性质和设计上的相似之处。

低级低延迟应用

首先,我们将从被认为是极低级的应用开始,这意味着非常接近硬件。请注意,所有低延迟应用至少有一部分是低级的,因为按照定义,这就是实现低延迟性能的方式。然而,这些应用的大部分处理的是主要与低级细节相关的整个应用;让我们接下来讨论这些。

电信

我们已经讨论过,C++ 是最快的编程语言之一。它在构建电话交换机、路由器、互联网、太空探测器以及电信基础设施的各个部分中得到了广泛应用。这些应用需要处理大量的并发连接,并促进它们之间的通信。这些应用需要以速度和效率执行这些任务,使它们成为低延迟应用的优秀例子。

嵌入式系统

由于 C++ 与其他高级编程语言相比更接近硬件,因此它被用于低延迟敏感的嵌入式系统。这些应用的例子包括用于医学领域的机器、手术工具、智能手表等。C++ 通常是医疗应用的优选语言,例如 MRI 机器、实验室测试系统以及管理患者信息的系统。此外,还有用于建模医疗数据、进行研究模拟等用例。

编译器

有趣的是,各种编程语言的编译器使用 C 和 C++ 来构建这些语言的编译器。原因再次是,C 和 C++ 是接近硬件的低级语言,可以有效地构建这些编译器。编译器应用程序本身能够非常大地优化编程语言的代码,并生成低延迟的机器代码。

操作系统

从微软 Windows 到 macOS 再到 Linux 本身,所有主要的操作系统都是用 C++编写的——这又是 C++作为低级语言使其成为低延迟应用理想选择的一个例子。操作系统极其庞大且极其复杂。除此之外,它们还必须具有低延迟和高度性能,才能成为具有竞争力的现代操作系统。

例如,Linux 通常是许多高负载服务器以及为低延迟应用设计的服务器的首选操作系统,因此操作系统本身需要非常高的性能。除了传统的操作系统之外,C 和 C++也被广泛用于构建移动操作系统,如 iOS、Android 和 Windows 手机内核。总的来说,操作系统需要在管理所有系统和硬件资源方面非常快速和高效。构建操作系统的 C++开发者可以利用语言的能力来构建超低延迟的操作系统。

云/分布式系统

开发和使用云和分布式存储及处理系统的组织对低延迟有非常高的要求。因此,它们严重依赖像 C++这样的编程语言。分布式存储系统必须支持非常快速和高效的文件系统操作,因此需要接近硬件。此外,分布式处理通常意味着高并发级别,依赖低延迟的多线程库,以及高负载容忍和可扩展性优化要求。

数据库

数据库是另一类需要低延迟、高并发和并行性的应用的好例子。数据库也是许多不同商业领域许多不同应用中的关键组件。Postgres、MySQL 和 MongoDB(目前最受欢迎的数据库系统)都是用 C 和 C++编写的——这又是为什么 C++是低延迟应用首选语言的一个例子。C++也是设计和构建数据库以优化存储效率的理想选择。

飞行软件和交通控制

商用飞机和军用飞机的飞行软件是具有低延迟关键应用的一类。在这里,代码不仅需要遵循非常严格的指南,非常健壮,并且经过非常彻底的测试,而且应用程序还需要可预测地响应和反应事件,并在严格的延迟阈值内。

交通控制软件依赖于许多传感器,这些传感器需要监控车辆的速度、位置和流量,并将这些信息传输到中央软件。软件随后使用这些信息来控制交通标志、地图和交通灯。显然,对于这种实时应用,它需要具有低延迟并且能够快速高效地处理大量数据。

高级低延迟应用

在本小节中,我们将讨论许多人可能认为稍微高级一点的低延迟应用。这些是人们在尝试解决商业问题时通常会想到的应用;然而,需要注意的是,这些应用仍然需要实现和使用低级优化技术,以提供所需性能。

图形和视频游戏应用

图形应用需要超快的渲染性能,这又是一个低延迟应用的例子。图形软件采用计算机视觉、图像处理等技术,通常涉及在众多大型矩阵上进行大量非常快且非常高效的矩阵运算。当涉及到视频游戏中的图形渲染时,对低延迟性能的要求更为严格,因为这些是交互式应用,速度和响应性对用户体验至关重要。如今,视频游戏通常在多个平台上提供,以覆盖更广泛的受众。这意味着这些应用,或者这些应用的简化版本,需要在低端设备上运行,这些设备可能没有很多计算和内存资源。总体而言,视频游戏有很多资源密集型操作——渲染图形、同时处理多个玩家、快速响应用户输入等。C++非常适合所有这些应用,并被用于创建许多知名游戏,如《反恐精英》、《星际争霸》和《魔兽世界》,以及游戏引擎如虚幻引擎。C++也适合不同的游戏平台——Windows PC、任天堂 Switch、Xbox 和 PlayStation。

增强现实和虚拟现实应用

增强现实AR)和虚拟现实VR)都是增强和增强现实生活环境或创建全新虚拟环境的技术。虽然 AR 只是通过向我们的实时视图添加数字元素来增强环境,但 VR 则创建了一个完全新的模拟环境。因此,这些应用将图形渲染和视频游戏应用提升到了一个新的水平。

增强现实(AR)和虚拟现实(VR)技术已经找到了许多不同的商业应用场景,例如设计和建筑、维护和修理、培训和教学、医疗保健、零售和营销,甚至是在技术本身领域。AR 和 VR 应用与视频游戏应用有类似的要求,需要实时处理来自各种来源的大量数据,并且需要无缝且平滑地处理用户交互。这些应用的技术挑战在于处理有限的处理能力和可用内存,可能有限的移动带宽,以及保持低延迟和实时性能,以免影响用户体验。

浏览器

网络浏览器通常比它们看起来要复杂。网络浏览器中包含渲染引擎,这些引擎需要低延迟和高效的处理。此外,通常还需要与数据库和交互式渲染代码进行交互,以便用户不必等待很长时间才能更新内容或响应交互式内容。由于网络浏览器的低延迟要求,C++经常被选为这种应用的优先语言也就不足为奇了。实际上,一些最受欢迎的网络浏览器(如 Google Chrome、Mozilla Firefox、Safari、Opera 等)都大量使用了 C++。

搜索引擎

搜索引擎是另一个需要低延迟和高度高效的数据结构、算法和代码库的应用场景。现代搜索引擎,如 Google,使用诸如网络爬虫技术、索引基础设施、页面排名算法以及其他复杂算法(包括机器学习)等技术。Google 的搜索引擎依赖于 C++以高度低延迟和高效的方式实现所有这些要求。

许多高级库通常有严格的功能要求,并且可以被视为低延迟应用本身,但通常,它们是更大低延迟应用和业务的关键组件。这些库涵盖了不同的领域——网络编程、数据结构、更快的算法、数据库、多线程、数学库(例如,机器学习)等等。这些库需要非常低的延迟和高性能处理,例如涉及大量矩阵运算的计算,其中许多矩阵也可能非常大。

应该在这里清楚的是,在这些应用中性能是至关重要的——C++经常被大量使用的另一个领域。尽管像 TensorFlow 这样的许多库在 Python 中可用,但实际上,这些库的核心机器学习数学运算实际上是用 C++实现的,以支持在大型数据集上运行这些机器学习方法。

银行和金融应用程序

银行应用程序是另一类需要每天处理数百万笔交易的低延迟应用,需要低延迟、高并发性和健壮性。大型银行有数百万客户和数十亿笔交易,所有这些都需要正确且快速地执行,并且能够扩展以处理客户负载,从而数据库和服务器负载。正如我们之前讨论的那样,C++自动成为许多这些银行应用程序的选择。

当涉及到金融建模、电子交易系统和交易策略等应用时,低延迟比其他任何领域都更为关键。C++的速度和确定性性能使其非常适合处理数十亿的市场更新、发送数百万订单以及在交易所进行交易,尤其是在高频交易(HFT)方面。由于市场更新非常快,交易应用程序需要非常快速地获取正确数据以执行交易,否则会导致损失,这些损失可能会破坏大量的交易利润,甚至更糟。在研究和开发方面,跨多个交易所的多种交易工具的模拟也需要进行大规模的低延迟分布式处理,以便快速高效地完成。定量开发和研究以及风险分析库也用 C++编写,因为它们需要尽可能快地处理大量数据。其中一个最好的例子是定价和风险库,它计算期权产品的公平交易价格并运行许多模拟以评估期权风险,因为搜索空间是巨大的。

移动电话应用程序

现代移动应用程序功能丰富。此外,它们必须在具有非常有限的硬件资源的平台上运行。这使得这些应用程序的实现必须具有非常低的延迟,并且在使用它们有限的资源时必须非常高效。然而,这些应用程序仍然需要非常快速地响应用户交互,可能需要处理后端连接,并在移动设备上渲染高质量的图形。Android 和 Windows OS 等移动平台、Google Chrome 和 Firefox 等浏览器以及 YouTube 等应用程序都有大量的 C++参与。

物联网和机器对机器应用

物联网IoT)和机器对机器M2M)应用基于连接设备自动收集、存储和交换数据。总体而言,虽然物联网和 M2M 在本质上相似,但在网络、可扩展性、互操作性和人机交互等方面存在一些差异。

物联网(IoT)是一个广泛的概念,指的是将不同的物理设备连接在一起。物联网设备通常是嵌入在其他更大设备中的执行器和传感器,例如智能恒温器、冰箱、门铃、汽车、智能手表、电视和医疗设备。这些设备在具有有限计算资源、电源需求和最小可用内存资源的平台上运行。

M2M 是一种通信方法,其中多个机器通过有线或无线连接相互交互,无需任何人为监督或交互。这里的关键点是互联网连接对于 M2M 不是必需的。因此,物联网是 M2M 的一个子集,但 M2M 是一个更广泛的基于 M2M 通信系统的宇宙。M2M 技术被应用于不同的应用中,如安全、追踪和追溯、自动化、制造和设施管理。

我们之前已经讨论过这些应用,但在此再次总结,物联网和 M2M 技术被应用于电信、医疗和保健、制药、汽车和航空航天工业、零售和物流及供应链管理、制造以及军事卫星数据分析系统等应用中。

本节主要介绍了不同商业领域和用例,在这些领域中低延迟应用蓬勃发展,在某些情况下,低延迟应用对业务来说是必需的。我们的希望是您能理解低延迟应用被应用于许多不同的领域,尽管这可能并不立即明显。本节的另一个目标是确定这些应用之间共享的相似之处,尽管它们被设计来解决不同的商业问题。

摘要

在本章中,我们介绍了低延迟应用。首先,我们定义了延迟敏感型和延迟关键型应用以及不同的延迟度量。然后,我们讨论了在低延迟应用中重要的不同指标以及其他定义低延迟应用要求的考虑因素。

我们在本章的一节中探讨了为什么 C++是跨不同业务领域低延迟应用中最常选择的语言。具体来说,我们讨论了语言本身的特点以及语言的灵活性和底层性质,这使得 C++在低延迟应用中成为完美的选择。

最后,我们考察了不同业务领域中的许多低延迟应用的例子以及它们共享的相似之处。这次讨论的要点是,尽管业务案例不同,但这些应用共享许多共同的要求和特性。再次强调,在这里,C++对于大多数(如果不是所有)这些不同业务领域的低延迟应用都是一个很好的选择。

在下一章中,我们将更详细地讨论一些最受欢迎的低延迟应用。在本书中,我们将使用低延迟电子交易作为一个案例研究来理解和应用 C++低延迟技术。然而,在我们这样做之前,我们将探讨其他低延迟应用,例如实时视频流、实时离线和在线视频游戏应用,以及物联网应用。

第二章:在 C++ 中设计一些常见的低延迟应用程序

在本章中,我们将探讨不同领域的应用,包括视频流、在线游戏、实时数据分析以及电子交易。我们将了解它们的行为,以及在极低延迟考虑下需要实时执行哪些功能。我们将介绍电子交易生态系统,因为我们将将其作为本书的案例研究,并从零开始使用 C++ 构建系统,重点关注理解和使用低延迟理念。

在本章中,我们将涵盖以下主题:

  • 理解直播视频流媒体应用程序中的低延迟性能

  • 理解在游戏应用中哪些低延迟约束是重要的

  • 讨论物联网(IoT)和零售分析系统的设计

  • 探索低延迟电子交易

本章的目标是深入探讨不同商业领域中低延迟应用程序的一些技术方面。在本章结束时,你应该能够理解和欣赏实时视频流、离线和在线游戏应用、物联网机器和应用程序以及电子交易等应用程序所面临的技术挑战。你将能够理解技术进步如何提供不同的解决方案来解决这些问题,并使这些业务可行且有利可图。

理解直播视频流媒体应用程序中的低延迟性能

在本节中,我们将首先讨论视频流应用程序中低延迟性能背后的细节。我们将定义与直播视频流相关的重要概念和术语,以建立对该领域和业务用例的理解。我们将了解这些应用程序中的延迟原因及其商业影响。最后,我们将讨论构建和支持低延迟视频流应用程序的技术、平台和解决方案。

定义低延迟流媒体中的重要概念

在这里,我们将首先定义一些与低延迟流媒体应用程序相关的重要概念和术语。让我们从一些基础知识开始,然后逐步深入到更复杂的概念。

视频流中的延迟

视频流被定义为实时或接近实时传输的音视频内容。通常,延迟指的是输入事件和输出事件之间的时间延迟。在直播视频流应用的背景下,延迟特指从直播视频流撞击录制设备的摄像头开始,然后传输到目标观众屏幕,并在那里渲染和显示的时间。应该很容易直观地理解为什么这也被称为直播视频流应用中的玻璃到玻璃延迟。在视频流应用中,玻璃到玻璃延迟非常重要,无论实际应用是什么,无论是视频通话、其他应用的直播视频流,还是在线视频游戏渲染。在直播中,视频延迟基本上是视频帧在录制端捕捉到视频帧在观众端显示之间的延迟。另一个常见的术语是延迟,它通常只是指高于预期的玻璃到玻璃延迟,用户可能会将其感知为性能降低或抖动。

视频分发服务和内容分发网络

视频分发服务VDS)是一个相对容易理解的概念的时髦说法。VDS 基本上意味着负责从源端接收多个视频和音频流并将其呈现给观众的系统。VDS 最著名的例子之一就是内容分发网络CDN)。CDN 是一种在全球范围内高效分发内容的方法。

转码、转封装和转比特率

让我们讨论与音频视频流编码相关的三个概念:

  • 转码指的是将媒体流从一种格式(例如,更低级别的细节,如编解码器、视频大小、采样率、编码器格式等)解码的过程,并可能以不同的格式或不同的参数进行重新编码。

  • 转封装与转码类似,但在这里,交付格式发生变化,而编码没有变化,就像转码的情况一样。

  • 转比特率也与转码类似,但我们会改变视频比特率;通常,它会压缩到更低的值。视频比特率是每秒传输的比特数(或千比特数),它捕捉视频流中的信息和质量。

  • 在下一节中,我们将了解低延迟视频流应用中延迟的来源。

理解视频流应用中延迟的来源

让我们看看玻璃到玻璃旅程中发生的事情的细节。本节中我们的最终动机是了解视频流应用中延迟的来源。此图从摄像头到显示的高层次描述了玻璃到玻璃旅程中发生的事情:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_2.1_B19434.jpg

图 2.1 – 在实时视频流应用中的玻璃到玻璃传输过程

讨论玻璃到玻璃传输过程中的步骤

我们将首先了解低延迟视频流应用中玻璃到玻璃传输过程中的所有步骤和组件。存在两种延迟形式——初始启动延迟以及直播开始后视频帧之间的延迟。通常对于用户体验来说,略微更长的启动延迟远比视频帧之间的延迟更受欢迎,但在尝试减少一种延迟的同时通常会有所权衡。因此,我们需要了解哪个指标对于特定用例来说更重要,并相应地调整设计和技术细节。以下是广播者到接收者的玻璃到玻璃传输过程中的步骤:

  1. 广播者端的摄像头捕捉和处理音频和视频

  2. 广播者的视频消费和包装

  3. 编码器对内容进行转码、转封装和转码率调整

  4. 通过适当的协议(s)在网络中发送数据

  5. 通过 VDS(如 CDN)进行分发

  6. 接收端接收和缓冲

  7. 在观众设备上解码内容

  8. 在接收端处理数据包丢失、网络变化等问题

  9. 在观众选择的设备上渲染音频-视频内容

  10. 可能收集观众的交互输入(选择、音频、视频等)用于交互式应用,并在需要时将它们发送回广播者

现在我们已经描述了从发送者到接收者以及可能返回到发送者的内容交付细节,在下一节中,我们将描述在该路径上可能出现延迟的地方。通常,每一步不会花费很长时间,但多个组件中的高延迟可能会累积并导致用户体验的显著下降。

描述路径上高延迟的可能性

我们将探讨低延迟视频流应用中高延迟的原因。在之前小节中讨论的玻璃到玻璃路径的每个组件中,都有许多原因导致这种情况。

物理距离、服务器负载和互联网质量
  • 这是一个显而易见的问题:源和目的地之间的物理距离将影响玻璃到玻璃延迟。当从不同国家流式传输视频时,这种情况有时非常明显。

  • 除了距离之外,互联网连接本身的质量也会影响流式传输延迟。缓慢或带宽有限的连接会导致不稳定、缓冲和延迟。

根据同时流式传输视频的用户数量以及这给流媒体路径中的服务器带来的负载,延迟和用户体验可能会有所不同。过载的服务器会导致响应时间变慢,延迟增加,缓冲和延迟,甚至可能导致流式传输完全停止。

捕获设备和硬件

视频和音频捕获设备对端到端延迟有很大影响。将音频和视频帧转换为数字信号需要时间。记录器、编码器、处理器、重新编码器、解码器和重新传输器等高级系统对最终用户体验有显著影响。捕获设备和硬件将决定延迟值。

流媒体协议、传输和抖动缓冲区

考虑到存在不同的流媒体协议(我们将在稍后讨论),最终的选择可以决定视频流应用的网络延迟。如果协议没有针对动态自适应流进行优化,它可能会增加延迟。总的来说,直播视频流协议分为两大类——基于 HTTP 和非基于 HTTP 的——这两大类选项之间的延迟和可扩展性存在差异,这将改变最终系统的性能。

在 VDS 路径中选择的互联网路由可以改变端到端延迟。这些路由也可能随时间变化,数据包可能在某些跳转处排队,甚至可能在接收端顺序错乱。处理这些问题的软件被称为抖动缓冲区。如果 CDN 存在问题,也可能导致额外的延迟。此外,还有一些限制,例如编码比特率(较低的比特率意味着每单位时间内传输的数据更少,从而导致较低的延迟),这可能会改变遇到的延迟。

编码——转码和转速率

编码过程决定了最终视频输出的压缩、格式等,编码协议的选择和质量将对性能产生巨大影响。此外,还有许多观众设备(电视、手机、PC、Mac 等)和网络(3G、4G、5G、LAN、Wi-Fi 等)选项,流媒体提供商需要实现自适应比特率ABR)来有效地处理这些选项。运行编码器的计算机或服务器需要足够的 CPU 和内存资源来跟上传入的音视频数据。无论我们是在计算机上使用编码软件,还是在BoxCasterTeradek等编码硬件上进行编码,我们都会产生从几毫秒到几秒的处理延迟。编码器需要执行的任务包括摄取原始视频数据、缓冲内容,并在转发之前对其进行解码、处理和重新编码。

在观众的设备上解码和播放

假设内容在没有引起明显延迟的情况下到达观众的设备,客户端仍然必须解码、播放和渲染内容。视频播放器不会逐个渲染接收到的视频段,而是有一个接收到的段缓冲区,通常在内存中。这意味着在视频开始播放之前,会缓冲几个段,具体取决于所选段的实际大小,这可能会在最终用户端引起延迟。例如,如果我们选择包含 10 秒视频的段长度,最终用户的播放器至少必须接收到一个完整的段才能播放,这将在发送者和接收者之间引入额外的 10 秒延迟。通常,这些段长在 2 到 10 秒之间,试图在优化网络效率和玻璃到玻璃延迟之间取得平衡。显然,观众设备、平台、硬件、CPU、内存和播放器效率等因素可能会增加玻璃到玻璃延迟。

在低延迟视频流中测量延迟

在低延迟视频流应用中测量延迟并不极端复杂,因为我们关注的延迟范围至少应该是几秒钟,这样用户才能感知到延迟或滞后。测量端到端视频延迟的最简单方法如下:

  • 首先应该使用一个场记板应用程序。场记板是用于在电影制作过程中同步视频和音频的工具,有应用程序可以检测由于延迟导致的两个流之间的同步问题。

  • 另一个选项是将视频流重新发布给自己,通过排除网络因素来测量在捕获、编码、解码和渲染步骤中是否存在任何延迟。

  • 一个明显的解决方案是截图两个运行相同直播流的屏幕,以发现差异。

  • 测量实时视频流延迟的最佳解决方案是在源端给视频流本身添加时间戳,然后接收者可以使用它来确定玻璃到玻璃延迟。显然,发送者和接收者使用的时钟需要相互之间合理地同步。

理解高延迟的影响

在我们了解高延迟对低延迟视频流应用的影响之前,首先,我们需要定义不同应用可接受的延迟是多少。对于不需要太多实时交互的视频流应用,5 秒以内的延迟是可以接受的。对于需要支持直播和交互式用例的流应用,1 秒以内的延迟对用户来说就足够了。显然,对于点播视频,延迟不是问题,因为它已经预先录制,没有直播组件。总的来说,实时直播应用中的高延迟会对最终用户体验产生负面影响。实时关键动机是观众希望感到连接,并得到身临其境的感觉。接收和渲染内容的大延迟会破坏实时观看的感觉。最令人烦恼的体验之一就是实时视频因延迟而经常暂停和缓冲。

让我们简要讨论由于延迟造成的实时视频流应用的主要负面影响。

低音视频质量

如果流系统的组件无法实现实时延迟,通常会导致更高的压缩级别。由于音频视频数据的高压缩级别,音频质量有时会听起来混乱和刮擦,视频质量可能会模糊和像素化,所以整体用户体验会更差。

缓冲暂停和延迟

缓冲是可能破坏用户体验的最糟糕的事情之一,因为观众会经历抖动性能,而不是平滑体验。如果视频不断暂停以缓冲和赶上,这对观众来说非常令人沮丧,并可能导致观众退出视频、平台或业务本身,再也不回来。

音视频同步问题

在许多实时音频视频流应用的实现中,音频数据与视频数据分开发送,因此音频数据可以比视频数据更快地到达接收器。这是因为从本质上讲,音频数据的大小比视频数据小,由于高延迟,视频数据可能在接收器端落后于音频数据。这导致同步问题,并损害了观众对实时视频流体验的感受。

播放 - 回放和快进

即使应用不是 100%的实时,高延迟也可能导致回放和快进的问题。这是因为音频视频数据将不得不重新发送,以便最终用户的播放器可以与新选定的位置重新同步。

探索低延迟视频流技术

在本节中,我们将探讨适用于音频-视频数据编码、解码、流式传输和分发的不同技术协议。这些协议专门为低延迟视频流应用和平台设计。这些协议分为两大类 – 基于 HTTP 的协议和非基于 HTTP 的协议 – 但对于低延迟视频流,通常来说,基于 HTTP 的协议是首选,正如本节将要展示的。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_2.2_B19434.jpg

图 2.2 – 实时视频流延迟和技术

非基于 HTTP 的协议

非基于 HTTP 的协议结合了用户数据报协议UDP)和传输控制协议TCP)来从发送方传输数据到接收方。这些协议可用于低延迟应用,但许多协议没有对自适应流媒体技术的先进支持,并且可扩展性有限。这些协议的例子包括实时流协议RTSP)和实时消息协议RTMP),我们将在下一节中讨论。

RTSP

RTSP 是一种应用层协议,它曾用于视频的低延迟流式传输。它还具有播放功能,允许播放和暂停视频内容,并且可以处理多个数据流。然而,这已不再是当今的流行做法,并且已被其他更现代的协议所取代,我们将在后面的章节中看到。RTSP 被现代协议如 HLS 和 DASH 所取代,因为许多接收器不支持 RTSP;它与 HTTP 不兼容,并且随着基于 Web 的流式传输应用程序的出现而失去了人气。

Flash 和 RTMP

Flash 应用程序曾经非常流行。它们使用 RTMP,并且对于低延迟流式传输用例表现良好。然而,由于许多原因,包括大多数与安全相关的原因,Flash 作为一项技术已经大幅下降其受欢迎程度。随着需求的增长,Web 浏览器和 CDN 已经移除了对 RTMP 的支持,因为它在扩展性方面表现不佳。RTMP 是一种流式传输协议,它实现了流式传输中的低延迟,但如前所述,现在正被其他技术所取代。

基于 HTTP 的协议

基于 HTTP 的协议通常将连续的音视频数据流分解成长度为 2 到 10 秒的小段。这些段随后通过 CDN 或网络服务进行传输。由于它们仍然具有可接受的低延迟、功能丰富且可扩展性更好,因此这些协议是低延迟实时流应用的优选协议。然而,这些协议确实存在我们之前提到的一个缺点:延迟的产生取决于段落的长度。最小延迟至少是段落的长度,因为接收器在能够播放之前需要接收至少一个完整的段落。在某些情况下,延迟可能以段长度为单位的倍数增加,这取决于视频播放设备的实现。例如,iOS 在播放第一个段落之前至少缓冲三个到五个段落,以确保平滑渲染。

以下是一些基于 HTTP 的协议的示例:

  • HTTP 实时 (HLS

  • HTTP 动态 (HDS

  • 微软平滑 (MSS

  • 通过 HTTP 的动态自适应流 (DASH

  • 通用媒体应用 格式 (CMAF

  • 高效流 协议 (HESP

我们将在本节中讨论一些这些协议,以了解它们的工作原理以及它们如何在实时视频流应用中实现低延迟性能。总体而言,这些协议旨在扩展到数百万个同时接收器,并支持自适应流和播放。基于 HTTP 的流协议使用标准 HTTP 协议进行通信,并需要一个服务器进行分发。相比之下,我们稍后将探讨的Web 实时通信 (WebRTC)是一种点对点 (P2P)协议,可以从技术上在两台机器之间建立直接通信,并跳过中间机器或服务器的需求。

HLS

HLS 既用于实时也用于点播音视频内容传输,并且可以非常有效地扩展。HLS 通常由视频传输平台从 RTMP 转换而来。使用 RTMP 和 HLS 是实现低延迟并将流传输到所有设备的最佳方式。有一种名为低延迟 HLS (LL-HLS)的变体可以将延迟降低到 2 秒以下,但它仍然是实验性的。LL-HLS 通过利用流和渲染部分段的能力,而不是要求完整段,从而实现了低延迟音视频实时流。HLS 和 LL-HLS 作为最广泛使用的 ABR 流协议的成功,源于其可扩展性适用于众多用户,以及与大多数类型的设备、浏览器和播放器的兼容性。

CMAF

CMAF 相对较新;严格来说,它并不是一个全新的格式,而是为视频流封装和传输各种协议的形式。它与基于 HTTP 的协议(如 HLS 和 DASH)一起工作,用于编码、封装和解码视频片段。这通常有助于企业通过降低存储成本和音视频流延迟来提高效率。

DASH

DASH 是由动态图像专家组MPEG)的工作创建的,是我们之前讨论的 HLS 协议的替代品。它与 HLS 非常相似,因为它准备不同质量级别的音视频内容,并将它们分成小段以实现 ABR 流。在底层,DASH 仍然依赖于 CMAF,具体来说,它依赖的一个特性是分块编码,这有助于将一个片段分成几个毫秒的小子片段。它依赖的另一个特性是分块传输编码,它将这些发送到分发层的子片段实时分发。

HESP

HESP 是另一种 ABR 基于 HTTP 的流媒体协议。这个协议有雄心勃勃的目标,包括超低延迟、提高可扩展性、支持目前流行的 CDN、降低带宽需求以及减少在流之间切换的时间(即启动新的音视频流的延迟)。由于其延迟极低(<500 毫秒),它成为了 WebRTC 协议的竞争对手,但 HESP 可能成本较高,因为它不是一个开源协议。

基本上,与其它协议相比,HESP 的主要不同之处在于它依赖于两个数据流而不是一个。其中一个数据流(只包含关键帧或快照帧)被称为初始化流。另一个数据流包含对初始化流中帧进行增量更改的数据,这个数据流被称为续流。因此,虽然初始化流中的关键帧包含快照数据并需要更高的带宽,但它们支持在播放过程中快速定位视频中的各种位置。但是,续流带宽较低,因为它只包含更改,并且可以在接收器视频播放器与初始化流同步后快速回放。

虽然在纸面上,HESP 可能听起来完美无缺,但它有几个缺点,例如编码和存储两个流而不是一个的成本更高,需要编码和分发两个流而不是一个,以及需要在接收器平台上的播放器上进行更新以解码和渲染这两个流。

WebRTC

WebRTC 被视为实时视频流媒体行业的新标准,它允许亚秒级延迟,因此可以在大多数平台和几乎每个浏览器(如 Safari、Chrome、Opera、Firefox 等)上播放。它是一个 P2P 协议(即,它可以在设备或流媒体应用之间创建直接通信通道)。WebRTC 的一个大优点是它不需要额外的插件来支持音频-视频流和回放。它还支持 ABR 和双向实时音频-视频流的自适应视频质量变化。尽管 WebRTC 使用 P2P 协议并且可以建立用于会议的直接连接,但性能仍然依赖于硬件和网络质量,因为这对于所有协议来说都是一个考虑因素,无论它们是 P2P 还是其他类型。

WebRTC 确实存在一些挑战,例如需要自己的多媒体服务器基础设施,需要加密交换的数据,处理 UDP 间隙的安全协议,尝试以经济高效的方式在全球范围内扩展,以及处理 WebRTC 组合的多个协议所带来的工程复杂性。

探索低延迟流媒体解决方案和平台

在本节中,我们将探讨一些最流行的低延迟视频流解决方案和商业平台。这些平台基于我们在上一节中讨论的所有技术,来解决与实时音频-视频流应用中高延迟相关的大量商业问题。请注意,许多这些平台支持和使用多个底层流媒体协议,但我们将提到主要用于这些平台的主要协议。

Twitch

Twitch 是一个非常受欢迎的在线平台,主要用于视频游戏玩家实时直播他们的游戏,并通过聊天、评论、捐赠等方式与目标受众互动。不用说,这需要低延迟流媒体以及扩展到大型社区的能力,这正是 Twitch 所提供的。Twitch 使用 RTMP 来满足其广播需求。

Zoom

Zoom 是 COVID 大流行和远程办公时代中流行起来的实时视频会议平台之一。Zoom 提供实时低延迟的音频和视频会议,几乎没有延迟,并支持许多同时在线的用户。在视频会议期间,它还提供屏幕共享和群组聊天等功能。Zoom 主要使用 WebRTC 流媒体协议技术。

Dacast

Dacast 是一个用于广播事件的平台,尽管它的低延迟性能不如一些其他实时流媒体应用,但在广播目的上仍然具有可接受的表现。它价格合理且运行良好,但并不支持大量的交互式工作流程。Dacast 也使用 RTMP 流媒体协议。

Ant Media Server

Ant Media 服务器使用 WebRTC 技术提供极低延迟的视频流平台,旨在在本地或云端的 企业级使用。它也被用于需要核心实时视频流功能的现场视频监控和基于监控的应用。CacheFly 使用基于自定义 Websocket 的端到端流解决方案。

Vimeo

Vimeo 是另一个非常流行的视频流平台,虽然不是业务中最快的,但仍然被广泛使用。它主要用于存放实时直播事件广播和点播视频分发应用。Vimeo 默认使用 RTMP 流,但也支持其他协议,包括 HLS。

哇哦

Wowza 在在线实时视频流领域已经存在很长时间,非常可靠且广泛使用。它被许多大型公司如索尼、Vimeo 和 Facebook 使用,专注于在非常大规模上提供商业和企业级的视频流服务。Wowza 是另一个使用 RTMP 流协议技术的平台。

Evercast

Evercast 是一个超低延迟的流平台,它为协作内容创作和编辑应用以及直播应用找到了很多用途。由于它能够支持超低延迟性能,多个协作者能够流式传输他们的工作空间并创建一个实时协作编辑的环境。由于 COVID 大流行、远程工作和协作以及在线协作教育系统的需求,这类用例在近年来爆炸式增长。Evercast 主要在其流服务器上使用 WebRTC。

CacheFly

CacheFly 是另一个提供现场事件直播视频流服务的平台。它提供可接受的低延迟(以秒计),并且对于实时音视频广播应用具有良好的可扩展性。CacheFly 使用基于自定义 Websocket 的端到端流解决方案。

Vonage Video API

Vonage 视频 API(之前称为 TokBox)是另一个提供实时视频流功能并针对大型企业以支持企业级应用的平台。它支持数据加密,这使得它成为寻找音频视频会议、会议和在线培训的企业、公司和医疗保健公司的首选选择。Vonage 使用 RTMP 以及 HLS 作为其广播技术。

Open Broadcast Software (OBS)

OBS 是另一个低延迟的视频流平台,它也是开源的,这使得它在很多可能因为企业级解决方案而变得有威慑力的圈子中很受欢迎。许多直播内容创作者使用 OBS,甚至一些平台如 Facebook Live 和 Twitch 也使用了 OBS 的某些部分。OBS 支持多种协议,如 RTMP 和 Secure Reliable Transport (SRT)。

在这里,我们结束了对直播视频流应用低延迟考虑因素的讨论。接下来,我们将过渡到视频游戏应用,与直播视频流应用相比,它们有一些共同的特点,尤其是在在线视频游戏方面。

理解在游戏应用中低延迟约束的重要性

自从 20 世纪 60 年代游戏首次诞生以来,电子游戏已经发生了巨大的变化,如今,电子游戏不再仅仅是独自游玩,甚至也不再是和旁边的人一起游玩或对抗。如今,游戏涉及全球各地的许多玩家,甚至这些游戏的品质和复杂性也大大增加。当谈到现代游戏应用时,超低延迟和高可扩展性是非协商性的要求。随着 AR 和 VR 等新技术的出现,这进一步增加了对超低延迟性能的需求。此外,随着移动游戏与在线游戏的结合,复杂的游戏应用已经移植到智能手机上,需要超低延迟的内容分发系统、多人游戏系统和超级快速的处理速度。

在上一节中,我们详细讨论了低延迟实时视频流应用,包括交互式流应用。在本节中,我们将探讨低延迟考虑因素、高延迟影响以及促进视频游戏应用中低延迟性能的技术。由于许多现代视频游戏要么是在线的,要么是在云中,或者由于多人游戏功能而具有强大的在线存在感,因此上一节中学到的很多东西在这里仍然很重要。实时流式传输和渲染视频游戏、防止延迟以及快速有效地响应用户交互是游戏应用中的必要条件。此外,还有一些额外的概念、考虑因素和技术,可以最大化低延迟游戏性能。

低延迟游戏应用中的概念

在我们了解游戏应用中高延迟的影响以及如何提高这些应用的延迟之前,我们将定义并解释一些与游戏应用及其性能相关的概念。当谈到低延迟游戏应用时,最重要的概念是刷新率响应时间输入延迟。这些应用的主要目标是尽量减少玩家与屏幕上他们控制的角色之间的延迟。实际上,这意味着任何用户输入都会立即反映在屏幕上,并且由于游戏环境的变化而对角色所做的任何更改也会立即在屏幕上渲染。当游戏感觉非常流畅,玩家感觉他们真的身处屏幕上渲染的游戏世界时,就达到了最佳的用户体验。现在,让我们深入讨论与低延迟游戏应用相关的重要概念。

延迟

在计算机科学和在线视频游戏应用中,延迟是指从数据从用户的计算机发送到服务器(或可能是其他玩家的计算机)直到数据返回到原始用户计算机的时间。通常,延迟的幅度取决于应用;对于低延迟电子交易,这将是数百微秒,而对于游戏应用,通常是数十到数百毫秒。延迟基本上衡量了在没有服务器或客户端机器上的任何处理延迟的情况下,服务器和客户端之间通信的速度。

游戏应用对实时性的要求越接近,所需的延迟时间就越低。这通常适用于像第一人称射击FPS)和体育赛车游戏这样的游戏,而像大型多人在线MMO)游戏和一些实时策略RTS)游戏则可以容忍更高的延迟。通常,游戏界面本身会具备延迟功能或实时显示延迟统计数据。一般来说,50 到 100 毫秒的延迟是可以接受的,超过 100 毫秒可能会在游戏过程中造成明显的延迟,而任何高于这个数值的延迟都会使玩家的体验大打折扣,变得不可行。通常,低于 25 毫秒的延迟是理想的,它能够保证良好的响应速度,清晰的视觉效果,以及无游戏延迟。

每秒帧数 (FPS)

FPS(不要与第一人称射击游戏混淆)是在线游戏应用中另一个重要的概念。FPS 衡量显卡每秒可以渲染多少帧或图像。FPS 也可以用于衡量显示器硬件本身(即显示器硬件本身可以显示或更新的帧数)。更高的 FPS 通常会导致游戏世界的渲染更平滑,用户体验对输入和游戏事件更敏感。较低的 FPS 会导致游戏和渲染感觉僵硬、卡顿和闪烁,总体上,会导致游戏乐趣和接受度显著降低。

对于一个游戏要能正常运作或甚至可玩,30 FPS 是最低必要条件,这可以支持游戏机和一些 PC 游戏。只要 FPS 保持在 20 FPS 以上,这些游戏就可以继续玩,而不会有明显的延迟和退化。对于大多数游戏,60 FPS 或更高是大多数显卡、PC、显示器和电视容易支持的理想性能范围。超过 60 FPS,下一个里程碑是 120 FPS,这仅适用于连接到至少支持 144-Hz 刷新率显示器的顶级游戏硬件。超过这个范围,240 FPS 是可达到的最大帧率,需要与 240-Hz 刷新率的显示器配对。这种高端配置通常仅适用于最大的游戏爱好者。

刷新率

刷新率是一个与每秒帧数(FPS)非常密切相关的概念,尽管技术上它们略有不同,但它们确实相互影响。刷新率还衡量屏幕刷新的速度,并影响硬件可以支持的最大 FPS。和 FPS 一样,刷新率越高,在游戏过程中屏幕上动画运动时的渲染过渡就越平滑。最大刷新率控制着可以达到的最大 FPS,因为尽管显卡的渲染速度可能比显示器刷新速度快,但瓶颈变成了显示器刷新率。当 FPS 超过刷新率时,我们遇到的一种显示问题是称为屏幕撕裂。屏幕撕裂是指显卡(GPU)没有与显示器同步,因此有时显示器会在当前帧的上方绘制一个不完整的帧,导致屏幕上出现水平或垂直的分割,部分帧和完整帧重叠。这并不完全破坏游戏体验,但至少如果偶尔发生,可能会分散注意力,如果非常频繁,则可能完全破坏游戏画面的质量。处理屏幕撕裂有各种技术,我们稍后会探讨,例如垂直同步V-Sync)、自适应同步FreeSyncFast SyncG-Sync可变刷新率VRR)。

输入延迟

输入延迟衡量的是用户生成输入(如按键、鼠标移动或鼠标点击)与屏幕上对该输入作出响应之间的延迟。这基本上是硬件和游戏对用户输入和交互的响应速度。显然,对于所有游戏来说,这个值都不是零,它是硬件本身(控制器、鼠标、键盘、互联网连接、处理器、显示器等)或游戏软件本身(处理输入、更新游戏和角色状态、调度图形更新、通过显卡渲染以及刷新显示器)的总和。当输入延迟较高时,游戏感觉不灵敏且延迟,这可能会影响玩家在多人或在线游戏中的表现,甚至完全破坏用户的游戏体验。

响应时间

响应时间常被误认为是输入延迟,但它们是不同的术语。响应时间指的是像素响应时间,这基本上意味着像素改变颜色所需的时间。虽然输入延迟影响游戏响应速度,但响应时间影响屏幕上渲染动画的模糊度。直观地说,如果像素响应时间较高,当在屏幕上渲染运动或动画时,像素改变颜色所需的时间更长,从而导致模糊。较低的像素响应时间(1 毫秒或更低)会导致清晰和锐利的图像和动画质量,即使对于具有快速摄像机移动的游戏也是如此。这类游戏的良好例子包括第一人称射击游戏和赛车游戏。当响应时间较高时,我们会遇到一种称为鬼影的伪影,这指的是当有运动时,轨迹和伪影缓慢地从屏幕上消失。通常,鬼影和较高的像素响应时间并不是问题,现代硬件可以轻松提供小于 5 毫秒的响应时间,并渲染清晰的动画。

网络带宽

网络带宽以相同的方式影响在线游戏应用,就像它会影响实时视频流应用一样。带宽衡量每秒可以上传到或从游戏应用服务器下载多少兆比特。带宽还受到数据包丢失的影响,我们将在下一部分讨论,并且根据玩家和连接到的游戏服务器的位置而变化。"竞争"是网络带宽时需要考虑的另一个术语。竞争是指尝试访问同一服务器或共享资源的并发用户数量,以及这会不会导致服务器过载。

网络数据包丢失和抖动

网络数据包丢失是在网络中传输数据包时不可避免的事实。网络数据包丢失会降低有效带宽,并导致重传和恢复协议,这会引入额外的延迟。一些数据包丢失是可以容忍的,但当网络数据包丢失率非常高时,它们会降低在线游戏应用的用户体验,甚至可能使其完全停止。抖动类似于数据包丢失,但在此情况下,数据包到达顺序错误。由于游戏软件位于用户端,这会引入额外的延迟,因为接收器必须保存顺序错误的数据包,等待尚未到达的数据包,然后按顺序处理数据包。

网络协议

当涉及到网络协议时,有两大协议用于在互联网上传输数据:TCPUDP。TCP 通过跟踪成功送达接收者的数据包并具有重传丢失数据包的机制,提供了一种可靠的传输协议。这里的优势很明显,因为应用程序不能在数据包和信息丢失的情况下运行。这里的缺点是,这些额外的机制来检测和处理数据包丢失会导致额外的延迟(额外的毫秒数)并更有效地使用可用带宽。必须依赖 TCP 的应用程序示例包括在线购物和在线银行,在这些情况下,确保数据正确送达至关重要,即使它晚些时候送达。UDP 则侧重于确保数据尽可能快地送达,并具有更高的带宽效率。然而,它这样做是以不保证交付甚至不保证数据包顺序交付为代价的,因为它没有重传丢失数据包的机制。UDP 对于可以容忍一些数据包丢失而不会完全崩溃的应用程序以及更倾向于丢失信息而不是延迟信息的应用程序来说效果很好。此类应用程序的示例包括实时视频流和某些在线游戏应用组件。例如,一些视频组件或在线视频游戏中的渲染组件可以通过 UDP 传输,但某些组件,如用户输入和游戏及玩家状态更新,需要通过 TCP 发送。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_2.3_B19434.jpg

图 2.3 – 端到端视频游戏系统中的组件

提高游戏应用性能

在上一节中,我们讨论了一些适用于低延迟游戏应用的概念及其对应用和用户体验的影响。在本节中,我们将探讨游戏应用中高延迟来源的更多细节,并讨论我们可以采取的步骤来提高游戏应用的延迟和性能,从而改善用户体验。

从开发者的角度来考虑游戏应用优化

首先,我们来看看游戏开发者用来优化游戏应用性能的方法和技术。让我们快速描述一下开发者采用的一些优化技术——一些适用于所有应用,而另一些则仅适用于游戏应用。

管理内存、优化缓存访问和优化热点路径

与其他低延迟应用一样,游戏应用必须高效地使用可用资源并最大化运行时性能。这包括正确管理内存以避免内存泄漏,尽可能预先分配和初始化尽可能多的东西。避免在关键路径上使用垃圾回收和动态内存分配与释放等机制,对于满足一定的运行时性能预期也很重要。这对于游戏应用尤其相关,因为视频游戏中有很多对象,尤其是那些创建和处理大型世界的对象。

大多数低延迟应用的一个重要方面是尽可能高效地使用数据和指令缓存。游戏应用也不例外,尤其是考虑到它们必须处理的大量数据。

许多应用,包括游戏应用,在关键循环中花费了大量的时间。对于游戏应用来说,这可能是一个检查输入、根据物理引擎更新游戏状态、角色状态等,并在屏幕上渲染,以及生成音频输出的循环。游戏开发者通常花费大量时间关注这个关键路径上执行的操作,就像我们在任何低延迟应用中运行紧密循环时所做的。

视锥剔除

在计算机图形学中,术语“视锥”(view frustum)指的是当前屏幕上可见的游戏世界的一部分。“视锥剔除”(frustum culling)是一个术语,指的是确定屏幕上哪些对象是可见的,并且只渲染屏幕上的那些对象的技术。另一种思考方式是,大多数游戏引擎会尽量减少对屏幕外对象的处理能力。这通常是通过将对象的显示或渲染功能与其数据和管理逻辑(如位置、状态、下一步动作等)分离来实现的。消除不在屏幕上的对象的渲染开销,可以将处理成本降低到极低。另一种引入这种分离的方法是,有一个更新方法用于对象在屏幕上时,另一个更新方法用于对象在屏幕外时。

缓存计算和使用数学近似

这是一个易于理解的优化技术,适用于需要执行大量昂贵数学计算的应用程序。特别是游戏应用,在它们的物理引擎中数学计算非常重,尤其是在拥有大型世界和世界中有大量对象的 3D 游戏中。在这种情况下,使用缓存值而不是每次都重新计算,使用查找表以内存使用换取 CPU 使用来查找值,以及使用数学近似而不是极其精确但昂贵的表达式等优化技术被使用。这些优化技术在视频游戏领域已经使用了很长时间,因为长期以来,硬件资源极其有限,开发这样的系统需要依赖这些技术。

来自id Software(该公司的开创性工作推动了诸如《狼人杀》、《毁灭战士》、《雷神之锤》等游戏)的射线投射引擎是老日子里低延迟软件开发的一个令人印象深刻的杰作。另一个例子是那些屏幕上有许多敌人但许多敌人有相似的运动模式并且可以重用而不是重新计算的情况。

优先处理关键任务并利用 CPU 空闲时间

处理巨大游戏世界中许多对象的引擎通常有许多频繁更新的对象。而不是在每一帧更新时更新每个对象,游戏引擎需要优先处理需要在关键部分执行的任务(例如,自上一帧以来视觉属性已更改的对象)。一个简单的实现是为每个对象提供一个成员方法,游戏引擎可以使用它来检查自上一帧以来是否已更改,并优先更新这些对象的更新。例如,一些游戏组件,如场景(固定环境对象、天气、光照等)和抬头显示HUD)并不经常改变,并且通常具有极其有限的动画序列。与更新这些组件相关的任务比其他一些游戏组件的任务优先级稍低。

将任务分类为高优先级和低优先级任务还意味着游戏引擎可以通过确保在所有硬件和游戏设置中执行高优先级任务来保证良好的游戏体验。如果游戏引擎检测到大量的 CPU 空闲时间,它可以添加额外的低优先级功能(如粒子引擎、光照、阴影、大气效果等)。

根据层、深度和纹理顺序绘制调用

一个游戏引擎需要确定向显卡发送哪些渲染或绘制调用。为了优化性能,这里的目的是不仅要最小化发出的绘制调用数量,还要对这些绘制调用进行排序和分组,以最优的方式执行它们。当将对象渲染到屏幕上时,我们必须考虑以下层次或因素:

  • 全屏层:这包括 HUD、游戏层、半透明效果层等

  • 视口层:如果存在镜子、传送门、分屏等,则存在这些层

  • 深度考虑:我们需要按照从后向前或从远到近的顺序绘制对象

  • 纹理考虑:这包括纹理、着色、光照等

在这些不同层次和组件的排序以及绘制调用发送到显卡的顺序上需要做出各种决策。一个例子是在透明对象可能按从后向前排序(即首先按深度排序,然后按纹理排序)的情况下。对于不透明对象,可能首先按纹理排序,并消除位于不透明对象后面的对象的绘制调用。

从玩家的角度接近游戏应用优化

对于游戏应用,很多性能取决于最终用户的硬件、操作系统和游戏设置。本节描述了最终用户可以采取的一些措施,以在不同的设置和不同的资源可用性下最大化游戏性能。

升级硬件

提高游戏应用性能的第一个明显方法就是提高最终用户游戏运行的硬件。一些重要的候选者包括游戏显示器、鼠标、键盘和控制器。具有更高刷新率的游戏显示器(例如支持 1920 x 1080p(像素)分辨率的 360-Hz 显示器和支持 2560 x 1440p 分辨率的 240-Hz 显示器)可以提供高质量的渲染和流畅的动画,并增强游戏体验。我们还可以使用具有极高轮询率的鼠标,这允许点击和移动比以前更快地被记录下来,从而减少延迟和滞后。同样,对于键盘,游戏键盘具有更高的轮询率,可以提高响应时间,尤其是在有很多连续按键的游戏中,这通常是 RTS 游戏的情况。这里还要提到的一个重要点是,使用针对特定游戏机和平台官方和信誉良好的控制器通常会产生最佳性能。

游戏显示器刷新率

我们之前已经讨论过这个方面几次,但随着非常高质量的图像和动画的兴起,游戏显示器的质量和容量本身也变得相当重要。在这里,关键是要有一个高刷新率显示器,同时具有低像素响应时间,以便动画可以快速且平滑地渲染和更新。配置还必须避免我们之前讨论过的屏幕撕裂、重影和模糊等伪影。

升级您的显卡

升级显卡是另一种可以通过提高帧率从而改善游戏性能的选项。NVIDIA 发现,在某些情况下,升级显卡和 GPU 驱动程序可以帮助游戏性能提高超过 20%。NVIDIA GeForce、ATI Radeon、Intel HD 显卡等都是流行的供应商,它们提供更新和优化的驱动程序,可以根据用户平台上安装的显卡来提升游戏性能。

超频显卡

除了升级 GPU,或者作为升级 GPU 的补充,另一个可能的改进领域是尝试通过超频 GPU 来增加 FPS。超频GPU 的原理是通过提高 GPU 的频率,从而最终增加 GPU 的 FPS 输出。超频 GPU 的一个缺点是内部温度会升高,在极端情况下可能导致过热。因此,在超频时,你应该监控温度的升高,逐步增加超频级别,并在过程中进行监控,确保 PC、笔记本电脑或游戏机有足够的冷却措施。GPU 超频可以使性能提升大约 10%。

升级您的 RAM

这是一种明显的通用改进技术,也适用于低延迟游戏应用。向 PC、智能手机、平板电脑或游戏机添加额外的 RAM,可以让游戏应用和图形渲染任务发挥最佳性能。幸运的是,过去十年中 RAM 的成本大幅下降,因此这是一种提升游戏应用性能的简单方法,并且非常推荐。

调整硬件、操作系统和游戏设置

在前面的子节中,我们讨论了一些可以升级硬件资源以改善游戏应用性能的选项。在本节中,我们将讨论可以针对硬件、平台、操作系统以及游戏设置本身进行优化的设置,以进一步推动游戏应用性能。

启用游戏模式

游戏模式是适用于高端电视和类似高端显示器等显示器的设置。启用游戏模式会禁用显示器的额外功能,这可以提高图像和动画质量,但代价是更高的延迟。启用游戏模式会导致图像质量略有下降,但可以通过减少渲染延迟来帮助改善低延迟游戏应用程序的最终用户体验。Windows 10 上的 Windows 游戏模式就是一个游戏模式的例子,当启用时,它会优化游戏性能。

使用高性能模式

我们在这里讨论的高性能模式指的是电源设置。不同的电源设置试图在电池使用和性能之间进行优化;高性能模式会更快地消耗电池电量,并且可能比低性能模式更高地提高内部温度,但同时也提高了正在运行的应用程序的性能。

延迟自动更新

自动更新是 Windows 中特别常见的一项功能,它会自动下载和安装安全修复程序。虽然这通常不是一个大问题,但如果在我们进行在线游戏会话的过程中开始了一个特别大的自动更新下载和安装,那么它可能会影响游戏性能和体验。如果这与一个利用高处理器使用和带宽的游戏会话同时发生,自动更新可能会激增处理器使用率和带宽消耗,并降低游戏性能。因此,当运行对延迟敏感和资源密集型的游戏应用程序时,关闭或延迟自动 Windows 更新通常是一个好主意。

关闭后台服务

这是另一种类似于我们刚才讨论的延迟自动更新的选项。在这里,我们找到并关闭可能正在后台运行但并非必需以正确运行低延迟游戏会话的应用程序和服务。实际上,关闭这些应用程序可以防止它们在游戏会话中意外和非确定性地消耗硬件资源。通过使尽可能多的资源可用给该应用程序,这最大化了低延迟游戏应用程序的性能。

达到或超过刷新率

我们之前已经讨论过屏幕撕裂的概念,所以我们至少需要一个系统,其中帧率至少等于或超过刷新率,以防止屏幕撕裂。FreeSync 和 G-Sync 等技术可以在提供低延迟性能的同时,实现无撕裂的平滑渲染。当帧率超过刷新率时,延迟仍然保持低,但如果帧率开始以很大的幅度超过刷新率,屏幕撕裂可能会再次出现。这可以通过使用 V-Sync 技术或有意限制帧率来解决。FreeSync 和 G-Sync 需要硬件支持,因此您需要兼容的 GPU 才能使用这些技术。但 FreeSync 和 G-Sync 的优点是,您可以完全禁用引入延迟的 V-Sync,只要您的硬件支持,您就可以获得低延迟和无撕裂的渲染体验。

禁用三重缓冲和 V-Sync,并仅在全屏模式下运行

我们之前解释过,V-Sync 由于需要将 GPU 渲染的帧与显示设备同步,可能会引入额外的延迟。三重缓冲只是 V-Sync 的另一种形式,其目标也是减少屏幕撕裂。当游戏在窗口模式下运行时,三重缓冲尤其重要,此时游戏在窗口内运行而不是全屏。关键点是,为了禁用 V-Sync 和三重缓冲以改善延迟和性能,我们必须仅在全屏模式下运行。

优化游戏设置以实现低延迟和高帧率

现代游戏提供了大量选项和设置,旨在最大化性能(有时以牺牲渲染质量为代价),最终用户可以根据他们的目标硬件、平台、网络资源和性能需求来优化这些参数。例如,降低抗锯齿设置,或者降低分辨率。最后,调整与观看距离、纹理渲染、阴影和照明相关的设置,也可以以降低渲染质量为代价来最大化性能。抗锯齿旨在在低分辨率环境中渲染高分辨率图像时,将平滑边缘而不是锯齿边缘呈现出来,因此降低抗锯齿设置会降低图像的平滑度,但会加速低延迟性能。如果需要额外的性能,也可以降低如火焰、水、运动模糊和镜头光晕等高级渲染效果。

进一步优化您的硬件

在最后两个小节中,我们讨论了通过升级硬件资源和调整硬件、操作系统和游戏设置来优化低延迟游戏应用选项。在本节最后,我们将讨论如何进一步挤压性能,以及我们有哪些选项可以进一步优化在线低延迟游戏应用性能。

安装 DirectX 12 Ultimate

DirectX 是由微软开发的 Windows 图形和游戏 API。将 DirectX 升级到最新版本意味着游戏平台可以访问最新的修复和改进以及更好的性能。目前,DirectX 12 Ultimate 是最新版本,预计 DirectX 13 将在 2022 年底或 2023 年初发布。

碎片整理和优化磁盘

硬盘碎片整理发生在文件在硬盘上创建和删除时,以及空闲和已用磁盘空间块分散或碎片化,导致驱动器性能降低。硬盘驱动器HDD)和固态驱动器SSD)通常是大多数游戏平台常用的两种存储选项。SSD 比 HDD 快得多,并且通常不会遭受许多与碎片化相关的问题,但仍然可能随着时间的推移而变得不理想。例如,Windows 有一个磁盘碎片整理和优化应用程序来优化驱动器的性能,这可以提高游戏应用程序的性能。

确保笔记本电脑冷却效果最佳

当由于处理器、网络、内存和磁盘使用率高而承受重负载时,笔记本电脑或 PC 的内部温度会升高。除了危险之外,它还迫使笔记本电脑通过限制资源消耗来尝试自己冷却,从而最终影响性能。我们特别提到笔记本电脑来解释这个问题,因为 PC 通常比笔记本电脑有更好的空气流动和冷却能力。通过清理通风口和风扇,去除灰尘和污垢,将它们放在坚硬、光滑和平坦的表面上,使用外部电源供电以不耗尽电池,甚至可能使用额外的冷却支架,可以提升笔记本电脑的游戏性能。

使用 NVIDIA Reflex 低延迟技术

NVIDIA Reflex低延迟技术旨在最小化从用户点击鼠标或按下键盘或控制器上的键的那一刻起,到该动作在屏幕上产生影响的时刻所测量的输入延迟。我们已经在本文中讨论了延迟的来源,NVIDIA 将其分解为从输入设备到处理器和显示器的九个部分。NVIDIA Reflex 软件通过改善 CPU 和 GPU 之间的通信路径,通过跳过不必要的任务和暂停来优化帧交付和渲染,并加速 GPU 渲染时间,从而加快了这一关键路径的性能。NVIDIA 还提供 NVIDIA Reflex 延迟分析器来测量使用这些低延迟增强所实现的加速速度。

讨论物联网和零售分析系统的设计

在上一章中,我们讨论了物联网和零售分析以及它们所创造的不同用例。本节的重点将简要讨论用于实现这些应用和用例低延迟性能的技术。请注意,物联网是一个仍在积极发展和演变的科技领域,因此在接下来的几年中,将会有许多突破和进步。让我们快速回顾一下物联网和零售数据分析的一些重要用例。许多这些新的应用和未来可能性都是由 5G 无线技术、边缘计算人工智能AI)的研究和进步所推动的。我们将在下一节中探讨这些方面,以及其他有助于使用低延迟物联网和零售数据分析的应用技术。

许多应用属于远程检查/分析类别,在这些类别中,无人机可以在远程技术人员、监控基础设施(如桥梁、隧道、铁路、公路和水道)以及变压器、公用事业电线、天然气管道和电力及电话线路等领域作为第一道防线来替代人类。将这些应用与人工智能技术相结合,可以增强数据分析的复杂性,从而创造新的机会和用例。引入增强现实技术也增加了可能性。现代汽车收集大量数据,并且随着自动驾驶汽车的可能性,物联网的应用用例将进一步扩展。农业自动化、航运和物流、供应链管理、库存和仓库管理以及车队管理为物联网技术创造了大量额外的用例,并分析了这些设备生成和收集的数据。

确保物联网设备低延迟

在本节中,我们将探讨一些有助于在物联网应用和零售分析中实现低延迟性能的考虑因素。请注意,我们之前讨论的许多针对实时视频流和在线视频游戏用例的考虑因素也适用于此处,例如硬件资源、编码和解码数据流、内容分发机制以及硬件和系统级优化。为了简洁起见,我们在此不会重复这些技术,但我们将介绍一些特定于物联网和零售数据分析的低延迟考虑因素。

P2P 连接

物联网设备的 P2P 连接在物联网设备之间或物联网设备与最终用户应用程序之间建立直接连接。用户的设备输入直接发送到目标物联网设备,中间没有任何第三方服务或服务器,以最小化延迟。同样,来自物联网设备的数据直接从设备流回其他设备。P2P 方法是对通过云连接物联网设备的替代方案,因为云有额外的延迟,这是由于额外的服务器数据库、云工作实例等造成的。P2P 也被称为物联网的应用使能平台(AEP),这是一种对基于云的 AEP 的替代方案。

使用第五代无线技术(5G)

5G 无线技术提供更高的带宽、超低延迟、可靠性和可扩展性。不仅最终用户从 5G 中受益,它还帮助了需要低延迟和实时数据流和处理的物联网设备和应用的所有步骤。5G 的低延迟促进了更快速和更可靠的库存跟踪、运输服务监控、对分销物流的实时可见性等。5G 网络的设计考虑到了所有不同的物联网用例,因此它非常适合所有类型的物联网应用以及更多。

理解边缘计算

边缘计算是一种分布式处理技术,其关键点是将处理应用程序和数据存储组件尽可能靠近数据源,在这种情况下,是捕获数据的物联网设备。边缘计算打破了旧的模式,即数据由远程设备记录,然后传输到中央存储和处理位置,然后将结果传输回设备和客户端应用程序。这项令人兴奋的新技术正在改变大量由众多物联网设备生成的数据如何传输、存储和处理。边缘计算的主要目标是降低在长距离传输大量数据时的带宽成本,并支持超低延迟,以促进需要尽可能快速和高效处理大量数据的实时应用程序。此外,它还可以降低企业的成本,因为它们不一定需要集中式和基于云的存储和处理解决方案。这一点在物联网应用中尤为重要,因为设备生成数据的规模巨大,这意味着带宽消耗将呈指数增长。

理解边缘计算系统的物理架构的所有细节是困难的,并且超出了本书的范围。然而,在非常高的层面上,客户端设备和物联网设备连接到附近的边缘模块。通常,服务提供商或企业部署了许多网关和服务器,以建立自己的边缘网络来支持这些边缘计算操作。可以使用这些边缘模块的设备范围从物联网传感器、笔记本电脑和计算机、智能手机和平板电脑、摄像头、麦克风,以及你能想象到的任何其他设备。

理解 5G 和边缘计算之间的关系

我们之前提到,5G 的设计和开发是考虑到物联网和边缘计算的。因此,物联网、5G 和边缘计算都是相互关联的,它们相互协作以最大化这些物联网应用的使用案例和性能。理论上,边缘计算可以部署到非 5G 网络上,但显然,5G 是首选网络。然而,情况并非相反;要发挥 5G 的真正力量,你需要一个边缘计算基础设施来真正最大化 5G 提供的所有功能。这是直观的,因为没有边缘计算基础设施,设备的数据必须长途跋涉才能得到处理,然后结果也必须长途跋涉才能到达最终用户的应用或其他设备。在这些情况下,即使你有 5G 网络,由于数据传输距离造成的延迟远大于使用 5G 获得的延迟改进。因此,在物联网应用和需要实时分析零售数据的应用中,边缘计算是必要的。

理解边缘计算和人工智能之间的关系

数据分析技术、机器学习和人工智能已经彻底改变了从物联网设备收集的零售和非零售数据是如何被分析以得出有意义的见解的。NVIDIA 在开发新的硬件解决方案方面是先驱,不仅推动了边缘计算,还推动了人工智能处理的极限。Jetson AGX Orin 是 NVIDIA 如何将人工智能和机器人功能打包成一个单一产品的特别好的例子。

我们不会过多地介绍 Jetson AGX Orin,因为这既不是本书的重点,也不在本书的范围内。Jetson AGX Orin 具有一些使其非常适合人工智能、机器人和自动驾驶汽车的品质——它体积紧凑、功能强大且节能。其功率和能效使其适用于人工智能应用并实现边缘计算。特别是这一最新型号允许开发者将人工智能、机器人、自然语言处理NLP)、计算机视觉等结合到一个紧凑的包中,使其非常适合机器人。该设备还具有多个 I/O 连接器,兼容许多不同的传感器(MIPI、USB、摄像头等)。此外,还有额外的硬件扩展插槽以支持存储、无线等功能。这款强大的 GPU 设备非常适合深度学习(以及经典机器学习)和计算机视觉应用,如机器人。

购买和部署边缘计算系统

当涉及到购买和设置边缘计算基础设施时,企业通常会选择以下两种途径之一:定制组件并在内部构建和管理基础设施,或者使用为企业提供和管理边缘服务的供应商。

在内部构建和管理边缘计算基础设施需要来自 IT、网络和业务部门的专长。然后,他们可以从硬件供应商(如 IBM、Dell 等)选择边缘设备,并为特定用例设计和管理 5G 网络基础设施。这种选择仅对那些认为为特定用例定制边缘计算基础设施具有价值的的大型企业有意义。至于由第三方供应商协助和管理边缘计算基础设施的选项,供应商将根据费用设置硬件、软件和网络架构。这把复杂系统如边缘计算基础设施的管理留给在该领域具有专长的公司,如 GE 和西门子,从而使客户企业能够专注于在此基础设施之上构建。

利用邻近性

我们在前面章节中已经隐含地讨论了这一点,但现在我们将在这里明确地讨论。物联网应用的一个关键要求是达到超低延迟性能,而实现这一目标的关键是利用物联网用例中涉及的不同设备和应用之间的邻近性。边缘计算是利用邻近性来最小化从数据捕获到处理以及与其他设备或客户端应用共享结果之间的延迟的关键。正如我们之前所看到的,非边缘计算基础设施的最大瓶颈是数据中心和处理资源与数据源和结果目的地之间的距离。随着分布式的数据中心相互之间相隔数英里,这种情况会变得更糟,最终导致临界的高延迟和滞后。显然,将边缘计算资源放置在数据源附近是推动物联网采用、物联网用例以及将物联网业务扩展到大量设备和用户的关键。

降低云成本

这是我们之前讨论过的另一个问题,但我们将在本节中正式讨论它。目前有数十亿个物联网设备,它们产生连续的数据流。任何有效的物联网驱动业务都需要在大规模增加设备和客户端数量的情况下具有极高的可扩展性,这会导致记录和由边缘计算处理的数据量呈指数增长,以及将结果传输到其他设备和客户端。依赖于集中式云基础设施的数据密集型基础设施无法以经济高效的方式支持物联网应用,而且数据和云基础设施本身也成为企业开支的一个重大部分。明显的解决方案是找到一种低成本边缘解决方案(第三方或内部),并使用它来满足物联网数据捕获、存储和处理的需求。这消除了与在云解决方案中传输数据相关的成本,并可以显著提高边缘计算可靠性并降低成本。

我们将通过以下图表总结对低延迟物联网应用的讨论,以展示物联网应用的当前和未来状态:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_2.4_B19434.jpg

图 2.4 – 物联网应用的当前和未来状态

探索低延迟电子交易

低延迟应用的最后一个例子是用于低延迟电子交易和超低延迟电子交易的应用,也称为 HFT。在本书的剩余部分,我们将从头开始使用 C++构建一个完整的端到端低延迟电子交易系统。因此,在本节中,我们将简要讨论电子交易应用实现低延迟性能的重要考虑因素,然后在后续章节中详细介绍底层细节。《Sebastian Donadio、Sourav Ghosh 和 Romain Rossier 合著的《开发高频交易系统》》对于有兴趣的读者来说是一本了解低延迟电子交易系统更详细信息的优秀书籍。本书的重点将是使用 C++从头开始设计和构建每个组件,以了解低延迟应用开发,但那本书可以作为 HFT 业务背后额外理论的良好参考。

理解现代电子交易中低延迟的需求

随着电子交易现代化和高速交易(HFT)的兴起,对于这些应用来说,低延迟比以往任何时候都更加重要。在许多情况下,实现更低的延迟会导致交易收入的直接增加。在某些情况下,存在一种持续的竞争,试图不断降低延迟以保持市场中的竞争优势。在极端情况下,如果一个参与者落后于最低可能延迟的军备竞赛,他们可能会倒闭。

现代电子市场的交易机会极为短暂,因此只有能够处理市场数据并找到这种机会,并迅速下单以应对这种机会的市场参与者才能盈利。反应不够快意味着你只能获得机会的一小部分,通常只有最快的参与者能获得所有利润,而所有其他较慢的参与者则一无所获。这里的另一个细微之处在于,如果一个参与者不够快地反应市场事件,他们也可能在交易中处于错误的一边,并输给那些能够足够快地反应事件的参与者。在这种情况下,交易利润不仅会降低,而且交易收入可能会变成负数(即亏损)。为了更好地理解这一点,让我们举一个我们将在这本书中构建的例子:市场创造和流动性获取算法。

不深入细节的话,市场做市算法在市场中持有订单,其他参与者可以在需要时与之交易。因此,市场做市算法需要不断重新评估其活跃订单,并根据市场条件调整它们的价格和数量。然而,流动性获取算法并不总是持有市场中的活跃订单。这个算法相反地等待机会出现,然后与市场做市算法的活跃订单进行交易。对高频交易市场的简单看法就是市场做市算法和流动性获取算法之间持续的战斗,因为它们自然站在对立的立场。

在这种设置中,当市场做市算法在修改其市场中的活跃订单时速度较慢时,它会亏损。例如,根据市场条件,短期内市场价格明显会上涨;如果市场做市算法的卖出订单有被执行的风险,因为它不再想以那些价格卖出,那么市场做市算法会尝试移动或取消其卖出订单。同时,一个流动性获取算法会尝试看是否可以发送一个买入订单来与市场做市者的卖出订单进行交易。在这场竞争中,如果市场做市算法比流动性获取算法慢,它将无法修改或取消其卖出订单。如果流动性获取算法慢,它将无法执行其想要的订单,要么是因为一个不同的(并且更快的)算法在它之前执行了,要么是因为市场做市者能够避开。这个例子应该清楚地表明,延迟直接影响到电子交易的交易收入。

对于高频交易(HFT),客户端的交易应用可以在亚毫秒级延迟内接收和处理市场数据,分析信息,寻找机会,并向交易所发送订单,所有这些操作都在亚毫秒级延迟内完成,并且使用现场可编程门阵列FPGAs)可以将延迟降低到亚微秒级。FPGAs 是一种可重新编程的特殊硬件芯片,可以直接在芯片上构建极其专业化和低延迟的功能。理解 FPGAs 的细节以及开发和使用它们是一个超出本书范围的先进主题。

尽管我们在前面的例子中提到了交易表现和收入,但低延迟在电子交易业务的其它方面也很重要,这些方面可能并不立即明显。显然,交易收入和表现仍然是交易应用的主要关注点;长期业务连续性的另一个重要要求是实时风险管理。由于每个电子市场都有许多交易工具,并且这些工具在一天中持续变化价格,因此风险管理系统需要跟上大量的数据,这些数据涵盖了全天所有交易所和所有产品。

此外,由于公司在其所有产品和交易所上采用高频交易策略,因此公司在这所有产品上的头寸会全天快速变化。一个实时风险管理系统需要评估公司在这所有产品上的不断变化的敞口与市场价格,以追踪全天的盈亏和风险。风险评估指标和系统本身可能相当复杂;例如,在期权交易中,通常会在实时或接近实时的情况下运行蒙特卡洛模拟,以尝试找到最坏情况下的风险评估。一些风险管理系统还负责在超过其风险限制时关闭自动化交易策略。这些风险系统通常被添加到多个组件中——一个中央风险系统、订单网关以及交易策略本身——但我们将在本书的后续章节中了解这些细节。

实现电子交易中的最低延迟

在本节中,我们将简要讨论在实施低延迟电子交易系统时的一些高级思想和概念。当然,随着我们在接下来的章节中构建电子交易生态系统,我们将通过示例以更详细的方式重新审视这些内容。

优化交易服务器硬件

获取强大的交易服务器以支持低延迟交易操作是第一步。通常,这些服务器的处理能力取决于交易系统进程的架构,例如我们期望运行多少个进程,我们期望消耗多少网络资源,以及我们期望这些应用程序消耗多少内存。通常,低延迟交易应用程序在繁忙的交易期间具有高 CPU 使用率,低内核使用率(系统调用),低内存消耗,以及相对较高的网络资源使用率。CPU 寄存器、缓存架构和容量也很重要,通常,如果可能的话,我们会尝试获取更大的尺寸,但这些可能会非常昂贵。高级考虑因素,如非一致性内存访问NUMA)、处理器指令集、指令流水线和指令并行性、缓存层次架构细节、超线程和超频 CPU 通常也会被考虑,但这些是极其高级的优化技术,超出了本书的范围。

网络接口卡、交换机和内核旁路

需要支持超低延迟交易应用(特别是那些必须读取大量市场数据、更新网络数据包并处理它们的)的交易服务器需要专门的网络接口卡NICs)和交换机。适用于此类应用的优选网络接口卡需要具有非常低的延迟性能、低抖动和大的缓冲容量,以处理市场数据突发而不会丢失数据包。此外,适用于现代电子交易应用的优选网络接口卡支持一条特别低延迟的路径,该路径避免了系统调用和缓冲区复制,称为内核旁路Solarflare是一个例子,它提供了OpenOnloadef_vi以及TCPDirect等 API,当使用它们的网络接口卡时可以绕过内核;Exablaze是另一个支持内核旁路的专用网络接口卡的例子。网络交换机在网络拓扑结构中的多个位置出现,支持位于彼此较远处的交易服务器之间的互连,以及交易服务器和电子交易所服务器之间的互连。对于网络交换机,一个重要的考虑因素是交换机可以支持的缓冲区大小,以缓冲需要转发的数据包。另一个重要要求是交换机接收数据包并将其转发到正确接口之间的延迟,称为交换延迟。交换延迟通常非常低,在数十到数百纳秒的范围内,但这适用于所有通过交换机的入站或出站流量,因此需要保持一致的低延迟,以避免对交易性能产生负面影响。

理解多线程、锁、上下文切换和 CPU 调度

我们在上一章讨论了与带宽和低延迟密切相关但技术上不同的概念。有时人们错误地认为具有更多线程的架构总是具有更低的延迟,但这并不总是正确的。多线程在某些低延迟电子交易系统的领域增加了价值,我们将在本书中构建的系统中使用它。但这里的关键点是,在使用 HFT 系统中的额外线程时,我们需要小心,因为虽然增加线程通常可以提高需要它的应用程序的吞吐量,但它有时也会导致应用程序的延迟增加。随着线程数量的增加,我们必须考虑并发和线程安全,如果我们需要在线程之间进行同步和并发使用锁,那么这会增加额外的延迟和上下文切换。上下文切换不是免费的,因为调度器和操作系统必须保存被切换出的线程或进程的状态,并加载将要运行的线程或进程的状态。许多锁实现都是基于内核系统调用的,这比用户空间例程更昂贵,因此进一步增加了高度多线程应用程序的延迟。为了获得最佳性能,我们试图让 CPU 调度器做很少的工作(即,计划运行的进程和线程永远不会被上下文切换出来,并保持在用户空间中运行)。此外,将特定的线程和进程固定到特定的 CPU 核心上相当常见,这消除了上下文切换,操作系统不需要寻找空闲核心来调度任务,并且还提高了内存访问效率。

动态分配内存和管理内存

动态内存分配是在运行时对任意大小的内存块进行请求。在非常高的层面上,动态内存的分配和释放是由操作系统通过遍历一个空闲内存块列表,并尝试分配一个与程序请求大小相匹配的连续块来处理的。动态内存释放是通过将释放的块附加到操作系统管理的空闲块列表中来处理的。随着程序一天天运行,内存越来越碎片化,搜索这个列表可能会产生越来越高的延迟。此外,如果动态内存的分配和释放位于相同的临界路径上,那么每次都会产生额外的开销。这是我们之前讨论过的,也是我们选择 C++作为构建低延迟和资源受限应用程序首选语言的主要原因之一。在本书的后续章节中,我们将探讨动态内存分配的性能影响以及避免它的技术,当我们构建自己的交易系统时。

静态链接与动态链接,以及编译时间与运行时

链接是将高级编程语言源代码转换为特定架构的机器代码过程中的编译或翻译步骤。链接将不同库中的代码片段连接在一起——这些库可以是代码库内部的或外部的独立库。在链接步骤中,我们有两种选择:静态链接动态链接

动态链接是指链接器在链接时不将库中的代码合并到最终的二进制文件中。相反,当主应用程序第一次需要共享库中的代码时,解析是在运行时进行的。显然,当共享库代码第一次被调用时,会带来特别大的额外成本。更大的缺点是,由于编译器和链接器在编译和链接时不将代码合并,它们无法执行可能的优化,从而导致应用程序整体效率低下。

静态链接是指链接器将应用程序代码和库依赖项的代码安排到一个单一的二进制可执行文件中。这里的优点是库已经在编译时被链接,因此操作系统在应用程序开始执行之前不需要在运行时启动时查找和解析依赖项,通过加载依赖库。更大的优点是,这为程序在编译和链接时进行超级优化创造了机会,从而在运行时产生更低的延迟。静态链接相对于动态链接的缺点是,应用程序的二进制文件要大得多,每个依赖于相同外部库的应用程序二进制文件都有一个所有外部库代码的副本编译并链接到二进制文件中。对于超低延迟的电子交易系统来说,通常会将所有依赖库静态链接,以最小化运行时性能延迟。

我们在上一章讨论了编译时间与运行时处理的比较,那种方法试图将尽可能多的处理移动到编译步骤,而不是在运行时。这增加了编译时间,但运行时性能延迟要低得多,因为很多工作已经在编译时完成。在接下来的几章中,我们将详细探讨这一点,特别是在构建我们的 C++电子交易系统时。

摘要

在本章中,我们探讨了不同商业领域中的不同低延迟应用。目标是了解低延迟应用如何影响不同领域的业务,以及一些这些应用共享的相似之处,例如硬件要求、优化、软件设计、性能优化,以及用于实现这些性能要求的不同革命性技术。

我们首先详细研究的应用是实时、低延迟、在线视频流应用。我们讨论了不同的概念,调查了高延迟的来源,以及它如何影响性能和业务。最后,我们讨论了不同的技术和解决方案,以及平台,这些平台有助于低延迟视频流应用的成功。

我们接下来研究的应用与视频流应用有很多重叠——离线和在线视频游戏应用。我们介绍了一些适用于离线和在线游戏应用的概念和考虑因素,并解释了它们对用户体验的影响,从而最终影响业务性能。我们讨论了在尝试最大化这些应用的性能时需要考虑的众多事项,从适用于实时视频流应用的大量因素到游戏应用额外的硬件和软件考虑因素。

然后,我们简要讨论了物联网(IoT)设备和零售数据收集与分析应用对低延迟性能的要求。这是一个相对较新且快速发展的技术,预计在未来十年内将积极增长。在物联网设备方面正在进行大量研究和进步,我们在取得进展的同时发现了新的商业理念和用例。我们讨论了 5G 无线和边缘计算技术如何打破中心数据存储和处理的老模式,以及这对物联网设备和应用为何至关重要。

在本章中,我们简要讨论的最后一些应用是低延迟电子交易和高频交易(HFT)应用。我们保持了讨论的简短性,并专注于在最大化低延迟和超低延迟电子交易应用性能方面的高级理念。我们这样做是因为我们将在本书剩余的章节中从头开始构建一个完整的端到端 C++低延迟电子交易生态系统。当我们这样做时,我们将通过示例和性能数据讨论、理解和实现所有不同的低延迟 C++概念和理念,因此关于这一应用将有更多内容。

我们将从不同低延迟应用的讨论转向对 C++编程语言的更深入讨论。我们将讨论使用 C++实现低延迟性能的正确方法,不同的现代 C++特性,以及如何释放现代 C++编译器优化的力量。

第三章:从低延迟应用程序的角度探索 C++概念

在本章中,我们假设读者对 C++编程概念、特性和等内容有中级理解。我们将讨论如何接近 C++中的低延迟应用程序开发。我们将继续讨论在低延迟应用程序中应避免的特定 C++特性。然后,我们将讨论使 C++成为低延迟应用程序完美选择的键特性,以及我们将在本书的其余部分如何使用它们。最后,我们将讨论如何最大化编译器优化,以及哪些 C++编译器标志对低延迟应用程序很重要。

在本章中,我们将涵盖以下主题:

  • 接近 C++中的低延迟应用程序开发

  • 避免陷阱并利用 C++特性以最小化应用程序延迟

  • 最大化 C++编译器优化参数

在下一节中,我们将首先讨论在 C++中开发低延迟应用程序时需要考虑的高级思想。

技术要求

本书的所有代码都可以在本书的 GitHub 仓库中找到,该仓库地址为github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP。本章的源代码位于仓库中的 Chapter3 目录下。

接近 C++中的低延迟应用程序开发

在本节中,我们将讨论在 C++中尝试构建低延迟应用程序时需要牢记的高级思想。总的来说,这些思想包括理解应用程序运行的架构、对延迟敏感的应用程序用例、选择的语言(在本例中为 C++),如何使用开发工具(编译器、链接器等)以及如何在实践中测量应用程序性能以了解哪些部分的应用程序需要首先优化。

首先编写正确的代码,然后进行优化

对于低延迟应用程序,应用程序在不同用例和场景下的正确行为以及边缘条件的鲁棒处理仍然是首要关注点。一个快速但无法完成我们所需任务的应用程序是无用的,因此,在开发低延迟应用程序时,最佳方法是首先确保代码的正确性,而不是速度。一旦应用程序运行正确,焦点应转移到优化应用程序的关键部分,同时保持正确性。这确保了开发者将时间集中在正确的部分进行优化,因为通常会发现我们对哪些部分对性能至关重要的直觉与实际情况不符。优化代码也可能比编写正确代码花费更长的时间,因此,首先优化最重要的部分非常重要。

设计最优的数据结构和算法

设计针对应用程序用例最优化的自定义数据结构是构建低延迟应用程序的重要组成部分。在考虑可扩展性、健壮性和在实际情况和遇到的数据下的性能时,需要仔细考虑应用程序关键部分使用的每个数据结构。重要的是要理解为什么我们在这里提到“实际情况”,因为即使不同的数据结构本身具有相同的输出或行为,不同的数据结构选择在不同用例和输入数据下也会表现更好。在我们讨论不同可能的数据结构和算法来解决相同问题的例子之前,让我们快速回顾一下大 O 符号。大 O 符号用于描述执行特定任务时的渐近最坏情况时间复杂度。这里的渐近一词用来描述我们讨论的是在理论上无限(在实际情况中是一个异常大的)数据点上的性能测量。渐近性能消除了所有常数项,仅描述性能作为输入数据元素数量的函数。

使用不同的数据结构来解决相同问题的简单例子之一是通过键值在容器中搜索条目。我们可以通过使用具有预期平均复杂度为 O(1) 的哈希表实现,或者使用具有复杂度为 O(n) 的数组来解决此问题,其中 n 是容器中元素的数量。虽然在纸上可能看起来哈希表显然是更好的选择,但其他因素,如元素数量、将哈希函数应用于键的复杂度等,可能会改变选择哪种数据结构。在这种情况下,对于少量元素,由于更好的缓存性能,数组解决方案更快,而对于大量元素,哈希表解决方案更好。在这里,我们选择了一个次优算法,因为该算法的底层数据结构在实际应用中由于缓存性能表现更好。

另一个略有不同的例子是使用查找表而不是重新计算某些数学函数的值,例如三角函数。虽然从预计算的查找表中查找结果应该总是比执行一些计算更快,但这并不总是正确的。例如,如果查找表非常大,那么评估浮点表达式的成本可能低于从主内存中获取缓存未命中并读取查找表值的成本。如果从主内存访问查找表导致大量缓存污染,从而降低应用程序代码其他部分的性能,那么整体应用程序性能也可能更好。

注意处理器

现代处理器具有许多架构和功能细节,低延迟应用程序开发者应该了解这些细节,尤其是 C++开发者,因为它允许非常低级别的控制。现代处理器拥有多个核心、更大的专用寄存器组、流水线指令处理(在执行当前指令的同时预取下一个所需的指令)、指令级并行性、分支预测、扩展指令集以促进更快和专门的处理器,等等。应用程序开发者对其应用程序将运行的处理器这些方面的理解越好,他们就能更好地避免次优代码和/或编译选择,并确保编译的机器代码针对其目标架构是最佳的。至少,开发者应该指导编译器使用编译器优化标志输出针对其特定目标架构的代码,但我们将在此章节的后面讨论这个话题。

理解缓存和内存访问成本

通常,在低延迟应用程序开发中,为了减少完成的工作量或执行的指令数量,人们会在数据结构和算法的设计和开发上投入大量精力。虽然这是正确的方法,但在本节中,我们想指出,考虑缓存和内存访问同样重要。

在上一小节“设计最优的数据结构和算法”中,我们看到了一个常见现象,即那些在纸上表现不佳的数据结构和算法往往能超越那些在纸上表现最优的。这背后的一个重要原因是,最优解决方案的更高缓存和内存访问成本可能会超过由于指令数量减少而节省的时间。另一种思考方式是,尽管从算法步骤数量的角度来看工作量较少,但在实际操作中,使用现代处理器、缓存和内存访问架构,完成这些工作需要更长的时间。

让我们快速回顾一下现代计算机架构中的内存层次结构。请注意,我们在这里回顾的细节可以在我们另一本书《开发高频交易系统》中找到。这里的关键点是内存层次结构以这种方式工作:如果 CPU 在寄存器中找不到它需要的下一个数据或指令,它会去 L0 缓存,如果在那里也找不到,它会去 L1 缓存、L2、其他缓存,然后按此顺序去主内存。请注意,存储的访问是从最快到最慢的,这也恰好是从空间最少到空间最多的顺序。有效低延迟和缓存友好型应用程序开发的技巧在于编写能够意识到代码和数据访问模式的代码,以最大化在最快形式的存储中找到数据的可能性。这依赖于最大化时间局部性空间局部性的概念。这些术语意味着最近访问的数据很可能在缓存中,而我们刚刚访问的数据旁边的数据很可能也在缓存中,分别。以下图表直观地展示了寄存器、缓存和内存银行,并提供了一些从 CPU 访问的时间数据。请注意,根据硬件的不同以及技术不断进行的改进,访问时间有很大的变化。这里的关键教训应该是,当我们从 CPU 寄存器到缓存银行再到主内存时,访问时间有显著的增加。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_3.1_B19434.jpg

图 3.1 – 现代计算机架构中内存的层次结构。

我建议您仔细思考算法在局部以及整个应用程序全局的缓存和内存访问模式,以确保您的源代码优化了缓存和内存访问模式,这将提升整体应用程序的性能。如果您有一个函数在调用时执行非常快,但会造成大量的缓存污染,这将降低整个应用程序的性能,因为其他组件将承担额外的缓存未命中惩罚。在这种情况下,我们未能实现我们的目标,即使我们可能已经成功使这个函数在局部上表现最优。

理解 C++ 功能在底层是如何工作的

在开发低延迟应用程序时,开发者对高级语言抽象在较低级别或“底层”是如何工作的有极其深入的理解是非常重要的。对于非延迟敏感的应用程序,这可能并不那么重要,因为如果应用程序的行为符合开发者的意图,那么他们的源代码如何以极低级别的细节实现这一点并不相关。

对于 C++中的低延迟应用程序,开发者对其程序如何编译成机器代码的了解越多,他们就越能有效地使用编程语言来实现低延迟性能。C++中可用的许多高级抽象提高了开发的速度和便捷性、健壮性和安全性、可维护性、软件设计的优雅性等,但并非所有这些在低延迟应用程序中都是最优的。

许多 C++特性,如动态多态、动态内存分配和异常处理,对于大多数应用程序来说都是很好的补充。然而,当涉及到低延迟应用程序时,最好避免或少量使用,或者以特定方式使用,因为它们具有更大的开销。

相反,传统的编程实践建议开发者将一切分解成许多非常小的函数以提高可重用性;在适用的情况下使用递归函数;使用面向对象编程OOP)原则,如继承和虚函数;始终使用智能指针而不是原始指针;等等。这些原则对于大多数应用程序来说是合理的,但对于低延迟应用程序,这些原则需要仔细评估和谨慎使用,因为它们可能会增加非平凡的额外开销和延迟。

这里的关键要点是,对于低延迟应用程序的开发者来说,了解每个 C++特性非常重要,以了解它们如何在机器代码中实现,它们对硬件资源有什么影响,以及它们在实际中的表现。

利用 C++编译器

现代的 C++编译器确实是一件令人着迷的软件。为了构建这些编译器以使其健壮和正确,投入了巨大的努力。还投入了大量努力,使它们在将开发者的高级源代码转换为机器指令以及它们如何尝试优化代码方面非常智能。对于希望尽可能从其应用程序中提取性能的低延迟应用程序开发者来说,了解编译器如何将开发者的代码转换为机器指令,它如何尝试优化代码以及何时失败是很重要的。我们将在本章中广泛讨论编译器的工作原理和优化机会,以便我们能够学会在优化最终应用程序表示(机器代码可执行文件)时与编译器合作,而不是对抗它。

测量和提高性能

我们提到,理想的应用程序开发之旅首先是为了确保正确性而构建应用程序,然后才考虑优化它。我们也提到,当涉及到识别性能瓶颈时,开发者的直觉往往是不正确的。

最后,我们也提到,优化应用程序的任务可能比正确执行它的任务花费的时间长得多。因此,在开始优化之旅之前,建议开发者尝试在实际约束和输入下运行应用程序,以检查性能。在应用程序中添加不同形式的仪器来测量性能和找到瓶颈,以了解和优先考虑优化机会是很重要的。这也是一个重要的步骤,因为随着应用程序的发展,测量和改进性能继续是工作流程的一部分,也就是说,测量和改进性能是应用程序演变的一部分。在本书的最后一节“分析和改进性能”中,我们将通过一个实际案例研究来讨论这个想法,以更好地理解这一点。

避免陷阱并利用 C++特性以最小化应用程序延迟

在本节中,我们将探讨不同的 C++特性,如果使用得当,可以最小化应用程序延迟。我们还将讨论如何使用这些特性来优化应用程序性能的细节。现在,让我们开始学习如何正确使用这些特性以最大化应用程序性能并避免陷阱以最小化延迟。请注意,本章的所有代码片段都存储在本书的 GitHub 仓库的Chapter3目录中。

选择存储

在函数内部创建的局部变量默认存储在栈上,栈内存也用于存储函数的返回值。假设没有创建大对象,相同的栈存储空间会被大量重用,由于引用的局部性,这导致了出色的缓存性能。

寄存器变量最接近处理器,是可用的最快存储形式。它们极其有限,编译器会尝试将它们用于使用最频繁的局部变量,这也是更喜欢局部变量的另一个原因。

静态变量从缓存性能的角度来看效率低下,因为这种内存不能被其他变量重用,访问静态变量的操作可能只占所有内存访问的一小部分。因此,最好避免使用静态变量以及具有类似低效缓存性能的全局变量。

volatile关键字指示编译器禁用许多依赖于变量值在没有编译器知识的情况下不改变假设的优化。这应该只在多线程用例中谨慎使用,因为它阻止了将变量存储在寄存器中并将它们从缓存强制刷新到主内存的优化,每次值改变时都会这样做。

动态分配的内存分配和释放效率低下,并且根据其使用方式,可能会遭受较差的缓存性能。关于动态分配内存的低效率将在本节后面的动态分配 内存子节中进一步讨论。

C++优化技术的一个例子是利用存储选择优化的小字符串优化(SSO)。SSO 尝试在字符串小于一定大小(通常是 32 个字符)时使用局部存储,而不是默认的动态分配内存来存储字符串内容。

总结来说,你应该仔细考虑在程序执行过程中数据存储的位置,尤其是在关键部分。我们应该尽可能使用寄存器和局部变量,并优化缓存性能。仅在必要时或当它不影响关键路径上的性能时,才使用易失性、静态、全局和动态内存。

选择数据类型

只要最大的寄存器大小大于整数大小,C++的整数操作通常非常快。小于或大于寄存器大小的整数有时会比常规整数稍微慢一些。这是因为处理器必须使用多个寄存器来存储单个变量,并对大整数应用一些进位逻辑。相反,处理小于寄存器大小的整数通常是通过使用常规寄存器、清零高位、仅使用低位和可能调用类型转换操作来完成的。请注意,额外的开销非常小,通常不是需要担心的事情。有符号和无符号整数速度相同,但在某些情况下,无符号整数比有符号整数更快。唯一有符号整数操作稍微慢一些的情况是处理器需要检查和调整符号位。同样,当存在时,额外的开销非常小,在大多数情况下我们不需要担心。我们将查看不同操作的成本——加法、减法、比较、位操作等通常只需要一个时钟周期。乘法操作需要更长的时间,而除法操作需要最长时间。

使用类型转换和转换操作

在有符号和无符号整数之间进行转换是无成本的。将较小大小的整数转换为较大大小的整数可能只需要一个时钟周期,但有时可以优化为无成本。将整数大小从较大大小转换为较小大小没有额外的成本。

浮点数、双精度浮点数和长双精度浮点数之间的转换通常是无成本的,除非在极少数情况下。将有符号和无符号整数转换为浮点数或双精度浮点数需要几个时钟周期。从无符号整数到浮点数或双精度浮点数的转换可能比有符号整数需要更长的时间。

将浮点值转换为整数的操作可能非常昂贵——50 到 100 个时钟周期或更多。如果这些转换位于关键路径上,低延迟应用程序的开发者通常会尝试通过启用特殊指令集、避免或重构这些转换(如果可能的话)、使用特殊的汇编语言舍入实现等方式来使这些转换更高效。

将指针从一个类型转换为另一个类型是完全免费的;转换是否安全是开发者的责任。将对象的指针类型转换成指向不同对象的指针类型违反了严格的别名规则,该规则指出 不同类型的两个指针不能指向相同的内存位置,这实际上意味着编译器可能不会使用相同的寄存器来存储这两个不同的指针,即使它们指向相同的地址。记住,CPU 寄存器是处理器可用的最快存储形式,但存储容量极其有限。因此,当额外的寄存器被用来存储相同的变量时,这是对寄存器的不高效使用,并会对整体性能产生负面影响。

这里提供了一个将指针类型转换成不同对象的示例。此示例使用从 double *uint64_t * 的转换,并使用 uint64_t 指针修改符号位。这不过是一种复杂且更有效的方法来实现 x = -std::abs(x),但展示了这是如何违反严格的别名规则(在 GitHub 的 Chapter3 中的 strict_alias.cpp):

#include <cstdio>
#include <cstdint>
int main() {
  double x = 100;
  const auto orig_x = x;
  auto x_as_ui = (uint64_t *) (&x);
  *x_as_ui |= 0x8000000000000000;
  printf(“orig_x:%0.2f x:%0.2f &x:%p &x_as_ui:%p\n”,
       orig_x, x, &x, x_as_ui);
}

它会产生类似以下内容:

orig_x:100.00 x:-100.00 &x:0x7fff1e6b00d0 &x_as_ui:0x7fff1e6b00d0

使用现代 C++ 的类型转换操作,const_caststatic_castreinterpret_cast 在使用时不会产生任何额外的开销。然而,当涉及到 dynamic_cast,它将某个类的对象转换为不同类的对象时,这可能在运行时变得昂贵。dynamic_cast 通过使用 运行时类型信息 (RTTI) 来检查转换是否有效,这很慢,并且如果转换无效可能会抛出异常——这使得它更安全,但增加了延迟。

优化数值运算

通常,双精度计算所需的时间与单精度操作大致相同。一般来说,对于整数和浮点数,加法运算很快,乘法运算比加法运算略贵,而除法运算比乘法运算贵得多。整数乘法大约需要 5 个时钟周期,浮点数乘法大约需要 8 个时钟周期。大多数处理器上整数加法只需要一个时钟周期,而浮点数加法大约需要 2-5 个时钟周期。浮点数除法和整数除法在处理器和是否有特殊浮点操作的情况下,大约需要相同的时间,大约是 20-80 个时钟周期。

编译器会尽可能重写和简化表达式,以优先考虑更快的操作,例如将除法重写为倒数乘法。乘以 2 的幂次的值和除以 2 的幂次的值要快得多,因为编译器将它们重写为位移操作,这要快得多。当编译器使用这种优化时,会有额外的开销,因为它必须处理符号和舍入误差。显然,这仅适用于在编译时可以确定是 2 的幂次的值。例如,在处理多维数组时,编译器尽可能将乘法转换为位移操作。

在同一表达式中混合单精度和双精度操作,以及涉及浮点数和整数的表达式应避免,因为它们隐式地强制类型转换。我们之前看到类型转换并不总是免费的,所以这些表达式可能需要比我们预期的更长的时间来计算。例如,当在表达式中混合单精度和双精度值时,单精度值必须首先转换为双精度值,这可能在计算表达式之前消耗几个时钟周期。同样,当在表达式中混合整数和浮点值时,要么浮点值必须转换为整数,要么整数必须转换为浮点值,这会在最终的计算时间上增加几个时钟周期。

优化布尔和位运算

布尔操作(如&&||)的评估方式是,对于&&,如果第一个操作数为假,则不评估第二个操作数;对于||,如果第一个操作数为真,则不评估第二个操作数。一种简单的优化技术是将&&的操作数按从低到高的概率排序为真。

类似地,对于||,将操作数从最高到最低的概率排序为真是最优的。这种技术被称为&&布尔操作,如果第一个操作数为假,则不应评估第二个操作数。或者对于||布尔操作,如果第一个操作数为真,则不应评估第二个操作数,依此类推。

使用布尔变量的另一个方面是理解它们是如何存储的。布尔变量是以 8 位存储的,而不是单个位,这与我们从它们的使用方式中获得的直觉可能不符。这意味着涉及布尔值的操作必须以这种方式实现,即除了 0 以外的任何 8 位值都被视为 1,这导致在实现中包含与 0 的比较的分支。例如,c = a && b; 表达式是这样实现的:

if(a != 0) {
 if(b != 0) {
   c = true;
 } else {
   c = false;
 }
} else {
 c = false;
}

如果可以保证ab的值只能是 0 或 1,那么c = a && b;将简单地是c = a & b;,这将非常快,避免了分支和与分支相关的开销。

位运算也可以通过将整数的每一位视为一个单独的布尔变量,然后使用位掩码操作重写涉及多个布尔比较的表达式来加速其他布尔表达式的处理。例如,考虑以下表达式,其中market_stateuint64_t类型,而PreOpenOpeningTrading是表示不同市场状态的枚举值:

if(market_state == PreOpen ||
   market_state == Opening ||
   market_state == Trading) {
  // do something...
}

可以重写为以下形式:

if(market_state & (PreOpen | Opening | Trading)) {
  // do something...
}

如果枚举值被选择,使得market_state变量中的每一位代表一个真或假的状态,那么PreOpenOpeningTrading枚举可以设置为0x0010x0100x100

初始化、销毁、复制和移动对象

开发者定义的类的构造函数和析构函数应尽可能轻量级和高效,因为它们可以在开发者没有预期的情况下被调用。保持这些方法非常简单和紧凑也允许编译器将这些方法内联以提高性能。同样适用于复制和移动构造函数,应保持简单,尽可能使用移动构造函数而不是复制构造函数。在需要高度优化的许多情况下,开发者可以删除默认构造函数和复制构造函数,以确保不会创建不必要的或意外的对象副本。

使用引用和指针

许多 C++特性都是围绕通过this指针隐式访问类成员构建的,因此无论开发者是否明确这样做,通过引用和指针访问都非常频繁。通过指针和引用访问对象与直接访问对象一样高效。这是因为大多数现代处理器都支持高效地获取指针值并解引用它们。使用引用和指针的缺点是它们需要额外的寄存器来存储指针本身,另一个寄存器则用于执行解引用指令以访问指针值所指向的变量。

指针算术与整数算术一样快,除了计算指针之间的差异需要除以对象的大小,这可能会非常慢。如果对象类型的大小是 2 的倍数,这通常是原始类型和优化结构的情况,这并不一定是问题。

智能指针是现代 C++的一个重要特性,它提供了安全性、生命周期管理、自动内存管理和对动态分配对象的清晰所有权控制。由于引用计数开销,智能指针如std::unique_ptrstd::shared_ptrstd::weak_ptr使用std::shared_ptr,但通常,智能指针预计只会给整个程序带来很少的开销,除非有很多这样的指针。

使用指针的另一个重要方面是它可以防止由于 a[0]a[n-1]b 而产生的编译器优化。这意味着这种优化是有效的,因为 *b 对于整个循环来说是常数,可以一次性计算:

void func(int* a, int* b, int n) {
  for(int i = 0; i < n; ++i) {
    a[i] = *b;
  }
}

当开发者确信没有依赖于指针别名副作用的行为时,有真正两种选项可以指导编译器假设没有指针别名。对于编译器,在函数参数或函数上使用 __restrict__ 或类似的指定关键字 __restrict 来指定指针上没有别名。然而,这只是一个提示,编译器不保证这会有所不同。另一种选项是指定 -fstrict-aliasing 编译器选项,以全局假设没有指针别名。以下代码块演示了为前面的 func() 函数(GitHub 上Chapter3pointer_alias.cpp)使用 restrict 指定符:

void func(int *__restrict a, int *__restrict b, int n) {
  for (int i = 0; i < n; ++i) {
    a[i] = *b;
  }
}

优化跳转和分支

在现代处理器流水线中,指令和数据以阶段性地被获取和解析。当存在分支指令时,处理器会尝试预测哪个分支会被采取,并从该分支获取和解析指令。然而,当处理器错误地预测了采取的分支时,它需要 10 个或更多的时钟周期来检测到错误预测。之后,它必须花费大量的时钟周期从正确的分支获取指令和数据,并对其进行评估。关键点是每次分支预测错误都会浪费很多时钟周期。

让我们讨论一下 C++ 中最常用的跳转和分支形式:

  • if-else 分支是讨论分支时最常想到的事情。如果可能的话,最好避免长链的 if-else 条件,因为随着它们的增长,正确预测它们变得困难。保持条件数量小,并尝试使它们结构化以便更可预测,这是优化它们的方法。

  • forwhile 循环也是分支类型,如果循环计数相对较小,通常可以很好地预测。当然,嵌套循环和包含难以预测的退出条件的循环会使情况变得复杂。

  • switch 语句是具有多个跳转目标的分支,因此它们可能很难预测。当标签值分布广泛时,编译器必须将 switch 语句用作一系列的 if-else 分支树。与 switch 语句一起工作的优化技术是分配按顺序递增的案例标签值,因为它们有很大机会被实现为跳转表,这要高效得多。

在可能的情况下,用包含不同输出值的表查找替换源代码中的分支是一种很好的优化。我们还可以创建一个以跳转条件为索引的函数指针表,但请注意,函数指针不一定比分支本身更高效。

(GitHub 上Chapter3loop_unroll.cpp):

   int a[5]; a[0] = 0;
    for(int i = 1; i < 5; ++i)
      a[i] = a[i-1] + 1;

编译器可以将循环展开成以下这里显示的代码。请注意,对于这样一个简单的例子,编译器很可能还会使用额外的优化并将这个循环进一步缩减。但到目前为止,我们只限制自己展示循环展开的影响:

    int a[5];
    a[0] = 0;
    a[1] = a[0] + 1; a[2] = a[1] + 1;
    a[3] = a[2] + 1; a[4] = a[3] + 1;

使用if constexpr (condition-expression) {}格式进行编译时分支可以显然帮助很多,因为它将分支的开销移到了编译时,但这要求condition-expression是可以在编译时评估的。这实际上是编译时多态模板元编程范式的技术部分,我们将在本节中的使用编译时多态子节中进一步讨论。

由于开发者对预期的用例有更好的了解,因此可以在源代码中为编译器提供分支预测提示。这些提示在整体上并没有显著差异,因为现代处理器擅长在经过几次迭代后学习哪些分支最有可能被采取。对于 GNU C++,这些提示传统上是通过__builtin_expect实现的:

#define LIKELY_CONDITION(x) __builtin_expect(!!(x), 1)
#define UNLIKELY_CONDITION (x) __builtin_expect(!!(x), 0)

对于 C++ 20,这些被标准化为[[likely]][[unlikely]]属性。

高效地调用函数

调用函数有许多相关的开销——获取函数地址和跳转到它的开销,向其传递参数并返回结果,设置栈帧,保存和恢复寄存器,异常处理,代码缓存缺失的可能延迟,等等。

在将代码库拆分为函数时,为了最大化性能,以下是一些需要考虑的一般事项。

在创建过多的函数之前先思考

只有在存在足够的可重用性以证明其合理性时,才应创建函数。创建函数的标准应该是逻辑程序流程和可重用性,而不是代码长度,因为,正如我们所看到的,调用函数不是免费的,创建过多的函数不是一个好主意。

将相关函数分组

类成员函数和非类成员函数通常按它们创建的顺序分配内存地址,因此将频繁调用彼此或操作相同数据集的性能关键函数分组在一起通常是一个好主意。这有助于提高代码和数据缓存性能。

链接时间优化(LTO)或整个程序优化(WPO)

当编写性能关键函数时,如果可能的话,将它们放在它们被使用的同一模块中是很重要的。这样做可以解锁大量的编译器优化,其中最重要的是能够内联函数调用。

使用static关键字声明一个函数相当于将其放在inline关键字中,这也同样可以达到目的,但我们将这在下一节中探讨。

为编译器指定 WPO 和 LTO 参数指示它将整个代码库视为一个单一模块,并启用跨模块的编译器优化。如果不启用这些编译器选项,优化将发生在同一模块内的函数之间,但不会在模块之间发生,这对于通常具有大量源文件和模块的大型代码库来说可能相当不理想。

宏、内联函数和模板元编程

宏表达式是一个预处理器指令,甚至在编译开始之前就会展开。这消除了与运行时调用和返回函数相关的开销。然而,宏也有一些缺点,例如命名空间冲突、神秘的编译错误、不必要的条件表达式评估等。

内联函数,无论它们是否是类的一部分,都类似于宏,但解决了与宏相关的大量问题。内联函数在编译和链接时展开其使用,并消除了与函数调用相关的开销。

使用模板元编程,可以将大量的计算负载从运行时移动到编译时。这涉及到使用部分和完全模板特化和递归循环模板。然而,模板元编程可能比较笨拙且难以使用、编译和调试,并且只有在性能改进足以证明增加的开发不适时才真正应该使用。我们将在不久的将来探讨模板和模板元编程。

避免使用函数指针

通过函数指针调用函数比直接调用函数有更大的开销。一方面,如果指针发生变化,编译器就无法预测将被调用哪个函数,也无法预取指令和数据。此外,这也阻止了许多编译器优化,因为这些优化不能在编译时内联。

std::function是现代 C++中一个更强大的构造,但应该仅在必要时使用,因为存在误用的可能性,并且与直接内联函数相比,会有几个时钟周期的额外开销。std::bind也是在使用时需要非常小心的另一个构造,也应该仅在绝对必要时使用。如果必须使用std::function,尝试看看是否可以使用 lambda 表达式而不是std::bind,因为通常调用 lambda 表达式要快几个时钟周期。总的来说,使用std::function和/或std::bind时要小心,因为许多开发者惊讶地发现这些构造可以执行虚函数调用并在底层调用动态内存分配。

通过引用或指针传递函数参数

对于原始类型,按值传递参数非常高效。对于作为函数参数的复合类型,首选的传递方式是 const 引用。const 性意味着对象不能被修改,并允许编译器基于此进行优化,而引用允许编译器可能内联对象本身。如果函数需要修改传递给它的对象,那么显然应该使用非 const 引用或指针。

从函数返回简单类型

返回原始类型的函数非常高效。返回复合类型则效率低得多,在某些情况下甚至可能创建几个副本,这在某些情况下尤其不理想,尤其是如果这些类型很大且/或具有缓慢的复制构造函数和赋值运算符。当编译器可以应用返回值优化RVO)时,它可以消除创建的临时副本,并直接将结果写入调用者的对象。返回复合类型的最佳方式是让调用者创建该类型的对象,并使用引用或指针将其传递给函数以供修改。

让我们通过一个例子来解释 RVO 会发生什么;假设我们有以下函数定义和函数调用(位于 GitHub 上Chapter3rvo.cpp):

#include <iostream>
struct LargeClass {
  int i;
  char c;
  double d;
};
auto rvoExample(int i, char c, double d) {
  return LargeClass{i, c, d};
}
int main() {
  LargeClass lc_obj = rvoExample(10, ‘c’, 3.14);
}

使用 RVO 时,rvoExample()函数中不再创建一个临时的LargeClass对象,然后将其复制到main()中的LargeClass lc_obj对象,而是rvoExample()函数可以直接更新lc_obj,避免临时对象和复制。

避免递归函数或用循环替换它们

递归函数由于反复调用自身的开销而不太高效。此外,递归函数可以在堆栈中非常深入,占用大量堆栈空间,在最坏的情况下甚至会导致堆栈溢出。这会导致由于新的内存区域而出现大量的缓存未命中,使得预测返回地址变得困难且效率低下。在这种情况下,用循环代替递归函数会显著提高效率,因为它避免了递归函数遇到的大量缓存性能问题。

使用位字段

位字段只是开发者控制分配给每个成员的位数的结构。这使得数据尽可能紧凑,并且大大提高了许多对象的缓存性能。位字段成员通常也使用位掩码操作来修改,这些操作非常高效,正如我们之前所看到的。访问位字段成员的效率低于访问常规结构成员,因此仔细评估使用位字段并提高缓存性能是否值得是很重要的。

使用运行时多态

函数是实现运行时多态的关键,但与非虚函数调用相比,它们有额外的开销。

通常,编译器无法在编译时确定将调用哪个虚拟函数的实现。在运行时,除非大多数时候调用相同的虚拟函数版本,否则这会导致许多分支预测错误。编译器可以通过使用函数确定在编译时调用的虚拟函数实现,但由于在存在函数的情况下,编译器无法应用许多编译时优化,其中最重要的是内联优化。

C++中的继承是另一个重要的面向对象编程概念,但要注意当继承结构变得过于复杂时,可能会引入许多细微的低效。子类从其父类继承每个数据成员,因此子类的尺寸可能会变得相当大,导致缓存性能不佳。

通常,我们可以在多个父类中继承,而不是考虑使用 GitHub 上Chapter3中的composition.cpp构建OrderBook,它基本上持有Order对象的向量,以两种不同的方式。如果正确使用,继承模型的好处是它现在继承了std::vector提供的所有方法,而组合模型则需要实现它们。在这个例子中,我们通过在CompositionOrderBook中实现一个size()方法来演示这一点,它调用std::vector对象的size()方法,而InheritanceOrderBook则直接从std::vector继承它:

#include <cstdio>
#include <vector>
struct Order { int id; double price; };
class InheritanceOrderBook : public std::vector<Order> { };
class CompositionOrderBook {
  std::vector<Order> orders_;
public:
  auto size() const noexcept {
    return orders_.size();
  }
};
int main() {
  InheritanceOrderBook i_book;
  CompositionOrderBook c_book;
  printf(InheritanceOrderBook::size():%lu Composi
       tionOrderBook:%lu\n”, i_book.size(), c_book.size());
}

C++的dynamic_cast,正如我们之前讨论的,通常使用 RTTI 信息来执行转换,并且也应该避免使用。

使用编译时多态

让我们讨论使用运行时多态的替代方案,即使用模板来实现编译时多态。模板类似于宏,意味着它们在编译前被展开,因此不仅消除了运行时开销,还解锁了额外的编译器优化机会。模板使编译器生成的机器代码超级高效,但它们以增加源代码复杂性和更大的可执行文件大小为代价。

使用 CRTPvirtual 函数和基类与派生类的关系相似,但略有不同。这里展示了将运行时多态转换为编译时多态的一个简单例子。在两种情况下,派生类 SpecificRuntimeExampleSpecificCRTPExample 都重写了 placeOrder() 方法。本小节讨论的代码位于 GitHub 仓库中本书的 Chapter3 目录下的 crtp.cpp 文件中。

使用虚函数实现的运行时多态

这里,我们有一个实现运行时多态的例子,其中 SpecificRuntimeExample 继承自 RuntimeExample 并重写了 placeOrder() 方法:

#include <cstdio>
class RuntimeExample {
public:
  virtual void placeOrder() {
    printf(RuntimeExample::placeOrder()\n”);
  }
};
class SpecificRuntimeExample : public RuntimeExample {
public:
  void placeOrder() override {
    printf(SpecificRuntimeExample::placeOrder()\n”);
  }
};

使用 CRTP 实现的编译时多态

现在我们实现与上一节讨论的类似的功能,但不是使用运行时多态,而是使用编译时多态。在这里,我们使用 CRTP 模式,SpecificCRTPExample 特化/实现了 CRTPExample 接口,并通过 actualPlaceOrder() 方法提供了 placeOrder() 的不同实现:

template <typename actual_type>
class CRTPExample {
public:
  void placeOrder() {
    static_cast<actual_type*>(this)->actualPlaceOrder();
  }
  void actualPlaceOrder() {
    printf(CRTPExample::actualPlaceOrder()\n”);
  }
};
class SpecificCRTPExample : public CRTPExample<Specific
     CRTPExample> {
public:
  void actualPlaceOrder() {
    printf(SpecificCRTPExample::actualPlaceOrder()\n”);
  }
};

在两种情况下调用多态方法

最后,在以下代码片段中,我们展示了如何创建 SpecificRuntimeExampleSpecificCRTPExample 对象。然后我们分别使用 placeOrder() 方法调用运行时和编译时多态:

int main(int, char **) {
  RuntimeExample* runtime_example = new SpecificRuntimeEx
       ample();
  runtime_example->placeOrder();
  CRTPExample<SpecificCRTPExample> crtp_example;
  crtp_example.placeOrder();
  return 0;
}

运行此代码将产生以下输出,第一行使用运行时多态,第二行使用编译时多态:

SpecificRuntimeExample::placeOrder()
SpecificCRTPExample::actualPlaceOrder()

使用额外的编译时处理

模板元编程是一个更通用的术语,意味着编写能够生成更多代码的代码。这里的优势也是将计算从运行时移动到编译时,并最大化编译器优化机会和运行时性能。使用模板元编程可以编写几乎任何东西,但它可能变得极其复杂和难以理解、维护和调试,导致编译时间非常长,并将二进制文件大小增加到非常大的程度。

处理异常

C++异常处理系统旨在在运行时检测意外的错误条件,并从该点优雅地恢复或关闭。当涉及到低延迟应用时,评估异常处理的使用非常重要,因为虽然确实在罕见错误情况下,异常处理会带来最大的延迟,但在不抛出异常的情况下仍然可能有一些开销。与在各种情况下优雅地处理异常时使用的逻辑相关的某些账务开销。在嵌套函数中,异常需要传播到最顶层的调用函数,并且每个堆栈帧都需要清理。这被称为堆栈回溯,需要异常处理程序跟踪它在异常期间需要向后遍历的所有信息。

对于低延迟应用,异常可以通过使用throw()noexcept指定来逐函数禁用,或者通过编译器标志在整个程序中禁用。这允许编译器假设某些或所有方法不会抛出异常,因此处理器不必担心保存和跟踪恢复信息。请注意,使用noexcept或禁用 C++异常处理系统并非没有缺点。一方面,通常,除非抛出异常,否则 C++异常处理系统不会增加很多额外的开销,因此这个决定必须经过仔细考虑。另一个观点是,如果标记为noexcept的方法由于某种原因抛出异常,则异常将无法再向上传播到堆栈,程序将立即终止。这意味着禁用 C++异常处理系统(部分或全部)会使处理失败和异常变得更加困难,并且完全由开发者负责。通常,这意味着开发者仍然需要确保不会遇到或处理其他地方的异常错误条件,但关键是现在开发者可以明确控制这一点,并将其移出关键的热点路径。因此,在开发和测试阶段,通常不会禁用 C++异常处理系统,但在最后的优化步骤中,我们才考虑移除异常处理。

访问缓存和内存

自从讨论 C++特性的不同用途时,我们经常提到缓存性能,因为访问主内存比执行 CPU 指令或访问寄存器或缓存存储所使用的时钟周期要慢得多。在尝试优化缓存和内存访问时,以下是一些需要记住的一般要点。

数据对齐

对齐的变量,即它们放置在变量大小的倍数内存位置上的变量,访问效率最高。处理器中的“字大小”术语描述了处理器读取和处理的数据位数,对于现代处理器来说,要么是 32 位,要么是 64 位。这是因为处理器可以在单次读取操作中从内存中读取一个变量,直到字大小。如果变量在内存中对齐,那么处理器不需要做任何额外的工作来将其放入所需的寄存器进行处理。

由于这些原因,对齐变量更易于处理,编译器将自动处理变量的对齐。这包括在类或结构体中的成员变量之间添加填充,以保持这些变量的对齐。当我们向结构体添加成员变量,并预期会有很多对象时,仔细考虑额外添加的填充非常重要,因为结构体的大小将大于预期。这个结构体或类的对象实例中的额外空间意味着,如果有很多这样的对象,它们的缓存性能可能会更差。这里推荐的方法是对结构体的成员进行重新排序,以使额外填充最小化,从而保持成员对齐。

我们将看到一个示例,它以三种不同的方式对结构体内部的相同成员进行排序——一种是在保持每个变量对齐的同时添加了大量额外的填充,另一种是开发人员重新排序成员变量以最小化由于编译器添加的填充造成的空间浪费,最后,我们使用pack()指令来消除所有填充。此代码可在 GitHub 存储库中本书的Chapter3/alignment.cpp文件中找到:

#include <cstdio>
#include <cstdint>
#include <cstddef>
struct PoorlyAlignedData {
  char c;
  uint16_t u;
  double d;
  int16_t i;
};
struct WellAlignedData {
  double d;
  uint16_t u;
  int16_t i;
  char c;
};
#pragma pack(push, 1)
struct PackedData {
  double d;
  uint16_t u;
  int16_t i;
  char c;
};
#pragma pack(pop)
int main() {
  printf(“PoorlyAlignedData c:%lu u:%lu d:%lu i:%lu
       size:%lu\n”,
         offsetof(struct PoorlyAlignedData,c), offsetof
              (struct PoorlyAlignedData,u), offsetof(struct
              PoorlyAlignedData,d), offsetof(struct PoorlyA
              lignedData,i), sizeof(PoorlyAlignedData));
  printf(“WellAlignedData d:%lu u:%lu i:%lu c:%lu
       size:%lu\n”,
         offsetof(struct WellAlignedData,d), offsetof
              (struct WellAlignedData,u), offsetof(struct
              WellAlignedData,i), offsetof(struct WellAligned
              Data,c), sizeof(WellAlignedData));
  printf(“PackedData d:%lu u:%lu i:%lu c:%lu size:%lu\n”,
         offsetof(struct PackedData,d), offsetof(struct
              PackedData,u), offsetof(struct PackedData,i),
              offsetof(struct PackedData,c), sizeof
              (PackedData));
}

这段代码在我的系统上输出以下内容,显示了同一结构体三种不同设计中不同数据成员的偏移量。请注意,第一个版本有额外的 11 字节填充,第二个版本由于重新排序,只有额外的 3 字节填充,而最后一个版本没有额外的填充:

PoorlyAlignedData c:0 u:2 d:8 i:16 size:24
WellAlignedData d:0 u:8 i:10 c:12 size:16
PackedData d:0 u:8 i:10 c:12 size:13

访问数据

缓存友好的数据访问(读取和/或写入)是指数据按顺序或部分顺序访问。如果数据是反向访问的,那么它的效率低于这种访问方式,如果数据是随机访问的,那么缓存性能会更差。这是需要考虑的一点,尤其是在访问多维数组对象和/或对象驻留在具有非平凡底层存储的对象容器时。

例如,访问数组中的元素比访问链表、树或哈希表容器中的元素要高效得多,这是因为连续的内存存储与随机的内存存储位置相比。从算法复杂性的角度来看,线性搜索数组比使用哈希表效率低,因为数组搜索有O(n)的理论算法复杂度,而哈希表有O(1)。然而,如果元素数量足够少,那么使用数组仍然可以获得更好的性能,一个很大的原因是由于缓存性能和算法开销。

使用大型数据结构

当处理大型多维矩阵数据集时,例如进行线性代数运算,缓存访问性能主导了运算的性能。通常,矩阵运算的实际算法实现与经典文本中用于优化缓存性能的矩阵访问操作顺序不同。最佳方法是对不同算法和访问模式进行性能测量,并找到在不同矩阵维度、缓存竞争条件等情况下表现最佳的方案。

将变量分组在一起

当设计类和方法或非方法函数时,将一起访问的变量分组可以显著提高缓存性能,通过减少缓存未命中次数来实现。我们讨论了,相比于全局、静态和动态分配的内存,优先使用局部变量可以带来更好的缓存性能。

将函数分组在一起

将类成员函数和非成员函数分组在一起,使得一起使用的函数在内存中靠近,这也导致了更好的缓存性能。这是因为函数在内存地址中的位置取决于它们在开发者的源代码中的位置,相邻的函数会被分配到彼此接近的地址。

动态分配内存

动态分配的内存有几种良好的使用场景,特别是当容器的大小在编译时未知,并且它们可以在应用程序实例的生命周期中增长或缩小时。对于非常大且占用大量栈空间的对象,动态分配的内存也非常重要。如果分配和释放操作不在关键路径上执行,并且使用分配的内存块,那么动态分配的内存可以在低延迟应用中发挥作用,这样不会损害缓存性能。

动态分配内存的一个缺点是分配和释放内存块的过程非常缓慢。不同大小的内存块的重复分配和释放会导致堆碎片化,也就是说,它会在分配的内存块之间创建不同大小的空闲内存块。

分散的堆使得分配和释放过程变得更加缓慢。除非开发者对此非常小心,否则分配的内存块可能无法最优地对齐。通过指针访问的动态分配内存会导致指针别名,并阻止编译器优化,正如我们之前所看到的。动态分配内存还有其他缺点,但对于低延迟应用来说,这些是最大的缺点。因此,在低延迟应用中,最好完全避免使用动态分配的内存,或者至少要谨慎且少量地使用。

多线程

如果低延迟应用使用多线程,那么线程以及这些线程之间的交互应该被仔细设计。启动和停止线程需要时间,因此最好在需要时避免启动新线程,而是使用工作线程的线程池。任务切换或上下文切换是指一个线程被暂停或阻塞,另一个线程开始在其位置上执行。上下文切换非常昂贵,因为它需要操作系统保存当前线程的状态,加载下一个线程的状态,开始处理,等等,通常伴随着内存读写、缓存未命中、指令流水线停滞等等。

使用锁和互斥量在线程之间进行同步也是昂贵的,并且涉及到对并发访问和上下文切换开销的额外检查。当多个线程访问共享资源时,它们需要使用 volatile 关键字,这也阻止了编译器进行一些优化。此外,不同的线程可能争夺相同的缓存行,并使彼此的缓存失效,这种竞争导致糟糕的缓存性能。每个线程都有自己的堆栈,因此最好将共享数据保持在最小,并在线程的堆栈上本地分配变量。

最大化 C++ 编译器优化参数

在本节的最后,我们将了解现代 C++ 编译器在优化开发者编写的 C++ 代码方面的先进性和神奇之处。我们将了解编译器如何在编译、链接和优化阶段优化 C++ 代码,以生成尽可能高效的机器代码。我们将了解编译器如何优化高级 C++ 代码,以及它们何时未能做到最好。我们将接着讨论应用开发者可以做什么来帮助编译器完成优化任务。最后,我们将通过具体查看 GNU 编译器GCC)来探讨现代 C++ 编译器中可用的不同选项。让我们首先了解编译器是如何优化我们的 C++ 程序的。

理解编译器优化

在本小节中,我们将了解编译器在多次遍历高级 C++代码时采用的不同的编译优化技术。编译器通常首先执行局部优化,然后尝试全局优化这些较小的代码段。它在预处理、编译、链接和优化阶段通过翻译的机器代码进行多次遍历来实现这一点。总的来说,大多数编译器优化技术都有一些共同的主题,其中一些相互重叠,一些则相互冲突,我们将在下一节中探讨。

优化常见情况

这个概念也适用于软件开发,并有助于编译器更好地优化代码。如果编译器能够理解程序执行将花费大部分时间在哪些代码路径上,它就可以优化常见路径以使其更快,即使这会减慢很少走的路径。这总体上会带来更好的性能,但通常在编译时对编译器来说更难实现,因为除非开发者添加指令来指定这一点,否则并不明显哪些代码路径更有可能。我们将讨论开发者可以向编译器提供的提示,以帮助在运行时指定哪些代码路径更有可能。

最小化分支

现代处理器通常在需要之前预取数据和指令,以便处理器可以尽可能快地执行指令。然而,当存在跳转和分支(条件和非条件)时,处理器无法提前以 100%的确定性知道哪些指令和数据将被需要。这意味着有时处理器错误地预测了分支的执行,因此预取的指令和数据是不正确的。当这种情况发生时,会额外产生惩罚,因为现在处理器必须移除错误预取的指令和数据,并用正确的指令和数据替换它们,然后执行它们。诸如循环展开、内联和分支预测提示等技术有助于减少分支和分支预测错误,从而提高性能。我们将在本节稍后更详细地探讨这些概念。

在某些情况下,开发者可以通过重构代码来避免分支并实现相同的行为。有时,这些优化机会只有开发者可以利用,因为他们比编译器更深入地理解代码和行为。下面将展示如何将使用分支的代码块转换为避免分支的示例。这里有一个枚举来跟踪执行时的侧面,以及跟踪最后买入/卖出的数量,以及以两种不同的方式更新位置。第一种方式使用fill_side变量的分支,第二种方法通过假设fill_side变量只能有BUY/SELL值并且可以转换为整数以索引到数组来避免这种分支。此代码可在Chapter3/branch.cpp文件中找到:

#include <cstdio>
#include <cstdint>
#include <cstdlib>
enum class Side : int16_t { BUY = 1, SELL = -1 };
int main() {
  const auto fill_side = (rand() % 2 ? Side::BUY : Side
       ::SELL);
  const int fill_qty = 10;
  printf(“fill_side:%s fill_qty:%d.\n”, (fill_side == Side
       ::BUY ? “BUY” : (fill_side == Side::SELL ? “SELL” :
         “INVALID”)), fill_qty);
  { // with branching
    int last_buy_qty = 0, last_sell_qty = 0, position = 0;
    if (fill_side == Side::BUY) {
      position += fill_qty; last_buy_qty = fill_qty;
    } else if (fill_side == Side::SELL) {
      position -= fill_qty; last_sell_qty = fill_qty; }
    printf(“With branching - position:%d last-buy:%d last-
         sell:%d.\n”, position, last_buy_qty,
           last_sell_qty);
  }
  { // without branching
    int last_qty[3] = {0, 0, 0}, position = 0;
    auto sideToInt = [](Side side) noexcept { return
         static_cast<int16_t>(side); };
    const auto int_fill_side = sideToInt(fill_side);
    position += int_fill_side * fill_qty;
    last_qty[int_fill_side + 1] = fill_qty;
    printf(“Without branching - position:%d last-buy:%d
         last-sell:%d.\n”, position, last_qty[sideToInt
           (Side::BUY) + 1], last_qty[side
             ToInt(Side::SELL)+
             1]);
  }
}

并且分支和分支无实现都计算相同的值:

fill_side:BUY fill_qty:10.
With branching - position:10 last-buy:10 last-sell:0.
Without branching - position:10 last-buy:10 last-sell:0.

重新排序和调度指令

编译器可以通过重新排序指令来利用高级处理器,以便在指令、内存和线程级别上发生并行处理。编译器可以检测代码块之间的依赖关系,并重新排序它们,以便程序仍然正确运行但执行速度更快,通过在处理器级别并行执行指令和处理数据。现代处理器甚至在没有编译器的情况下也可以重新排序指令,但如果编译器能使其更容易,那就更好了。这里的主要目标是防止现代处理器(具有多个流水线处理器)中的停顿和气泡,通过选择和排序指令以保持原始逻辑流程。

这里展示了如何通过重新排序表达式来利用并行性的一个简单示例。请注意,这有点假设性质,因为实际实现将根据处理器和编译器的不同而有很大差异:

x = a + b + c + d + e + f;

按照目前的写法,这个表达式有一个数据依赖性,将按顺序执行,大致如下,并花费 5 个时钟周期:

x = a + b;
x = x + c;
x = x + d;
x = x +e;
x = x + f;

它可以被重新排序成以下指令,并且假设高级处理器可以一次执行两个加法操作,可以减少到三个时钟周期。这是因为像x = a + b;p = c + d;这样的两个操作可以并行执行,因为它们彼此独立:

x = a + b; p = c + d;
q = e + f; x = x + p;
x = x + q;

使用根据架构的特殊指令

在编译过程中,编译器可以选择使用哪些 CPU 指令来实现高级程序逻辑。当编译器为特定架构生成可执行文件时,它可以使用该架构支持的特定指令。这意味着有机会生成更高效的指令序列,这些序列利用了架构提供的特殊指令。我们将在了解编译器优化标志部分中查看如何指定这一点。

向量化

现代处理器可以使用向量寄存器并行地对多个数据执行多个计算。例如,SSE2 指令集具有 128 位向量寄存器,可以根据这些类型的大小用于对多个整数或浮点值执行多个操作。进一步扩展,例如 AVX2 指令集具有 256 位向量寄存器,可以支持更高程度的向量化操作。这种优化可以从技术上被视为之前根据架构使用特殊指令部分讨论的一部分。

为了更好地理解向量化,让我们来看一个非常简单的例子,这个例子中有一个循环操作两个数组,并将结果存储在另一个数组中(GitHub 中Chapter3vector.cpp文件):

  const size_t size = 1024;
  float x[size], a[size], b[size];
  for (size_t i = 0; i < size; ++i) {
    x[i] = a[i] + b[i];
  }

对于支持特殊向量寄存器的架构,例如我们之前讨论的 SSE2 指令集,它可以同时存储 4 个 4 字节的浮点值并一次执行 4 次加法。在这种情况下,编译器可以利用向量化优化技术,并通过循环展开将其重写为以下代码,以使用 SSE2 指令集:

  for (size_t i = 0; i < size; i += 4) {
    x[i] = a[i] + b[i];
    x[i + 1] = a[i + 1] + b[i + 1];
    x[i + 2] = a[i + 2] + b[i + 2];
    x[i + 3] = a[i + 3] + b[i + 3];

强度降低

强度降低是一个术语,用来描述编译器优化过程,其中复杂的且成本较高的操作被替换为更简单且成本更低的指令,以提高性能。一个经典的例子是编译器将涉及除以某个值的操作替换为乘以该值的倒数。另一个例子是将通过循环索引的乘法操作替换为加法操作。

我们在这里展示的最简单的例子是尝试通过将浮点值除以其最小有效价格增量来将价格从双精度表示转换为整数表示。演示编译器如何执行强度降低的变体是一个简单的乘法而不是除法。请注意,inv_min_price_increment = 1 / min_price_increment;是一个constexpr表达式,因此它不会在运行时评估。此代码位于Chapter3/strength.cpp文件中:

#include <cstdint>
int main() {
  const auto price = 10.125; // prices are like: 10.125,
       10.130, 10.135...
  constexpr auto min_price_increment = 0.005;
  [[maybe_unused]] int64_t int_price = 0;
  // no strength reduction
  int_price = price / min_price_increment;
  // strength reduction
  constexpr auto inv_min_price_increment = 1 /
       min_price_increment;
  int_price = price * inv_min_price_increment;
}

内联

调用函数的成本很高,正如我们之前所见。这包括几个步骤:

  • 保存变量的当前状态和执行状态

  • 加载被调用函数中的变量和指令

  • 执行它们,并可能在函数调用后返回值并继续执行

编译器会尝试在可能的情况下用函数体替换对函数的调用,以移除与调用函数相关的开销并优化性能。不仅如此,一旦它用函数的实际体替换了对函数的调用,这就为更多的优化打开了空间,因为编译器可以检查这个新的更大的代码块。

常量折叠和常量传播

常量折叠是一种不言而喻的优化技术,适用于存在输出可以在编译时完全计算的表达式,这些表达式不依赖于运行时分支或变量。然后,编译器在编译时计算这些表达式,并用编译时常量输出值替换这些表达式的评估。

一种类似且紧密相关的编译器优化会跟踪代码中已知为编译时常量的值,并试图传播这些常量值并解锁额外的优化机会。这种优化技术被称为常量传播。一个例子是,如果编译器可以确定循环迭代器的起始值、增量值或停止值,则可以执行循环展开。

死代码消除 (DCE)

DCE(删除未引用代码)适用于编译器可以检测到对程序行为没有影响的代码块。这可能是由于从未被需要的代码块,或者计算最终没有使用或影响结果的情况。一旦编译器检测到这样的死代码块,它就可以移除它们并提高程序性能。现代编译器在运行某些代码的结果最终未被使用时发出警告,以帮助开发者找到此类情况,但编译器无法在编译时检测到所有这些情况,并且一旦翻译成机器代码指令,仍然有机会进行 DCE。

公共子表达式消除 (CSE)

CSE是一种特定的优化技术,其中编译器查找重复的指令集或计算。在这里,编译器重构代码,通过只计算一次结果并在需要的地方使用该值来消除这种冗余。

窥孔优化

窥孔优化是一个相对通用的编译器优化术语,指的是编译器试图在指令的短序列中搜索局部优化的技术。我们使用“局部”这个术语,因为编译器并不一定试图理解整个程序并全局优化它。然而,当然,通过反复和迭代地执行窥孔优化,编译器可以在全局范围内达到相当程度的优化。

尾调用优化

我们知道函数调用并不便宜,因为它们与传递参数和结果有关的开销,并影响缓存性能和处理器流水线。__attribute__ ((noinline)) 属性存在是为了显式阻止编译器将 factorial() 函数直接内联到 main() 中。你可以在 GitHub 上的 Chapter3/tail_call.cpp 源文件中找到这个示例:

auto __attribute__ ((noinline)) factorial(unsigned n) ->
     unsigned {
  return (n ? n * factorial(n - 1) : 1);
}
int main() {
  [[maybe_unused]] volatile auto res = factorial(100);
}

对于这个实现,我们预计在 factorial() 函数的机器代码中会找到一个对自身的调用,但是当开启优化编译时,编译器执行尾调用优化,并将 factorial() 函数实现为一个循环而不是递归。要观察这个机器代码,你可以使用类似以下的方式编译此代码:

g++ -S -Wall -O3 tail_call.cpp ; cat tail_call.s

在那个 tail_call.s 文件中,你会看到 main() 中对 factorial() 的调用类似于以下示例。如果你是第一次查看汇编代码,那么让我们快速描述你将遇到的指令。

  • movl 指令将一个值移动到寄存器中(以下代码块中的 100)

  • call 指令调用一个函数(带有名称修饰的 factorial(),这是 C++ 编译器在中间代码中更改函数名称的步骤,参数通过 edi 寄存器传递)

  • testl 指令比较两个寄存器,如果它们相等则设置零标志

  • jejne 检查零标志是否被设置,如果设置了则跳转到指定的内存地址(je),如果没有设置则跳转到指定的内存地址(jne

  • ret 指令从函数返回,返回值位于 eax 寄存器中:

    main:
    
    .LFB1
    
        Movl    $100, %edi
    
        Call    _Z9factorialj
    

当你查看 factorial() 函数本身时,你会找到一个循环(jejne 指令),而不是对自身的额外 call 指令:

_Z9factorialj:
.LFB0:
    Movl    $1, %eax
    testl    %edi, %edi
    je    .L4
.L3:
    Imull    %edi, %eax
    subl    $1, %edi
    jne    .L3
    ret
.L4:
    ret

循环展开

循环展开 多次复制循环体。有时编译器在编译时无法知道循环将执行多少次 – 在这种情况下,它将部分展开循环。对于循环体小且/或可以确定循环将执行的次数较少的循环,编译器可以完全展开循环。这避免了检查循环计数器和与条件分支或循环相关的开销。这就像函数内联一样,将函数的调用替换为函数体。对于循环展开,整个循环被展开并替换了条件循环体。

额外的循环优化

循环展开 是编译器使用的主要的循环相关优化技术,但还有额外的循环优化:

  • 循环分裂 将循环分解为多个循环,这些循环操作更小的数据集以提高缓存引用局部性。

  • 循环融合做的是相反的事情,即如果两个相邻的循环执行相同的次数,它们可以被合并成一个以减少循环开销。

  • while循环在条件if语句内部被转换成do-while循环。当循环执行时,这减少了两次跳转的总数,并且通常应用于预期至少执行一次的循环。

  • 循环交换交换内部循环和外部循环,尤其是在这样做可以导致更好的缓存引用局部性时——例如,在迭代数组的情况下,连续访问内存会产生巨大的性能差异。

寄存器变量

寄存器是内部处理器内存,并且由于它们是最接近处理器的,因此是处理器上可用的最快存储形式。正因为如此,编译器试图将访问次数最多的变量存储在寄存器中。然而,寄存器是有限的,因此编译器需要有效地选择要存储的变量,这种选择的有效性可以对性能产生重大影响。编译器通常选择诸如局部变量、循环计数器和迭代变量、函数参数、常用表达式或归纳变量(每次循环迭代通过固定量变化的变量)等变量。编译器可以放置在寄存器中的变量有一些限制,例如需要通过指针或引用获取地址的变量或需要驻留在主内存中的变量。

现在,我们通过一个非常简单的例子来说明编译器如何使用归纳变量转换循环表达式。请参阅以下代码(GitHub 上的Chapter3/induction.cpp):

  for(auto i = 0; i < 100; ++i)
    a[i] = i * 10 + 12;
gets transformed into something of the form presented below
     and avoids the multiplication in the loop and replaces
     it
       with an induction variable based addition.
  int temp = 12;
  for(auto i = 0; i < 100; ++i) {
    a[i] = temp;
    temp += 10;
  }

生存范围分析

术语生存范围描述了变量活跃或被使用的代码块。如果同一个代码块中有多个变量具有重叠的生存范围,那么每个变量都需要不同的存储位置。然而,如果有生存范围不重叠的变量,则编译器可以在每个生存范围内为多个变量使用相同的寄存器。

重载材料化

重载材料化是一种编译器技术,其中编译器选择重新计算一个值(假设计算是微不足道的)而不是访问包含该计算值的内存位置。这个重新计算的输出值必须存储在寄存器中,因此这项技术与寄存器分配技术协同工作。这里的主要目标是避免访问缓存和主内存,因为它们比访问寄存器存储要慢。当然,这取决于确保重新计算的时间比缓存或内存访问要少。

代数简化

编译器可以找到可以使用代数法则进一步简化和简化的表达式。虽然软件开发者不会不必要地复杂化表达式,但与开发者最初在 C++中编写的相比,存在更简单的表达式形式。代数简化的机会也出现在编译器由于内联、宏展开、常量折叠等迭代优化代码时。

这里需要注意的是,编译器通常不会对浮点运算应用代数简化,因为在 C++中,由于精度问题,浮点运算不安全进行简化。需要打开标志来强制编译器执行不安全的浮点代数简化,但开发者显式且正确地简化它们会更好。

我们可以想到的最简单的例子是编译器可能会重写这个表达式:

if(!a && !b) {}

这里,它使用两个操作而不是之前的三种操作:

if(!(a || b)) {}

归纳变量分析

归纳变量相关的编译器优化技术的理念是,一个关于循环计数变量的线性函数表达式可以被简化为一个对前一个值的简单加法表达式。最简单的例子可能是计算数组中元素地址,其中下一个元素位于当前元素位置加上对象类型大小的内存位置。这只是一个简单的例子,因为在现代编译器和处理器中,有专门的指令来计算数组元素的地址,并且归纳实际上并没有在那里使用,但基于归纳变量的优化仍然会对其他循环表达式进行。

循环不变式代码移动

当编译器可以确定某些代码和指令在整个循环过程中都是常数时,该表达式可以被移出循环。如果循环中有表达式根据分支条件条件性地产生一个值或另一个值,这些也可以被移出循环。此外,如果循环中每个分支上都要执行表达式,这些也可以移出分支,甚至可能移出循环。存在许多这样的优化可能性,但基本思想是,不需要在每次循环迭代上执行或可以在循环开始之前评估一次的代码属于循环不变式代码重构的范畴。以下是一个假设的例子,说明编译器如何实现循环不变式代码移动。第一个块是开发者最初编写的,但编译器可以理解对doSomething()的调用和涉及b变量的表达式是循环不变式,并且只需要计算一次。你将在Chapter3/loop_invariant.cpp文件中找到此代码:

#include <cstdlib>
int main() {
  auto doSomething = [](double r) noexcept { return 3.14 *
       r * r; };
  [[maybe_unused]] int a[100], b = rand();
  // original
  for(auto i = 0; i < 100; ++i)
    a[i] = (doSomething(50) + b * 2) + 1;
  // loop invariant code movement
  auto temp = (doSomething(50) + b * 2) + 1;
  for(auto i = 0; i < 100; ++i)
    a[i] = temp;
}

基于静态单赋值(SSA)的优化

SSA(单赋值)是原始程序的一种转换形式,其中指令被重新排序,以便每个变量只在一个地方被赋值。在此转换之后,编译器可以应用许多额外的优化,利用每个变量只在一个地方被赋值的属性。

虚拟化

虚拟化是一种编译器优化技术,特别是针对 C++,它试图在调用虚拟函数时避免虚表vtable)查找。这种优化技术归结为编译器在编译时确定正确的调用方法。即使在使用虚拟函数的情况下,这也可能发生,因为在某些情况下,对象类型在编译时是已知的,例如当只有一个纯虚拟函数的实现时。

另一个例子是,编译器可以确定在某些上下文或代码分支中只创建和使用了一个派生类,并且它可以替换使用虚表进行的间接功能调用,以直接调用正确的派生类型的方法。

了解编译器何时无法优化

在本节中,我们将讨论在哪些不同的情况下,编译器无法应用我们在上一节中讨论的一些优化技术。了解编译器何时无法优化将帮助我们开发避免这些失败的 C++代码,从而使代码能够被编译器高度优化,生成高效的机器代码。

模块间优化失败

当编译器编译整个程序时,它会根据文件逐个独立编译模块。因此,编译器除了它当前正在编译的模块之外,没有关于模块中函数的信息。这阻止了它能够优化跨模块的函数,我们看到的许多技术都无法应用,因为编译器不理解整个程序。现代编译器通过使用LTO(链接时优化)来解决此类问题,在单独的模块编译完成后,链接器在编译时将不同的模块视为同一翻译单元的一部分。这激活了我们之前讨论的所有优化,因此当尝试优化整个应用程序时,启用 LTO 非常重要。

动态内存分配

我们已经知道动态内存分配在运行时速度较慢,并给应用程序引入了非确定性的延迟。它们还有另一个副作用,那就是指向这些动态分配内存块的指针中的指针别名。我们将在下一节更详细地探讨指针别名,但对于动态分配的内存块,编译器无法确定指针一定会指向不同且不重叠的内存区域,尽管对于程序员来说这可能是显而易见的。这阻止了依赖于对齐数据或假设对齐的各种编译器优化,以及我们将在下一节看到的与指针别名相关的低效性。局部存储和声明也更缓存高效,因为当新函数被调用和局部对象被创建时,内存空间会频繁地被重用。动态分配的内存块可以在内存中随机分布,导致较差的缓存性能。

指针别名

当通过指针或引用访问变量时,虽然对于开发者来说可能很明显哪些指针指向不同且不重叠的内存位置,但编译器不能 100%确定。换句话说,编译器不能保证一个指针没有指向代码块中的另一个变量,或者不同的指针没有指向重叠的内存位置。由于编译器必须假设这种可能性,这阻止了我们之前讨论的许多编译器优化,因为它们不能再安全地应用。在 C++代码中,有方法可以指定编译器可以安全假设不是别名的指针。另一种方法是指示编译器在整个代码中假设没有指针别名,但这需要开发者分析所有指针和引用,并确保永远不会发生别名,这并不简单。最后,最后一个选项是显式优化代码,同时考虑到这些阻碍编译器优化的因素,这也不简单。

我们关于处理指针别名的建议是执行以下操作:

  1. 在函数声明中传递指针到函数时,使用__restrict关键字来指示编译器假设带有该指定符的指针没有指针别名。

  2. 如果需要额外的优化,我们建议显式优化代码路径,并意识到指针别名的考虑因素。

  3. 最后,如果仍然需要额外的优化,我们可以指示编译器在整个代码库中假设没有指针别名,但这是一个危险的选择,并且只能作为最后的手段使用。

浮点归纳变量

编译器通常不使用归纳变量优化来处理浮点表达式和变量。这是因为我们之前讨论过的舍入误差和精度问题。这阻止了编译器在处理浮点表达式和值时的优化。有一些编译器选项可以启用不安全的浮点优化,但开发者必须确保检查每个表达式并以这种方式构建它们,即这些由于编译器优化引起的精度问题不会产生意外的副作用。这不是一个简单任务;因此,开发者应该小心地显式优化浮点表达式或分析不安全编译器优化带来的副作用。

虚函数和函数指针

我们已经讨论过,当涉及到虚函数和函数指针时,由于在许多情况下编译器无法确定在运行时将调用哪个方法,因此编译器无法在编译时进行优化。

了解编译器优化标志

到目前为止,我们已经讨论了编译器使用的不同优化技术,以及编译器未能优化我们的 C++ 代码的不同情况。生成优化低延迟代码有两个基本关键点。第一个是编写高效的 C++ 代码,并在编译器可能无法做到的情况下手动优化。其次,你可以尽可能地向编译器提供可见性和信息,以便它能够做出正确和最佳的优化决策。我们可以通过配置编译器的编译器标志来传达我们的意图。

在本节中,我们将了解 GCC 的编译器标志,因为这是我们将在本书中使用的编译器。然而,大多数现代编译器都有配置优化标志的选项,就像我们将在本节中讨论的那样。

接近编译器优化标志

在高层次上,对 GCC 编译器优化标志的一般方法是以下内容:

  • 通常首选最高优化级别,因此 –O3 是一个很好的起点,并启用了许多优化,我们将在稍后看到。

  • 在实践中测量应用程序的性能是衡量和优化最关键代码路径的最佳方式。GCC 本身可以执行 -fprofile-generate 选项。编译器确定程序的流程并计算每个函数和代码分支被执行的次数,以找到关键代码路径的优化。

  • 启用 –flto 参数为我们的应用程序启用 LTO。-fwhole-program 选项启用 WPO 以启用过程间优化,将整个代码库视为一个整体程序。

  • 允许编译器为应用程序运行的具体架构生成构建是一个好主意。这可以让编译器使用特定于该架构的特殊指令集,并最大化优化机会。对于 GCC,这可以通过使用 –march 参数来实现。

  • 建议禁用 -no-rtti 参数。

  • 可以指导 GCC 编译器启用快速浮点数值优化,甚至启用不安全的浮点优化。GCC 提供了 -ffp-model=fast-funsafe-math-optimizations-ffinite-math-only 选项来启用这些不安全的浮点优化。当使用这些标志时,开发者必须仔细考虑操作顺序和由此产生的精度。当使用如 -ffinite-math-only 这样的参数时,请确保所有浮点变量和表达式都是有限的,因为这种优化依赖于这个属性。-fno-trapping-math-fno-math-errno 允许编译器通过假设不会依赖异常处理或 errno 全局变量来错误信号,将包含浮点操作的循环向量化。

理解 GCC 优化标志的细节

在本节中,我们将提供有关 GCC 优化标志的更多详细信息。可用的优化标志列表非常大,超出了本书的范围。首先,我们将描述在 GCC 中启用高级优化指令 –O1–O2–O3 可以启用什么,并鼓励感兴趣的读者从 GCC 手册中详细了解每个指令。

优化级别 -O1

–O1 是第一个优化级别,并启用以下表格中展示的以下标志。在这个级别,编译器试图在不大幅增加编译、链接和优化时间的情况下,减少代码大小和执行时间。这些是最重要的优化级别,基于本章讨论的内容提供了巨大的优化机会。我们将在下面讨论一些标志。

-fdce–fdse 执行 DCE 和 死存储 消除DSE)。

-fdelayed-branch 在许多架构上受支持,并试图重新排序指令,以尝试在延迟分支指令之后最大化流水线的吞吐量。

-fguess-branch-probability 尝试根据开发者未提供的启发式方法猜测分支概率。

-fif-conversion-fif-conversion2 尝试通过使用类似于本章讨论的技巧将它们转换为无分支等价物来消除分支。

-fmove-loop-invariants 启用循环不变代码移动优化。

如果你对这些标志感兴趣,你应该调查它们的细节,因为讨论每个参数超出了本书的范围。

-fauto-inc-dec-fshrink-wrap
-fbranch-count-reg-fshrink-wrap-separate
-fcombine-stack-adjustments-fsplit-wide-types
-fcompare-elim-fssa-backprop
-fcprop-registers-fssa-phiopt
-fdce-ftree-bit-ccp
-fdefer-pop-ftree-ccp
-fdelayed-branch-ftree-ch
-fdse-ftree-coalesce-vars
-fforward-propagate-ftree-copy-prop
-fguess-branch-probability-ftree-dce
-fif-conversion-ftree-dominator-opts
-fif-conversion2-ftree-dse
-finline-functions-called-once-ftree-forwprop
-fipa-modref-ftree-fre
-fipa-profile-ftree-phiprop
-fipa-pure-const-ftree-pta
-fipa-reference-ftree-scev-cprop
-fipa-reference-addressable-ftree-sink
-fmerge-constants162-ftree-slsr
-fmove-loop-invariants-ftree-sra
-fmove-loop-stores-ftree-ter
-fomit-frame-pointer-funit-at-a-time
-freorder-blocks

表 3.1 – 当启用 -O1 时,GCC 优化的标志

优化级别 -O2

-O2 是下一个优化级别,在这个级别上,GCC 将执行更多的优化,并导致编译和链接时间更长。-O2 除了启用 –O1 的标志外,还添加了以下表格中的标志。我们将简要讨论其中的一些标志,并将每个标志的详细讨论留给感兴趣的读者去探索。

-falign-functions-falign-labels-falign-loops 将函数、跳转目标和循环位置的起始地址对齐,以便处理器尽可能高效地访问它们。本章讨论的关于最佳数据对齐的原则也适用于指令地址。

-fdelete-null-pointer-checks 允许程序假设解引用空指针是不安全的,并利用这个假设来进行常量折叠、消除空指针检查等操作。

-fdevirtualize-fdevirtualize-speculatively 尽可能地将虚函数调用转换为直接函数调用。这反过来又可能导致更多的优化,因为内联。

-fgcse 启用 全局公共子表达式消除GCSE)和常量传播。

-finline-functions-finline-functions-called-once-findirect-inlining 增强了编译器在尝试内联函数和寻找由于先前优化传递而产生的间接内联机会时的积极性。

-falign-functions -falign-jumps-foptimize-sibling-calls
-falign-labels -falign-loops-foptimize-strlen
-fcaller-saves-fpartial-inlining
-fcode-hoisting-fpeephole2
-fcrossjumping-freorder-blocks-algorithm=stc
-fcse-follow-jumps -fcse-skip-blocks-freorder-blocks-and-partition -freorder-functions
-fdelete-null-pointer-checks-frerun-cse-after-loop
-fdevirtualize -fdevirtualize-speculatively-fschedule-insns -fschedule-insns2
-fexpensive-optimizations-fsched-interblock -fsched-spec
-ffinite-loops-fstore-merging
-fgcse -fgcse-lm-fstrict-aliasing
-fhoist-adjacent-loads-fthread-jumps
-finline-functions-ftree-builtin-call-dce
-finline-small-functions-ftree-loop-vectorize
-findirect-inlining-ftree-pre
-fipa-bit-cp -fipa-cp -fipa-icf-ftree-slp-vectorize
-fipa-ra -fipa-sra -fipa-vrp-ftree-switch-conversion -ftree-tail-merge
-fisolate-erroneous-paths-dereference-ftree-vrp
-flra-remat-fvect-cost-model=very-cheap

表 3.2 – 当启用 -O2 时,除了来自 -O1 的标志之外,GCC 启用的优化标志

优化级别 –O3

–O3 是 GCC 中最激进的优化选项,它会在程序性能更好的情况下进行优化,即使这会导致可执行文件大小增加。-O3 启用了下一表中列出的以下标志,除了 –O2。我们首先简要讨论几个重要标志,然后提供完整的列表。

-fipa-cp-clone 通过以更高的可执行文件大小为代价换取执行速度,创建函数克隆以增强跨程序常量传播和其他形式的优化。

-fsplit-loops 尝试将循环拆分,如果可以通过将循环的一侧和另一侧分开来避免循环内的分支,例如,在一个循环中检查交易算法的执行方向,并在循环内执行两个不同的代码块的情况下。

-funswitch-loops 将循环不变分支移出循环以最小化分支。

-fgcse-after-reload-fsplit-paths
-fipa-cp-clone -floop-interchange-ftree-loop-distribution
-floop-unroll-and-jam-ftree-partial-pre
-fpeel-loops-funswitch-loops
-fpredictive-commoning-fvect-cost-model=dynamic
-fsplit-loops-fversion-loops-for-strides

表 3.3 – 当启用 -O3 时,除了来自 -O2 的标志之外,GCC 启用的优化标志

我们将讨论一些额外的编译器优化标志,我们在优化低延迟应用程序时发现它们很有用。

静态链接

–l library 选项传递给链接器以指定要链接的可执行文件与哪个库。然而,如果链接器找到一个名为 liblibrary.a 的静态库和一个名为 liblibrary.so 的共享库,那么我们必须指定 –static 参数以防止与共享库链接并选择静态库。我们之前已经讨论过为什么对于低延迟应用程序,静态链接比共享库链接更受欢迎。

目标架构

–march参数用于指定编译器应构建的最终可执行二进制文件的目标架构。例如,–march=native指定编译器应为其正在构建的架构构建可执行文件。我们在此重申,当编译器知道应用程序将被构建以在哪个架构上运行时,它可以利用有关该架构的信息,例如扩展指令集等,以改进优化。

警告

–Wall–Wextra–Wpendantic参数控制编译器在检测到各种不同情况时生成的警告数量,这些情况在技术上不是错误,但可能是危险的。对于大多数应用程序来说,建议启用这些参数,因为它们可以检测开发者代码中的潜在错误和打字错误。虽然这些参数不会直接影响编译器优化应用程序的能力,但有时,警告会迫使开发者检查模糊或次优代码的情况,例如意外的或隐式的类型转换,这可能是低效的。–Werror参数将这些警告转换为错误,并将迫使开发者在编译成功之前检查并修复每个生成编译器警告的情况。

不安全快速数学

在没有充分考虑和尽职调查的情况下,不应启用这类编译器优化标志。在 C++中,编译器无法应用许多依赖于诸如浮点运算产生有效值、浮点表达式具有结合性等属性的浮点优化。概括来说,这是因为浮点值在硬件中的表示方式,而且许多这些优化可能导致精度损失和不同的(甚至可能是错误的)结果。启用–ffast-math参数反过来会启用以下参数:

  • –fno-math-errno

  • –funsafe-math-optimizations

  • –ffinite-math-only

  • –fno-rounding-math

  • –fno-signaling-nans

  • –fcx-limited-range

  • –fexcess-precision=fast

这些参数将允许编译器对浮点表达式应用优化,即使它们是不安全的。这些参数在三个优化级别中都不会自动启用,因为它们是不安全的,并且只有在开发者确信没有因为这些问题出现错误或副作用时才应该启用。

摘要

在本章中,首先,我们讨论了适用于任何编程语言开发低延迟应用程序的一般建议。我们讨论了这些应用程序的理想软件工程方法,以及如何考虑、设计、开发和评估使用的数据结构和算法等构建块。

我们强调,在低延迟应用开发方面,对处理器架构、缓存和内存布局及访问、C++编程语言在底层的工作原理以及编译器如何优化你的代码等主题的深入了解将决定你的成功。对于低延迟应用来说,测量和提升性能也是一个关键组成部分,但我们将在这本书的末尾深入探讨这些细节。

我们花费了大量时间讨论不同的 C++原则、构造和特性,目的是理解它们在较低层面的实现方式。这里的目的是摆脱次优实践,并强调使用 C++进行低延迟应用开发的某些理想方面。

在本书的剩余部分,当我们构建我们的低延迟电子交易交换生态系统(相互交互的应用集合)时,我们将加强并基于这里讨论的这些想法,避免某些 C++特性并使用其他特性。

在本章的最后部分,我们详细讨论了 C++编译器的许多方面。我们试图理解编译器如何优化开发者的高级代码,即他们有哪些技术可用。我们还调查了编译器未能优化开发者代码的场景。那里的目标是让你了解如何在尝试输出尽可能优化的机器代码时利用编译器,并帮助编译器避免编译器无法优化的条件。最后,我们查看 GNU GCC 编译器可用的不同编译器优化标志,这是我们将在本书的其余部分使用的编译器。

在下一章中,我们将把我们的理论知识付诸实践,我们将跳入用 C++实现低延迟应用的一些常见构建块。我们将保持构建这些组件以实现低延迟和高性能的目标。我们将仔细使用本章讨论的原则和技术来构建这些高性能组件。在后面的章节中,我们将使用这些组件来构建一个电子交易生态系统。

第四章:构建低延迟应用程序的 C++构建块

在上一章中,我们详细且技术性地讨论了如何在 C++中开发低延迟应用程序的方法。我们还研究了 C++编程语言的技术细节以及 GCC 编译器。现在,我们将从理论讨论转向自己构建一些实际的低延迟 C++组件。

我们将构建一些相对通用的组件,这些组件可以用于各种不同的低延迟应用程序,例如我们在上一章中讨论的那些。在我们本章构建这些基本构建块时,我们将学习如何有效地使用 C++编写高性能的 C++代码。我们将在本书的其余部分使用这些组件来展示这些组件在我们设计和构建的电子交易生态系统中如何定位。

本章将涵盖以下主题:

  • C++多线程用于低延迟多线程应用程序

  • 设计 C++内存池以避免动态内存分配

  • 使用无锁队列传输数据

  • 构建低延迟日志框架

  • 使用套接字进行 C++网络编程

技术要求

本书的所有代码都可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP。本章的源代码位于仓库中的Chapter4目录。

我们期望您至少具备中级 C++编程经验,因为我们假设您对广泛使用的 C++编程特性有很好的理解。我们还假设您在 C++网络编程方面有一些经验,因为网络编程是一个庞大的主题,无法在本书中涵盖。对于本书,从本章开始,我们将使用 CMake 和 Ninja 构建系统,因此我们期望您理解 CMake、g++、Ninja、Make 或其他类似的构建系统,以便能够构建本书的代码示例。

本书源代码开发环境的规格在此展示。我们提供此环境的详细信息,因为本书中展示的所有 C++代码不一定可移植,可能需要在您的环境中进行一些小的修改才能工作:

  • Linux 5.19.0-41-generic #42~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 18 17:40:00 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

  • g++ (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0

  • cmake 版本 3.23.2

  • 1.10.2

C++多线程用于低延迟多线程应用程序

我们将构建的第一个组件非常小,但仍然非常基础。本节将设计和实现创建和运行执行线程的方法。这些将在整个低延迟系统的许多不同部分中使用,具体取决于系统不同子组件的设计。根据系统设计,不同的组件可能作为一个流水线协同工作,以促进并行处理。我们将在我们的电子交易系统中以这种方式使用多线程框架。另一个用例是将非关键任务,如将日志记录到磁盘、计算统计数据等,传递给后台线程。

在我们继续到创建和操作线程的源代码之前,让我们首先快速定义一些有用的宏。我们将在本书中编写的源代码的许多地方使用这些函数,从本章开始。

定义一些有用的宏和函数

大多数低延迟应用程序运行在现代流水线处理器上,这些处理器在需要执行之前预先获取指令和数据。我们在上一章讨论过,分支预测错误非常昂贵,会导致流水线停滞,向其中引入气泡。因此,低延迟应用程序的重要开发实践是减少分支的数量。由于分支不可避免,因此也很重要尽可能地使它们尽可能可预测。

我们有两个简单的宏,我们将使用它们向编译器提供分支提示。这些宏使用了__builtin_expect GCC 内置函数,该函数重新排序编译器生成的机器指令。实际上,编译器使用开发者提供的分支预测提示来生成机器代码,该代码在假设分支更有可能被采取的情况下进行了优化。

注意,当涉及到分支预测时,指令重排只是完整画面的一部分,因为处理器在运行指令时使用了一个硬件分支预测器。注意,现代硬件分支预测器在预测分支和跳转方面非常出色,尤其是在相同的分支被多次采取的情况下,甚至在至少有容易预测的分支模式的情况下。

这两个宏如下所示:

#define LIKELY(x) __builtin_expect(!!(x), 1)
#define UNLIKELY(x) __builtin_expect(!!(x), 0)

LIKELY(x)宏指定由x指定的条件很可能为真,而UNLIKELY(x)宏则相反。作为一个使用示例,我们将在下一组函数中很快使用UNLIKELY宏。在 C++20 中,这像[[likely]][[unlikely]]属性一样被标准化,以标准且可移植的方式执行相同的功能。

我们接下来将定义两个额外的函数,但它们只是在我们代码库中的断言中使用。这些应该相当直观;ASSERT 在条件评估为 false 时记录一条消息并退出,而 FATAL 则简单地记录一条消息并退出。注意这里使用了 UNLIKELY 来指定我们并不期望 !cond 条件评估为 true。还请注意,在关键代码路径上使用 ASSERT 方法并不是免费的,主要是因为 if 检查。这是我们最终将更改以从发布构建中优化出去的事情,但到目前为止,我们将保留它,因为它应该非常便宜:

inline auto ASSERT(bool cond, const std::string& msg)
  noexcept {
  if(UNLIKELY(!cond)) {
    std::cerr << msg << std::endl;
    exit(EXIT_FAILURE);
  }
}
inline auto FATAL(const std::string& msg) noexcept {
  std::cerr << msg << std::endl;
  exit(EXIT_FAILURE);
}

本节中讨论的代码可以在本书 GitHub 仓库的 Chapter4/macros.h 源文件中找到。请注意,macros.h 头文件包含了以下两个头文件:

#include <cstring>
#include <iostream>

现在,让我们跳转到下一节,讨论线程创建和操作功能。

创建和启动新线程

下面的代码块中定义的方法创建了一个新的线程对象,在线程上设置线程亲和性(稍后会有更多介绍),并将线程在执行期间将运行的函数和相关参数传递给线程。这是通过将 thread_body lambda 传递给 std::thread 构造函数来实现的。注意使用了 可变参数模板完美转发 来允许此方法使用,运行各种函数、任意类型和任意数量的参数。创建线程后,该方法会等待直到线程成功启动或失败,因为未能设置线程亲和性,这就是调用 t->join() 的作用。现在忽略对 setThreadCore(core_id) 的调用;我们将在下一节中讨论它:

#pragma once
#include <iostream>
#include <atomic>
#include <thread>
#include <unistd.h>
#include <sys/syscall.h>
template<typename T, typename... A>
inline auto createAndStartThread(int core_id, const
  std::string &name, T &&func, A &&... args) noexcept {
  std::atomic<bool> running(false), failed(false);
  auto thread_body = [&] {
    if (core_id >= 0 && !setThreadCore(core_id)) {
      std::cerr << "Failed to set core affinity for " <<
        name << " " << pthread_self() << " to " << core_id
          << std::endl;
      failed = true;
      return;
    }
    std::cout << "Set core affinity for " << name << " " <<
      pthread_self() << " to " << core_id << std::endl;
    running = true;
    std::forward<T>(func)((std::forward<A>(args))...);
  };
  auto t = new std::thread(thread_body);
  while (!running && !failed) {
    using namespace std::literals::chrono_literals;
    std::this_thread::sleep_for(1s);
  }
  if (failed) {
    t->join();
    delete t;
    t = nullptr;
  }
  return t;
}

本节中讨论的代码可以在本书 GitHub 仓库的 Chapter4/thread_utils.h 源文件中找到。现在,让我们跳转到最后一节,在 setThreadCore(core_id) 函数中设置线程亲和性。

设置线程亲和性

在这里,我们将讨论设置线程亲和性的源代码,这是我们在上一节中看到的线程创建 lambda 表达式的功能。在我们讨论源代码之前,请记住,如果线程之间有大量的上下文切换,这会给线程性能带来很多开销。线程在 CPU 内核之间跳跃也会因为类似的原因损害性能。对于性能关键型线程设置线程亲和性对于低延迟应用来说非常重要,以避免这些问题。

现在,让我们看看如何在setThreadCore()方法中设置线程亲和性。首先,我们使用CPU_ZERO()方法清除cpu_set_t变量,它只是一个标志数组。然后,我们使用CPU_SET()方法启用我们想要将其核心固定的core_id的入口。最后,我们使用pthread_setaffinity_np()函数设置线程亲和性,如果失败则返回false。注意这里使用pthread_self()来获取要使用的线程 ID,这是有意义的,因为这是在createAndStartThread()中从我们创建的std::thread实例中调用的:

inline auto setThreadCore(int core_id) noexcept {
  cpu_set_t cpuset;
  CPU_ZERO(&cpuset);
  CPU_SET(core_id, &cpuset);
  return (pthread_setaffinity_np(pthread_self(), sizeof
    (cpu_set_t), &cpuset) == 0);
}

本节讨论的代码可以在本书 GitHub 仓库的Chapter4/thread_utils.h源文件中找到。这些代码块属于Common命名空间,当你查看 GitHub 仓库中的thread_utils.h源文件时,你会看到这一点。

构建示例

在我们结束本节之前,让我们快速看一下一个使用我们刚刚创建的线程工具的简单示例。这个示例可以在本书 GitHub 仓库的Chapter4/thread_example.cpp源文件中找到。请注意,本章的库和所有示例都可以使用包含在Chapter4目录中的CMakeLists.txt构建。我们还提供了两个简单的脚本,build.shrun_examples.sh,在设置正确的cmakeninja二进制文件路径后,用于构建和运行这些示例。请注意,这里的cmakeninja是任意构建系统选择,如果需要,你可以将其更改为任何其他构建系统。

这个示例应该相当直观——我们创建并启动两个线程,执行一个模拟任务,即添加传递给它的两个参数(ab)。然后,我们在退出程序之前等待线程完成执行:

#include "thread_utils.h"
auto dummyFunction(int a, int b, bool sleep) {
  std::cout << "dummyFunction(" << a << "," << b << ")" <<
    std::endl;
  std::cout << "dummyFunction output=" << a + b <<
    std::endl;
  if(sleep) {
    std::cout << "dummyFunction sleeping..." << std::endl;
    using namespace std::literals::chrono_literals;
    std::this_thread::sleep_for(5s);
  }
  std::cout << "dummyFunction done." << std::endl;
}
int main(int, char **) {
  using namespace Common;
  auto t1 = createAndStartThread(-1, "dummyFunction1",
    dummyFunction, 12, 21, false);
  auto t2 = createAndStartThread(1, "dummyFunction2",
    dummyFunction, 15, 51, true);
  std::cout << "main waiting for threads to be done." <<
    std::endl;
  t1->join();
  t2->join();
  std::cout << "main exiting." << std::endl;
  return 0;
}

当程序执行时,将输出类似以下内容:

(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter4$ ./cmake-build-release/thread_example
Set core affinity for dummyFunction1 140124979386112 to -1
dummyFunction(12,21)
dummyFunction output=33
dummyFunction done.
Set core affinity for dummyFunction2 140124970993408 to 1
dummyFunction(15,51)
dummyFunction output=66
dummyFunction sleeping...
main waiting for threads to be done.
dummyFunction done.
main exiting.

让我们继续到下一节,我们将讨论在运行时需要创建和丢弃对象时如何避免动态内存分配。

设计 C++内存池以避免动态内存分配

我们已经就动态内存分配、操作系统需要执行的步骤以及为什么动态内存分配速度慢进行了多次讨论。实际上,动态内存分配非常慢,以至于低延迟应用程序会尽可能在关键路径上避免它。没有创建和删除许多对象,我们就无法构建有用的应用程序,而动态内存分配对于低延迟应用程序来说太慢了。

理解内存池的定义

首先,让我们正式定义什么是内存池以及为什么我们需要它。许多应用程序(包括低延迟应用程序)需要能够处理许多对象以及未知数量的对象。通过未知数量的对象,我们指的是无法提前确定对象的预期数量,也无法确定对象的最大数量。显然,可能的最大对象数量是系统内存能够容纳的数量。处理这些对象的传统方法是在需要时使用动态内存分配。在这种情况下,堆内存被视为内存池——即从其中分配和释放内存的内存池。不幸的是,这些操作很慢,我们将通过使用我们自己的自定义内存池来控制系统中内存的分配和释放。我们定义内存池为任何我们可以从中请求额外内存或对象并将空闲内存或对象返回的地方。通过构建我们自己的自定义内存池,我们可以利用使用模式并控制分配和释放机制以实现最佳性能。

理解内存池的使用案例

当提前知道将需要的特定类型对象的确切数量时,你可以决定在需要时创建正好那个数量的对象。在实践中,有许多情况下无法提前知道确切的对象数量。这意味着我们需要在运行时动态地创建对象。如前所述,动态内存分配是一个非常缓慢的过程,对于低延迟应用程序来说是一个问题。我们使用术语内存池来描述特定类型的对象池,这就是我们将在本节中构建的内容。我们将在这本书中使用内存池来分配和释放我们无法预测的对象。

我们将使用的解决方案是在启动时预分配大量内存块,并在运行时提供所需数量的内存——即,从这个存储池中自行执行内存分配和释放步骤。这最终在许多不同的原因上表现出显著的优势,例如,我们可以将内存池的使用限制在我们的系统中的某些组件上,而不是服务器上运行的所有进程。我们还可以控制内存存储和分配释放算法,调整它们以针对我们的特定应用程序进行优化。

让我们先为我们的内存池做一些设计决策。我们内存池的所有源代码都存储在这本书的 GitHub 仓库中的Chapter4/mem_pool.h源文件中。

设计内存池存储

首先,我们需要决定如何在内存池内部存储元素。在这里,我们实际上有两个主要的选择——使用类似旧式数组(T[N])或 std::array 在栈上存储它们,或者使用类似旧式指针(T*)或类似 std::vector 的方式在堆上存储。根据内存池的大小、使用频率、使用模式和应用程序本身,一个选择可能比另一个更好。例如,我们可能预计在内存池中需要大量的内存,要么是因为存储的对象很大,要么是因为有很多这样的对象。在这种情况下,堆分配将是首选,以适应大量的内存需求,同时不影响栈内存。如果我们预计对象很少或对象很小,我们应该考虑使用栈实现。如果我们预计很少访问对象,将它们放在栈上可能会遇到更好的缓存性能,但对于频繁访问,两种实现都应该同样有效。就像很多其他选择一样,这些决策总是通过实际测量性能来做出的。对于我们的内存池,我们将使用 std::vector 和堆分配,同时注意它不是线程安全的。

我们还需要一个变量来跟踪哪些块是空闲的或正在使用的。最后,我们还需要一个变量来跟踪下一个空闲块的位置,以便快速处理分配请求。这里需要注意的一个重要事项是我们有两个选择:

  • 我们使用两个向量——一个用于跟踪对象,另一个用于跟踪空闲或空标记。这种解决方案在以下图中展示;请注意,在这个例子中,我们假设这两个向量位于非常不同的内存位置。我们试图说明的是,访问空闲或空标记和对象本身可能会引起缓存未命中,因为它们相距很远。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_4.1_B19434.jpg

图 4.1 – 使用两个向量跟踪对象并显示哪些索引是空闲或正在使用的内存池实现

  • 我们维护一个结构体(一个结构体、一个类或原始对象)的单个向量,每个结构体存储对象和变量来表示空闲或空标志。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/bd-lwltc-app-cpp/img/Figure_4.2_B19434.jpg

图 4.2 – 使用单个向量跟踪对象并查看它是否空闲或正在使用的内存池实现

从缓存性能的角度来看,第二个选择更好,因为访问紧接在对象之后放置的对象和空闲标记,比访问两个可能相距甚远的向量中的不同位置要好。这也是因为在几乎所有使用模式中,如果我们访问对象,我们也会访问空闲标记,反之亦然:

#pragma once
#include <cstdint>
#include <vector>
#include <string>
#include "macros.h"
namespace Common {
  template<typename T>
  class MemPool final {
private:
  struct ObjectBlock {
    T object_;
    bool is_free_ = true;
  };
  std::vector<ObjectBlock> store_;
  size_t next_free_index_ = 0;
};

接下来,我们需要看看如何在构造函数中初始化这个内存池,以及一些构造和赋值任务的样板代码。

初始化内存池

初始化我们的内存池相当简单——我们只需接受一个参数,指定我们的内存池的初始大小,并将向量初始化得足够大,以容纳这么多同时分配的对象。在我们的设计中,我们不会添加功能来调整内存池的大小超过其初始大小,但如果需要,这是一个相对简单的扩展来添加。请注意,这个初始向量初始化是内存池唯一一次动态分配内存的时间,因此内存池应该在关键路径执行开始之前创建。这里有一点需要注意,我们添加了一个断言来确保类型为 T 的实际对象是 ObjectBlock 结构中的第一个;我们将在 处理 释放 部分看到这个要求的原因:

public:
  explicit MemPool(std::size_t num_elems) :
      store_(num_elems, {T(), true}) /* pre-allocation of
        vector storage. */ {
    ASSERT(reinterpret_cast<const ObjectBlock *>
      (&(store_[0].object_)) == &(store_[0]), "T object
        should be first member of ObjectBlock.");
  }

现在是一些样板代码——我们将删除默认构造函数、拷贝构造函数和移动构造函数方法。我们也会对拷贝赋值运算符和移动赋值运算符做同样处理。我们这样做是为了防止这些方法在没有我们意识的情况下被意外调用。这也是我们使构造函数显式化的原因——以禁止我们不期望的隐式转换:

  MemPool() = delete;
  MemPool(const MemPool&) = delete;
  MemPool(const MemPool&&) = delete;
  MemPool& operator=(const MemPool&) = delete;
  MemPool& operator=(const MemPool&&) = delete;

现在,让我们继续编写代码,通过提供 T-类型模板参数的空闲对象来处理分配请求。

处理新的分配请求

处理分配请求是一个简单的任务,即在我们的内存池存储中找到一个空闲的块,我们可以很容易地使用 next_free_index_ 跟踪器来完成这个任务。然后,我们更新该块的 is_free_ 标记,使用 placement new 初始化类型为 T 的对象块,然后更新 next_free_index_ 以指向下一个可用的空闲块。

注意两点——第一点是,我们使用 placement new 返回类型为 T 的对象,而不是与 T 大小相同的内存块。这并不是绝对必要的,如果内存池的使用者希望负责从我们返回的内存块中构建对象,则可以将其删除。在大多数编译器的实现中,placement new 可能会添加一个额外的 if 检查,以确认提供给它的内存块不是空的。

第二件事,这更多的是我们根据应用程序进行的设计选择,那就是我们调用 updateNextFreeIndex() 来更新 next_free_index_ 指向下一个可用的空闲块,这可以通过除了这里提供的方式以外的不同方式实现。要回答哪种实现是最佳的,那就是它 取决于 并需要在实践中进行测量。现在,让我们首先看看 allocate() 方法,在这里,我们再次使用变长模板参数来允许将任意参数转发到 T 的构造函数。请注意,在这里我们使用 placement new 操作符从内存块中构造具有给定参数的 T 类型的对象。记住,new 是一个可以如果需要被覆盖的操作符,而 placement new 操作符跳过了分配内存的步骤,而是使用提供的内存块:

    template<typename... Args>
    T *allocate(Args... args) noexcept {
      auto obj_block = &(store_[next_free_index_]);
      ASSERT(obj_block->is_free_, "Expected free
        ObjectBlock at index:" + std::to_string
          (next_free_index_));
      T *ret = &(obj_block->object_);
      ret = new(ret) T(args...); // placement new.
      obj_block->is_free_ = false;
      updateNextFreeIndex();
      return ret;
    }

让我们来看看 updateNextFreeIndex() 方法。这里有两个需要注意的地方——首先,我们有一个分支用于处理索引绕到末尾的情况。虽然这在这里添加了一个 if 条件,但有了 UNLIKELY() 规范和我们对硬件分支预测器的预期,即总是预测该分支不会被取,这不应该以有意义的方式损害我们的性能。当然,如果我们真的想的话,我们可以将循环分成两个循环并移除那个 if 条件——也就是说,第一个循环一直循环到 next_free_index_ == store_.size(),第二个循环从 0 开始:

其次,我们添加了一个检查来检测内存池完全满的情况,并在这种情况下失败。显然,在实践中有更好的处理方式,不需要失败,但为了简洁和保持在本书的范围内,我们现在将只在这种情况下失败:

  private:
    auto updateNextFreeIndex() noexcept {
      const auto initial_free_index = next_free_index_;
      while (!store_[next_free_index_].is_free_) {
        ++next_free_index_;
        if (UNLIKELY(next_free_index_ == store_.size())) {
          // hardware branch predictor should almost always
              predict this to be false any ways.
          next_free_index_ = 0;
        }
        if (UNLIKELY(initial_free_index ==
          next_free_index_)) {
          ASSERT(initial_free_index != next_free_index_,
            "Memory Pool out of space.");
        }
      }
    }

下一节将处理处理释放或返回类型为 T 的对象回内存池以回收它们作为空闲资源的情况。

处理释放

释放是一个简单的问题,就是找到我们内部 store_ 中的正确 ObjectBlock,它与正在释放的 T 对象相对应,并将该块的 is_free_ 标记设置为 true。在这里,我们使用 reinterpret_castT* 转换为 ObjectBlock*,这是可以做的,因为对象 TObjectBlock 的第一个成员。这应该现在解释了我们在 初始化内存池 部分中添加的断言。我们也在这里添加了一个断言,以确保用户尝试释放的元素属于这个内存池。当然,可以更优雅地处理这样的错误情况,但为了简洁和保持讨论在本书的范围内,我们将把这个留给你:

    auto deallocate(const T *elem) noexcept {
      const auto elem_index = (reinterpret_cast<const
        ObjectBlock *>(elem) - &store_[0]);
      ASSERT(elem_index >= 0 && static_cast<size_t>
        (elem_index) < store_.size(), "Element being
          deallocated does not belong to this Memory
            pool.");
      ASSERT(!store_[elem_index].is_free_, "Expected in-use
        ObjectBlock at index:" + std::to_string
          (elem_index));
      store_[elem_index].is_free_ = true;
    }

这就结束了我们对内存池的设计和实现。让我们来看一个简单的例子。

使用示例使用内存池

让我们看看我们刚刚创建的内存池的一个简单且易于理解的示例。此代码位于Chapter4/mem_pool_example.cpp文件中,可以使用之前提到的CMake文件构建。它创建了一个原始double类型的内存池和另一个自定义MyStruct类型的内存池。然后,它从这个内存池中分配和释放一些元素,并打印出值和内存位置:

#include "mem_pool.h"
struct MyStruct {
  int d_[3];
};
int main(int, char **) {
  using namespace Common;
  MemPool<double> prim_pool(50);
  MemPool<MyStruct> struct_pool(50);
  for(auto i = 0; i < 50; ++i) {
    auto p_ret = prim_pool.allocate(i);
    auto s_ret = struct_pool.allocate(MyStruct{i, i+1,
      i+2});
    std::cout << "prim elem:" << *p_ret << " allocated at:"
      << p_ret << std::endl;
    std::cout << "struct elem:" << s_ret->d_[0] << "," <<
      s_ret->d_[1] << "," << s_ret->d_[2] << " allocated
        at:" << s_ret << std::endl;
    if(i % 5 == 0) {
      std::cout << "deallocating prim elem:" << *p_ret << "
        from:" << p_ret << std::endl;
      std::cout << "deallocating struct elem:" << s_ret
        ->d_[0] << "," << s_ret->d_[1] << "," << s_ret->
           d_[2] << " from:" << s_ret << std::endl;
      prim_pool.deallocate(p_ret);
      struct_pool.deallocate(s_ret);
    }
  }
  return 0;
}

使用以下命令运行此示例应产生与此处所示类似的输出:

(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter4$ ./cmake-build-release/mem_pool_example
prim elem:0 allocated at:0x5641b4d1beb0
struct elem:0,1,2 allocated at:0x5641b4d1c220
deallocating prim elem:0 from:0x5641b4d1beb0
deallocating struct elem:0,1,2 from:0x5641b4d1c220
prim elem:1 allocated at:0x5641b4d1bec0
struct elem:1,2,3 allocated at:0x5641b4d1c230
prim elem:2 allocated at:0x5641b4d1bed0
...

在下一节中,我们将构建一个非常类似的功能——无锁队列。

使用无锁队列传输数据

多线程低延迟应用的 C++线程部分,我们暗示了拥有多个线程的一个可能应用是设置一个流水线系统。在这里,一个组件线程执行部分处理并将结果转发到流水线的下一阶段进行进一步处理。我们将在我们的电子交易系统中使用这种设计,但关于这一点,后面还会详细介绍。

线程和进程之间的通信

在进程和/或线程之间传输数据时有很多选项。进程间通信IPC),例如互斥锁、信号量、信号、内存映射文件和共享内存,可以用于这些目的。当存在对共享数据的并发访问并且重要要求是避免数据损坏时,这也会变得复杂。另一个重要要求是确保读取器和写入者对共享数据有一致的视图。要从一个线程传输信息到另一个线程(或从一个进程传输到另一个进程),最佳方式是通过一个两个线程都可以访问的数据队列。在并发访问环境中构建数据队列并使用锁来同步是一个选项。由于这种设计具有并发访问的性质,因此必须使用锁或互斥锁或类似的东西来防止错误。然而,锁和互斥锁非常低效,会导致上下文切换,这会极大地降低关键线程的性能。因此,我们需要一个无锁队列来促进线程之间的通信,而不需要锁和上下文切换的开销。请注意,我们在这里构建的无锁队列仅用于单生产者单消费者SPSC)——也就是说,只有一个线程向队列写入,只有一个线程从队列中消费。更复杂的无锁队列用例将需要额外的复杂性,这超出了本书的范围。

设计无锁队列存储

对于无锁队列,我们再次有选择在栈上或堆上分配存储的选项。在这里,我们再次选择std::vector并在堆上分配内存。此外,我们创建两个std::atomic变量——一个称为next_write_index_——来跟踪下一个写入队列的索引。

第二个变量,称为next_read_index_,用于跟踪队列中下一个未读元素的位置。由于我们假设只有一个线程向队列写入,只有一个线程从队列读取,因此实现相对简单。现在,让我们首先设计和实现无锁队列数据结构的内部存储。本节讨论的源代码可以在本书 GitHub 仓库的Chapter4/lf_queue.h源文件中找到。

关于std::atomic的简要说明——它是一种现代 C++构造,允许线程安全的操作。它让我们可以在不使用锁或互斥锁的情况下读取、更新和写入共享变量,并且在保持操作顺序的同时完成这些操作。关于std::atomic和内存排序的详细讨论超出了本书的范围,但您可以在我们另一本书《开发高频交易系统》中找到参考资料。

首先,让我们在以下代码片段中定义这个类的数据成员:

#pragma once
#include <iostream>
#include <vector>
#include <atomic>
namespace Common {
  template<typename T>
  class LFQueue final {
  private:
    std::vector<T> store_;
    std::atomic<size_t> next_write_index_ = {0};
    std::atomic<size_t> next_read_index_ = {0};
    std::atomic<size_t> num_elements_ = {0};
  };
}

这个类包含一个std::vector对象store_,它是一个T模板对象类型的实际数据队列。一个std::atomic<size_t> next_write_index_变量跟踪这个向量中的索引,下一个元素将被写入的位置。同样,一个std::atomic<size_t> next_read_index_变量跟踪这个向量中的索引,下一个要读取或消费的元素可用的位置。这些变量需要是std::atomic<>类型,因为读写操作是从不同的线程执行的。

初始化无锁队列

我们的无锁队列构造函数与之前看到的内存池构造函数非常相似。我们在构造函数中动态分配整个向量的内存。我们可以扩展这个设计,允许无锁队列在运行时调整大小,但到目前为止,我们将坚持使用固定大小的队列:

template<typename T>
class LFQueue final {
public:
  LFQueue(std::size_t num_elems) :
      store_(num_elems, T()) /* pre-allocation of vector
        storage. */ {
  }

我们在这里有关于默认构造函数、拷贝构造函数和移动构造函数以及赋值运算符的类似样板代码。这些代码被删除的原因是我们之前讨论过的:

  LFQueue() = delete;
  LFQueue(const LFQueue&) = delete;
  LFQueue(const LFQueue&&) = delete;
  LFQueue& operator=(const LFQueue&) = delete;
  LFQueue& operator=(const LFQueue&&) = delete;

接下来,我们将查看添加新元素到队列的代码。

向队列中添加元素

向队列中添加新元素的代码分为两部分;第一部分,getNextToWriteTo(),返回一个指向下一个要写入新数据的元素的指针。第二部分,updateWriteIndex(),在元素被写入提供的槽位后,增加写索引next_write_index_。我们设计它是这样的,而不是只有一个write()函数,我们提供给用户一个指向元素的指针,如果对象相当大,那么不需要更新或覆盖所有内容。此外,这种设计使得处理竞争条件变得容易得多:

  auto getNextToWriteTo() noexcept {
    return &store_[next_write_index_];
  }
  auto updateWriteIndex() noexcept {
      next_write_index_ = (next_write_index_ + 1) %
        store_.size();
      num_elements_++;
  }

在下一节中,我们将使用一个非常类似的设计来消费队列中的元素。

从队列中消费元素

要从队列中消费元素,我们做的是向队列中添加元素的反操作。就像我们设计的那样,将write()分成两部分,我们将消费队列中的元素也分成两部分。我们有一个getNextToRead()方法,它返回要消费的下一个元素的指针,但不更新读取索引。如果没有任何元素要消费,此方法将返回nullptr。第二部分是updateReadIndex(),它在元素被消费后仅更新读取索引:

  auto getNextToRead() const noexcept -> const T * {
    return (next_read_index_ == next_write_index_) ?
      nullptr : &store_[next_read_index_];
  }
  auto updateReadIndex() noexcept {
      next_read_index_ = (next_read_index_ + 1) %
        store_.size();
      ASSERT(num_elements_ != 0, "Read an invalid element
        in:" + std::to_string(pthread_self()));
      num_elements_--;
  }

我们还定义了另一种简单的方法来返回队列中的元素数量:

    auto size() const noexcept {
      return num_elements_.load();
    }

这样,我们就完成了针对 SPSC 用例的无锁队列的设计和实现。让我们在下一小节中看看一个使用此组件的示例。

使用无锁队列

如何使用无锁数据队列的示例可以在Chapter4/lf_queue_example.cpp文件中找到,并按照之前提到的方式进行构建。此示例创建了一个消费者线程,并向它提供了一个无锁队列实例。然后,生产者生成并添加一些元素到该队列中,消费者线程检查队列并消费队列元素,直到队列为空。执行的两个线程——生产者和消费者——在生成一个元素和消费它之间等待很短的时间:

#include "thread_utils.h"
#include "lf_queue.h"
struct MyStruct {
  int d_[3];
};
using namespace Common;
auto consumeFunction(LFQueue<MyStruct>* lfq) {
  using namespace std::literals::chrono_literals;
  std::this_thread::sleep_for(5s);
  while(lfq->size()) {
    const auto d = lfq->getNextToRead();
    lfq->updateReadIndex();
    std::cout << "consumeFunction read elem:" << d->d_[0]
      << "," << d->d_[1] << "," << d->d_[2] << " lfq-size:"
        <<lfq->size() << std::endl;
    std::this_thread::sleep_for(1s);
  }
  std::cout << "consumeFunction exiting." << std::endl;
}
int main(int, char **) {
  LFQueue<MyStruct> lfq(20);
  auto ct = createAndStartThread(-1, "", consumeFunction,
    &lfq);
  for(auto i = 0; i < 50; ++i) {
    const MyStruct d{i, i * 10, i * 100};
    *(lfq.getNextToWriteTo()) = d;
    lfq.updateWriteIndex();
    std::cout << "main constructed elem:" << d.d_[0] << ","
      << d.d_[1] << "," << d.d_[2] << " lfq-size:" <<
        lfq.size() << std::endl;
    using namespace std::literals::chrono_literals;
    std::this_thread::sleep_for(1s);
  }
  ct->join();
  std::cout << "main exiting." << std::endl;
  return 0;
}

运行此示例程序的输出如下,其中仅包括生产者和消费者对无锁队列的写入和读取操作:

(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter4$ ./cmake-build-release/lf_queue_example
Set core affinity for  139710770276096 to -1
main constructed elem:0,0,0 lfq-size:1
main constructed elem:1,10,100 lfq-size:2
main constructed elem:2,20,200 lfq-size:3
main constructed elem:3,30,300 lfq-size:4
consumeFunction read elem:0,0,0 lfq-size:3
main constructed elem:4,40,400 lfq-size:4
consumeFunction read elem:1,10,100 lfq-size:3
main constructed elem:5,50,500 lfq-size:4
consumeFunction read elem:2,20,200 lfq-size:3
main constructed elem:6,60,600 lfq-size:4
consumeFunction read elem:3,30,300 lfq-size:3
main constructed elem:7,70,700 lfq-size:4
consumeFunction read elem:4,40,400 lfq-size:3
...

接下来,我们将使用我们刚刚构建的一些组件——线程和无锁队列——构建一个低延迟日志框架。

构建低延迟日志框架

现在,我们将使用之前几节中构建的一些组件构建一个低延迟日志框架。日志是任何应用程序的重要组成部分,无论是记录一般的应用行为、警告、错误,甚至是性能统计信息。然而,许多重要的日志输出实际上来自性能关键组件,这些组件位于关键路径上。

一种简单的日志方法是将输出到屏幕,而一种稍微好一点的方法是将日志保存到一个或多个日志文件中。然而,这里我们有一些问题——磁盘 I/O 非常慢且不可预测,字符串操作和格式化本身也很慢。出于这些原因,在性能关键线程上执行这些操作是一个糟糕的想法,因此在本节中,我们将构建一个解决方案来减轻这些缺点,同时保留按需输出日志的能力。

在我们跳入日志类之前,我们将定义一些实用方法来获取当前系统时间以及将它们转换为字符串以供日志记录使用。

设计时间相关的实用方法

我们将定义一个简单的实用函数来获取当前系统时间以及一些常数,以便于不同单位之间的转换。时间实用函数的代码可以在本书 GitHub 仓库的Chapter4/time_utils.h中找到:

#pragma once
#include <chrono>
#include <ctime>
namespace Common {
  typedef int64_t Nanos;
  constexpr Nanos NANOS_TO_MICROS = 1000;
  constexpr Nanos MICROS_TO_MILLIS = 1000;
  constexpr Nanos MILLIS_TO_SECS = 1000;
  constexpr Nanos NANOS_TO_MILLIS = NANO_TO_MICROS *
    MICROS_TO_MILLIS;
  constexpr Nanos NANOS_TO_SECS = NANOS_TO_MILLIS *
    MILLIS_TO_SECS;
  inline auto getCurrentNanos() noexcept {
    return std::chrono::duration_cast
      <std::chrono::nanoseconds>(std::chrono::
        system_clock::now().time_since_epoch()).count();
  }
  inline auto& getCurrentTimeStr(std::string* time_str) {
    const auto time = std::chrono::system_clock::
      to_time_t(std::chrono::system_clock::now());
    time_str->assign(ctime(&time));
    if(!time_str->empty())
      time_str->at(time_str->length()-1) = '\0';
    return *time_str;
  }
}

现在,让我们设计日志类本身,从下一节开始。

设计低延迟日志

为了构建这个低延迟日志框架,我们将创建一个后台日志线程,其唯一任务是向磁盘上的日志文件写入日志行。这里的想法是将慢速磁盘 I/O 操作以及字符串格式化操作从主性能关键线程卸载到这个后台线程。有一点需要理解的是,将日志写入磁盘不必是瞬时的——也就是说,大多数系统可以容忍事件发生和相关信息被写入磁盘之间的某些延迟。我们将使用本章第一部分创建的多线程函数来创建这个日志线程,并分配给它的任务是写入日志文件。

为了从主性能关键线程将需要记录的数据发布到这个日志线程,我们将使用我们在上一节中创建的无锁数据队列。日志的工作方式是,性能敏感的线程不会直接将信息写入磁盘,而是简单地将信息推送到这个无锁队列。正如我们之前讨论的,日志线程将从这个队列的另一端消费并写入磁盘。这个组件的源代码可以在本书 GitHub 仓库的Chapter4目录下的logging.hlogging.cpp文件中找到。

定义一些日志结构

在我们开始设计日志本身之前,我们将首先定义将跨无锁队列从性能敏感线程传输到日志线程的基本信息块。在这个设计中,我们简单地创建一个能够保存我们将要记录的不同类型的结构。首先,让我们定义一个枚举,它指定了指向的结构所指向的值的类型;我们将把这个枚举称为LogType

#pragma once
#include <string>
#include <fstream>
#include <cstdio>
#include "types.h"
#include "macros.h"
#include "lf_queue.h"
#include "thread_utils.h"
#include "time_utils.h"
namespace Common {
constexpr size_t LOG_QUEUE_SIZE = 8 * 1024 * 1024;
enum class LogType : int8_t {
  CHAR = 0,
  INTEGER = 1, LONG_INTEGER = 2, LONG_LONG_INTEGER = 3,
  UNSIGNED_INTEGER = 4, UNSIGNED_LONG_INTEGER = 5,
  UNSIGNED_LONG_LONG_INTEGER = 6,
  FLOAT = 7, DOUBLE = 8
};
}

现在,我们可以定义一个LogElement结构,它将保存要推送到队列的下一个值,并最终从日志线程将日志写入文件。这个结构包含一个类型为LogType的成员,用于指定它持有的值的类型。这个结构中的另一个成员是不同可能的基本类型的联合。这本来是使用std::variant的好地方,因为它是现代 C++中内置了LogType type_(指定联合包含的内容)的类型安全的联合。然而,std::variant的运行时性能较差;因此,我们选择在这里继续使用旧式的联合:

struct LogElement {
  LogType type_ = LogType::CHAR;
  union {
    char c;
    int i; long l; long long ll;
    unsigned u; unsigned long ul; unsigned long long ull;
    float f; double d;
  } u_;
};

在定义了LogElement结构之后,让我们继续定义日志类中的数据。

初始化日志数据结构

我们的日志记录器将包含几个重要的对象。首先,一个std::ofstream文件对象是数据写入的日志文件。其次,一个LFQueue<LogElement>对象是用于从主线程向日志线程传输数据的无锁队列。接下来,std::atomic<bool>在需要时停止日志线程的处理,以及一个std::thread对象,即日志线程。最后,std::string是文件名,我们仅提供此信息:

class Logger final {
private:
  const std::string file_name_;
  std::ofstream file_;
  LFQueue<LogElement> queue_;
  std::atomic<bool> running_ = {true};
  std::thread *logger_thread_ = nullptr;
};

现在,让我们继续构建我们的日志记录器、日志记录器队列和日志记录器线程。

创建日志记录器和启动日志记录线程

在日志记录器构造函数中,我们将使用适当的大小初始化日志记录器队列,保存file_name_用于信息目的,打开输出日志文件对象,并创建和启动日志记录线程。请注意,如果我们无法打开输出日志文件或无法创建和启动日志记录线程,我们将退出。正如我们之前提到的,显然有更多宽容和优雅的方式来处理这些失败,但我们将不会在本书中探讨这些方法。请注意,在这里我们将createAndStartThread()中的core_id参数设置为-1,以当前不设置线程的亲和性。一旦我们理解了整个生态系统的设计,我们将在本书的后面部分重新审视如何将每个线程分配给 CPU 核心的设计,并将对其进行性能调优:

  explicit Logger(const std::string &file_name)
      : file_name_(file_name), queue_(LOG_QUEUE_SIZE) {
    file_.open(file_name);
    ASSERT(file_.is_open(), "Could not open log file:" +
      file_name);
    logger_thread_ = createAndStartThread(-1,
      "Common/Logger", [this]() { flushQueue(); });
    ASSERT(logger_thread_ != nullptr, "Failed to start
      Logger thread.");
  }

我们传递一个名为flushQueue()的方法,这个日志记录线程将运行。正如其名所示,并且与我们之前讨论的一致,这个线程将清空日志数据的队列并将数据写入文件;我们将在下一节中查看。flushQueue()的实现很简单。如果原子的running_布尔值为true,它将在循环中运行,执行以下步骤:它消费任何推送到无锁队列queue_的新元素,并将它们写入我们创建的file_对象。它解包队列中的LogElement对象,并根据类型将联合的正确成员写入文件。当无锁队列为空时,线程将休眠一毫秒,然后再次检查是否有新的元素要写入磁盘:

  auto flushQueue() noexcept {
    while (running_) {
      for (auto next = queue_.getNextToRead();
        queue_.size() && next; next = queue_
          .getNextToRead()) {
        switch (next->type_) {
          case LogType::CHAR: file_ << next->u_.c; break;
          case LogType::INTEGER: file_ << next->u_.i; break;
          case LogType::LONG_INTEGER: file_ << next->u_.l; break;
          case LogType::LONG_LONG_INTEGER: file_ << next->
             u_.ll; break;
          case LogType::UNSIGNED_INTEGER: file_ << next->
             u_.u; break;
          case LogType::UNSIGNED_LONG_INTEGER: file_ <<
             next->u_.ul; break;
          case LogType::UNSIGNED_LONG_LONG_INTEGER: file_
              << next->u_.ull; break;
          case LogType::FLOAT: file_ << next->u_.f; break;
          case LogType::DOUBLE: file_ << next->u_.d; break;
        }
        queue_.updateReadIndex();
        next = queue_.getNextToRead();
      }
      using namespace std::literals::chrono_literals;
      std::this_thread::sleep_for(1ms);
    }
  }

我们日志记录器类的析构函数很重要,因此让我们看看它需要执行哪些清理任务。首先,析构函数等待日志线程消耗无锁队列,因此它等待直到队列为空。一旦队列为空,它将running_标志设置为false,以便日志线程可以完成其执行。为了等待日志线程完成执行——即从flushQueue()方法返回,它调用日志线程上的std::thread::join()方法。最后,它关闭file_对象,将任何缓冲数据写入磁盘,然后我们完成:

  ~Logger() {
    std::cerr << "Flushing and closing Logger for " <<
      file_name_ << std::endl;
    while (queue_.size()) {
      using namespace std::literals::chrono_literals;
      std::this_thread::sleep_for(1s);
    }
    running_ = false;
    logger_thread_->join();
    file_.close();
  }

最后,我们将添加之前多次讨论的关于构造函数和赋值运算符的常规样板代码:

  Logger() = delete;
  Logger(const Logger &) = delete;
  Logger(const Logger &&) = delete;
  Logger &operator=(const Logger &) = delete;
  Logger &operator=(const Logger &&) = delete;

在本节中,我们看到了组件从队列中读取并写入磁盘的部分。在下一节中,我们将看到数据如何作为性能关键线程的日志过程的一部分添加到无锁队列中。

将数据推送到日志队列

要将数据推送到日志队列,我们将定义几个重载的 pushValue() 方法来处理不同类型的参数。每个方法都做同样的事情,即逐个将值推送到队列中。这里值得注意的一点是,对于我们将要讨论的内容,存在更有效的实现;然而,它们涉及额外的复杂性,我们为了简洁和限制本书的覆盖范围而省略了它们。当我们讨论它们时,我们将指出潜在的改进区域。

首先,我们创建一个 pushValue() 的变体来推送类型为 LogElement 的对象,它将从我们即将定义的其他 pushValue() 函数中被调用。它基本上写入无锁队列的下一个位置并增加写索引:

  auto pushValue(const LogElement &log_element) noexcept {
    *(queue_.getNextToWriteTo()) = log_element;
    queue_.updateWriteIndex();
  }

pushValue() 的下一个简单变体是针对单个字符值,它基本上只是创建一个类型为 LogElement 的对象,调用我们刚才讨论的 pushValue() 方法,并将 LogElement 对象传递给它:

  auto pushValue(const char value) noexcept {
    pushValue(LogElement{LogType::CHAR, {.c = value}});
  }

现在,我们为 const char* 创建 pushValue() 的一个变体——即字符集合。这个实现逐个遍历字符并调用我们之前实现的 pushValue()。这是一个潜在的改进区域,我们可以使用单个 memcpy() 来复制数组中的所有字符,而不是逐个遍历它们。我们还需要处理队列末尾索引环绕的一些边缘情况,但我们将把它留给您进一步探索:

  auto pushValue(const char *value) noexcept {
    while (*value) {
      pushValue(*value);
      ++value;
    }
  }

接下来,我们为 const std::string& 创建 pushValue() 的另一个变体,这相当直接,并使用我们之前创建的 pushValue()

  auto pushValue(const std::string &value) noexcept {
    pushValue(value.c_str());
  }

最后,我们需要为不同的原始类型添加 pushValue() 的变体。它们与我们为单个字符值构建的非常相似,如下所示:

  auto pushValue(const int value) noexcept {
    pushValue(LogElement{LogType::INTEGER, {.i = value}});
  }
  auto pushValue(const long value) noexcept {
    pushValue(LogElement{LogType::LONG_INTEGER, {.l =
      value}});
  }
  auto pushValue(const long long value) noexcept {
    pushValue(LogElement{LogType::LONG_LONG_INTEGER, {.ll =
      value}});
  }
  auto pushValue(const unsigned value) noexcept {
    pushValue(LogElement{LogType::UNSIGNED_INTEGER, {.u =
      value}});
  }
  auto pushValue(const unsigned long value) noexcept {
    pushValue(LogElement{LogType::UNSIGNED_LONG_INTEGER,
      {.ul = value}});
  }
  auto pushValue(const unsigned long long value) noexcept {
    pushValue(LogElement{LogType::UNSIGNED_LONG_LONG_INTEGER,
  {.ull = value}});
  }
  auto pushValue(const float value) noexcept {
    pushValue(LogElement{LogType::FLOAT, {.f = value}});
  }
  auto pushValue(const double value) noexcept {
    pushValue(LogElement{LogType::DOUBLE, {.d = value}});
  }

到目前为止,我们已经实现了两个目标——将磁盘输出操作移动到后台日志线程,并将将原始值格式化为字符串格式的任务移动到后台线程。接下来,我们将添加性能敏感线程使用 pushValue() 方法将数据推送到无锁队列的功能。

添加一个有用且通用的日志函数

我们将定义一个log()方法,它与printf()函数非常相似,但稍微简单一些。它之所以简单,是因为格式说明符只是一个用于替换所有不同原始类型的%字符。此方法使用变长模板参数来支持任意数量和类型的参数。它寻找%字符,然后在其位置替换下一个值,调用我们在上一节中定义的其中一个重载的pushValue()方法。之后,它递归地调用自身,但这次,值指向模板参数包中的第一个参数:

  template<typename T, typename... A>
  auto log(const char *s, const T &value, A... args)
  noexcept {
    while (*s) {
      if (*s == '%') {
        if (UNLIKELY(*(s + 1) == '%')) {
          ++s;
        } else {
          pushValue(value);
          log(s + 1, args...);
          return;
        }
      }
      pushValue(*s++);
    }
    FATAL("extra arguments provided to log()");
  }

此方法应使用类似以下示例的方式进行调用:

int int_val = 10;
std::string str_val = "hello";
double dbl_val = 10.10;
log("Integer:% String:% Double:%",
  int_val, str_val, dbl_val);

我们在这里构建的log()方法无法处理没有传递参数给它的情况。因此,我们需要一个额外的重载log()方法来处理将简单的const char *传递给它的情况。我们在这里添加了一个额外的检查,以确保没有将额外的参数传递给此方法或上述log()方法:

  auto log(const char *s) noexcept {
    while (*s) {
      if (*s == '%') {
        if (UNLIKELY(*(s + 1) == '%')) {
          ++s;
        } else {
          FATAL("missing arguments to log()");
        }
      }
      pushValue(*s++);
    }
  }

这完成了我们低延迟日志框架的设计和实现。使用我们的多线程例程和无锁队列,我们创建了一个框架,其中性能关键线程将字符串格式化和磁盘文件写入任务卸载到后台记录器线程。现在,让我们看看如何创建、配置和使用我们刚刚创建的记录器的一个好例子。

使用示例学习如何使用记录器

我们将提供一个基本示例,创建一个Logger对象,并将其配置为将日志写入logging_example.log文件。然后,通过记录器将该文件中记录了几种不同的数据类型。此示例的源代码可以在Chapter4/logging_example.cpp文件中找到:

#include "logging.h"
int main(int, char **) {
  using namespace Common;
  char c = 'd';
  int i = 3;
  unsigned long ul = 65;
  float f = 3.4;
  double d = 34.56;
  const char* s = "test C-string";
  std::string ss = "test string";
  Logger logger("logging_example.log");
  logger.log("Logging a char:% an int:% and an
    unsigned:%\n", c, i, ul);
  logger.log("Logging a float:% and a double:%\n", f, d);
  logger.log("Logging a C-string:'%'\n", s);
  logger.log("Logging a string:'%'\n", ss);
  return 0;
}

运行此代码的输出可以通过查看当前目录下logging_example.log文件的 内容来查看,如下所示:

(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter4$ cat logging_example.log
Logging a char:d an int:3 and an unsigned:65
Logging a float:3.4 and a double:34.56
Logging a C-string:'test C-string'
Logging a string:'test string'

在此框架中,调用log()方法产生的唯一开销是遍历字符串中的字符并将字符和值推送到无锁队列的开销。现在,我们将讨论网络编程和套接字的使用,我们将在以后使用它们来促进不同进程之间的通信。

使用套接字进行 C++网络编程

在本节的最后,我们将构建我们基本构建块中的最后一个——一个使用 Unix 套接字进行网络编程的框架。我们将使用这个框架来构建一个监听传入 TCP 连接的服务器和一个能够与这样的服务器建立 TCP 连接的客户端。我们还将使用这个框架来发布 UDP 流量并从多播流中消费。请注意,为了限制讨论的范围,我们只将讨论 Unix 套接字,而不涉及任何内核绕过能力。使用内核绕过并利用支持它的网络接口卡NICs)提供的内核绕过 API 超出了本书的范围。另外,我们期望你有一些基本的网络套接字知识或经验,理想情况下,使用 C++编程网络套接字。

构建基本的套接字 API

在这里我们的目标是创建一个机制来创建网络套接字,并用正确的参数初始化它。这个方法将被用来创建监听器、接收器和发送器套接字,以通过 UDP 和 TCP 协议进行通信。在我们深入到创建套接字本身的例程之前,让我们首先定义一些我们将要在最终方法中使用到的实用方法。所有基本套接字 API 的代码都位于 GitHub 仓库中这本书的Chapter4/socket_utils.cpp文件中。注意,在我们调查功能实现之前,我们将展示Chapter4/socket_utils.h头文件,它包含了我们将要实现的全部include文件和函数签名:

#pragma once
#include <iostream>
#include <string>
#include <unordered_set>
#include <sys/epoll.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <sys/socket.h>
#include <fcntl.h>
#include "macros.h"
#include "logging.h"
namespace Common {
  constexpr int MaxTCPServerBacklog = 1024;
  auto getIfaceIP(const std::string &iface) -> std::string;
  auto setNonBlocking(int fd) -> bool;
  auto setNoDelay(int fd) -> bool;
  auto setSOTimestamp(int fd) -> bool;
  auto wouldBlock() -> bool;
  auto setMcastTTL(int fd, int ttl) -> bool;
  auto setTTL(int fd, int ttl) -> bool;
  auto join(int fd, const std::string &ip, const
    std::string &iface, int port) -> bool;
  auto createSocket(Logger &logger, const std::string
    &t_ip, const std::string &iface, int port, bool is_udp,
       bool is_blocking, bool is_listening, int ttl, bool
         needs_so_timestamp) -> int;
}

现在,让我们从这些方法的实现开始,从下一节开始。

获取接口信息

我们需要构建的第一个实用方法是转换以字符串形式表示的网络接口,使其能够被我们将要使用的底层套接字例程使用。我们称之为getIfaceIP(),当我们指定要监听、连接或通过的网络接口时,我们将需要这个方法。我们使用getifaddrs()方法来获取所有接口的信息,它返回一个包含这些信息的链表结构,ifaddrs。最后,它使用getnameinfo()信息来获取其余方法中要使用的最终名称:

#include "socket_utils.h"
namespace Common {
  auto getIfaceIP(const std::string &iface) -> std::string {
    char buf[NI_MAXHOST] = {'\0'};
    ifaddrs *ifaddr = nullptr;
    if (getifaddrs(&ifaddr) != -1) {
      for (ifaddrs *ifa = ifaddr; ifa; ifa = ifa->ifa_next) {
        if (ifa->ifa_addr && ifa->ifa_addr->sa_family ==
          AF_INET && iface == ifa->ifa_name) {
          getnameinfo(ifa->ifa_addr, sizeof(sockaddr_in),
            buf, sizeof(buf), NULL, 0, NI_NUMERICHOST);
          break;
        }
      }
      freeifaddrs(ifaddr);
    }
    return buf;
  }
}

例如,在我的系统中,以下网络接口如下所示:

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
wlp4s0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.10.104  netmask 255.255.255.0  broadcast 192.168.10.255

getIfaceIP ("lo") 返回 127.0.0.1,而 getIfaceIP ("wlp4s0") 返回 192.168.10.104

接下来,我们将继续到下一个重要的实用函数,这个函数会影响需要网络套接字的应用程序的性能。

将套接字设置为非阻塞模式

我们将要构建的下一个实用函数是设置套接字为非阻塞的。一个阻塞套接字是指在其上进行的读取调用将无限期地阻塞,直到有数据可用。由于许多原因,这通常不是极低延迟应用的理想设计。主要原因之一是阻塞套接字是通过用户空间和内核空间之间的切换实现的,这非常低效。当套接字需要被唤醒或解除阻塞时,需要从内核空间到用户空间进行中断、中断处理程序等操作来处理事件。此外,被阻塞的性能关键线程将产生上下文切换开销,正如已经讨论过的,这对性能有害。

以下setNonBlocking()方法使用fcntl()例程与F_GETFL来首先检查套接字文件描述符,看它是否已经是非阻塞的。如果不是非阻塞的,那么它将再次使用fcntl()例程,但这次使用F_SETFL来添加非阻塞位,该位设置在文件描述符上。如果套接字文件描述符已经是非阻塞的或者该方法能够成功将其设置为非阻塞,则返回true

auto setNonBlocking(int fd) -> bool {
  const auto flags = fcntl(fd, F_GETFL, 0);
  if (flags == -1)
    return false;
  if (flags & O_NONBLOCK)
    return true;
  return (fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1);
}

接下来,我们将通过禁用Nagle 算法来启用 TCP 套接字的另一个重要优化。

禁用 Nagle 算法

不深入太多细节,Nagle 算法用于改善 TCP 套接字的缓冲区,并防止与在 TCP 套接字上保证可靠性相关的开销。这是通过延迟一些数据包而不是立即发送它们来实现的。对于许多应用来说,这是一个很好的特性,但对于低延迟应用,禁发送数据包的延迟是必不可少的。

幸运的是,禁用 Nagle 算法只需通过设置套接字选项TCP_NODELAY,使用setsockopt()例程即可,如下所示:

auto setNoDelay(int fd) -> bool {
  int one = 1;
  return (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY,
    reinterpret_cast<void *>(&one), sizeof(one)) != -1);
}

在我们最终实现创建套接字的功能之前,我们将在下一节定义几个额外的例程来设置可选的和/或附加功能。

设置附加参数

首先,我们将定义一个简单的方法来检查套接字操作是否会阻塞。这是一个简单的检查全局errno错误变量与两个可能值EWOULDBLOCKEINPROGRESS的对比:

auto wouldBlock() -> bool {
  return (errno == EWOULDBLOCK || errno == EINPROGRESS);
}

接下来,我们定义一个方法来设置非多播套接字的IP_TTL套接字选项和多播套接字的IP_MULTICAST_TTL,使用setsockopt()例程,如下所示:

auto setTTL(int fd, int ttl) -> bool {
  return (setsockopt(fd, IPPROTO_IP, IP_TTL,
    reinterpret_cast<void *>(&ttl), sizeof(ttl)) != -1);
}
auto setMcastTTL(int fd, int mcast_ttl) noexcept -> bool {
  return (setsockopt(fd, IPPROTO_IP, IP_MULTICAST_TTL,
    reinterpret_cast<void *>(&mcast_ttl), sizeof
      (mcast_ttl)) != -1);
}

最后,我们定义最后一个方法,它将允许我们在网络数据包击中网络套接字时生成软件时间戳。注意,如果我们有支持硬件时间戳的特殊硬件(如 NICs),我们将在这里启用并使用它们。然而,为了限制本书的范围,我们将假设您没有特殊硬件,只能使用setsockopt()方法设置SO_TIMESTAMP选项来启用软件时间戳:

  auto setSOTimestamp(int fd) -> bool {
    int one = 1;
    return (setsockopt(fd, SOL_SOCKET, SO_TIMESTAMP,
      reinterpret_cast<void *>(&one), sizeof(one)) != -1);
  }

这完成了我们对套接字相关实用函数的讨论,现在我们可以继续最终实现创建通用 Unix 套接字的功能。

创建套接字

createSocket()方法的第一个部分,我们首先检查是否提供了一个非空的t_ip,它表示接口 IP,例如192.168.10.104,如果没有,我们将使用之前构建的getIfaceIP()方法从提供的接口名称中获取一个。我们还需要根据传入的参数填充addrinfo结构,因为我们需要将其传递给getaddrinfo()例程,该例程将返回一个链表,最终将用于构建实际的套接字。注意,在createSocket()方法中,每次我们无法创建套接字或用正确的参数初始化它时,我们返回-1 以表示失败:

  auto createSocket(Logger &logger, const std::string
    &t_ip, const std::string &iface, int port,
                    bool is_udp, bool is_blocking, bool
                      is_listening, int ttl, bool
                        needs_so_timestamp) -> int {
    std::string time_str;
    const auto ip = t_ip.empty() ? getIfaceIP(iface) :
      t_ip;
    logger.log("%:% %() % ip:% iface:% port:% is_udp:%
      is_blocking:% is_listening:% ttl:% SO_time:%\n",
        __FILE__, __LINE__, __FUNCTION__,
               Common::getCurrentTimeStr(&time_str), ip,
                 iface, port, is_udp, is_blocking,
                   is_listening, ttl, needs_so_timestamp);
    addrinfo hints{};
    hints.ai_family = AF_INET;
    hints.ai_socktype = is_udp ? SOCK_DGRAM : SOCK_STREAM;
    hints.ai_protocol = is_udp ? IPPROTO_UDP : IPPROTO_TCP;
    hints.ai_flags = is_listening ? AI_PASSIVE : 0;
    if (std::isdigit(ip.c_str()[0]))
      hints.ai_flags |= AI_NUMERICHOST;
    hints.ai_flags |= AI_NUMERICSERV;
    addrinfo *result = nullptr;
    const auto rc = getaddrinfo(ip.c_str(), std::
      to_string(port).c_str(), &hints, &result);
    if (rc) {
      logger.log("getaddrinfo() failed. error:% errno:%\n",
        gai_strerror(rc), strerror(errno));
      return -1;
    }

下一节将检查传递给createSocket()方法的参数,并使用我们之前构建的所有方法来设置所需的正确套接字参数。注意,我们使用getaddrinfo()返回的addrinfo *结果对象通过socket()例程创建套接字。

首先,我们实际调用创建套接字的功能:

  int fd = -1;
  int one = 1;
  for (addrinfo *rp = result; rp; rp = rp->ai_next) {
    fd = socket(rp->ai_family, rp->ai_socktype, rp
      ->ai_protocol);
    if (fd == -1) {
      logger.log("socket() failed. errno:%\n",
         strerror(errno));
      return -1;
    }

接下来,我们使用之前定义的方法将其设置为非阻塞模式并禁用 Nagle 算法:

    if (!is_blocking) {
      if (!setNonBlocking(fd)) {
        logger.log("setNonBlocking() failed. errno:%\n",
          strerror(errno));
        return -1;
      }
      if (!is_udp && !setNoDelay(fd)) {
        logger.log("setNoDelay() failed. errno:%\n",
          strerror(errno));
        return -1;
      }
    }

如果套接字不是监听套接字,接下来我们将套接字连接到目标地址:

    if (!is_listening && connect(fd, rp->ai_addr, rp
      ->ai_addrlen) == 1 && !wouldBlock()) {
      logger.log("connect() failed. errno:%\n",
        strerror(errno));
      return -1;
    }

然后,如果我们想创建一个监听传入连接的套接字,我们需要设置正确的参数并将套接字绑定到客户端尝试连接的特定地址。我们还需要为这种套接字配置调用listen()例程。注意,这里我们引用了一个MaxTCPServerBacklog参数,其定义如下:

constexpr int MaxTCPServerBacklog = 1024;

现在,让我们看看如何将套接字设置为监听套接字:

    if (is_listening && setsockopt(fd, SOL_SOCKET,
      SO_REUSEADDR, reinterpret_cast<const char *>(&one),
        sizeof(one)) == -1) {
      logger.log("setsockopt() SO_REUSEADDR failed.
        errno:%\n", strerror(errno));
      return -1;
    }
    if (is_listening && bind(fd, rp->ai_addr, rp->
      ai_addrlen) == -1) {
      logger.log("bind() failed. errno:%\n",
        strerror(errno));
      return -1;
    }
    if (!is_udp && is_listening && listen(fd,
      MaxTCPServerBacklog) == -1) {
      logger.log("listen() failed. errno:%\n",
        strerror(errno));
      return -1;
    }

最后,我们为刚刚创建的套接字设置 TTL 值并返回套接字。我们还将使用之前创建的setSOTimestamp()方法设置从传入数据包中获取数据接收时间戳的能力:

    if (is_udp && ttl) {
      const bool is_multicast = atoi(ip.c_str()) & 0xe0;
      if (is_multicast && !setMcastTTL(fd, ttl)) {
        logger.log("setMcastTTL() failed. errno:%\n",
          strerror(errno));
        return -1;
      }
      if (!is_multicast && !setTTL(fd, ttl)) {
        logger.log("setTTL() failed. errno:%\n",
          strerror(errno));
        return -1;
      }
    }
      if (needs_so_timestamp && !setSOTimestamp(fd)) {
        logger.log("setSOTimestamp() failed. errno:%\n",
          strerror(errno));
        return -1;
      }
  }
  if (result)
    freeaddrinfo(result);
  return fd;
}

现在我们已经讨论并实现了我们低级套接字方法的细节,我们可以继续到下一节,构建一个稍微高级一点的抽象,它建立在上述方法之上。

实现发送/接收 TCP 套接字

现在我们已经完成了创建套接字和设置它们不同参数的基本方法的设计和实现,我们可以开始使用它们了。首先,我们将实现一个 TCPSocket 结构,它建立在上一节中创建的套接字工具之上。TCPSocket 可以用于发送和接收数据,因此它将在 TCP 套接字服务器和客户端中都被使用。

定义 TCP 套接字的数据成员

让我们跳入我们对 TCPSocket 结构的实现,从我们需要的数据成员开始。由于这个套接字将用于发送和接收数据,我们将创建两个缓冲区——一个用于存储要发送的数据,另一个用于存储刚刚读取的数据。我们还将把对应于我们的 TCP 套接字的文件描述符存储在 fd_ 变量中。我们还将创建两个标志:一个用于跟踪发送套接字是否已连接,另一个用于检查接收套接字是否已连接。我们还将保存一个 Logger 对象的引用,纯粹是为了记录目的。最后,我们将存储一个 std::function 对象,我们将使用它来将回调分发给想要从该套接字读取数据的组件,当有新数据可供消费时。本节的代码位于本书 GitHub 仓库的 Chapter4/tcp_socket.hChapter4/tcp_socket.cpp 中:

#pragma once
#include <functional>
#include "socket_utils.h"
#include "logging.h"
namespace Common {
  constexpr size_t TCPBufferSize = 64 * 1024 * 1024;
  struct TCPSocket {
    int fd_ = -1;
    char *send_buffer_ = nullptr;
    size_t next_send_valid_index_ = 0;
    char *rcv_buffer_ = nullptr;
    size_t next_rcv_valid_index_ = 0;
    bool send_disconnected_ = false;
    bool recv_disconnected_ = false;
    struct sockaddr_in inInAddr;
    std::function<void(TCPSocket *s, Nanos rx_time)>
      recv_callback_;
    std::string time_str_;
    Logger &logger_;
  };
}

我们定义了一个默认的接收回调,我们将使用它来初始化 recv_callback_ 数据成员。这个方法只是记录确认回调被调用的信息:

    auto defaultRecvCallback(TCPSocket *socket, Nanos
      rx_time) noexcept {
      logger_.log("%:% %() %
        TCPSocket::defaultRecvCallback() socket:% len:%
          rx:%\n", __FILE__, __LINE__, __FUNCTION__,
                  Common::getCurrentTimeStr(&time_str_),
                    socket->fd_, socket->
                      next_rcv_valid_index_, rx_time);
    }

接下来,让我们看看 TCPSocket 结构的构造函数。

构造和销毁 TCP 套接字

对于构造函数,我们将在堆上创建 send_buffer_rcv_buffer_ char * 存储空间,并通过 lambda 方法将 defaultRecvCallback() 方法分配给 recv_callback_ 成员变量。请注意,我们将套接字的接收和发送缓冲区的大小设置为 TCPBufferSize,如此处定义:

constexpr size_t TCPBufferSize = 64 * 1024 * 1024;
    explicit TCPSocket(Logger &logger)
        : logger_(logger) {
      send_buffer_ = new char[TCPBufferSize];
      rcv_buffer_ = new char[TCPBufferSize];
      recv_callback_ = this {
        defaultRecvCallback(socket, rx_time); };
    }

然后,我们创建 destroy() 和析构函数来执行直接的清理任务。我们将关闭套接字文件描述符,并销毁在构造函数中创建的接收和发送缓冲区:

  auto TCPSocket::destroy() noexcept -> void {
    close(fd_);
    fd_ = -1;
  }
  ~TCPSocket() {
    destroy();
    delete[] send_buffer_; send_buffer_ = nullptr;
    delete[] rcv_buffer_; rcv_buffer_ = nullptr;
  }

我们定义了之前看到的样板代码,以防止意外的或非故意的构造、复制或赋值:

  // Deleted default, copy & move constructors and
    assignment-operators.
  TCPSocket() = delete;
  TCPSocket(const TCPSocket &) = delete;
  TCPSocket(const TCPSocket &&) = delete;
  TCPSocket &operator=(const TCPSocket &) = delete;
  TCPSocket &operator=(const TCPSocket &&) = delete;

接下来,让我们尝试对这个套接字执行一个关键操作——建立 TCP 连接。

建立 TCP 连接

对于这个结构,我们将定义一个 connect() 方法,它基本上是创建、初始化和连接 TCPSocket 的过程。我们将使用上一节中创建的 createSocket() 方法,并使用正确的参数来实现这一点:

  auto TCPSocket::connect(const std::string &ip, const
    std::string &iface, int port, bool is_listening) ->
      int {
    destroy();
    fd_ = createSocket(logger_, ip, iface, port, false,
      false, is_listening, 0, true);
    inInAddr.sin_addr.s_addr = INADDR_ANY;
    inInAddr.sin_port = htons(port);
    inInAddr.sin_family = AF_INET;
    return fd_;
  }

接下来,我们将继续到我们套接字中的下一个关键功能——发送和接收数据。

发送和接收数据

我们在讨论中提到,当有新数据可用时,感兴趣的监听者将通过recv_callback_ std::function机制得到通知。因此,我们只需要为这个结构的使用者提供一个send()方法来发送数据。请注意,这个send()方法只是简单地将提供的数据复制到输出缓冲区,而实际的写入操作将在我们即将看到的sendAndRecv()方法中完成:

  auto TCPSocket::send(const void *data, size_t len)
    noexcept -> void {
    if (len > 0) {
      memcpy(send_buffer_ + next_send_valid_index_, data,
        len);
      next_send_valid_index_ += len;
    }
  }

最后,我们拥有了TCPSocket结构体最重要的方法,即sendAndRecv()方法,它将可用的数据读取到rcv_buffer_中,增加计数器,并在读取到一些数据时调度recv_callback_。该方法的后半部分执行相反的操作——尝试使用send()例程将数据写入send_buffer_,并更新索引跟踪变量:

  auto TCPSocket::sendAndRecv() noexcept -> bool {
    char ctrl[CMSG_SPACE(sizeof(struct timeval))];
    struct cmsghdr *cmsg = (struct cmsghdr *) &ctrl;
    struct iovec iov;
    iov.iov_base = rcv_buffer_ + next_rcv_valid_index_;
    iov.iov_len = TCPBufferSize - next_rcv_valid_index_;
    msghdr msg;
    msg.msg_control = ctrl;
    msg.msg_controllen = sizeof(ctrl);
    msg.msg_name = &inInAddr;
    msg.msg_namelen = sizeof(inInAddr);
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    const auto n_rcv = recvmsg(fd_, &msg, MSG_DONTWAIT);
    if (n_rcv > 0) {
      next_rcv_valid_index_ += n_rcv;
      Nanos kernel_time = 0;
      struct timeval time_kernel;
      if (cmsg->cmsg_level == SOL_SOCKET &&
          cmsg->cmsg_type == SCM_TIMESTAMP &&
          cmsg->cmsg_len == CMSG_LEN(sizeof(time_kernel))) {
        memcpy(&time_kernel, CMSG_DATA(cmsg),
          sizeof(time_kernel));
        kernel_time = time_kernel.tv_sec * NANOS_TO_SECS +
          time_kernel.tv_usec * NANOS_TO_MICROS;
      }
      const auto user_time = getCurrentNanos();
      logger_.log("%:% %() % read socket:% len:% utime:%
        ktime:% diff:%\n", __FILE__, __LINE__,
          __FUNCTION__,
                  Common::getCurrentTimeStr(&time_str_),
                    fd_, next_rcv_valid_index_, user_time,
                      kernel_time, (user_time -
                        kernel_time));
      recv_callback_(this, kernel_time);
    }
    ssize_t n_send = std::min(TCPBufferSize,
      next_send_valid_index_);
    while (n_send > 0) {
      auto n_send_this_msg = std::min(static_cast<ssize_t>
        (next_send_valid_index_), n_send);
      const int flags = MSG_DONTWAIT | MSG_NOSIGNAL |
         (n_send_this_msg < n_send ? MSG_MORE : 0);
      auto n = ::send(fd_, send_buffer_, n_send_this_msg,
        flags);
      if (UNLIKELY(n < 0)) {
        if (!wouldBlock())
          send_disconnected_ = true;
        break;
      }
      logger_.log("%:% %() % send socket:% len:%\n",
        __FILE__, __LINE__, __FUNCTION__,
          Common::getCurrentTimeStr(&time_str_), fd_, n);
      n_send -= n;
      ASSERT(n == n_send_this_msg, "Don't support partial
        send lengths yet.");
    }
    next_send_valid_index_ = 0;
    return (n_rcv > 0);
  }

这就结束了我们对TCPSocket类的讨论。接下来,我们将构建一个封装并管理TCPSocket对象的类。它将被用于实现充当服务器的组件中的 TCP 服务器功能。

构建 TCP 服务器组件

在上一节中,我们构建了一个TCPSocket类,它可以被需要连接到 TCP 连接并发送和接收数据的组件使用。在本节中,我们将构建一个TCPServer组件,该组件内部管理多个这样的TCPSocket对象。它还管理诸如监听、接受和跟踪新传入连接以及在此套接字集合上发送和接收数据等任务。TCPServer组件的所有源代码都包含在 GitHub 仓库中本书的Chapter4/tcp_server.hChapter4/tcp_server.cpp文件中。

定义 TCP 服务器的数据成员

首先,我们将定义并描述TCPServer类将包含的数据成员。它需要一个文件描述符efd_和一个相应的TCPSocket listener_socket_来表示它将监听新传入客户端连接的套接字。它维护一个epoll_event events_数组,该数组将用于监控监听套接字的文件描述符,以及连接客户端的套接字描述符。它将有几个std::vectors套接字对象——我们期望从中接收数据的套接字,我们期望在其上发送数据的套接字,以及断开连接的套接字。我们很快就会看到这些是如何被使用的。

这个类有两个 std::function 对象 – 一个用于在接收到新数据时分发回调,另一个在当前轮次轮询套接字的所有回调完成后分发。为了更好地解释这一点,我们将首先使用 epoll 调用来找到所有有数据要读取的套接字,为每个有数据的套接字分发 recv_callback_,最后,当所有套接字都得到通知时,分发 recv_finished_callback_。这里还有一个需要注意的事项,即 recv_callback_ 提供了 TCPSocket,这是数据接收的套接字,以及 Nanos rx_time 来指定该套接字上数据的软件接收时间。接收时间戳用于按接收的确切顺序处理 TCP 数据包,因为 TCP 服务器监控并从许多不同的客户端 TCP 套接字中读取:

#pragma once
#include "tcp_socket.h"
namespace Common {
  struct TCPServer {
  public:
    int efd_ = -1;
    TCPSocket listener_socket_;
    epoll_event events_[1024];
    std::vector<TCPSocket *> sockets_, receive_sockets_,
      send_sockets_, disconnected_sockets_;
    std::function<void(TCPSocket *s, Nanos rx_time)>
      recv_callback_;
    std::function<void()> recv_finished_callback_;
    std::string time_str_;
    Logger &logger_;
  };
}

在下一节中,我们将查看初始化这些字段和反初始化 TCP 服务器的代码。

初始化和销毁 TCP 服务器

TCPServer 的构造函数很简单 – 它初始化 listener_socket_logger_,并设置默认的回调接收者,就像我们之前对 TCPSocket 所做的那样:

    explicit TCPServer(Logger &logger)
        : listener_socket_(logger), logger_(logger) {
      recv_callback_ = this {
        defaultRecvCallback(socket, rx_time); };
      recv_finished_callback_ = [this]() {
        defaultRecvFinishedCallback(); };
    }

我们在这里定义默认的接收回调方法,这些方法除了记录回调已被接收外不做任何事情。这些方法无论如何都是占位符,因为我们将在实际应用中设置不同的方法:

    auto defaultRecvCallback(TCPSocket *socket, Nanos
      rx_time) noexcept {
      logger_.log("%:% %() %
        TCPServer::defaultRecvCallback() socket:% len:%
          rx:%\n", __FILE__, __LINE__, __FUNCTION__,
            Common::getCurrentTimeStr(&time_str_), socket->
              fd_, socket->next_rcv_valid_index_, rx_time);
    }
    auto defaultRecvFinishedCallback() noexcept {
      logger_.log("%:% %() % TCPServer::
       defaultRecvFinishedCallback()\n", __FILE__,
       __LINE__, __FUNCTION__,
        Common::getCurrentTimeStr(&time_str_));
    }

销毁套接字的代码同样简单 – 我们关闭文件描述符并销毁 TCPSocket listener_socket_

  auto TCPServer::destroy() {
    close(efd_);
    efd_ = -1;
    listener_socket_.destroy();
  }

最后,我们展示了之前为这个类看到的样板代码:

    TCPServer() = delete;
    TCPServer(const TCPServer &) = delete;
    TCPServer(const TCPServer &&) = delete;
    TCPServer &operator=(const TCPServer &) = delete;
    TCPServer &operator=(const TCPServer &&) = delete;

接下来,让我们了解初始化监听套接字的代码。

启动并监听新的连接

TCPServer::listen() 方法首先创建一个新的 epoll 实例,使用 epoll_create() Linux 系统调用,并将其保存在 efd_ 变量中。它使用我们之前构建的 TCPSocket::connect() 方法来初始化 listener_socket_,但这里,重要的是我们将 listening 参数设置为 true。最后,我们使用 epoll_add() 方法将 listener_socket_ 添加到要监控的套接字列表中,因为最初,这是唯一需要监控的套接字。我们将在下一节中查看这个 epoll_add() 方法:

  auto TCPServer::listen(const std::string &iface, int
    port) -> void {
    destroy();
    efd_ = epoll_create(1);
    ASSERT(efd_ >= 0, "epoll_create() failed error:" +
      std::string(std::strerror(errno)));
    ASSERT(listener_socket_.connect("", iface, port, true)
      >= 0,
           "Listener socket failed to connect. iface:" +
             iface + " port:" + std::to_string(port) + "
               error:" + std::string
                 (std::strerror(errno)));
    ASSERT(epoll_add(&listener_socket_), "epoll_ctl()
      failed. error:" + std::string(std::strerror(errno)));
  }

现在,让我们看看如何在下一小节中构建 epoll_add() 和相应的 epoll_del() 方法。

添加和删除监控套接字

epoll_add() 方法用于将 TCPSocket 添加到要监控的套接字列表中。它使用 epoll_ctl() 系统调用和 EPOLL_CTL_ADD 参数将提供的套接字文件描述符添加到 efd_ epoll 类成员中。EPOLLET 启用了 边缘触发式 epoll 选项,简单来说就是当需要读取数据时你只会被通知一次,而不是持续的提醒。在这种模式下,何时读取数据取决于应用程序的开发者。EPOLLIN 用于在数据可读时进行通知:

  auto TCPServer::epoll_add(TCPSocket *socket) {
    epoll_event ev{};
    ev.events = EPOLLET | EPOLLIN;
    ev.data.ptr = reinterpret_cast<void *>(socket);
    return (epoll_ctl(efd_, EPOLL_CTL_ADD, socket->fd_,
      &ev) != -1);
  }

epoll_del()epoll_add()相反——仍然使用epoll_ctl(),但这次,EPOLL_CTL_DEL参数从被监控的套接字列表中移除TCPSocket

  auto TCPServer::epoll_del(TCPSocket *socket) {
    return (epoll_ctl(efd_, EPOLL_CTL_DEL, socket->fd_,
      nullptr) != -1);
  }

我们在这里构建的del()方法将从被监控的套接字列表以及套接字的不同数据成员容器中移除TCPSocket

  auto TCPServer::del(TCPSocket *socket) {
    epoll_del(socket);
    sockets_.erase(std::remove(sockets_.begin(),
      sockets_.end(), socket), sockets_.end());
    receive_sockets_.erase(std::remove
      (receive_sockets_.begin(), receive_sockets_.end(),
        socket), receive_sockets_.end());
    send_sockets_.erase(std::remove(send_sockets_.begin(),
      send_sockets_.end(), socket), send_sockets_.end());
  }

现在,我们可以看看这个子节中最重要的方法——TCPServer::poll(),它将用于执行以下列出的几个任务:

  • 调用epoll_wait(),检测是否有任何新的传入连接,如果有,就将它们添加到我们的容器中

  • epoll_wait()的调用中检测已从客户端断开的套接字,并将它们从我们的容器中移除

  • epoll_wait()的调用中检查是否有套接字准备好读取数据或有传出数据

让我们将整个方法分解成几个块——首先,是调用epoll_wait()方法的块,其中epoll实例和最大事件数等于我们容器中套接字的总数,没有超时:

  auto TCPServer::poll() noexcept -> void {
    const int max_events = 1 + sockets_.size();
    for (auto socket: disconnected_sockets_) {
      del(socket);
    }
    const int n = epoll_wait(efd_, events_, max_events, 0);

接下来,如果epoll_wait()返回的值大于 0,我们就遍历由epoll_wait()调用填充的events_数组。对于events_数组中的每个epoll_event,我们使用event.data.ptr对象并将其强制转换为TCPSocket*,因为这是我们如何在epoll_add()方法中设置events_数组的方式:

    bool have_new_connection = false;
    for (int i = 0; i < n; ++i) {
      epoll_event &event = events_[i];
      auto socket = reinterpret_cast<TCPSocket
        *>(event.data.ptr);

对于每个epoll_event条目,我们检查事件标志上是否设置了EPOLLIN标志,这将表示有一个新的套接字可以从中读取数据。如果这个套接字恰好是listener_socket_,即我们配置为监听连接的TCPServer的主套接字,我们可以看到我们有一个新的连接要添加。如果这是一个不同于listener_socket_的套接字,那么如果它尚未存在于列表中,我们就将其添加到receive_sockets_向量中:

      if (event.events & EPOLLIN) {
        if (socket == &listener_socket_) {
          logger_.log("%:% %() % EPOLLIN
            listener_socket:%\n", __FILE__, __LINE__,
              __FUNCTION__,
               Common::getCurrentTimeStr(&time_str_),
                 socket->fd_);
          have_new_connection = true;
          continue;
        }
        logger_.log("%:% %() % EPOLLIN socket:%\n",
          __FILE__, __LINE__, __FUNCTION__,
            Common::getCurrentTimeStr(&time_str_), socket-
              >fd_);
        if(std::find(receive_sockets_.begin(),
          receive_sockets_.end(), socket) ==
            receive_sockets_.end())
          receive_sockets_.push_back(socket);
      }

类似地,我们检查EPOLLOUT标志,这表示有一个我们可以向其发送数据的套接字,如果它尚未存在于send_sockets_向量中,我们就将其添加进去:

      if (event.events & EPOLLOUT) {
        logger_.log("%:% %() % EPOLLOUT socket:%\n",
          __FILE__, __LINE__, __FUNCTION__,
            Common::getCurrentTimeStr(&time_str_), socket-
             >fd_);
        if(std::find(send_sockets_.begin(),
          send_sockets_.end(), socket) ==
            send_sockets_.end())
          send_sockets_.push_back(socket);
      }

最后,我们检查是否设置了EPOLLERREPOLLHUP标志,这表示有错误或表示套接字从另一端关闭(挂起信号)。在这种情况下,我们将这个套接字添加到disconnected_sockets_向量中以便移除:

      if (event.events & (EPOLLERR | EPOLLHUP)) {
        logger_.log("%:% %() % EPOLLERR socket:%\n",
          __FILE__, __LINE__, __FUNCTION__,
            Common::getCurrentTimeStr(&time_str_), socket-
              >fd_);
        if(std::find(disconnected_sockets_.begin(),
          disconnected_sockets_.end(), socket) ==
            disconnected_sockets_.end())
          disconnected_sockets_.push_back(socket);
      }
    }

最后,在这个方法中,如果我们之前在代码块中检测到了新的连接,我们需要接受这个新的连接。我们使用带有listener_socket_文件描述符的accept()系统调用来实现这一点,并获取这个新套接字的文件描述符。我们还使用之前构建的setNonBlocking()setNoDelay()方法将套接字设置为非阻塞并禁用 Nagle 算法:

    while (have_new_connection) {
      logger_.log("%:% %() % have_new_connection\n",
        __FILE__, __LINE__, __FUNCTION__,
         Common::getCurrentTimeStr(&time_str_));
      sockaddr_storage addr;
      socklen_t addr_len = sizeof(addr);
      int fd = accept(listener_socket_.fd_,
        reinterpret_cast<sockaddr *>(&addr), &addr_len);
      if (fd == -1)
        break;
      ASSERT(setNonBlocking(fd) && setNoDelay(fd), "Failed
        to set non-blocking or no-delay on socket:" + std::
          to_string(fd));
      logger_.log("%:% %() % accepted socket:%\n",
        __FILE__, __LINE__, __FUNCTION__,
          Common::getCurrentTimeStr(&time_str_), fd);

最后,我们使用这个文件描述符创建一个新的TCPSocket对象,并将TCPSocket对象添加到sockets_receive_sockets_容器中:

      TCPSocket *socket = new TCPSocket(logger_);
      socket->fd_ = fd;
      socket->recv_callback_ = recv_callback_;
      ASSERT(epoll_add(socket), "Unable to add socket.
        error:" + std::string(std::strerror(errno)));
      if(std::find(sockets_.begin(), sockets_.end(),
        socket) == sockets_.end())
        sockets_.push_back(socket);
      if(std::find(receive_sockets_.begin(),
        receive_sockets_.end(), socket) ==
          receive_sockets_.end())
        receive_sockets_.push_back(socket);
    }
  }

这标志着我们查找新连接和断开连接的所有功能,以及监控现有连接以查看是否有可读数据的功能的结束。下一个子节通过演示如何从有可读或发送数据的套接字列表中发送和接收数据来结束我们的TCPServer类的讨论。

发送和接收数据

在以下示例中展示了在具有传入或传出数据的套接字列表上发送和接收数据的代码。实现非常直接——它只是简单地调用receive_sockets_send_sockets_中每个套接字的TCPSocket::sendAndRecv()方法。对于传入数据,对TCPSocket::sendAndRecv()的调用调度recv_callback_方法。在这里我们需要做的一件事是检查这次是否读取了任何数据,如果是,则在所有recv_callback_调用调度之后调度recv_finished_callback_

  auto TCPServer::sendAndRecv() noexcept -> void {
    auto recv = false;
    for (auto socket: receive_sockets_) {
      if(socket->sendAndRecv())
        recv = true;
    }
    if(recv)
      recv_finished_callback_();
    for (auto socket: send_sockets_) {
      socket->sendAndRecv();
    }
  }

这标志着我们TCPServer类的实现完成,让我们用一个简单的示例来总结本节中构建的所有内容,以结束我们的网络编程讨论。

构建 TCP 服务器和客户端的示例

在本节中,我们将构建一个示例,并使用在本节中实现的TCPSocketTCPServer类。这个示例可以在Chapter4/socket_example.cpp源文件中找到。这个简单的示例创建了一个TCPServer,它在lo接口上监听进入的连接,回环127.0.0.1 IP,以及监听端口12345TCPServer类通过使用tcpServerRecvCallback() lambda 方法连接到它的客户端接收数据,并且TCPServer通过一个简单的响应回应对客户端进行响应。然后,我们使用TCPSocket类创建了五个客户端,每个客户端都连接到这个TCPServer。最后,它们各自向服务器发送一些数据,服务器回送响应,每个客户端反复调用sendAndRecv()来发送和接收数据。TCPServer通过调用poll()sendAndRecv()来查找连接和数据,并读取它。

首先,展示设置回调 lambda 的代码:

#include "time_utils.h"
#include "logging.h"
#include "tcp_server.h"
int main(int, char **) {
  using namespace Common;
  std::string time_str_;
  Logger logger_("socket_example.log");
  auto tcpServerRecvCallback = &
  noexcept{
      logger_.log("TCPServer::defaultRecvCallback()
        socket:% len:% rx:%\n",
                  socket->fd_, socket->
                    next_rcv_valid_index_, rx_time);
      const std::string reply = "TCPServer received msg:" +
        std::string(socket->rcv_buffer_, socket->
          next_rcv_valid_index_);
      socket->next_rcv_valid_index_ = 0;
      socket->send(reply.data(), reply.length());
  };
  auto tcpServerRecvFinishedCallback = [&]()
  noexcept{
      logger_.log("TCPServer::defaultRecvFinishedCallback()\n");
  };
  auto tcpClientRecvCallback = &
  noexcept{
      const std::string recv_msg = std::string(socket->
        rcv_buffer_, socket->next_rcv_valid_index_);
      socket->next_rcv_valid_index_ = 0;
      logger_.log("TCPSocket::defaultRecvCallback()
        socket:% len:% rx:% msg:%\n",
      socket->fd_, socket->next_rcv_valid_index_, rx_time,
        recv_msg);
  };

然后,我们创建、初始化并连接服务器和客户端,如下所示:

  const std::string iface = "lo";
  const std::string ip = "127.0.0.1";
  const int port = 12345;
  logger_.log("Creating TCPServer on iface:% port:%\n",
    iface, port);
  TCPServer server(logger_);
  server.recv_callback_ = tcpServerRecvCallback;
  server.recv_finished_callback_ =
    tcpServerRecvFinishedCallback;
  server.listen(iface, port);
  std::vector < TCPSocket * > clients(5);
  for (size_t i = 0; i < clients.size(); ++i) {
    clients[i] = new TCPSocket(logger_);
    clients[i]->recv_callback_ = tcpClientRecvCallback;
    logger_.log("Connecting TCPClient-[%] on ip:% iface:%
      port:%\n", i, ip, iface, port);
    clients[i]->connect(ip, iface, port, false);
    server.poll();
  }

最后,我们有客户端发送数据,并在客户端和服务器上调用适当的轮询和发送/接收方法,如下所示:

  using namespace std::literals::chrono_literals;
  for (auto itr = 0; itr < 5; ++itr) {
    for (size_t i = 0; i < clients.size(); ++i) {
      const std::string client_msg = "CLIENT-[" +
        std::to_string(i) + "] : Sending " +
          std::to_string(itr * 100 + i);
      logger_.log("Sending TCPClient-[%] %\n", i,
        client_msg);
      clients[i]->send(client_msg.data(),
        client_msg.length());
      clients[i]->sendAndRecv();
      std::this_thread::sleep_for(500ms);
      server.poll();
      server.sendAndRecv();
    }
  }
  for (auto itr = 0; itr < 5; ++itr) {
    for (auto &client: clients)
      client->sendAndRecv();
    server.poll();
    server.sendAndRecv();
    std::this_thread::sleep_for(500ms);
  }
  return 0;
}

运行此示例,如以下所示,将在日志文件中输出类似以下内容:

(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter4$ ./cmake-build-release/socket_example ; cat socket_example.log
Creating TCPServer on iface:lo port:12345
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/socket_utils.cpp:68 createSocket() Sat Mar 25 11:32:55 2023 ip:127.0.0.1 iface:lo port:12345 is_udp:0 is_blocking:0 is_listening:1 ttl:0 SO_time:1
Connecting TCPClient-[0] on ip:127.0.0.1 iface:lo port:12345
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_server.cpp:74 poll() Sat Mar 25 11:32:55 2023 EPOLLIN listener_socket:5
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_server.cpp:97 poll() Sat Mar 25 11:32:55 2023 have_new_connection
…
Sending TCPClient-[0] CLIENT-[0] : Sending 0
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_socket.cpp:67 sendAndRecv() Sat Mar 25 11:32:55 2023 send socket:6 len:22
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_server.cpp:78 poll() Sat Mar 25 11:32:55 2023 EPOLLIN socket:7
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_socket.cpp:51 sendAndRecv() Sat Mar 25 11:32:55 2023 read socket:7 len:22 utime:1679761975918407366 ktime:0 diff:1679761975918407366
TCPServer::defaultRecvCallback() socket:7 len:22 rx:0TCPSocket::defaultRecvCallback() socket:12 len:0 rx:1679761987425505000 msg:TCPServer received msg:CLIENT-[3] : Sending 403
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter4/tcp_socket.cpp:51 sendAndRecv() Sat Mar 25 11:33:07 2023 read socket:14 len:47 utime:1679761987925931213 ktime:1679761987925816000 diff:115213
TCPSocket::defaultRecvCallback() socket:14 len:0 rx:1679761987925816000 msg:TCPServer received msg:CLIENT-[4] : Sending 404

这标志着我们关于使用套接字的 C++网络编程讨论的结束。我们涵盖了关于套接字编程的基本底层细节的很多内容。我们还从服务器和客户端的角度设计了实现了一些稍微高级的抽象,用于 TCP 和 UDP 通信。

概述

在本章中,我们进入了低延迟应用程序 C++开发的领域。我们构建了一些相对基础但极其有用的构建块,可用于各种低延迟应用程序目的。我们将许多与有效使用 C++和计算机架构特性相关的理论讨论付诸实践,以构建低延迟和高性能的应用程序。

第一个组件用于创建新的执行线程,并运行不同组件可能需要的函数。这里的一个重要功能是能够通过设置线程亲和性来控制新创建的线程被固定到的 CPU 核心。

我们构建的第二个组件旨在避免在关键代码路径上进行动态内存分配。我们重申了与动态内存分配相关的低效性,并设计了一个内存池,用于在构建时从堆中预分配内存。然后,我们向组件添加了实用工具,允许在运行时分配和释放对象,而不依赖于动态内存分配。

接下来,我们构建了一个无锁的、先进先出FIFO)风格的队列,用于在 SPSC 设置中线程间通信。这里的一个重要要求是,单个读者和单个写者能够无锁或互斥锁地访问队列中的共享数据。锁和互斥锁的缺失意味着上下文切换的缺失,正如讨论的那样,这是多线程应用程序中低效性和延迟的主要来源。

我们列表中的第四个组件是一个框架,旨在为对延迟敏感的应用程序提供高效的日志记录。日志记录对于所有应用程序来说都是非常重要的,如果不是强制性的话,包括低延迟应用程序。然而,由于磁盘 I/O、慢速字符串格式化等问题,传统的日志机制,如将日志写入磁盘上的日志文件,对于低延迟应用程序来说是不切实际的。为了构建这个组件,我们使用了我们构建的多线程机制,以及无锁的 FIFO 队列。

最后,我们深入讨论了设计我们的网络栈——如何创建网络套接字,如何使用它们创建 TCP 服务器和客户端,以及如何使用它们发布和消费多播流量。我们尚未使用这个最后一个组件,但在后续章节中,我们将使用这个组件来促进我们的电子交易交易所与不同市场参与者之间的通信。

现在,我们将继续进行一个案例研究项目,该项目我们将在本书的剩余部分构建——我们的电子交易生态系统。在下一章中,我们将首先关注设计和理解我们系统中各个组件的高级设计。我们将了解这些组件的目的、它们设计选择背后的动机以及信息在系统中的流动方式。下一章还将展示我们将在本书的其余部分实现的高级 C++接口的设计。

第二部分:使用 C++构建实时交易交易所

在本部分中,我们将描述和设计构成我们生态系统的交易应用,这些应用我们将从本书的起点开始构建——电子交易交易所、交易所市场数据发布、订单网关、客户端市场数据解码器和客户端交易算法框架。我们将实现跟踪客户订单并执行它们之间匹配的匹配引擎。我们还将构建发布市场数据供所有参与者使用的组件,以及它如何处理客户端连接和订单请求。由于现代电子交易所拥有数千名参与者以及巨大的订单流通过,因此重点关注非常低的延迟反应时间和高吞吐量。

本部分包含以下章节:

  • 第五章*,设计我们的交易生态系统*

  • 第六章*,构建 C++匹配引擎*

  • 第七章*,与市场参与者沟通*

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值