C---低延迟应用构建指南-全-
C++ 低延迟应用构建指南(全)
原文:
zh.annas-archive.org/md5/e3c367e5bf117e0b3804e1c041986096
译者:飞龙
前言
对于构建实用的低延迟应用程序来说,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++ 20 | Linux |
GCC 11.3.0 | Linux |
本书是在Linux 5.19.0-41-generic #42~22.04.1-Ubuntu x86_64 x86_64 x86_64 GNU/Linux
操作系统上开发的。它使用CMake 3.23.2
和Ninja 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 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每日收件箱中的精彩免费内容。
按照以下简单步骤获取优惠:
- 扫描下面的二维码或访问以下链接!二维码图片
https://packt.link/free-ebook/9781837639359
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件。
第一部分:介绍 C++概念和探索重要低延迟应用
在本部分,我们将介绍低延迟应用以及使用 C++ 开发低延迟应用的正确方法。我们将讨论不同业务领域中一些常见低延迟应用的技术细节。我们还将讨论与低延迟应用开发相关的细节,以及 C++ 概念和技术如何融入其中。此外,我们还将编写一些 C++ 代码,从电子交易交换的角度实现我们之前介绍的不同低延迟组件。
本部分包含以下章节:
-
第一章**,介绍 C++ 低延迟应用开发
-
第二章**,设计一些常见的 C++ 低延迟应用
-
第三章**,从低延迟应用的角度探索 C++ 概念
-
第四章**,构建低延迟应用的 C++ 基础
第一章:介绍 C++中的低延迟应用开发
让我们以低延迟应用为起点,通过在本章中介绍它们来开启我们的旅程。在本章中,我们将首先了解对延迟敏感和对延迟关键的应用的行为和需求。我们将了解应用延迟对依赖快速和严格响应时间的业务产生的巨大商业影响。
我们还将讨论为什么 C++是低延迟应用开发中最受欢迎的编程语言之一。我们将用这本书的大部分篇幅从头开始构建一个完整的低延迟电子交易系统。因此,这将是一个很好的章节,让你了解使用 C++的动机以及它为什么是低延迟应用中最流行的语言。
我们还将介绍不同业务领域的一些重要低延迟应用。部分动机是让你明白,延迟确实在不同业务领域对响应时间敏感的使用案例中非常重要。另一个动机是识别这些应用在行为、期望、设计和实现方面的相似性。尽管它们解决不同的商业问题,但这些应用的低延迟需求通常建立在相似的技术设计和实现原则之上。
在本章中,我们将涵盖以下主题:
-
理解对延迟敏感的应用的需求
-
理解为什么 C++是首选编程语言
-
介绍一些重要的低延迟应用
为了有效地构建超低延迟应用,我们首先应该理解我们将在这本书的其余部分中引用的术语和概念。我们还应该了解为什么 C++已经成为大多数低延迟应用开发的明确选择。始终牢记低延迟的商业影响也很重要,因为目标是构建低延迟应用以使业务的底线受益。本章讨论了这些想法,以便在我们深入本书其余部分的技术细节之前,你能建立一个良好的基础。
理解对延迟敏感的应用的需求
在本节中,我们将讨论一些概念,这些概念对于理解对延迟敏感的应用的哪些指标很重要。首先,让我们明确地定义延迟的含义和对延迟敏感的应用是什么。
延迟被定义为任务开始到任务完成之间的时间延迟。根据定义,任何处理或工作都会产生一些开销或延迟——也就是说,除非系统完全不工作,否则没有系统具有零延迟。这里的重要细节是,某些系统可能具有微不足道的毫秒分之一延迟,并且对额外微秒的容忍度可能很低。
低延迟应用是指那些尽可能快速执行任务并响应或返回结果的程序。这里的要点是,反应延迟是这类应用的重要标准,因为更高的延迟可能会降低性能,甚至使应用完全无用。另一方面,当这类应用以预期的低延迟运行时,它们可以击败竞争对手,以最大速度运行,实现最大吞吐量,或提高生产力和改善用户体验——具体取决于应用和业务。
低延迟可以被视为一个既定量又定性的术语。定量方面很明显,但定性方面可能并不一定明显。根据上下文,架构师和开发者可能在某些情况下愿意接受更高的延迟,但在某些情况下可能不愿意接受额外的微秒。例如,如果用户刷新网页或等待视频加载,几秒钟的延迟是可以接受的。然而,一旦视频加载并开始播放,就不再能够承受几秒钟的延迟来渲染或显示,而不会对用户体验产生负面影响。一个极端的例子是高速金融交易系统,其中额外的几微秒可能会在盈利公司和无法竞争的公司之间产生巨大的差异。
在以下小节中,我们将介绍适用于低延迟应用的一些术语。理解这些术语非常重要,这样我们才能继续讨论低延迟应用,因为我们将会频繁地引用这些概念。我们将讨论的概念和术语用于区分不同的延迟敏感型应用、延迟的测量以及这些应用的要求。
理解延迟敏感型与延迟关键型应用
延迟敏感型应用和延迟关键型应用之间存在微妙但重要的区别。延迟敏感型应用是指,随着性能延迟的降低,它提高了业务影响或盈利能力。因此,系统可能在较高的性能延迟下仍然功能正常,甚至可能盈利,但如果降低延迟,则可能获得更高的盈利能力。这类应用的例子包括操作系统(OSes)、网络浏览器、数据库等。
另一方面,延迟关键的应用程序是指当性能延迟高于某个阈值时,会完全失败的应用程序。这里的要点是,虽然延迟敏感的应用程序在更高的延迟下可能会损失部分盈利能力,但延迟关键的应用程序在足够高的延迟下会完全失败。这类应用的例子包括交通控制系统、金融交易系统、自动驾驶汽车和一些医疗设备。
测量延迟
在本节中,我们将讨论不同的测量延迟的方法。这些方法之间的真正区别在于,我们考虑的处理任务的开始和结束是什么。另一种方法是我们测量的单位——时间是其中最常见的一个,但在某些情况下,如果涉及到指令级测量,也可以使用 CPU 时钟周期。接下来,我们将查看不同的测量方法,但首先,我们展示一个通用服务器-客户端系统的图,而不深入使用案例或传输协议的具体细节。这是因为测量延迟是通用的,适用于具有这种服务器-客户端设置的许多不同应用程序。
图 1.1 – 具有不同跳数时间戳的通用服务器-客户端系统
我们在这里展示这个图是因为,在接下来的几个小节中,我们将定义并理解服务器到客户端以及返回服务器的往返路径上不同跳数之间的延迟。
首字节到达时间
首字节到达时间是指从发送者发送请求(或响应)的第一个字节到接收者接收第一个字节所经过的时间。这通常(但不一定)适用于具有数据传输操作且对延迟敏感的网络链路或系统。在图 1.1 中,首字节到达时间将是 和
之间的差异
往返时间
往返时间(RTT)是指数据包从一个进程传输到另一个进程所需的时间,以及响应数据包返回原始进程所需的时间。同样,这通常(但不一定)用于服务器和客户端进程之间的网络流量往返,但也可以用于一般情况下的两个进程之间的通信。
默认情况下,RTT 包括服务器进程读取、处理和响应发送者发送的请求所需的时间——也就是说,RTT 通常包括服务器处理时间。在电子交易的情况下,真正的 RTT 延迟基于三个组成部分:
-
首先,交易所信息到达参与者所需的时间
-
其次,算法分析信息和做出决策所需的时间
-
最后,决策从达到交易所并经过撮合引擎处理所需的时间
我们将在本书的最后一节,分析和改进性能中进一步讨论这个问题。
交易计时
交易计时(TTT)与 RTT 类似,是电子交易系统中最常用的术语。TTT 被定义为从数据包(通常是市场数据包)首次击中参与者的基础设施(交易服务器)到参与者完成处理该数据包并发送数据包(订单请求)到交易交易所的时间。因此,TTT 包括交易基础设施读取数据包、处理数据包、计算交易信号、根据该信号生成订单请求并将其发送出去所需的时间。发送出去通常意味着将数据写入网络套接字。我们将在本书的最后一节,分析和改进性能中重新审视这个主题,并对其进行更详细的探讨。在图 1.1中,TTT 将是和
之间的差异。
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 Library(ATL)是一个模板丰富的 C++库,用于帮助编程组件对象模型(COM)。它取代了之前的Microsoft Foundation Classes(MFC)库,并对其进行了改进。它由微软开发,是开源的,并且大量使用了重要的低延迟 C++特性,即奇特重复模板模式(CRTP),我们也将在此书中深入探讨并大量使用它。它支持 COM 功能,如双接口、ActiveX 控件、连接点、可拆卸接口、COM 枚举接口等,还有更多。
Eigen
Eigen是一个用于数学和科学应用的强大 C++库。它提供了线性代数、数值方法和求解器、复数等数值类型、几何特征和操作等功能。
LAPACK
线性代数包(LAPACK)是另一个专门用于线性代数、线性方程以及支持大矩阵例程的强大 C++库。它实现了许多功能,如求解联立线性方程、最小二乘法、特征值、奇异值分解(SVD)以及更多应用。
OpenCV
Open Source Computer Vision(OpenCV)是计算机图形和视觉相关应用中最知名的 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++多年演变的图表:
图 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 是一种在全球范围内高效分发内容的方法。
转码、转封装和转比特率
让我们讨论与音频视频流编码相关的三个概念:
-
转码指的是将媒体流从一种格式(例如,更低级别的细节,如编解码器、视频大小、采样率、编码器格式等)解码的过程,并可能以不同的格式或不同的参数进行重新编码。
-
转封装与转码类似,但在这里,交付格式发生变化,而编码没有变化,就像转码的情况一样。
-
转比特率也与转码类似,但我们会改变视频比特率;通常,它会压缩到更低的值。视频比特率是每秒传输的比特数(或千比特数),它捕捉视频流中的信息和质量。
-
在下一节中,我们将了解低延迟视频流应用中延迟的来源。
理解视频流应用中延迟的来源
让我们看看玻璃到玻璃旅程中发生的事情的细节。本节中我们的最终动机是了解视频流应用中延迟的来源。此图从摄像头到显示的高层次描述了玻璃到玻璃旅程中发生的事情:
图 2.1 – 在实时视频流应用中的玻璃到玻璃传输过程
讨论玻璃到玻璃传输过程中的步骤
我们将首先了解低延迟视频流应用中玻璃到玻璃传输过程中的所有步骤和组件。存在两种延迟形式——初始启动延迟以及直播开始后视频帧之间的延迟。通常对于用户体验来说,略微更长的启动延迟远比视频帧之间的延迟更受欢迎,但在尝试减少一种延迟的同时通常会有所权衡。因此,我们需要了解哪个指标对于特定用例来说更重要,并相应地调整设计和技术细节。以下是广播者到接收者的玻璃到玻璃传输过程中的步骤:
-
广播者端的摄像头捕捉和处理音频和视频
-
广播者的视频消费和包装
-
编码器对内容进行转码、转封装和转码率调整
-
通过适当的协议(s)在网络中发送数据
-
通过 VDS(如 CDN)进行分发
-
接收端接收和缓冲
-
在观众设备上解码内容
-
在接收端处理数据包丢失、网络变化等问题
-
在观众选择的设备上渲染音频-视频内容
-
可能收集观众的交互输入(选择、音频、视频等)用于交互式应用,并在需要时将它们发送回广播者
现在我们已经描述了从发送者到接收者以及可能返回到发送者的内容交付细节,在下一节中,我们将描述在该路径上可能出现延迟的地方。通常,每一步不会花费很长时间,但多个组件中的高延迟可能会累积并导致用户体验的显著下降。
描述路径上高延迟的可能性
我们将探讨低延迟视频流应用中高延迟的原因。在之前小节中讨论的玻璃到玻璃路径的每个组件中,都有许多原因导致这种情况。
物理距离、服务器负载和互联网质量
-
这是一个显而易见的问题:源和目的地之间的物理距离将影响玻璃到玻璃延迟。当从不同国家流式传输视频时,这种情况有时非常明显。
-
除了距离之外,互联网连接本身的质量也会影响流式传输延迟。缓慢或带宽有限的连接会导致不稳定、缓冲和延迟。
根据同时流式传输视频的用户数量以及这给流媒体路径中的服务器带来的负载,延迟和用户体验可能会有所不同。过载的服务器会导致响应时间变慢,延迟增加,缓冲和延迟,甚至可能导致流式传输完全停止。
捕获设备和硬件
视频和音频捕获设备对端到端延迟有很大影响。将音频和视频帧转换为数字信号需要时间。记录器、编码器、处理器、重新编码器、解码器和重新传输器等高级系统对最终用户体验有显著影响。捕获设备和硬件将决定延迟值。
流媒体协议、传输和抖动缓冲区
考虑到存在不同的流媒体协议(我们将在稍后讨论),最终的选择可以决定视频流应用的网络延迟。如果协议没有针对动态自适应流进行优化,它可能会增加延迟。总的来说,直播视频流协议分为两大类——基于 HTTP 和非基于 HTTP 的——这两大类选项之间的延迟和可扩展性存在差异,这将改变最终系统的性能。
在 VDS 路径中选择的互联网路由可以改变端到端延迟。这些路由也可能随时间变化,数据包可能在某些跳转处排队,甚至可能在接收端顺序错乱。处理这些问题的软件被称为抖动缓冲区。如果 CDN 存在问题,也可能导致额外的延迟。此外,还有一些限制,例如编码比特率(较低的比特率意味着每单位时间内传输的数据更少,从而导致较低的延迟),这可能会改变遇到的延迟。
编码——转码和转速率
编码过程决定了最终视频输出的压缩、格式等,编码协议的选择和质量将对性能产生巨大影响。此外,还有许多观众设备(电视、手机、PC、Mac 等)和网络(3G、4G、5G、LAN、Wi-Fi 等)选项,流媒体提供商需要实现自适应比特率(ABR)来有效地处理这些选项。运行编码器的计算机或服务器需要足够的 CPU 和内存资源来跟上传入的音视频数据。无论我们是在计算机上使用编码软件,还是在BoxCaster或Teradek等编码硬件上进行编码,我们都会产生从几毫秒到几秒的处理延迟。编码器需要执行的任务包括摄取原始视频数据、缓冲内容,并在转发之前对其进行解码、处理和重新编码。
在观众的设备上解码和播放
假设内容在没有引起明显延迟的情况下到达观众的设备,客户端仍然必须解码、播放和渲染内容。视频播放器不会逐个渲染接收到的视频段,而是有一个接收到的段缓冲区,通常在内存中。这意味着在视频开始播放之前,会缓冲几个段,具体取决于所选段的实际大小,这可能会在最终用户端引起延迟。例如,如果我们选择包含 10 秒视频的段长度,最终用户的播放器至少必须接收到一个完整的段才能播放,这将在发送者和接收者之间引入额外的 10 秒延迟。通常,这些段长在 2 到 10 秒之间,试图在优化网络效率和玻璃到玻璃延迟之间取得平衡。显然,观众设备、平台、硬件、CPU、内存和播放器效率等因素可能会增加玻璃到玻璃延迟。
在低延迟视频流中测量延迟
在低延迟视频流应用中测量延迟并不极端复杂,因为我们关注的延迟范围至少应该是几秒钟,这样用户才能感知到延迟或滞后。测量端到端视频延迟的最简单方法如下:
-
首先应该使用一个场记板应用程序。场记板是用于在电影制作过程中同步视频和音频的工具,有应用程序可以检测由于延迟导致的两个流之间的同步问题。
-
另一个选项是将视频流重新发布给自己,通过排除网络因素来测量在捕获、编码、解码和渲染步骤中是否存在任何延迟。
-
一个明显的解决方案是截图两个运行相同直播流的屏幕,以发现差异。
-
测量实时视频流延迟的最佳解决方案是在源端给视频流本身添加时间戳,然后接收者可以使用它来确定玻璃到玻璃延迟。显然,发送者和接收者使用的时钟需要相互之间合理地同步。
理解高延迟的影响
在我们了解高延迟对低延迟视频流应用的影响之前,首先,我们需要定义不同应用可接受的延迟是多少。对于不需要太多实时交互的视频流应用,5 秒以内的延迟是可以接受的。对于需要支持直播和交互式用例的流应用,1 秒以内的延迟对用户来说就足够了。显然,对于点播视频,延迟不是问题,因为它已经预先录制,没有直播组件。总的来说,实时直播应用中的高延迟会对最终用户体验产生负面影响。实时关键动机是观众希望感到连接,并得到身临其境的感觉。接收和渲染内容的大延迟会破坏实时观看的感觉。最令人烦恼的体验之一就是实时视频因延迟而经常暂停和缓冲。
让我们简要讨论由于延迟造成的实时视频流应用的主要负面影响。
低音视频质量
如果流系统的组件无法实现实时延迟,通常会导致更高的压缩级别。由于音频视频数据的高压缩级别,音频质量有时会听起来混乱和刮擦,视频质量可能会模糊和像素化,所以整体用户体验会更差。
缓冲暂停和延迟
缓冲是可能破坏用户体验的最糟糕的事情之一,因为观众会经历抖动性能,而不是平滑体验。如果视频不断暂停以缓冲和赶上,这对观众来说非常令人沮丧,并可能导致观众退出视频、平台或业务本身,再也不回来。
音视频同步问题
在许多实时音频视频流应用的实现中,音频数据与视频数据分开发送,因此音频数据可以比视频数据更快地到达接收器。这是因为从本质上讲,音频数据的大小比视频数据小,由于高延迟,视频数据可能在接收器端落后于音频数据。这导致同步问题,并损害了观众对实时视频流体验的感受。
播放 - 回放和快进
即使应用不是 100%的实时,高延迟也可能导致回放和快进的问题。这是因为音频视频数据将不得不重新发送,以便最终用户的播放器可以与新选定的位置重新同步。
探索低延迟视频流技术
在本节中,我们将探讨适用于音频-视频数据编码、解码、流式传输和分发的不同技术协议。这些协议专门为低延迟视频流应用和平台设计。这些协议分为两大类 – 基于 HTTP 的协议和非基于 HTTP 的协议 – 但对于低延迟视频流,通常来说,基于 HTTP 的协议是首选,正如本节将要展示的。
图 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)、自适应同步、FreeSync、Fast Sync、G-Sync和可变刷新率(VRR)。
输入延迟
输入延迟衡量的是用户生成输入(如按键、鼠标移动或鼠标点击)与屏幕上对该输入作出响应之间的延迟。这基本上是硬件和游戏对用户输入和交互的响应速度。显然,对于所有游戏来说,这个值都不是零,它是硬件本身(控制器、鼠标、键盘、互联网连接、处理器、显示器等)或游戏软件本身(处理输入、更新游戏和角色状态、调度图形更新、通过显卡渲染以及刷新显示器)的总和。当输入延迟较高时,游戏感觉不灵敏且延迟,这可能会影响玩家在多人或在线游戏中的表现,甚至完全破坏用户的游戏体验。
响应时间
响应时间常被误认为是输入延迟,但它们是不同的术语。响应时间指的是像素响应时间,这基本上意味着像素改变颜色所需的时间。虽然输入延迟影响游戏响应速度,但响应时间影响屏幕上渲染动画的模糊度。直观地说,如果像素响应时间较高,当在屏幕上渲染运动或动画时,像素改变颜色所需的时间更长,从而导致模糊。较低的像素响应时间(1 毫秒或更低)会导致清晰和锐利的图像和动画质量,即使对于具有快速摄像机移动的游戏也是如此。这类游戏的良好例子包括第一人称射击游戏和赛车游戏。当响应时间较高时,我们会遇到一种称为鬼影的伪影,这指的是当有运动时,轨迹和伪影缓慢地从屏幕上消失。通常,鬼影和较高的像素响应时间并不是问题,现代硬件可以轻松提供小于 5 毫秒的响应时间,并渲染清晰的动画。
网络带宽
网络带宽以相同的方式影响在线游戏应用,就像它会影响实时视频流应用一样。带宽衡量每秒可以上传到或从游戏应用服务器下载多少兆比特。带宽还受到数据包丢失的影响,我们将在下一部分讨论,并且根据玩家和连接到的游戏服务器的位置而变化。"竞争"是网络带宽时需要考虑的另一个术语。竞争是指尝试访问同一服务器或共享资源的并发用户数量,以及这会不会导致服务器过载。
网络数据包丢失和抖动
网络数据包丢失是在网络中传输数据包时不可避免的事实。网络数据包丢失会降低有效带宽,并导致重传和恢复协议,这会引入额外的延迟。一些数据包丢失是可以容忍的,但当网络数据包丢失率非常高时,它们会降低在线游戏应用的用户体验,甚至可能使其完全停止。抖动类似于数据包丢失,但在此情况下,数据包到达顺序错误。由于游戏软件位于用户端,这会引入额外的延迟,因为接收器必须保存顺序错误的数据包,等待尚未到达的数据包,然后按顺序处理数据包。
网络协议
当涉及到网络协议时,有两大协议用于在互联网上传输数据:TCP 和 UDP。TCP 通过跟踪成功送达接收者的数据包并具有重传丢失数据包的机制,提供了一种可靠的传输协议。这里的优势很明显,因为应用程序不能在数据包和信息丢失的情况下运行。这里的缺点是,这些额外的机制来检测和处理数据包丢失会导致额外的延迟(额外的毫秒数)并更有效地使用可用带宽。必须依赖 TCP 的应用程序示例包括在线购物和在线银行,在这些情况下,确保数据正确送达至关重要,即使它晚些时候送达。UDP 则侧重于确保数据尽可能快地送达,并具有更高的带宽效率。然而,它这样做是以不保证交付甚至不保证数据包顺序交付为代价的,因为它没有重传丢失数据包的机制。UDP 对于可以容忍一些数据包丢失而不会完全崩溃的应用程序以及更倾向于丢失信息而不是延迟信息的应用程序来说效果很好。此类应用程序的示例包括实时视频流和某些在线游戏应用组件。例如,一些视频组件或在线视频游戏中的渲染组件可以通过 UDP 传输,但某些组件,如用户输入和游戏及玩家状态更新,需要通过 TCP 发送。
图 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 和西门子,从而使客户企业能够专注于在此基础设施之上构建。
利用邻近性
我们在前面章节中已经隐含地讨论了这一点,但现在我们将在这里明确地讨论。物联网应用的一个关键要求是达到超低延迟性能,而实现这一目标的关键是利用物联网用例中涉及的不同设备和应用之间的邻近性。边缘计算是利用邻近性来最小化从数据捕获到处理以及与其他设备或客户端应用共享结果之间的延迟的关键。正如我们之前所看到的,非边缘计算基础设施的最大瓶颈是数据中心和处理资源与数据源和结果目的地之间的距离。随着分布式的数据中心相互之间相隔数英里,这种情况会变得更糟,最终导致临界的高延迟和滞后。显然,将边缘计算资源放置在数据源附近是推动物联网采用、物联网用例以及将物联网业务扩展到大量设备和用户的关键。
降低云成本
这是我们之前讨论过的另一个问题,但我们将在本节中正式讨论它。目前有数十亿个物联网设备,它们产生连续的数据流。任何有效的物联网驱动业务都需要在大规模增加设备和客户端数量的情况下具有极高的可扩展性,这会导致记录和由边缘计算处理的数据量呈指数增长,以及将结果传输到其他设备和客户端。依赖于集中式云基础设施的数据密集型基础设施无法以经济高效的方式支持物联网应用,而且数据和云基础设施本身也成为企业开支的一个重大部分。明显的解决方案是找到一种低成本边缘解决方案(第三方或内部),并使用它来满足物联网数据捕获、存储和处理的需求。这消除了与在云解决方案中传输数据相关的成本,并可以显著提高边缘计算可靠性并降低成本。
我们将通过以下图表总结对低延迟物联网应用的讨论,以展示物联网应用的当前和未来状态:
图 2.4 – 物联网应用的当前和未来状态
探索低延迟电子交易
低延迟应用的最后一个例子是用于低延迟电子交易和超低延迟电子交易的应用,也称为 HFT。在本书的剩余部分,我们将从头开始使用 C++构建一个完整的端到端低延迟电子交易系统。因此,在本节中,我们将简要讨论电子交易应用实现低延迟性能的重要考虑因素,然后在后续章节中详细介绍底层细节。《Sebastian Donadio、Sourav Ghosh 和 Romain Rossier 合著的《开发高频交易系统》》对于有兴趣的读者来说是一本了解低延迟电子交易系统更详细信息的优秀书籍。本书的重点将是使用 C++从头开始设计和构建每个组件,以了解低延迟应用开发,但那本书可以作为 HFT 业务背后额外理论的良好参考。
理解现代电子交易中低延迟的需求
随着电子交易现代化和高速交易(HFT)的兴起,对于这些应用来说,低延迟比以往任何时候都更加重要。在许多情况下,实现更低的延迟会导致交易收入的直接增加。在某些情况下,存在一种持续的竞争,试图不断降低延迟以保持市场中的竞争优势。在极端情况下,如果一个参与者落后于最低可能延迟的军备竞赛,他们可能会倒闭。
现代电子市场的交易机会极为短暂,因此只有能够处理市场数据并找到这种机会,并迅速下单以应对这种机会的市场参与者才能盈利。反应不够快意味着你只能获得机会的一小部分,通常只有最快的参与者能获得所有利润,而所有其他较慢的参与者则一无所获。这里的另一个细微之处在于,如果一个参与者不够快地反应市场事件,他们也可能在交易中处于错误的一边,并输给那些能够足够快地反应事件的参与者。在这种情况下,交易利润不仅会降低,而且交易收入可能会变成负数(即亏损)。为了更好地理解这一点,让我们举一个我们将在这本书中构建的例子:市场创造和流动性获取算法。
不深入细节的话,市场做市算法在市场中持有订单,其他参与者可以在需要时与之交易。因此,市场做市算法需要不断重新评估其活跃订单,并根据市场条件调整它们的价格和数量。然而,流动性获取算法并不总是持有市场中的活跃订单。这个算法相反地等待机会出现,然后与市场做市算法的活跃订单进行交易。对高频交易市场的简单看法就是市场做市算法和流动性获取算法之间持续的战斗,因为它们自然站在对立的立场。
在这种设置中,当市场做市算法在修改其市场中的活跃订单时速度较慢时,它会亏损。例如,根据市场条件,短期内市场价格明显会上涨;如果市场做市算法的卖出订单有被执行的风险,因为它不再想以那些价格卖出,那么市场做市算法会尝试移动或取消其卖出订单。同时,一个流动性获取算法会尝试看是否可以发送一个买入订单来与市场做市者的卖出订单进行交易。在这场竞争中,如果市场做市算法比流动性获取算法慢,它将无法修改或取消其卖出订单。如果流动性获取算法慢,它将无法执行其想要的订单,要么是因为一个不同的(并且更快的)算法在它之前执行了,要么是因为市场做市者能够避开。这个例子应该清楚地表明,延迟直接影响到电子交易的交易收入。
对于高频交易(HFT),客户端的交易应用可以在亚毫秒级延迟内接收和处理市场数据,分析信息,寻找机会,并向交易所发送订单,所有这些操作都在亚毫秒级延迟内完成,并且使用现场可编程门阵列(FPGAs)可以将延迟降低到亚微秒级。FPGAs 是一种可重新编程的特殊硬件芯片,可以直接在芯片上构建极其专业化和低延迟的功能。理解 FPGAs 的细节以及开发和使用它们是一个超出本书范围的先进主题。
尽管我们在前面的例子中提到了交易表现和收入,但低延迟在电子交易业务的其它方面也很重要,这些方面可能并不立即明显。显然,交易收入和表现仍然是交易应用的主要关注点;长期业务连续性的另一个重要要求是实时风险管理。由于每个电子市场都有许多交易工具,并且这些工具在一天中持续变化价格,因此风险管理系统需要跟上大量的数据,这些数据涵盖了全天所有交易所和所有产品。
此外,由于公司在其所有产品和交易所上采用高频交易策略,因此公司在这所有产品上的头寸会全天快速变化。一个实时风险管理系统需要评估公司在这所有产品上的不断变化的敞口与市场价格,以追踪全天的盈亏和风险。风险评估指标和系统本身可能相当复杂;例如,在期权交易中,通常会在实时或接近实时的情况下运行蒙特卡洛模拟,以尝试找到最坏情况下的风险评估。一些风险管理系统还负责在超过其风险限制时关闭自动化交易策略。这些风险系统通常被添加到多个组件中——一个中央风险系统、订单网关以及交易策略本身——但我们将在本书的后续章节中了解这些细节。
实现电子交易中的最低延迟
在本节中,我们将简要讨论在实施低延迟电子交易系统时的一些高级思想和概念。当然,随着我们在接下来的章节中构建电子交易生态系统,我们将通过示例以更详细的方式重新审视这些内容。
优化交易服务器硬件
获取强大的交易服务器以支持低延迟交易操作是第一步。通常,这些服务器的处理能力取决于交易系统进程的架构,例如我们期望运行多少个进程,我们期望消耗多少网络资源,以及我们期望这些应用程序消耗多少内存。通常,低延迟交易应用程序在繁忙的交易期间具有高 CPU 使用率,低内核使用率(系统调用),低内存消耗,以及相对较高的网络资源使用率。CPU 寄存器、缓存架构和容量也很重要,通常,如果可能的话,我们会尝试获取更大的尺寸,但这些可能会非常昂贵。高级考虑因素,如非一致性内存访问(NUMA)、处理器指令集、指令流水线和指令并行性、缓存层次架构细节、超线程和超频 CPU 通常也会被考虑,但这些是极其高级的优化技术,超出了本书的范围。
网络接口卡、交换机和内核旁路
需要支持超低延迟交易应用(特别是那些必须读取大量市场数据、更新网络数据包并处理它们的)的交易服务器需要专门的网络接口卡(NICs)和交换机。适用于此类应用的优选网络接口卡需要具有非常低的延迟性能、低抖动和大的缓冲容量,以处理市场数据突发而不会丢失数据包。此外,适用于现代电子交易应用的优选网络接口卡支持一条特别低延迟的路径,该路径避免了系统调用和缓冲区复制,称为内核旁路。Solarflare是一个例子,它提供了OpenOnload和ef_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 寄存器到缓存银行再到主内存时,访问时间有显著的增加。
图 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_cast
、static_cast
和 reinterpret_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;
}
如果可以保证a
和b
的值只能是 0 或 1,那么c = a && b;
将简单地是c = a & b;
,这将非常快,避免了分支和与分支相关的开销。
位运算也可以通过将整数的每一位视为一个单独的布尔变量,然后使用位掩码操作重写涉及多个布尔比较的表达式来加速其他布尔表达式的处理。例如,考虑以下表达式,其中market_state
是uint64_t
类型,而PreOpen
、Opening
和Trading
是表示不同市场状态的枚举值:
if(market_state == PreOpen ||
market_state == Opening ||
market_state == Trading) {
// do something...
}
可以重写为以下形式:
if(market_state & (PreOpen | Opening | Trading)) {
// do something...
}
如果枚举值被选择,使得market_state
变量中的每一位代表一个真或假的状态,那么PreOpen
、Opening
和Trading
枚举可以设置为0x001
、0x010
和0x100
。
初始化、销毁、复制和移动对象
开发者定义的类的构造函数和析构函数应尽可能轻量级和高效,因为它们可以在开发者没有预期的情况下被调用。保持这些方法非常简单和紧凑也允许编译器将这些方法内联以提高性能。同样适用于复制和移动构造函数,应保持简单,尽可能使用移动构造函数而不是复制构造函数。在需要高度优化的许多情况下,开发者可以删除默认构造函数和复制构造函数,以确保不会创建不必要的或意外的对象副本。
使用引用和指针
许多 C++特性都是围绕通过this
指针隐式访问类成员构建的,因此无论开发者是否明确这样做,通过引用和指针访问都非常频繁。通过指针和引用访问对象与直接访问对象一样高效。这是因为大多数现代处理器都支持高效地获取指针值并解引用它们。使用引用和指针的缺点是它们需要额外的寄存器来存储指针本身,另一个寄存器则用于执行解引用指令以访问指针值所指向的变量。
指针算术与整数算术一样快,除了计算指针之间的差异需要除以对象的大小,这可能会非常慢。如果对象类型的大小是 2 的倍数,这通常是原始类型和优化结构的情况,这并不一定是问题。
智能指针是现代 C++的一个重要特性,它提供了安全性、生命周期管理、自动内存管理和对动态分配对象的清晰所有权控制。由于引用计数开销,智能指针如std::unique_ptr
、std::shared_ptr
和std::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 上Chapter3
的pointer_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
条件,因为随着它们的增长,正确预测它们变得困难。保持条件数量小,并尝试使它们结构化以便更可预测,这是优化它们的方法。 -
for
和while
循环也是分支类型,如果循环计数相对较小,通常可以很好地预测。当然,嵌套循环和包含难以预测的退出条件的循环会使情况变得复杂。 -
switch
语句是具有多个跳转目标的分支,因此它们可能很难预测。当标签值分布广泛时,编译器必须将switch
语句用作一系列的if-else
分支树。与switch
语句一起工作的优化技术是分配按顺序递增的案例标签值,因为它们有很大机会被实现为跳转表,这要高效得多。
在可能的情况下,用包含不同输出值的表查找替换源代码中的分支是一种很好的优化。我们还可以创建一个以跳转条件为索引的函数指针表,但请注意,函数指针不一定比分支本身更高效。
(GitHub 上Chapter3
的loop_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 上Chapter3
的rvo.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 信息来执行转换,并且也应该避免使用。
使用编译时多态
让我们讨论使用运行时多态的替代方案,即使用模板来实现编译时多态。模板类似于宏,意味着它们在编译前被展开,因此不仅消除了运行时开销,还解锁了额外的编译器优化机会。模板使编译器生成的机器代码超级高效,但它们以增加源代码复杂性和更大的可执行文件大小为代价。
使用 CRTP 的 virtual
函数和基类与派生类的关系相似,但略有不同。这里展示了将运行时多态转换为编译时多态的一个简单例子。在两种情况下,派生类 SpecificRuntimeExample
和 SpecificCRTPExample
都重写了 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”);
}
};
在两种情况下调用多态方法
最后,在以下代码片段中,我们展示了如何创建 SpecificRuntimeExample
和 SpecificCRTPExample
对象。然后我们分别使用 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 中Chapter3
的vector.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
指令比较两个寄存器,如果它们相等则设置零标志 -
je
和jne
检查零标志是否被设置,如果设置了则跳转到指定的内存地址(je
),如果没有设置则跳转到指定的内存地址(jne
) -
ret
指令从函数返回,返回值位于eax
寄存器中:main:
.LFB1
Movl $100, %edi
Call _Z9factorialj
当你查看 factorial()
函数本身时,你会找到一个循环(je
和 jne
指令),而不是对自身的额外 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++代码中,有方法可以指定编译器可以安全假设不是别名的指针。另一种方法是指示编译器在整个代码中假设没有指针别名,但这需要开发者分析所有指针和引用,并确保永远不会发生别名,这并不简单。最后,最后一个选项是显式优化代码,同时考虑到这些阻碍编译器优化的因素,这也不简单。
我们关于处理指针别名的建议是执行以下操作:
-
在函数声明中传递指针到函数时,使用
__restrict
关键字来指示编译器假设带有该指定符的指针没有指针别名。 -
如果需要额外的优化,我们建议显式优化代码路径,并意识到指针别名的考虑因素。
-
最后,如果仍然需要额外的优化,我们可以指示编译器在整个代码库中假设没有指针别名,但这是一个危险的选择,并且只能作为最后的手段使用。
浮点归纳变量
编译器通常不使用归纳变量优化来处理浮点表达式和变量。这是因为我们之前讨论过的舍入误差和精度问题。这阻止了编译器在处理浮点表达式和值时的优化。有一些编译器选项可以启用不安全的浮点优化,但开发者必须确保检查每个表达式并以这种方式构建它们,即这些由于编译器优化引起的精度问题不会产生意外的副作用。这不是一个简单任务;因此,开发者应该小心地显式优化浮点表达式或分析不安全编译器优化带来的副作用。
虚函数和函数指针
我们已经讨论过,当涉及到虚函数和函数指针时,由于在许多情况下编译器无法确定在运行时将调用哪个方法,因此编译器无法在编译时进行优化。
了解编译器优化标志
到目前为止,我们已经讨论了编译器使用的不同优化技术,以及编译器未能优化我们的 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.sh
和run_examples.sh
,在设置正确的cmake
和ninja
二进制文件路径后,用于构建和运行这些示例。请注意,这里的cmake
和ninja
是任意构建系统选择,如果需要,你可以将其更改为任何其他构建系统。
这个示例应该相当直观——我们创建并启动两个线程,执行一个模拟任务,即添加传递给它的两个参数(a
和b
)。然后,我们在退出程序之前等待线程完成执行:
#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
和堆分配,同时注意它不是线程安全的。
我们还需要一个变量来跟踪哪些块是空闲的或正在使用的。最后,我们还需要一个变量来跟踪下一个空闲块的位置,以便快速处理分配请求。这里需要注意的一个重要事项是我们有两个选择:
- 我们使用两个向量——一个用于跟踪对象,另一个用于跟踪空闲或空标记。这种解决方案在以下图中展示;请注意,在这个例子中,我们假设这两个向量位于非常不同的内存位置。我们试图说明的是,访问空闲或空标记和对象本身可能会引起缓存未命中,因为它们相距很远。
图 4.1 – 使用两个向量跟踪对象并显示哪些索引是空闲或正在使用的内存池实现
- 我们维护一个结构体(一个结构体、一个类或原始对象)的单个向量,每个结构体存储对象和变量来表示空闲或空标志。
图 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_cast
将 T*
转换为 ObjectBlock*
,这是可以做的,因为对象 T
是 ObjectBlock
的第一个成员。这应该现在解释了我们在 初始化内存池 部分中添加的断言。我们也在这里添加了一个断言,以确保用户尝试释放的元素属于这个内存池。当然,可以更优雅地处理这样的错误情况,但为了简洁和保持讨论在本书的范围内,我们将把这个留给你:
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.h
和logging.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
错误变量与两个可能值EWOULDBLOCK
和EINPROGRESS
的对比:
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.h
和 Chapter4/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.h
和Chapter4/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);
}
最后,我们检查是否设置了EPOLLERR
或EPOLLHUP
标志,这表示有错误或表示套接字从另一端关闭(挂起信号)。在这种情况下,我们将这个套接字添加到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 服务器和客户端的示例
在本节中,我们将构建一个示例,并使用在本节中实现的TCPSocket
和TCPServer
类。这个示例可以在Chapter4/socket_example.cpp
源文件中找到。这个简单的示例创建了一个TCPServer
,它在lo
接口上监听进入的连接,回环127.0.0.1
IP,以及监听端口12345
。TCPServer
类通过使用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:0
…
TCPSocket::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++匹配引擎
-
第七章**,与市场参与者沟通
第五章:设计我们的交易生态系统
上一章我们直接进入了 C++的实战、低延迟开发,其中我们构建了一些将在本书剩余部分使用的基石。现在我们准备开始设计我们的电子交易生态系统,这将是本书剩余部分的主要项目,我们将学习低延迟应用开发的实际原则。首先,我们将讨论我们将为端到端电子交易生态系统构建的不同低延迟组件或应用程序的高层次设计和架构。我们还将设计它们之间的抽象、组件和交互,这些将在本书的其余部分实现。
在本章中,我们将涵盖以下主题:
-
理解电子交易生态系统的布局
-
在交易交易所设计 C++撮合引擎
-
理解交易所如何向参与者发布信息
-
构建市场参与者与交易所的接口
-
设计低延迟 C++交易算法的框架
让我们通过描述我们将在这本书的剩余部分设计和构建的电子交易生态系统的整体拓扑结构来开启这一章。我们将在下一节简要介绍不同的组件,然后在本章的其余部分进行更详细的讨论。有一点需要记住的是,我们将在本书中构建的电子交易生态系统是实践中存在的简化版本。这不仅是一个简化版本,而且它还是所有实际构建和运行完整电子交易生态系统所需组件的一个子集。我们选择在本书中构建的组件是因为它们是最对延迟敏感的组件,我们试图将我们的重点放在低延迟应用开发上。我们想提一下,在实践中,你将发现诸如在交易所和客户端端的历史数据捕获、与清算经纪人的连接、交易处理、会计和结算的后端系统、针对历史数据的回测框架(测试)以及许多其他组件。
理解电子交易生态系统的布局
首先,我们开始提供本书其余部分将要构建的电子交易生态系统的更高层次布局。在深入细节之前,我们首先声明这是一个简化的设计,它代表了电子交易市场实际发生的情况。简化是必要的,以便将范围限制在本书可以涵盖的内容内;然而,这仍然是对实践中发现内容的准确但简化的表示。需要注意的是,这里的目的是理解低延迟应用程序的设计和实现,因此我们要求您更多地关注我们应用 C++和计算机科学原理的应用,而不是交易生态系统本身的细节。
现在,让我们通过定义和解释电子交易生态系统及其组成部分的整体拓扑结构来开始这次介绍。
定义电子交易生态系统的拓扑结构
让我们先通过以下图表提供一个系统的鸟瞰图:
图 5.1 – 简单电子交易生态系统的拓扑结构
主要组成部分,如前图所示,如下所示,根据其属于交易所侧还是交易客户端/市场参与者侧进行高级别划分。
这些是交易所组件:
-
电子交易交易所的匹配引擎
-
交易交易所的订单网关服务器和协议编码器/解码器
-
交易所的市场数据编码器和发布者
这些是交易客户端组件:
-
对该市场数据感兴趣的市场参与者使用的市场数据消费者和解码器
-
市场参与者系统中的订单网关编码器和解码器客户端
-
参与者系统内的交易引擎
我们将在下一节中快速介绍这些组件中的每一个,然后在本书的其余部分详细讨论它们。
介绍电子交易生态系统的组成部分
在这里,我们将快速介绍构成电子交易生态系统的不同组件。需要注意的是,在竞争性生态系统中,每个组件都需要设计成能够以尽可能低的延迟处理事件和数据。另外,请注意,在市场波动加剧的时期,这些系统必须能够跟上并反应市场活动的大幅波动。
介绍市场数据发布者
交易交易所的市场数据发布者负责将匹配引擎维护的限价订单簿的每一项变更传达给市场参与者。与订单网关相比,这里的区别在于市场数据发布者发布的是面向所有参与者的公共数据,并且通常隐藏哪些订单属于哪个参与者的细节,以保持公平性。另一个区别是,订单网关基础设施只向受变更影响的订单所属的市场参与者传达订单更新,而不是所有市场参与者。市场数据发布者可以使用 TCP 或 UDP 来发布市场数据,但鉴于市场数据更新的量大,UDP 组播是首选的网络层协议。市场数据发布者还负责在发布更新之前将内部匹配引擎格式转换为市场数据格式。
介绍匹配引擎
电子交易交易所的匹配引擎是交易交易所最关键的部分。它负责处理市场参与者对其订单的请求,并更新其维护的限价订单簿。这些请求是在客户想要添加新订单、替换现有订单、取消现有订单等情况下生成的。限价订单簿是由所有参与者发送的所有订单汇总到一个中央单一簿中,包括出价(买入订单)和要价(卖出订单)。匹配引擎还负责执行跨价格匹配的订单(即,当买入价格高于或等于卖出价格时,将买入订单与卖出订单匹配)。在特殊市场状态,如PreOpen(市场开盘前),Auction/Opening(市场开盘的瞬间),PreOpenNoCancel(可以输入订单但不能取消),等等,规则略有不同,但我们不会担心这些规则或实现它们,以保持对低延迟应用开发的关注。
介绍交易所的订单网关服务器
交易所的订单网关服务器负责接受市场参与者的连接,以便他们可以发送订单请求并在其相应订单有更新时接收通知。订单网关服务器还负责在匹配引擎格式和订单网关消息协议之间翻译消息。用于订单网关服务器的网络协议始终是 TCP,以确保消息的有序交付和可靠性。
在市场参与者层面引入市场数据消费者
市场数据消费者是市场参与者侧交易所市场数据发布组件的补充。该组件负责订阅由市场数据发布者设置的上 UDP 流或 TCP 服务器,消费市场数据更新,并将市场数据协议解码成交易引擎其他部分使用的内部格式。
介绍订单网关编解码器客户端
订单网关客户端组件是市场参与者侧交易所订单网关服务器的补充。该组件的职责是建立并维护与交易所订单网关基础设施的 TCP 连接。它还负责将策略订单请求编码为正确的交易所订单消息协议,并将交易所响应解码成交易引擎使用的内部格式。
在市场参与者系统中介绍交易引擎
交易引擎是市场参与者交易系统的核心。这是智能所在之处,也是交易决策做出的地方。该组件负责从市场数据消费者组件中消费标准化市场数据更新。它通常还会构建完整的限价订单簿,以反映市场状态,或者至少是订单簿的简化版本,这取决于交易策略的要求。它通常还会在订单簿的流动性和价格基础上构建分析,并做出自动化的交易决策。该组件使用订单网关客户端组件与交易交易所进行通信。
现在我们已经介绍了电子交易生态系统中涉及的主要组件,我们将更详细地研究这些组件。首先,我们将从位于电子交易交易所系统中的匹配引擎开始。
在交易交易所设计 C++ 匹配引擎
在本节中,我们将讨论上一节中介绍的电子交易交易所系统内的匹配引擎组件。我们首先要做的是理解匹配引擎的作用以及为什么需要它。
理解匹配引擎的目的
在由单一交易交易所组成的电子交易生态系统中,通常情况下,有一个交易所负责接受和管理来自众多市场参与者的订单。在这种情况下,匹配引擎接受参与者可以为任何给定交易工具发送的不同类型的订单。订单是任何市场参与者向交易交易所发送的请求,以传达他们对购买或出售可交易产品的兴趣。每当匹配引擎从订单网关服务器基础设施接收到新订单时,它会检查这个新订单是否与现有订单的相反方交叉,以确定是否发生交易。对于本书的目的,我们假设市场参与者只发送限价订单并指定订单方向、数量和价格。限价订单是只能以市场参与者指定的价格或更好的价格执行的订单。
到现在为止,应该很明显,匹配引擎执行的是最关键的任务,即在不同市场参与者之间执行订单匹配,并且正确、公平地执行。这里的公平是指首先处理到达交易所的订单,这种先进先出(FIFO)的排序在订单网关基础设施中处理,我们将在稍后讨论。那些未能立即匹配的订单留在簿中,被称为被动订单。当新的订单以跨过被动订单的价格进入时,这些订单才有资格进行匹配。这种跨过被动订单价格的订单被称为积极订单。
匹配引擎将所有市场参与者发送的所有被动订单排列到一个称为订单簿的数据结构中。这个订单簿的细节将是我们下次讨论的主题。
理解交易所订单簿
限价订单簿包含了所有市场参与者针对单一交易工具的所有被动限价订单。这些订单通常按照从最高买入价到最低买入价排列被动买入订单,以及从最低卖出价到最高卖出价排列被动卖出订单。这种排序方式直观且自然,因为被动买入订单是从最高买入价到最低买入价进行匹配,而被动卖出订单是从最低卖出价到最高卖出价进行匹配。对于同一方和相同价格的订单,它们将根据发送时间按照先进先出(FIFO)的顺序排列。请注意,FIFO 只是排序标准之一;现代电子交易市场有不同的匹配算法类型,例如按比例分配(Pro Rata)以及一些 FIFO 和按比例分配的混合。按比例分配是一种匹配算法,其中较大的订单无论在 FIFO 队列中的位置如何,都会从积极订单中获得更大的成交量。对于我们的匹配引擎,我们只将实现 FIFO 匹配算法。
要完全理解订单簿的工作原理,我们将查看市场发生的一些场景以及它们如何影响订单簿。让我们首先确定订单簿的初始状态。假设有三个不同的市场参与者——客户 A、B 和 C 在买卖双方有订单。
客户 A 订单号 1 买入 20 @ 10.90 | 客户 B 订单号 5 卖出 10 @ 11.00 |
---|---|
客户 A 订单号 2 买入 10 @ 10.80 | 客户 C 订单号 6 卖出 5 @ 11.00 |
客户 B 订单号 3 买入 5 @ 10.80 | 客户 B 订单号 7 卖出 5 @ 11.10 |
客户 C 订单号 4 买入 100 @ 10.70 |
表 5.1 – 包含一些订单的初始限价订单簿状态
在这里,客户 A 有 2 个被动买入订单,数量分别为 20 和 10,价格分别为 10.90 和 10.80。客户 B 有一个数量为 5 的买入订单,价格为 10.80,以及 2 个卖出订单,数量分别为 10 和 5,价格分别为 11.00 和 11.10。客户 C 有 2 个被动订单,分别是数量为 5 的买入订单和数量为 5 的卖出订单,价格分别为 10.80 和 11.00。现在,假设客户 A 发送一个新的买入订单,数量为 10,价格为 10.90,而客户 B 发送一个新的卖出订单,数量为 10,价格为 11.20。更新的订单簿如下表所示,新的订单被突出显示。由于 FIFO 排序,新的订单号OrderId=8在相同价格下位于订单号OrderId=1的买入订单之后。
客户 A 订单号 1 买入 20 @ 10.90 | 客户 B 订单号 5 卖出 10 @ 11.00 |
---|---|
客户 A 订单号 8 买入 10 @ 10.90 | 客户 C 订单号 6 卖出 5 @ 11.00 |
客户 A 订单号 2 买入 10 @ 10.80 | 客户 B 订单号 7 卖出 5 @ 11.10 |
客户 B 订单号 3 买入 5 @ 10.80 | 客户 B 订单号 9 卖出 10 @ 11.20 |
客户 C 订单号 4 买入 100 @ 10.70 |
表 5.2 – 新增订单后的更新订单簿
现在,假设客户 A 将订单号OrderId=2的数量从 10 增加到 20。当以这种方式增加订单的数量时,该订单在 FIFO 排序中会失去优先级,并移至该价格级别的队列末尾。我们还假设客户 B 将订单号OrderId=5的数量从 10 减少到 1。请注意,根据市场规则,当减少订单的数量时,它不会失去队列中的优先级,并且仍然保持在原位。更新的订单簿如下所示,受影响的订单被突出显示:
客户 A 订单号 1 买入 20 @ 10.90 | 客户 B 订单号 5 卖出 1 @ 11.00 |
---|---|
客户 A 订单号 8 买入 10 @ 10.90 | 客户 C 订单号 6 卖出 5 @ 11.00 |
客户 A 订单号 3 买入 5 @ 10.80 | 客户 B 订单号 7 卖出 5 @ 11.10 |
客户 B 订单号 2 买入 20 @ 10.80 | 客户 B 订单号 9 卖出 10 @ 11.20 |
客户 C 订单号 4 买入 100 @ 10.70 |
表 5.3 – 修改订单后的订单簿状态
最后,让我们假设客户端 A 将订单 ID 为 4 的买入订单从 10.70 的价格修改为 10.90 的价格,数量没有变化。此订单操作的影响相当于取消订单并以新价格发送新订单。让我们还假设客户端 B 决定他们不再需要订单 ID 为 9 的卖出订单,并发送了取消请求。由于这两个操作,下一个显示的更新订单簿中,修改后的订单被突出显示,取消的订单已从订单簿中移除:
客户端 A 订单 ID 1 买入 20 @ 10.90 客户端 B 订单 ID 5 卖出 1 @ 11.00 |
---|
客户端 A 订单 ID 8 买入 10 @ 10.90 客户端 C 订单 ID 6 卖出 5 @ 11.00 |
客户端 C 订单 ID 4 买入 100 @ 10.90 客户端 B 订单 ID 7 卖出 5 @ 11.10 |
客户端 A 订单 ID 3 买入 5 @ 10.80 |
客户端 B 订单 ID 2 买入 20 @ 10.80 |
表 5.4 – 修改和取消操作后的限价订单簿状态
到目前为止,在我们讨论的场景中,还没有发生交易,因为订单活动是这样的,所有买入订单的价格都低于所有卖出订单的价格。让我们在下一节中继续讨论,看看当有一个可以跨越买入或卖出订单价格的积极订单时会发生什么,以及它会产生什么影响。
关于订单修改,有两点需要注意如下:
-
当订单修改以减少数量时,订单在队列中的优先级或位置不会改变
-
当订单修改以增加数量或修改订单价格时,它具有取消订单并发送带有新价格和数量值的订单的等效效果(即,将分配一个新的优先级)
在下一节中,我们将探讨匹配引擎需要执行的下一个大任务——匹配相互交叉的参与者订单。
匹配参与者订单
在本节中,我们将了解当市场参与者修改现有订单或以某种方式发送新订单,使得该订单的价格将导致与另一侧的现有被动订单匹配时会发生什么。在这种情况下,匹配引擎将此积极订单与从最积极到最不积极的价位的被动订单进行匹配。这意味着被动买单从最高到最低的买入价格进行匹配,被动卖单从最低到最高的卖出价格进行匹配。在被动订单未完全匹配,因为积极订单的数量小于另一侧的被动流动性时,剩余的流动性将保留在订单簿中。在积极订单未完全匹配,因为另一侧的被动流动性少于积极订单的数量时,剩余的数量将作为被动订单保留在订单簿中。
让我们了解匹配参与订单的不同情况,并假设订单簿的状态是我们在上一个部分留下的,如下所示:
客户 A 订单号 1 买入 20 @ 10.90 | 客户 B 订单号 5 卖出 1 @ 11.00 |
---|---|
客户 A 订单号 8 买入 10 @ 10.90 | 客户 C 订单号 6 卖出 5 @ 11.00 |
客户 C 订单号 4 买入 100 @ 10.90 | 客户 B 订单号 7 卖出 5 @ 11.10 |
客户 A 订单号 3 买入 5 @ 10.80 | |
客户 B 订单号 2 买入 20 @ 10.80 |
表 5.5 – 任何订单匹配之前的订单簿初始状态
现在,让我们假设客户 C 发送了一个数量为 50,卖出价格为 10.90 的卖出订单。这将导致卖出订单与 订单号=1 和 订单号=8 的买入订单完全匹配,并且与 订单号=4 的买入订单部分匹配,数量为 20,剩余数量为 80。完全匹配的订单将从订单簿中移除,部分匹配的订单将修改为新剩余数量。这次匹配交易后的更新订单簿如下所示:
客户 C 订单号 4 买入 80 @ 10.90 | 客户 B 订单号 5 卖出 1 @ 11.00 |
---|---|
客户 A 订单号 3 买入 5 @ 10.80 | 客户 C 订单号 6 卖出 5 @ 11.00 |
客户 B 订单号 2 买入 20 @ 10.80 | 客户 B 订单号 7 卖出 5 @ 11.10 |
表 5.6 – 反映激进订单和部分执行影响的订单簿
现在,让我们假设客户 A 发送了一个数量为 10,买入价格为 11.00 的买入订单。这将完全匹配 订单号=5 和 订单号=6 的卖出订单,并且激进买入订单上的剩余未匹配数量作为被动买入订单留在簿中。这次匹配交易后的更新订单簿如下所示:
客户 A 订单号 9 买入 4 @ 11.00 | 客户 B 订单号 7 卖出 5 @ 11.10 |
---|---|
客户 C 订单号 4 买入 80 @ 10.90 | |
客户 A 订单号 3 买入 5 @ 10.80 | |
客户 B 订单号 2 买入 20 @ 10.80 |
表 5.7 – 完全执行后的订单簿和激进方剩余数量
现在我们已经了解了在匹配引擎中会遇到的大量常见交互以及它们是如何处理的,以及它们与限价订单簿的交互方式,我们可以设计本书中将要构建的匹配引擎。
设计我们的匹配引擎
我们将在本书的剩余部分实现本章中讨论的每个 C++ 电子商务生态系统组件。然而,在我们开始下一章之前,了解这些组件的架构对于使实现细节更容易和更清晰是很重要的。我们在这里只展示了 图 5**.1 中的匹配引擎组件,这样我们可以更详细地讨论我们的匹配引擎设计:
图 5.2 – 匹配引擎组件设计
与图 5**.1相比,我们在本图中提供了更多细节,接下来我们将讨论匹配引擎的主要设计选择。
线程模型
在我们的系统中,匹配引擎、市场数据发布者和订单网关服务器将是独立的线程。这是故意的,以便每个组件都可以独立运行,在市场活动高峰期间,整个系统可以达到最大吞吐量。此外,每个组件还需要执行其他任务——例如,订单网关服务器必须与所有市场参与者保持连接,即使匹配引擎正忙也是如此。同样,让我们假设市场数据发布者正忙于在网络中发送市场数据;我们不希望匹配引擎或订单网关服务器减慢速度。我们已经在上一章的“为低延迟应用构建 C++构建块”部分中的“C++多线程低延迟应用”部分中看到了如何创建线程、设置它们的亲和性以及为它们分配任务。
线程间的通信
在这里需要讨论的另一件重要事情是匹配引擎与订单网关服务器基础设施之间的通信。订单网关服务器将来自市场参与者的订单请求序列化,并将它们转发给匹配引擎进行处理。匹配引擎需要为订单请求生成响应并将它们发送回订单网关服务器。此外,它还需要通知订单网关服务器参与者订单上的执行情况,以便他们可以了解交易情况。因此,需要一个双向队列,或者从订单网关服务器到匹配引擎的一个队列,以及从匹配引擎到订单网关服务器的一个队列。
另一个通信渠道是当匹配引擎生成并发送市场数据更新,以反映公共市场数据发布组件的限价订单簿更新状态时。
最后,由于匹配引擎、订单网关服务器和市场数据发布者都是不同的线程,这里我们找到了一个无锁队列的完美案例。我们将使用我们在上一章的“使用无锁队列传输数据”部分中创建的无锁 FIFO 队列。
限价订单簿
最后,对于限价订单簿,我们将使用几种不同的数据结构来高效地实现它。在不深入具体实现细节(我们将在下一章中探讨)的情况下,我们需要在双方都保持正确的排序顺序来维护买卖报价,以便在积极订单到来时进行高效的匹配。我们需要能够高效地在价格级别中插入和删除订单,以支持基于客户请求的添加、修改和删除订单等操作。在这里,另一个特别重要的考虑因素是,我们使用的数据结构和订单对象本身必须避免动态内存分配,并尽可能少地复制数据。我们将在上一章的“设计 C++内存池以避免动态内存分配”部分中大量使用我们创建的内存池。
理解交易所如何向参与者发布信息
上一节专门讨论了匹配引擎的细节,在讨论中,我们假设匹配引擎从订单网关服务器基础设施接收市场参与者的订单请求。我们还隐含地假设匹配引擎会将其维护的限价订单簿的变化通知给所有监听市场数据馈送的市场参与者。在本节中,我们将讨论匹配引擎依赖以与市场参与者通信的市场数据发布者和订单网关服务器组件。
通过市场数据通信市场事件
让我们先讨论市场数据发布者组件。这个组件负责将匹配引擎维护的限价订单簿的更新转换为。我们之前提到,市场数据网络层协议可以是 TCP 或 UDP,但通常,实践中首选的协议是 UDP,我们将在我们的市场数据发布者中也使用这个协议。
简而言之,市场数据协议代表了市场数据发布者通过 UDP(或在某些情况下 TCP)协议发布消息的格式。FIX Adapted for STreaming(FAST)是目前许多电子交易交易所使用的最知名和最受欢迎的市场数据消息格式。还有其他协议,如ITCH、PITCH、Enhanced Order Book Interface(EOBI)、Simple Binary Encoding(SBE)等,但为了本书的目的,我们将创建一个简单的自定义二进制协议,如 EOBI 或 SBE,我们将使用它。
由于 FIX 是金融应用中最常用的协议,我们将在此处介绍一些细节。FIX 数据组织成一系列标签
=值
样式的字段。通过一个简单的例子更容易理解这一点,所以对于一个假设的市场数据更新,你可能收到以下一系列字段来传达该更新的所有数据。这个假设的市场数据更新对应于向苹果公司股票(股票代码 AAPL,数值安全 ID 68475)添加一个数量为 1,000 的新买盘订单,价格为 175.16。
标签 | 修正名称 | 值 | 描述 |
---|---|---|---|
268 | NoMDEntries | 1 | 市场数据更新数量 |
279 | MDUpdateAction | 0 (New) | 市场数据更新类型 |
269 | MDEntryType | 0 (Bid) | 市场数据条目类型 |
48 | SecurityID | 68475 (AAPL) | 交易产品的整数标识符 |
270 | MDEntryPx | 175.16 | 本市场数据更新的价格 |
271 | MDEntrySize | 1000 | 本市场数据更新的数量 |
... | ... | ... | ... |
表 5.8 – 一个对应于假设市场数据更新的 FIX 消息示例
构成市场数据协议的不同类型的信息大致可以分为以下几类:
图 5.3 – 交易所发送的不同市场更新
让我们接下来讨论这些内容。
市场状态变化
这些消息通知市场参与者关于市场变化和/或撮合引擎状态的变化。通常,市场会经历诸如关闭(用于交易)、开盘前(常规交易会话前的市场状态)、开盘(当市场从开盘前状态过渡到交易状态时)和交易(常规交易会话)等状态。
仪器更新
交易所使用仪器更新消息来通知市场参与者关于可用于交易的不同仪器。一些交易所支持市场参与者可以即时创建的特殊类型的仪器,并且这些消息用于通知参与者此类仪器的变更。通常,这些消息用于通知参与者有关仪器元数据,例如最小价格增量、刻度值等。最小价格增量是订单价格之间的最小价格差异。在我们迄今为止看到的例子中,我们假设最小价格增量是 0.10(即有效价格是 0.10 的倍数)。刻度值是我们以比单个最小价格增量大的价格买卖时,所赚取或亏损的金额。非常常见的是,对于股票、交易所交易基金(ETF)等产品,刻度乘数仅为 1,这意味着盈亏仅仅是买卖价格之差。ETF 是在交易所交易的证券,是一种投资选择,由一篮子证券组成,即通过投资 ETF,你投资的是构成该 ETF 的资产组合。但对于一些杠杆产品,如期货、期权等,这个刻度乘数可能不是 1,最终的盈亏计算如下:
((sell-price – buy-price) / min-price-increment) * trade-qty *
tick-size
.
订单更新
市场数据发布者使用订单更新消息来传达对匹配引擎维护的限价订单簿中订单的变化——具体来说,是对我们在“设计交易交易所中的 C++ 匹配引擎”部分的“理解交易所订单簿”子节中讨论的类似订单簿的更新。通常,不同类型的订单更新消息如下:
-
instrument-id
、order-id
、price
、side
、quantity
和priority
。这里的priority
字段用于指定订单在该价格下的 FIFO 队列中的位置。 -
订单修改 – 交易所使用此消息让参与者知道一个被动订单在价格或数量或两者都进行了修改。此消息具有与订单添加消息类似的字段。如前所述,在大多数情况下(除非订单数量减少),将为订单修改事件分配一个新的订单优先级值。
-
instrument-id
和order-id
用于指定从订单簿中删除的订单。
交易消息
交易消息由交易所用于通知市场参与者市场发生了匹配。通常,这里的属性包括instrument-id
、积极订单的一侧、交易的执行价格和交易量。通常,当发生交易时,交易所还会发布所需数量的订单删除、订单修改和订单添加消息,以传达有关哪些订单已完全执行和/或部分执行,需要从簿中删除或修改以反映订单簿的新状态。
市场统计数据
这些是某些交易所发布的一些可选消息,用于传达有关交易工具的不同类型的统计数据。这些统计数据可以是有关交易工具的交易量、未平仓合约、最高价、最低价、开盘价和收盘价等信息。
我们已经讨论了关于市场数据消息类型及其试图传达的信息的许多细节。现在,我们准备设计我们将在我们的电子交易交易所中构建的市场数据发布器。
设计市场数据发布器
让我们讨论一下我们将在我们的电子交易所中实施的几个市场数据发布器设计细节。在这里,我们只展示了图 5.1中的市场数据发布器,以便我们可以更详细地讨论设计。
图 5.4 – 我们市场数据发布器基础设施的设计
市场数据发布器基础设施有两个主要组件。它们都使用我们在上一章的使用套接字的 C++网络编程部分中构建的套接字实用程序,将市场数据放在线上。这还包括我们构建的线程库,它将被用于创建、启动和运行市场数据发布器线程。
市场数据协议编码器
市场数据发布器基础设施内部的协议编码器组件负责对由匹配引擎发布的市场数据更新进行编码。市场数据编码器消耗反映订单簿变化的订单簿更新,并将它们转换为带有一些附加信息的公共市场数据消息格式。此组件还将增量市场数据更新发布到为增量流配置的 UDP 多播流。请记住,增量流仅包含可用于更新订单簿的市场更新,前提是参与者在增量更新之前对限价订单簿有准确的看法。编码后的市场数据更新还会发布到快照合成器组件,我们将在下一节中更详细地讨论。
市场数据流在网络流量方面通常非常大,并且在市场波动性高的时期会经历大量的活动爆发。由于 TCP 协议因为消息收到的确认和丢失数据的重传而增加了额外的带宽,通常情况下,UDP 是市场数据的首选网络协议。通过 UDP 的多播流进行流式传输也是首选,因为市场数据可以在多播流中一次性分发,所有感兴趣的订阅者都可以订阅该流,而不是通过 TCP 与每个市场数据消费者建立一对一的连接。这种设计并非没有缺点,即市场数据消费者可能会因为网络拥塞、硬件或软件缓慢等原因丢弃 UDP 数据包。当这种情况发生时,交易客户端维护的订单簿是不正确的,因为他们可能丢失了一个与新增订单、修改或取消订单等相对应的更新。这正是快照多播流解决的问题,我们将在后续章节中通过示例来探讨,但在下一节中,我们将简要介绍快照合成器组件。
快照合成器
快照合成器消费由市场数据协议编码器发布的编码市场数据更新,合成最新的限价订单簿快照,并将快照定期发布到快照多播流中。这里的重要点是快照合成不会干扰增量流发布,以便尽可能快地发布订单簿的增量更新。这是一个独立的执行线程,其唯一责任是根据增量更新生成订单簿的准确快照。此组件还在快照更新上添加正确的序列信息,以便在发布到快照 UDP 多播流之前在客户端端同步。这意味着在它发送的快照消息中,它将提供用于合成此快照消息的增量流中的最后一个序列号。这很重要,因为下游市场数据消费者客户端可以使用增量流中最后更新的这个序列号来执行成功的同步/追赶。当我们构建我们的市场数据发布器和市场数据消费者组件时,这一点将变得非常清楚,因为那时我们将通过示例涵盖所有细节。另一件需要理解的事情是,适用于我们系统中其他组件的低延迟标准不适用于这里,因为这是一个延迟和子采样的信息流。此外,客户端端预期数据包丢失将极为罕见,客户端端的快照同步过程较慢,因此尝试使此组件超低延迟是不必要的。对于我们的快照合成器组件,我们也将使用 UDP 协议以保持简单,但在实践中,这通常是 TCP 和 UDP 协议的组合。对于低延迟的市场参与者,预期 UDP 流中的数据包丢失将很少,因为通常,到交易所的网络连接以及沿途的交换机具有大带宽容量和低交换延迟。此外,参与者投入资源采购和安装超级快速的服务器,构建低延迟的市场数据消费者软件,并使用特殊的网络接口卡(NICs)来处理大量市场数据。
这本书中我们将构建的市场数据发布器基础设施的高级设计到此结束。接下来,我们需要讨论交易所用来通知市场参与者关于其订单请求响应以及订单何时被执行的其他渠道——订单网关接口。
通过订单网关接口通知市场参与者
我们讨论了市场数据消费者被电子交易交易所用来传播有关订单簿变化和交易所提供的不同交易工具匹配情况等公共信息。这里的要点是,这是对所有人开放的公共市场数据,只要他们能够访问并订阅市场数据流,就可以获取这些数据。本节将讨论交易所用来与市场参与者沟通其订单更新情况的另一个接口——订单网关接口。
公共市场数据馈送提供的信息与订单网关基础设施提供的信息之间有几个关键区别。
理解网络协议的差异
我们之前已经提到过,但在这里我们将再次强调,通常,市场数据发布者在网络层面使用 UDP 协议,而订单网关基础设施在其与市场参与者的连接中使用 TCP 协议。这是因为市场数据发布者发布的数据量非常大,需要尽可能快地发布,因此选择了 UDP 而不是 TCP。市场数据发布者通常有额外的同步机制来处理 UDP 上罕见的包丢失。订单网关基础设施依赖于 TCP,因为它需要一个可靠的方法与客户端通信,没有 TCP,这里的包丢失很难优雅地处理。直观上,如果客户端不确定他们的订单是否到达了交易所,或者不确定他们的订单更新或匹配时是否立即收到了通知,这将是一个大问题。
区分公共信息和私人信息
市场数据发布者与订单网关基础设施之间可能最大的区别是,市场数据发布者发布公共信息,同时隐藏一些敏感信息,例如订单属于哪个客户或哪些客户参与了匹配交易。这些信息也对所有市场参与者公开,目的是用来构建限价订单簿,以反映交易工具的状态。另一方面,订单网关服务器只向拥有正在更新的订单的客户发布订单更新通知。另一种思考方式是,为了接收和处理公共市场数据,参与者不需要在订单簿中有任何订单。但为了接收私人订单网关通知,参与者必须在订单簿中有订单,否则,交易所没有可以私下通知客户的内容。
为参与者发送订单请求
现在应该很明显的一个主要区别是,订单网关组件发送促进了双向通信通道。这意味着客户端可以向交易所发送订单请求,如新订单、修改订单、取消订单等。另一方面,正如我们讨论的那样,交易所使用订单网关基础设施向市场参与者发送订单的私有通知。市场数据发布者基础设施通常不处理任何客户端请求(即,通信路径仅从交易所到市场数据订阅者)。
我们需要在电子交易交换的一侧设计最后一个发送组件,即我们刚才讨论的订单网关基础设施;让我们在下一节中完成这项工作。
设计订单网关服务器
让我们讨论一下我们将在电子交易所中实现的订单网关服务器的一些设计细节。在这里,我们只展示了图 5.1的订单网关服务器基础设施,以便我们可以更详细地讨论我们的订单网关服务器的设计。
图 5.5 – 我们订单网关服务器基础设施的设计
与图 5.1相比,此图提供了更多细节,并分解了订单网关服务器基础设施子组件的一些细节。
TCP 连接服务器/管理器
订单网关基础设施内部的第一组件是 TCP 连接管理器。该组件负责设置一个 TCP 服务器,该服务器监听并接受来自市场参与者订单网关客户端的传入 TCP 连接。它还负责检测断开连接的客户端并将它们从活动连接列表中移除。最后,该组件需要将来自撮合引擎的订单响应转发给正确的客户端。我们将使用在前一章“为低延迟应用构建 C++构建块”中实现的套接字工具、TCP 套接字和 TCP 服务器功能。
FIFO 序列器
另一个这个组件需要完成的任务是在处理市场参与者的请求时保持公平性。如前所述,为了保持公平性,客户端响应必须按照它们在交易所基础设施中接收的确切顺序进行处理。因此,FIFO 序列器必须确保它按照接收顺序将客户端请求转发给匹配引擎,这些请求是通过 TCP 连接管理器维护的不同客户端连接发送的。
交易所消息协议解码器和编码器
编码器-解码器组件负责在交换消息协议和匹配引擎期望客户端请求的内部结构之间进行转换,并发布客户端响应。根据交换协议的复杂性,这可以简单到将正确的字段打包和提取到打包的二进制结构中。如果交换消息格式更复杂,那么将涉及额外的编码和解码步骤。对于本书的目的,我们将有一个简单的交换订单消息协议,它使用打包的二进制结构,并在交换匹配引擎使用的格式之上包含额外的信息。
这结束了我们对电子交易交易所的讨论,现在我们可以继续构建一个希望在这个交易所进行交易的参与者的客户端基础设施。
构建市场参与者对交易所的接口
我们现在将讨论市场参与者系统中组件的目的和设计。具体来说,我们将从讨论客户端交易系统中的市场数据消费者开始,它订阅、消费并解码交易所发布的公共市场数据。我们还将讨论客户端交易系统中的订单网关客户端基础设施,它连接到交易所订单网关服务器。订单网关客户端还负责向交易所发送订单请求并接收和解析客户端订单的响应。
理解市场数据消费者基础设施
市场数据消费者组件是电子交易交易所中市场数据发布组件的直接补充。它负责订阅和消费交易所发布的多播网络流量,解码和标准化从交易所协议读取的市场数据到内部格式,并实现与数据包丢失相关的同步机制。
订阅和消费 UDP 多播流量
第一项也是最为明显的任务是订阅交易所发布市场数据的多播流。通常,为了负载均衡,交易所将不同的交易工具分组在不同的多播流地址上。这使得客户端可以根据客户感兴趣的交易工具和产品选择交易所发布的数据的子集。通常,这涉及到客户端加入正确的多播流,这些地址是交易所公开的信息。
从交易所协议解码和标准化
市场数据消费者接下来需要做的是将交易所市场数据协议转换为参与者系统中其他组件使用的内部格式。根据交易所市场数据协议的不同,这个组件的部分可能在复杂性和性能延迟方面有所不同。最快的协议是那些需要最小解码的协议,如 EOBI 和 SBE,它们只是二进制打包的结构。这意味着市场数据格式是这样的,解码流仅仅涉及将字节流重新解释为我们期望在流中找到的二进制打包结构,并且速度是最快的。更复杂的协议,如 FAST,通常需要更长的时间来解码和标准化。
启动同步和包丢失同步
记住我们讨论过,通常交易所更倾向于使用 UDP 作为网络协议来向参与者传输市场数据。虽然这加快了数据向客户端的传输并实现了更高的吞吐量,但这同时也使我们容易受到 UDP 不可靠性导致的包丢失和乱序交付的影响。为了确保市场参与者能够以正确的顺序看到市场数据包,并在发生时检测到包丢失,通常,参与者需要检查包级和工具级序列号。
需要在交易所市场数据发布者和参与者市场数据消费者之间设计的一个机制是,从这样的包丢失中恢复。这个相同的机制也被在市场已经开放后加入市场数据流或如果参与者需要出于任何原因重新启动他们的市场数据消费者组件的参与者所使用。在所有这些情况下,客户端交易系统中的市场数据消费者需要进行一些同步,以获取当前和完整的限价订单簿状态。
本节解释了实现这种同步的常用设计。通常,交易所市场数据流被分为两大组——快照流和增量流。我们将解释为什么会有这两个流以及它们如何帮助市场参与者处理包丢失的情况。
增量市场数据流
增量市场数据流假设市场参与者已经拥有由撮合引擎维护的正确视图的限价订单簿,并且这个流只发布订单簿之前状态的增量更新。这意味着这个流的带宽需求要低得多,因为它只发布对簿的增量更新。通常,在正常运营条件下,预期市场参与者只订阅增量流以维护订单簿的正确状态。
如果客户端从这个流中丢弃一个数据包,那么他们维护的订单簿状态可能与匹配引擎所拥有的状态不一致。处理这种故障的机制是清除或重置参与者维护的订单簿。然后它需要订阅快照流,该流包含整个订单簿完整状态的数据(而不是只有增量更新)以再次同步到订单簿的正确状态。这里的协议是清除订单簿,开始排队从增量流接收到的增量更新,并等待构建订单簿的完整状态,然后将增量更新应用到该完整订单簿以完成同步。现在,让我们了解一些交易所发布的快照市场数据流的一些更多细节。
快照市场数据流
正如我们之前提到的,快照市场数据流包含可以从完全空状态构建完整订单簿的数据。通常,这个流只包含一个详尽的列表,对应于订单簿中存在的每个被动订单的“订单添加”消息。交易所通常会限制这个列表更新的频率和发布,这意味着它可能每几秒钟才发送一次快照消息流。这是因为,由于这个流包含关于每个交易工具订单簿中所有订单的信息,它可能会变得相当带宽密集。此外,由于数据包丢失极为罕见,并且参与者不介意在启动时等待几秒钟以获取订单簿的正确状态,因此节流通常不会产生很大的负面影响。
这就结束了市场数据协议和同步过程的讨论,因此现在我们可以设计我们将要实现的市场数据消费者。
设计市场数据消费者
让我们讨论一下我们将在我们的市场参与者交易系统中实现的市场数据消费者的一些设计细节。我们在这里只展示 图 5.1 中的市场数据消费者,以便我们可以更详细地讨论设计。
图 5.6 – 我们市场数据消费者基础设施的设计
让我们讨论市场参与者交易系统中市场数据消费者基础设施设计时的两个主要子组件。它们都使用我们在上一章的 C++ 网络编程使用套接字 部分中构建的套接字实用程序来订阅和消费网络上的市场数据。
快照和增量流同步器
市场数据消费者需要有一个子组件,除了订阅增量流外,还可以订阅快照流。记住,我们解释过,当市场参与者的系统首次启动或在白天中需要重启,或者从增量流中丢失市场数据包时,它没有正确的限价订单簿视图。在这种情况下,正确的恢复/同步程序是清除限价订单簿,订阅快照流,并等待收到完整的订单簿快照。此外,继续通过增量市场数据流传入的更新需要排队。一旦收到完整的快照,并且从快照中最后更新的序列号开始的全部增量更新都已排队并可用,我们就算完成了。此时,限价订单簿将从快照流中重建,并将所有排队的增量更新应用到这本书上以同步/赶上交易所。此时,消费者可以停止从快照流中消费数据并离开快照流,只从增量流中消费数据。负责此同步机制的市场数据消费者基础设施中的组件,我们将称之为快照和增量流同步子组件。
市场数据协议解码器
另一个子组件负责解码来自快照和/或增量市场数据流的传入数据流。该组件将数据从交易所数据源格式转换为交易策略框架的内部格式。这通常是交易所提供的字段的一个子集,并且通常在不同交易交易所之间进行标准化,以使交易策略框架独立于交易所特定的细节。对于我们的市场数据消费者基础设施,我们将保持此组件相当简单,因为我们将会使用打包的二进制结构,但如前所述,在实践中,这可以是一个更复杂的格式,例如 FAST。
我们讨论了市场参与者系统如何从交易所消费公共市场数据流的细节和设计。我们可以继续讨论订单网关客户端基础设施,参与者使用它来发送订单请求并接收响应和执行通知。
理解订单网关客户端基础设施
市场参与者交易系统中的订单网关客户端基础设施是一个连接到交易所订单网关服务器的 TCP 客户端。该组件执行的另一项任务是接收通过此 TCP 连接从交易所发送的更新,将接收到的交易所订单消息协议解码成用于整个系统的标准化内部格式。最后,订单网关客户端组件还负责接收交易框架请求的订单操作,并将它们编码成交易所理解的订单消息格式,然后将其发送到交易所。
这里需要记住的重要事情是,订单网关客户端必须始终与交易所保持可靠的 TCP 连接。这是为了确保交易所不会错过任何来自客户端的订单请求,并且客户端不会错过来自交易所的任何订单更新。除了 TCP 网络协议本身实现的可靠性机制外,通常还存在着由交易所和参与者实现的应用级可靠性机制。这种应用级可靠性机制通常包括在从交易所发送到客户端和从客户端发送到交易所的消息上严格递增序列号。此外,还可以实施心跳机制,这些机制简单地说就是从交易所发送到客户端和从客户端发送到交易所的消息,以检查在低活动期间连接是否仍然活跃。
此外,当客户端首次连接时,存在一些机制来验证和识别客户端,这通常是通过握手机制实现的,包括用户识别和密码等。还可能有额外的管理消息,例如登录认证消息,这取决于交换方式,并且可以有多种用途。对于本书的目的,我们将限制范围,不关注这些管理消息,因为它们对我们低延迟的目标无关紧要。
接下来,让我们设计我们的订单网关客户端基础设施。
设计订单网关客户端基础设施
让我们讨论一下我们将在市场参与者交易系统中实现的市场数据发布者的设计细节。在这里,我们只展示了图 5.1中的订单网关客户端,以便我们可以更详细地讨论设计。
图 5.7 – 我们订单网关客户端基础设施的设计
市场参与者交易系统中的订单网关客户端由两个简单的组件组成。
TCP 连接管理器
市场参与者交易系统中的订单网关客户端负责连接到交易所订单网关服务器并管理该连接。在实践中,单个参与者可能会出于负载均衡、冗余和延迟的原因与交易所建立多个连接。但在我们将要构建的电子交易生态系统中,我们将设计它,使得订单网关客户端仅与交易所订单网关服务器建立一个连接。我们将使用上一章在“使用套接字进行 C++网络编程”部分构建的 TCP 套接字客户端库。
订单网关协议编码器和解码器
订单消息格式编码器和解码器将交易策略使用的内部格式转换为交易所格式,并将交易所的订单响应和执行通知转换为策略框架的内部格式。这个组件的复杂性会根据交易所格式而变化,但为了我们的交易系统,我们将通过使用二进制打包结构来保持编码和解码的复杂性较低。
接下来,我们将从对订单网关基础设施的讨论转向参与者系统中最复杂(也是最有趣)的组件——交易策略框架。
设计低延迟 C++交易算法框架
现在我们已经讨论了市场数据消费者和订单网关客户端组件在市场参与者交易系统中的应用,接下来我们需要讨论的是做出交易决策的框架。这个组件是交易系统中最重要的组件之一,因为这里才是智能所在。所谓智能,我们指的是处理标准化市场数据更新的系统,构建市场状况视图,并计算交易分析以寻找交易机会并执行交易的系统。显然,这个组件依赖于市场数据消费者接收解码和标准化的市场数据更新,并使用订单网关客户端组件以解码和标准化的格式向交易所发送订单请求并接收订单响应。
构建订单簿
市场参与者需要根据交易所发布的市场数据构建限价订单簿。请注意,客户构建整个订单簿并不是严格必要的,特别是如果交易策略不需要那么细粒度的信息。为了本书的目的,我们将在我们的交易框架中构建完整的订单簿,但我们只是想指出,在所有情况下这并不是严格必要的。这样一个简单的例子就是只关心了解最积极定价订单的价格和/或数量的策略——即最高出价价格和最低要价价格(称为订单簿顶部(TOB)或最佳出价和要价(BBO))。另一个例子是只依赖交易价格做出决策且不需要查看完整订单簿的策略。
在这里需要重申的一点是,客户构建的订单簿与交易所维护的订单簿略有不同,因为客户通常不知道哪个订单属于哪个市场参与者。此外,根据交易所的不同,可能还有更多信息对市场参与者隐藏,例如哪些订单是冰山订单,哪些新订单是停止订单,自我匹配预防考虑等因素。冰山订单是指隐藏数量大于公开市场数据中显示数量的订单。停止订单是指处于休眠状态的订单,当达到特定价格时才会变为活跃状态。自我匹配预防(SMP)是一种约束,防止客户与自己交易,一些交易所选择在撮合引擎中实施此约束。在本书的范围内,我们将忽略并实现此类特殊功能。另一件需要理解的事情是,交易参与者拥有的订单簿是撮合引擎订单簿的一个略微延迟版本。这是因为撮合引擎更新其订单簿到交易客户端获取与变化相对应的市场更新之间存在一些延迟。
构建功能引擎
复杂的交易策略需要在仅仅订单簿的基础上构建额外的智能。这些交易策略需要在交易所发布的价格、流动性、交易交易和订单簿之上实现各种交易信号和智能。这里的想法是构建智能,这可能包括技术分析风格的指标、统计预测信号和模型,以及与市场微观结构相关的统计优势。关于各种交易信号和预测分析的详细讨论超出了本书的范围,但有许多专门讨论这个主题的文本。在实际应用中,对于这样的预测优势有许多不同的术语——交易信号、指标、特征等等。在交易系统中构建和连接这些预测信号集合的组件通常被称为特征/信号/指标引擎。在本书中,我们将为我们的交易策略构建一个最小的特征引擎,但在此我们重申,特征引擎可以根据策略的复杂性变得相当复杂和复杂。
开发执行逻辑
在构建订单簿并从当前市场状态中推导出一些交易信号之后,如果交易策略发现了机会,它们仍然需要在交易所执行它们的订单。这是通过发送新订单、修改现有订单以将它们移动到更激进或更保守的价格,以及/或者取消现有订单以避免被填充来实现的。负责发送、修改和取消订单的子组件,基本上是管理策略在交易所的订单,被称为执行系统。对于执行系统来说,能够快速对来自交易所的市场数据和订单响应做出反应,并尽可能快地发送订单请求,这一点非常重要。高频交易系统的盈利能力和可持续性很大一部分取决于在执行系统中实现尽可能低的延迟。
理解风险管理系统
风险管理系统是交易策略基础设施的重要组成部分。从技术角度讲,在实践中,现代电子交易生态系统中存在多层风险管理系统。在实践中,客户交易策略框架中存在风险管理系统,市场参与者的系统中存在订单网关客户端,以及清算经纪商端的后端系统。为了本书的目的,我们将在交易策略框架中仅实现一个最小风险管理系统。风险管理系统试图管理不同形式的风险,如下面的图所示:
图 5.8 – 自动化风险管理系统中的不同风险指标
让我们更详细地讨论这些风险指标。
基于订单数量的风险
许多交易系统关注的一个衡量标准是算法允许发送的单个订单的最大可能数量。这主要是为了防止系统中的错误和用户错误,导致算法意外发送比预期更大的订单。在实践中,这类错误被称为大拇指错误,指的是用户意外按下比预期更多的键的情况。
基于公司头寸的风险
风险的一个明显衡量标准是策略在某一交易工具中的头寸位置。头寸的大小直接决定了如果市场价格变动一定幅度,将损失多少资金。这就是为什么策略或公司在某一交易工具中的实际头寸非常重要,并且需要密切监控以确保其不超过约定的限制。请注意,实际头寸是指策略当前持有的头寸,并且这忽略了策略可能有的额外订单,这些订单可能会在执行时增加或减少头寸。
基于最坏情况头寸的风险
注意,在上一节中,我们提到实际头寸指标忽略了市场上存在的额外活跃订单数量。最坏情况头寸指标跟踪的是考虑到会增加实际头寸的活跃订单,以及实际头寸本身,头寸将会达到的位置。这意味着如果策略或公司是多头(从购买工具中获得头寸),那么它还会检查策略在市场上未执行的多头购买数量,以计算绝对最坏情况头寸。这很重要,因为一些策略可能永远不会积累到大的头寸,但可能始终在市场上有很多活跃订单。这种策略的完美例子是市场做市策略,我们将在本书的后面看到,但这里的重点是,在风险管理方面,考虑最坏情况是很重要的。
管理实现和未实现损失的风险
这就是人们在考虑电子交易中的风险时通常会想到的内容。这个风险指标跟踪并限制策略或公司损失的资金量。如果这个值超过某个阈值,那么根据公司在其经纪账户中的资金量、抵押品数量等因素,公司可能会面临后果。不仅需要跟踪策略在开仓和关仓时的实际损失,还需要跟踪未平仓头寸与市场价格的关系。
为了理解这一点,让我们解释以下场景:一个策略购买了一定数量的工具,然后以较低的价格卖出相同数量的数量,此时策略有实际损失且没有开放头寸。现在,让我们假设策略购买了一些交易工具,然后在购买后,策略持有长期头寸,市场中的工具价格下跌。在这里,这个策略不仅携带了之前一系列交易中的实际损失,现在它还在这最近开仓的长期头寸上有未实现损失。风险管理系统需要几乎实时地计算实现和未实现损失,以获得实际风险的准确视图。
基于交易量的风险
这个指标不一定是一个风险;一个策略在某个特定日子或通常情况下交易大量体积本身并不是问题。这个风险指标主要旨在防止软件或配置错误或意外市场条件下失控算法在市场中过度交易。这可以通过许多方式实现,但最简单的实现方式是在策略自动停止发送任何新订单或进一步交易之前,对策略允许交易的交易工具的量进行上限控制。通常,在这种情况下,外部人工操作员需要确保算法行为符合预期,然后继续交易策略或停止它。
管理订单、交易和损失的风险比率
在本小节中我们将讨论的风险指标属于基于比率的风险管理类别。我们所说的基于比率是指风险是根据滑动时间窗口计算的,以确保策略在每一个窗口中不会发送过多的订单,不会在每个时间窗口中进行过多的交易,不会在每个窗口中损失过多的资金,等等。再次强调,这些指标是为了防止交易策略出现意外行为或类似失控或失控算法的行为。这些是通过在时间窗口结束时重置基础指标的计数器(订单数量或交易数量或交易量或损失)或使用这些指标的滚动计数器来实现的。这些风险指标还隐含地防止交易策略在超级高度波动的时期或闪崩式场景中出现意外行为。
最后,我们将设计我们电子交易生态系统中最后一个主要组件。
设计我们的交易策略框架
让我们讨论一下我们将在参与者的交易系统中实施的交易策略框架的一些设计细节。在这里,我们只展示图 5.1中的交易策略框架,以便我们可以更详细地讨论设计。
图 5.9 – 我们交易策略框架的设计
现在我们将讨论我们将在本书中构建的交易策略框架的主要子组件的设计。请注意,我们使用“交易策略框架”和“交易引擎”这两个术语可以互换使用,在本书的上下文中它们意味着相同的东西——一个组件集合,用于容纳和运行自动化交易算法。
限价订单簿
交易策略框架中的限价订单簿与交易所匹配引擎构建的类似。显然,这里的目的是不是执行订单之间的匹配,而是从通过无锁队列由市场数据消费者消耗的市场数据更新中构建、维护和更新限价订单簿。支持高效地向此簿中插入、修改和删除订单的要求仍然适用。这里的另一个目标是使此订单簿可供功能引擎和交易策略组件所需的用例使用。可能有各种用例;一个例子是能够快速有效地为仅需要最佳价格和数量的组件合成 BBO 或 TOB。另一个例子是能够跟踪策略在限价订单簿中的自身订单,以找到它们在 FIFO 队列中的价格水平。另一个例子是能够从公开市场数据源检测策略订单的执行,这在私有订单源落后于公开市场数据源时可以提供很大的帮助。在我们这本书中构建的交易策略中实现这些细节超出了我们所能涵盖的范围。但在实践中,这些细节非常重要,因为从订单响应和市场数据中检测执行所获得的优势可以在延迟上达到数十、数百甚至数千微秒。在这里,我们将使用我们在“使用无锁队列传输数据”部分构建的无锁队列,以及我们在上一章“为低延迟应用构建 C++构建块”中构建的内存池。
功能引擎
我们之前提到,在这本书中我们将构建一个最小功能引擎。我们的功能引擎将只支持从我们的订单簿中可用的数据计算出的单个特征,并且这个单一特征将用于驱动我们的交易策略。当订单簿在价格或流动性方面发生实质性变化,以及市场发生交易时,这个特征将被更新。当特征更新时,交易策略可以使用新的特征值来重新评估其头寸、实时订单等,以做出交易决策。
交易策略
交易策略是最终基于众多因素做出交易决策的组件。交易决策取决于交易算法本身、特征引擎的特征值、订单簿的状态、策略订单在订单簿中的价格和 FIFO 位置、风险管理器的风险评估、订单管理器中活跃订单的状态等等。这就是交易策略框架的大部分复杂性所在,因为它需要处理许多不同的条件,并安全且有利可图地执行订单。在这本书中,我们将构建两种不同类型的基本交易算法——做市商,也称为流动性提供策略,以及套利策略,也称为流动性移除策略。做市商策略在订单簿中有被动订单,并依赖于其他市场参与者跨价差与我们交易。流动性移除策略是跨价差并发送主动订单以移除被动流动性的策略。
订单管理器
订单管理器组件是一个抽象层,它隐藏了发送订单请求、管理活跃订单状态、处理在途条件(我们稍后会解释)以及这些订单的响应、处理订单部分和全部执行的场景,以及管理头寸的底层细节。订单管理器还构建并维护了一些不同的数据结构来跟踪策略订单的状态。在某种程度上,订单管理器类似于限价订单簿,但它只管理属于策略的订单的一个小子集。
另一方面,订单管理中存在一些额外的复杂性,因为有些情况下,市场参与者向交易所发送的订单请求是在途的,同时交易所的匹配引擎发生了一些事件。一个在途条件的例子是,当客户端试图取消一个活跃订单并向交易所发送取消请求时。但是,当这个取消请求在途时,由于一个会匹配这个订单的对手方出现,交易所的匹配引擎会执行这个订单。然后,当取消请求最终到达匹配引擎时,订单已经被执行并从交易所的限价订单簿中移除,导致这个请求被拒绝。订单管理器需要能够准确且高效地处理所有这类不同场景。
在这本书中,我们将构建一个订单管理器,它可以用来管理被动和主动订单,并能够处理所有这些不同的条件。
风险管理器
风险经理跟踪我们在上一节“理解风险管理系统”中描述的不同风险指标。此外,风险经理还需要通知交易策略关于风险限制被突破的事件,以便交易策略可以降低风险或安全地关闭。在我们的交易基础设施中,我们将实施一些基本的风险指标,例如头寸、总损失和订单请求的消息速率。
摘要
这就结束了我们对电子交易生态系统主要组件的细节和设计的讨论。让我们总结一下我们讨论的概念、组件和交互,以及构建我们将要构建的电子交易生态系统的组件设计。
我们首先介绍了电子交易生态系统的拓扑结构。这包括电子交易交易所和许多想要在该交易所进行交易的市场参与者。从高层次来看,电子交易交易所基础设施本身由三个主要组件组成——匹配引擎、市场数据发布者和订单网关服务器基础设施。从市场参与者的角度来看,主要组件包括市场数据订阅者和消费者、包含所有子组件的交易策略框架以及订单网关客户端基础设施。
然后,我们深入探讨了交易所匹配引擎的细节。我们解释了该组件的职责以及它是如何构建、维护和更新限价订单簿并匹配相互交叉的参与者订单的。我们在那一节结束时设计了我们的简化匹配引擎组件及其子组件,这些将在下一章中实现。
接下来的讨论主题是交易所的市场数据发布者和订单网关服务器基础设施。我们详细描述了市场数据馈送由哪些不同消息组成,市场数据馈送协议,以及设计市场数据发布者内部的组件。我们还讨论了订单网关服务器,这是交易所作为市场参与者连接到、转发订单请求并接收订单响应和通知的端点。我们展示了包含所有子组件的订单网关服务器的设计,这些将在本书的后续章节中实现。
在那之后的部分,我们考察了市场参与者的交易系统。首先,我们讨论了市场数据消费者和订单网关客户端基础设施的细节,这些是参与者用来从交易所消费公共市场数据流并连接到以及与交易所通信的基础设施。我们还介绍了我们将要构建的市场数据消费者的设计,以及它是如何同步和解码交易所市场数据流的。最后,我们设计了订单网关客户端基础设施,这是交易系统将要用来连接到以及与交易所的订单网关服务器基础设施通信的。
本章的最后部分致力于描述和设计交易策略框架。我们描述了我们将需要构建此框架的主要组件——订单簿、功能引擎、执行逻辑框架以及风险管理子组件。最后,我们概述了我们将要构建的交易基础设施的设计,以便您在我们深入到后续章节中的底层细节之前,可以理解此组件的高级设计。
下一章将跳入我们本章设计的匹配引擎框架的实现细节。请注意,在实现我们的电子交易生态系统时,我们将重用我们在上一章中构建的大量基本构建块。随着我们开始实现系统的其余部分,构建基本构建块的动力将变得更加清晰。
第六章:构建 C++匹配引擎
我们在上一章讨论了本书中将构建的电子交易生态系统设计。我们将从交易所的匹配引擎开始。在本章中,我们将专注于基于客户输入的订单构建交易所匹配引擎的订单簿的任务。我们将实现跟踪这些订单、在订单交叉时进行匹配以及更新订单簿所需的各个数据结构和算法。"交叉"意味着当买方订单的价格等于或高于卖方订单的价格时,它们可以相互执行,但我们将在本章中更详细地讨论这一点。我们将专注于在这些操作中实现尽可能低的延迟,因为具有最佳基础设施的交易所可能进行最多的业务,并受到参与者的青睐。目前,我们不会担心交易交换中市场数据发布者和订单网关服务器组件的细节。
在本章中,我们将涵盖以下主题:
-
定义匹配引擎中的操作和交互
-
构建匹配引擎和交换外部数据
-
构建订单簿和匹配订单
我们将首先澄清一些假设,以简化匹配引擎并限制本书中可以涵盖的范围。我们将在第一部分定义一些类型、常量和基本结构。
技术要求
本书的所有代码都可以在本书的 GitHub 仓库github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
中找到。本章的源代码位于仓库的Chapter6
目录中。
重要的是,您已经阅读并理解了设计我们的交易生态系统章节中介绍的电子交易生态系统设计。请注意,在本章中,我们还将使用在第四章中构建的代码,即构建低延迟应用的 C++构建块,这些代码可以在本书 GitHub 仓库的Chapter6/common/
目录中找到。
本书源代码开发环境的规格如下所示。我们提供此环境的详细信息,因为本书中展示的所有 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 GNU/Linux.
-
GCC:
g++ (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0.
-
CMake:
cmake
版本 3.23.2. -
Ninja:
1.10.2.
定义匹配引擎中的操作和交互
在这里,我们将声明和定义我们在本章构建匹配引擎时需要的类型、常量和结构。
定义一些类型和常量
让我们定义一些常见的 typedef
来记录我们将在这本书的其余部分使用的类型。我们还将定义一些常量来表示一些存在的假设,纯粹是为了简化我们的匹配引擎设计。请注意,您不需要这些限制/常量,我们将此增强留给感兴趣的您。本小节的所有代码都可以在 GitHub 仓库中找到,该仓库位于本书的 Chapter6/common/types.h
文件中。
定义一些基本类型
我们将在我们的电子交易系统中定义一些类型来存储不同的属性,例如以下内容:
-
OrderId
用于标识订单 -
TickerId
用于标识交易工具 -
ClientId
用于交易所识别不同的客户 -
Price
用于存储工具的价格 -
Qty
用于存储订单的数量值 -
Priority
用于捕获在价格水平上的 先进先出(FIFO)队列中订单的位置,如 设计我们的交易 生态系统 章节中所述。 -
Side
用于表示订单的(买/卖)方
我们还将提供基本方法将这些转换为字符串,仅用于日志记录目的。让我们逐一查看这些代码块,以了解接下来的声明:
#pragma once
#include <cstdint>
#include <limits>
#include "common/macros.h"
首先,我们定义了 OrderId
类型来标识订单,它是一个简单的 uint64_t
,并添加了一个相应的 orderIdToString()
方法来记录它。我们还添加了一个 OrderId_INVALID
监视器方法来表示无效值:
namespace Common {
typedef uint64_t OrderId;
constexpr auto OrderId_INVALID =
std::numeric_limits<OrderId>::max();
inline auto orderIdToString(OrderId order_id) ->
std::string {
if (UNLIKELY(order_id == OrderId_INVALID)) {
return "INVALID";
}
return std::to_string(order_id);
}
我们定义了 TickerId
类型来标识交易工具,它是一个简单的 uint32_t
类型,并为它添加了一个相应的 tickerIdToString()
方法。我们有一个 TickerId_INVALID
监视器值用于无效工具:
typedef uint32_t TickerId;
constexpr auto TickerId_INVALID =
std::numeric_limits<TickerId>::max();
inline auto tickerIdToString(TickerId ticker_id) ->
std::string {
if (UNLIKELY(ticker_id == TickerId_INVALID)) {
return "INVALID";
}
return std::to_string(ticker_id);
}
ClientId
类型用于区分不同的交易参与者。ClientId_INVALID
值表示一个无效的监视器。clientIdToString()
方法用于日志记录目的:
typedef uint32_t ClientId;
constexpr auto ClientId_INVALID =
std::numeric_limits<ClientId>::max();
inline auto clientIdToString(ClientId client_id) ->
std::string {
if (UNLIKELY(client_id == ClientId_INVALID)) {
return "INVALID";
}
return std::to_string(client_id);
}
下一个类型是 Price
,用于捕获订单上的价格。我们还添加了一个 Price_INVALID
常量来表示无效价格。最后,一个 priceToString()
方法将这些值转换为字符串:
typedef int64_t Price;
constexpr auto Price_INVALID =
std::numeric_limits<Price>::max();
inline auto priceToString(Price price) -> std::string {
if (UNLIKELY(price == Price_INVALID)) {
return "INVALID";
}
return std::to_string(price);
}
Qty
类型是 typedef
为 uint32_t
,表示订单数量。我们还提供了常用的 Qty_INVALID
监视器值和 qtyToString()
方法将它们转换为字符串:
typedef uint32_t Qty;
constexpr auto Qty_INVALID =
std::numeric_limits<Qty>::max();
inline auto qtyToString(Qty qty) -> std::string {
if (UNLIKELY(qty == Qty_INVALID)) {
return "INVALID";
}
return std::to_string(qty);
}
Priority
类型只是 uint64_t
类型队列中的一个位置。我们分配了 Priority_INVALID
监视器值和 priorityToString()
方法:
typedef uint64_t Priority;
constexpr auto Priority_INVALID =
std::numeric_limits<Priority>::max();
inline auto priorityToString(Priority priority) ->
std::string {
if (UNLIKELY(priority == Priority_INVALID)) {
return "INVALID";
}
return std::to_string(priority);
}
Side
类型是一个枚举,包含两个有效值,如下面的代码块所示。我们同样定义了一个 sideToString()
方法,就像我们之前对其他类型所做的那样:
enum class Side : int8_t {
INVALID = 0,
BUY = 1,
SELL = -1
};
inline auto sideToString(Side side) -> std::string {
switch (side) {
case Side::BUY:
return "BUY";
case Side::SELL:
return "SELL";
case Side::INVALID:
return "INVALID";
}
return "UNKNOWN";
}
}
这些就是本章所需的所有基本类型。接下来,我们将定义一些限制以简化我们系统的设计。
定义一些限制和约束
我们将定义以下常量限制:
-
LOG_QUEUE_SIZE
表示日志记录器使用的无锁队列的大小。这表示在不使日志记录器队列满的情况下,可以保留在内存中的最大字符数。 -
ME_MAX_TICKERS
表示交易所支持的交易工具数量。 -
ME_MAX_CLIENT_UPDATES
表示匹配引擎尚未处理的来自所有客户端的最大未处理订单请求数量。这也代表了订单服务器尚未发布的匹配引擎的最大订单响应数量。 -
ME_MAX_MARKET_UPDATES
表示匹配引擎生成的尚未由市场数据发布者发布的最大市场更新数量。 -
ME_MAX_NUM_CLIENTS
表示在我们的交易生态系统中可以存在的最大同时市场参与者数量。 -
ME_MAX_ORDER_IDS
表示单个交易工具可能的最大订单数量。 -
ME_MAX_PRICE_LEVELS
表示匹配引擎维护的限价订单簿的价格级别的最大深度。
注意,这里选择这些值是任意性的;这些值可以根据我们运行的电子交易生态系统的容量增加或减少。我们选择 2 的幂次来允许在尝试计算地址时使用位移而不是乘法;然而,在现代处理器上这种影响是可以忽略不计的,我们不会建议过分担心这一点。我们之前描述的常量的来源在此处展示:
namespace Common {
constexpr size_t LOG_QUEUE_SIZE = 8 * 1024 * 1024;
constexpr size_t ME_MAX_TICKERS = 8;
constexpr size_t ME_MAX_CLIENT_UPDATES = 256 * 1024;
constexpr size_t ME_MAX_MARKET_UPDATES = 256 * 1024;
constexpr size_t ME_MAX_NUM_CLIENTS = 256;
constexpr size_t ME_MAX_ORDER_IDS = 1024 * 1024;
constexpr size_t ME_MAX_PRICE_LEVELS = 256;
}
这些是我们目前需要的所有常量。现在,我们可以将注意力转向匹配引擎内部需要的一些更复杂的结构。
设计匹配引擎
我们需要一些结构来使我们的匹配引擎能够与市场数据发布者和订单服务器组件进行通信。
定义 MEClientRequest 和 ClientRequestLFQueue 类型
MEClientRequest
结构由订单服务器用于将客户端的订单请求转发给匹配引擎。记住,从订单服务器到匹配引擎的通信是通过我们之前构建的无锁队列组件建立的。ClientRequestLFQueue
是MEClientRequest
对象的无锁队列的 typedef。这个结构的代码可以在 GitHub 仓库的Chapter6/order_server/client_request.h
文件中找到:
#pragma once
#include <sstream>
#include "common/types.h"
#include "common/lf_queue.h"
using namespace Common;
namespace Exchange {
在这里注意两点——我们使用#pragma pack()
指令来确保这些结构体是紧凑的,不包含任何额外的填充。这一点很重要,因为这些结构体将在后续章节中被作为平面二进制结构在网络中发送和接收。我们还定义了一个ClientRequestType
枚举来定义订单请求的类型——是新的订单请求还是取消现有订单的请求。我们还定义了一个INVALID
哨兵值和一个clientRequestTypeToString()
方法,将这个枚举转换为人类可读的字符串:
#pragma pack(push, 1)
enum class ClientRequestType : uint8_t {
INVALID = 0,
NEW = 1,
CANCEL = 2
};
inline std::string
clientRequestTypeToString(ClientRequestType type) {
switch (type) {
case ClientRequestType::NEW:
return "NEW";
case ClientRequestType::CANCEL:
return "CANCEL";
case ClientRequestType::INVALID:
return "INVALID";
}
return "UNKNOWN";
}
现在,我们可以定义MEClientRequest
结构体,它将包含交易参与者向交易所发出的单个订单请求的信息。请注意,这是匹配引擎使用的内部表示,但不一定是客户端发送的确切格式。我们将在下一章“与市场参与者通信”中探讨这一点。这个结构体的重要成员如下:
-
一个类型为
ClientRequestType
的type_
变量 -
发送此请求的交易客户端的
client_id_
变量,其类型为ClientId
-
一个
ticker_id_
变量,其类型为TickerId
,表示此请求针对的金融工具 -
为此请求所进行的订单的
OrderId
(order_id_
),这可能是一个新订单或引用现有订单 -
订单的
Side
在side_
变量中 -
订单的
Price
在price_
变量中 -
订单中保存的
Qty
在qty_
变量中
此外,我们还将添加一个简单的toString()
方法,以帮助我们在以后进行日志记录,如下所示:
struct MEClientRequest {
ClientRequestType type_ = ClientRequestType::INVALID;
ClientId client_id_ = ClientId_INVALID;
TickerId ticker_id_ = TickerId_INVALID;
OrderId order_id_ = OrderId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
auto toString() const {
std::stringstream ss;
ss << "MEClientRequest"
<< " ["
<< "type:" << clientRequestTypeToString(type_)
<< " client:" << clientIdToString(client_id_)
<< " ticker:" << tickerIdToString(ticker_id_)
<< " oid:" << orderIdToString(order_id_)
<< " side:" << sideToString(side_)
<< " qty:" << qtyToString(qty_)
<< " price:" << priceToString(price_)
<< "]";
return ss.str();
}
};
如前所述,我们还定义了ClientRequestLFQueue
类型定义来表示这些结构的锁-free 队列,如下面的代码片段所示。#pragma pack(pop)
简单地恢复了对齐设置到默认值——即不是紧密打包(这是我们通过指定#pragma pack(push, 1)
指令设置的)。这是因为我们只想紧密打包将通过网络发送的结构,而其他则不是:
#pragma pack(pop)
typedef LFQueue<MEClientRequest> ClientRequestLFQueue;
}
我们将定义一个与匹配引擎使用的类似结构体,用于向订单服务器组件发送订单响应。让我们在下一小节中看看这个结构体。
定义 MEClientResponse 和 ClientResponseLFQueue 类型
让我们展示匹配引擎用于向订单服务器组件发送订单响应的结构体的实现,以便将其分发给客户端。类似于上一节,我们还将定义ClientResponseLFQueue
,它是一个MEClientResponse
对象的锁-free 队列。此结构体的代码可在 GitHub 仓库的Chapter6/order_server/client_response.h
源文件中找到:
#pragma once
#include <sstream>
#include "common/types.h"
#include "common/lf_queue.h"
using namespace Common;
namespace Exchange {
首先,我们将定义一个ClientResponseType
枚举来表示客户端订单的响应类型。除了INVALID
哨兵值外,它还包含表示新订单请求被接受、订单被取消、订单被执行或取消请求被匹配引擎拒绝的值。我们还添加了clientResponseTypeToString()
方法,用于将ClientResponseType
值转换为字符串:
#pragma pack(push, 1)
enum class ClientResponseType : uint8_t {
INVALID = 0,
ACCEPTED = 1,
CANCELED = 2,
FILLED = 3,
CANCEL_REJECTED = 4
};
inline std::string
clientResponseTypeToString(ClientResponseType type) {
switch (type) {
case ClientResponseType::ACCEPTED:
return "ACCEPTED";
case ClientResponseType::CANCELED:
return "CANCELED";
case ClientResponseType::FILLED:
return "FILLED";
case ClientResponseType::CANCEL_REJECTED:
return "CANCEL_REJECTED";
case ClientResponseType::INVALID:
return "INVALID";
}
return "UNKNOWN";
}
最后,我们定义了匹配引擎内部使用的MEClientResponse
消息,用于在客户端订单有更新时与交易客户端通信订单响应消息。在我们查看源代码之前,此结构体中的重要数据成员如下列所示:
-
一个表示客户端响应类型的
ClientResponseType type_
变量 -
一个
client_id_
变量,类型为ClientId
,用于表示响应消息针对哪个市场参与者。 -
一个
ticker_id_
变量,类型为TickerId
,用于表示此响应的交易工具。 -
一个
client_order_id_
变量,用于标识此响应消息影响的订单的OrderId
。这个OrderId
是客户端在原始MEClientRequest
消息中为订单发送的。 -
一个名为
market_order_id_
的变量,也是OrderId
类型,但这个变量用于在公共市场数据流中识别这个订单。由于不同的市场参与者可能会发送具有相同client_order_id_
值的订单,因此这个OrderId
在所有市场参与者中是唯一的。即使在那些情况下,具有相同client_order_id_
的两个订单在其响应中也会有不同的market_order_id_
值。这个market_order_id_
值也用于生成此订单的市场更新。 -
一个
side_
变量,类型为Side
,用于表示此订单响应的方面。 -
客户端响应更新的
Price
以及它是否被接受、取消或执行。 -
一个
exec_qty_
变量,类型为Qty
,仅在订单执行事件中使用。这个变量用于在MEClientResponse
消息中保存执行了多少数量。这个值不是累积的,这意味着当订单被部分执行多次时,会为每次单独的执行生成一个MEClientResponse
消息,并且只包含那次特定执行中的数量,而不是所有执行的总和。 -
一个
leaves_qty_
变量,也是Qty
类型,表示原始订单的数量中有多少仍然在匹配引擎的订单簿中活跃。这用于传达此特定订单在簿中的大小,该订单仍然活跃,可能进行进一步的执行。
最后,我们还有我们常用的 toString()
方法,用于方便的日志记录。如前所述的 MEClientResponse
结构定义如下:
struct MEClientResponse {
ClientResponseType type_ = ClientResponseType::INVALID;
ClientId client_id_ = ClientId_INVALID;
TickerId ticker_id_ = TickerId_INVALID;
OrderId client_order_id_ = OrderId_INVALID;
OrderId market_order_id_ = OrderId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty exec_qty_ = Qty_INVALID;
Qty leaves_qty_ = Qty_INVALID;
auto toString() const {
std::stringstream ss;
ss << "MEClientResponse"
<< " ["
<< "type:" << clientResponseTypeToString(type_)
<< " client:" << clientIdToString(client_id_)
<< " ticker:" << tickerIdToString(ticker_id_)
<< " coid:" << orderIdToString(client_order_id_)
<< " moid:" << orderIdToString(market_order_id_)
<< " side:" << sideToString(side_)
<< " exec_qty:" << qtyToString(exec_qty_)
<< " leaves_qty:" << qtyToString(leaves_qty_)
<< " price:" << priceToString(price_)
<< "]";
return ss.str();
}
};
#pragma pack(pop)
ClientResponseLFQueue
类型定义如下,它表示我们之前讨论的结构的无锁队列:
typedef LFQueue<MEClientResponse> ClientResponseLFQueue;
}
这就结束了我们需要表示客户端请求和匹配引擎响应的结构讨论。让我们继续到下一小节中的市场更新结构。
定义 MEMarketUpdate 和 MEMarketUpdateLFQueue 类型
市场更新结构由匹配引擎用于向市场数据发布组件提供市场数据更新。我们还有一个 MEMarketUpdateLFQueue
类型来表示 MEMarketUpdate
对象的无锁队列。此代码可以在 Chapter6/exchange/market_data/market_update.h
源文件中找到:
#pragma once
#include <sstream>
#include "common/types.h"
using namespace Common;
namespace Exchange {
MEMarketUpdate
结构体也需要是一个打包结构体,因为它将是通过网络发送和接收的消息的一部分;因此,我们再次使用#pragma pack()
指令。在我们定义结构体之前,我们需要定义MarketUpdateType
枚举,它表示订单市场更新中的更新操作。除了承担INVALID
哨兵值外,它还可以用来表示订单簿中订单被添加、修改或取消的事件,以及市场中的交易事件:
#pragma pack(push, 1)
enum class MarketUpdateType : uint8_t {
INVALID = 0,
ADD = 1,
MODIFY = 2,
CANCEL = 3,
TRADE = 4
};
inline std::string
marketUpdateTypeToString(MarketUpdateType type) {
switch (type) {
case MarketUpdateType::ADD:
return "ADD";
case MarketUpdateType::MODIFY:
return "MODIFY";
case MarketUpdateType::CANCEL:
return "CANCEL";
case MarketUpdateType::TRADE:
return "TRADE";
case MarketUpdateType::INVALID:
return "INVALID";
}
return "UNKNOWN";
}
最后,我们定义了MEMarketUpdate
结构体,它包含以下重要的数据成员:
-
MarketUpdateType
的type_
变量,用于表示市场更新的类型。 -
一个类型为
OrderId
的order_id_
变量,用于表示限价订单簿中特定订单,该订单更新适用于此订单。 -
一个类型为
TickerId
的ticker_id_
变量,用于表示此更新适用的交易工具。 -
一个表示此订单方向的
Side
变量。 -
一个表示此市场订单更新中确切价格的
Price
变量。 -
一个类型为
Priority
的priority_
字段,正如我们之前讨论的,它将用于指定此订单在 FIFO 队列中的确切位置。我们构建了一个所有价格相同的订单的 FIFO 队列。此字段指定了此订单在该队列中的位置/位置。
完整的MEMarketUpdate
结构体如下所示,以及MEMarketUpdateLFQueue
类型定义,它捕获了MEMarketUpdate
结构体消息的无锁队列:
struct MEMarketUpdate {
MarketUpdateType type_ = MarketUpdateType::INVALID;
OrderId order_id_ = OrderId_INVALID;
TickerId ticker_id_ = TickerId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
Priority priority_ = Priority_INVALID;
auto toString() const {
std::stringstream ss;
ss << "MEMarketUpdate"
<< " ["
<< " type:" << marketUpdateTypeToString(type_)
<< " ticker:" << tickerIdToString(ticker_id_)
<< " oid:" << orderIdToString(order_id_)
<< " side:" << sideToString(side_)
<< " qty:" << qtyToString(qty_)
<< " price:" << priceToString(price_)
<< " priority:" << priorityToString(priority_)
<< "]";
return ss.str();
}
};
#pragma pack(pop)
typedef Common::LFQueue<Exchange::MEMarketUpdate>
MEMarketUpdateLFQueue;
}
这就完成了我们需要表示和发布来自匹配引擎的市场数据更新的结构。在下一个小节中,我们将构建一些结构和定义一些类型,我们将使用它们来构建限价订单簿。
设计交易所订单簿
在本节中,我们将定义一些构建块,这些构建块将被用来高效地构建、维护和更新限价订单簿。在我们讨论我们需要的每个结构和对象之前,我们将为您展示一个图表,以帮助您建立对限价订单簿实现的直观理解。
限价订单簿组织为一系列买方订单(称为挂单)和卖方订单(称为卖单)。在匹配引擎中,价格相同的订单按照先进先出(FIFO)的顺序组织。我们已在设计我们的交易生态系统章节中讨论了这些细节,在设计交易交易所中的 C++匹配引擎部分。
对于我们在匹配引擎内部构建的订单簿,我们有一个包含活跃订单的买入价格和卖出价格列表。每个价格水平由 MEOrdersAtPrice
结构表示,如下所示。买入价格按从高到低的价格水平排序,卖出价格按从低到高的价格水平排序。每个 MEOrdersAtPrice
使用双向链表从高到低优先级存储单个订单。每个单个订单的信息包含在 MEOrder
结构中。我们将使用类型为 OrdersAtPriceHashMap
的哈希表跟踪每个价格水平,该哈希表按该级别的价格索引。我们还将使用类型为 OrderHashMap
的哈希表跟踪每个 MEOrder
对象,按其 market_order_id_
值索引。表示我们匹配引擎订单簿此设计的图示如下。
图 6.1 – 匹配引擎内部限价订单簿的设计
既然我们已经讨论了限价订单簿数据结构的整体设计和构成它的组件,我们可以开始定义实现该设计所需的基本结构。在下一小节中,我们首先设计基本块——用于存储单个订单信息的 MEOrder
结构。
定义 MEOrder、OrderHashMap 和 ClientOrderHashMap 类型
第一个结构用于在订单簿内部存储单个限价订单的信息,我们将称之为 MEOrder
。这将在以下代码块中展示,代码可以在 GitHub 仓库的 Chapter6/matcher/me_order.h
和 Chapter6/matcher/me_order.cpp
源文件中找到。
MEOrder
结构中包含以下重要数据成员,用于保存表示单个订单在限价订单簿中所需的属性:
-
一个类型为
TickerId
的ticker_id_
变量,用于表示该订单对应的工具。 -
一个类型为
ClientId
的client_id_
变量,用于捕获拥有此订单的市场参与者。 -
两个
OrderId
集合,正如我们之前讨论的那样——client_order_id_
,这是客户端在其订单请求中发送的内容,以及market_order_id_
,由匹配引擎生成,且在整个客户端中是唯一的。 -
Side side_
用于表示订单是买入订单还是卖出订单。 -
一个类型为
Price
的price_
变量,用于表示订单的价格。 -
Qty qty_
用于表示在订单簿中仍然活跃的订单数量。 -
一个类型为
Priority
的priority_
变量,正如我们之前讨论的那样,它将代表此订单在具有相同side_
和price_
值的其他MEOrder
实例队列中的确切位置。 -
MEOrder
结构体也有两个指向其他MEOrder
对象的指针。这是因为MEOrder
对象也被维护为一个双向链表,按照在MEOrdersAtPrice
结构体中的价格级别排列,正如我们在上一节中讨论的那样:
#pragma once
#include <array>
#include <sstream>
#include "common/types.h"
using namespace Common;
namespace Exchange {
struct MEOrder {
TickerId ticker_id_ = TickerId_INVALID;
ClientId client_id_ = ClientId_INVALID;
OrderId client_order_id_ = OrderId_INVALID;
OrderId market_order_id_ = OrderId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
Priority priority_ = Priority_INVALID;
MEOrder *prev_order_ = nullptr;
MEOrder *next_order_ = nullptr;
// only needed for use with MemPool.
MEOrder() = default;
MEOrder(TickerId ticker_id, ClientId client_id, OrderId
client_order_id, OrderId market_order_id, Side side,
Price price,Qty qty, Priority priority, MEOrder
*prev_order, MEOrder *next_order) noexcept
: ticker_id_(ticker_id),
client_id_(client_id),
client_order_id_(client_order_id),
market_order_id_(market_order_id),
side_(side),
price_(price),
qty_(qty),
priority_(priority),
prev_order_(prev_order),
next_order_(next_order) {}
auto toString() const -> std::string;
};
此外,OrderHashMap
类型用于表示一个哈希表,使用std::array
实现,其中OrderId
是键,MEOrder
是值。我们还将定义另一个类型,ClientOrderHashMap
,它也是一个哈希表,使用std::array
来表示从ClientId
到OrderHashMap
对象的映射:
typedef std::array<MEOrder *, ME_MAX_ORDER_IDS>
OrderHashMap;
Typedef std::array<OrderHashMap, ME_MAX_NUM_CLIENTS>
ClientOrderHashMap;
}
我们为MEOrder
结构体提供了toString()
方法,该方法非常简单,可在Chapter6/exchange/matcher/me_order.cpp
文件中找到:
#include "me_order.h"
namespace Exchange {
auto MEOrder::toString() const -> std::string {
std::stringstream ss;
ss << "MEOrder" << "["
<< "ticker:" << tickerIdToString(ticker_id_) << " "
<< "cid:" << clientIdToString(client_id_) << " "
<< "oid:" << orderIdToString(client_order_id_) << " "
<< "moid:" << orderIdToString(market_order_id_) << " "
<< "side:" << sideToString(side_) << " "
<< "price:" << priceToString(price_) << " "
<< "qty:" << qtyToString(qty_) << " "
<< "prio:" << priorityToString(priority_) << " "
<< "prev:" << orderIdToString(prev_order_ ?
prev_order_->market_order_id_ :
OrderId_INVALID) << " "
<< "next:" << orderIdToString(next_order_ ?
next_order_->market_order_id_ :
OrderId_INVALID) << "]";
return ss.str();
}
}
接下来,我们将构建一些包含和管理订单对象的其他结构体。
定义MEOrdersAtPrice
和OrdersAtPriceHashMap
类型
如图 6**.1所示,我们定义了另一个结构体,用于维护MEOrder
对象列表,我们称之为MEOrdersAtPrice
。这个结构体,在下面的代码块中展示,将用于存储在相同价格下输入的所有订单,按照先进先出(FIFO)优先级顺序排列。这是通过创建一个按优先级从高到低排列的MEOrder
对象的单链表来实现的。为此,我们创建了一个first_me_order_
类型的MEOrder
指针变量,它将代表此价格级别的第一个订单,而随后的其他订单将按照 FIFO 顺序链接在一起。
MEOrdersAtPrice
结构体也有两个指向MEOrdersAtPrice
对象的指针,一个用于前一个(prev_entry_
),一个用于下一个(next_entry_
)。这是因为该结构体本身是MEOrdersAtPrice
对象的双向链表中的一个节点。MEOrdersAtPrice
的双向链表按照买卖双方的从最积极到最不积极的顺序排列价格。
该结构体包含的两个其他变量分别是side_
变量,其类型为Side
,以及price_
变量,其类型为Price
,分别代表此价格级别的买卖方向和价格:
namespace Exchange {
struct MEOrdersAtPrice {
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
MEOrder *first_me_order_ = nullptr;
MEOrdersAtPrice *prev_entry_ = nullptr;
MEOrdersAtPrice *next_entry_ = nullptr;
我们添加了一个默认构造函数和一个简单的自定义容器来初始化此结构体的对象:
MEOrdersAtPrice() = default;
MEOrdersAtPrice(Side side, Price price, MEOrder
*first_me_order, MEOrdersAtPrice *prev_entry,
MEOrdersAtPrice *next_entry)
: side_(side), price_(price),
first_me_order_(first_me_order),
prev_entry_(prev_entry), next_entry_(next_entry) {}
我们还添加了一个简单的toString()
方法,用于日志记录,如下所示:
auto toString() const {
std::stringstream ss;
ss << "MEOrdersAtPrice["
<< "side:" << sideToString(side_) << " "
<< "price:" << priceToString(price_) << " "
<< "first_me_order:" << (first_me_order_ ?
first_me_order_->toString() : "null") << " "
<< "prev:" << priceToString(prev_entry_ ?
prev_entry_->price_ : Price_INVALID) << " "
<< "next:" << priceToString(next_entry_ ?
next_entry_->price_ : Price_INVALID) << "]";
return ss.str();
}
};
OrdersAtPriceHashMap
类型表示一个哈希表,通过std::array
实现,用于表示从价格到MEOrdersAtPrice
的映射:
typedef std::array<MEOrdersAtPrice *,
ME_MAX_PRICE_LEVELS> OrdersAtPriceHashMap;
}
这就完成了关于设置匹配引擎和限价订单簿的初始类型、定义和基本结构的本节内容。接下来,我们可以看看匹配引擎框架是如何构建的。
构建匹配引擎和交换外部数据
在本节中,我们将构建匹配引擎类的各个部分。处理客户端请求、构建和更新限价订单簿以及生成订单响应和市场更新的大部分繁重工作将转交给订单簿类,我们将在下一节讨论。请重新阅读上一章中关于在交易交易所中设计 C++匹配引擎的设计 C++匹配引擎的章节,以复习我们将构建的组件及其设计原则。我们在此处呈现该章节的图表,以便于参考,展示了匹配引擎的设计。
图 6.2 – 我们匹配引擎组件的设计
匹配引擎是一个独立的执行线程,它从ClientRequestLFQueue
中消费订单请求,将订单响应发布到ClientResponseLFQueue
,并将市场更新发布到MEMarketUpdateLFQueue
。让我们首先声明并定义一些用于构建、销毁、线程管理和匹配引擎的样板功能的代码。
构建匹配引擎
MatchingEngine
类包含一些重要的数据成员 – 首先,一个用于跟踪每个交易工具的限价订单簿的OrderBookHashMap
对象。该类还包含以下对象的指针 – ClientRequestLFQueue
、ClientResponseLFQueue
和MEMarketUpdateLFQueue
,所有这些都将通过构造函数传递给它。让我们首先声明并定义一些用于构建、销毁、线程管理和匹配引擎的样板功能的代码。我们还将有一个用于跟踪线程状态的布尔变量run_
,一个time_str_
字符串和一个Logger
对象来输出一些日志。下一节代码的源文件可在 GitHub 上这本书的Chapter6/exchange/matcher/matching_engine.h
中找到。
首先,我们需要包含以下头文件来构建我们的匹配引擎:
#pragma once
#include "common/thread_utils.h"
#include "common/lf_queue.h"
#include "common/macros.h"
#include "order_server/client_request.h"
#include "order_server/client_response.h"
#include "market_data/market_update.h"
#include "me_order_book.h"
我们接下来声明构造函数和析构函数方法,并添加start()
和stop()
方法,分别启动和停止主匹配引擎循环的执行,我们将在稍后构建它:
namespace Exchange {
class MatchingEngine final {
public:
MatchingEngine(ClientRequestLFQueue *client_requests,
ClientResponseLFQueue *client_responses,
MEMarketUpdateLFQueue *market_updates);
~MatchingEngine();
auto start() -> void;
auto stop() -> void;
我们添加了我们通常的构造函数和赋值运算符的样板代码,以防止意外复制:
// Deleted default, copy & move constructors and
// assignment-operators.
MatchingEngine() = delete;
MatchingEngine(const MatchingEngine &) = delete;
MatchingEngine(const MatchingEngine &&) = delete;
MatchingEngine &operator=(const MatchingEngine &) =
delete;
MatchingEngine &operator=(const MatchingEngine &&) =
delete;
最后,我们添加这个MatchingEngine
类的数据成员,如之前所述。类型为OrderBookHashMap
的ticker_order_book_
变量用于存储每个工具的MEOrderBook
。我们将ClientRequestLFQueue
、ClientResponseLFQueue
和MEMarketUpdateLFQueue
类型的incoming_requests_
、outgoing_ogw_responses_
和outgoing_md_updates_
指针分别存储,以与其他线程进行通信。然后,我们有run_
布尔变量,我们将其标记为volatile
,因为它将从不同的线程中被访问:
private:
OrderBookHashMap ticker_order_book_;
ClientRequestLFQueue *incoming_requests_ = nullptr;
ClientResponseLFQueue *outgoing_ogw_responses_ =
nullptr;
MEMarketUpdateLFQueue *outgoing_md_updates_ = nullptr;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
};
}
让我们看看构造函数、析构函数以及创建并启动执行 run()
方法的线程的 start()
方法的实现(我们很快就会看到)。这段代码位于 Chapter6/exchange/matcher/matching_engine.cpp
源文件中。构造函数本身很简单——它初始化内部数据成员并为每个支持的交易工具创建一个 MEOrderBook
实例:
#include "matching_engine.h"
namespace Exchange {
MatchingEngine::MatchingEngine(ClientRequestLFQueue
*client_requests, ClientResponseLFQueue
*client_responses, MEMarketUpdateLFQueue
*market_updates)
: incoming_requests_(client_requests),
outgoing_ogw_responses_(client_responses),
outgoing_md_updates_(market_updates),
logger_("exchange_matching_engine.log") {
for(size_t i = 0; i < ticker_order_book_.size(); ++i) {
ticker_order_book_[i] = new MEOrderBook(i, &logger_,
this);
}
}
析构函数与构造函数相反,重置内部数据成员变量。它还会删除在构造函数中创建的 MEOrderBook
对象:
MatchingEngine::~MatchingEngine() {
run_ = false;
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(1s);
incoming_requests_ = nullptr;
outgoing_ogw_responses_ = nullptr;
outgoing_md_updates_ = nullptr;
for(auto& order_book : ticker_order_book_) {
delete order_book;
order_book = nullptr;
}
}
start()
方法创建并启动一个新线程,将其分配给 MatchingEngine::run()
方法。在这样做之前,它启用 run_
标志,因为它控制 run()
方法的执行:
auto MatchingEngine::start() -> void {
run_ = true;
ASSERT(Common::createAndStartThread(-1,
"Exchange/MatchingEngine", [this]() { run(); }) !=
nullptr, "Failed to start MatchingEngine thread.");
}
stop()
方法只是将 run_
标志设置为 false
,这反过来会导致 run()
方法退出其主循环,但这一点很快就会变得清晰:
auto MatchingEngine::stop() -> void {
run_ = false;
}
}
接下来,我们将研究处理匹配引擎如何消费订单请求并发布订单响应和市况更新的源代码。但首先,让我们展示匹配引擎线程执行的 main run()
循环。这段代码非常简单——它只是从 incoming_requests_
无锁队列中消费 MEClientRequest
对象,并将它们转发到 processClientRequest()
方法。为了实现这一点,它简单地检查 LFQueue::getNextToRead()
方法以查看是否有有效的条目可供读取,如果有,就将该条目处的对象转发以进行处理,并使用 LFQueue::updateReadIndex()
方法更新无锁队列中的读取索引。这段代码位于 Chapter6/exchange/matcher/matching_engine.h
源文件中:
auto run() noexcept {
logger_.log("%:% %() %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_));
while (run_) {
const auto me_client_request =
incoming_requests_->getNextToRead();
if (LIKELY(me_client_request)) {
logger_.log("%:% %() % Processing %\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
me_client_request->toString());
processClientRequest(me_client_request);
incoming_requests_->updateReadIndex();
}
}
}
现在,让我们看看处理客户端请求的源代码。
从订单网关队列中消费和发布
首先,我们将从 matching_engine.h
头文件中 MatchingEngine
类的 processClientRequest()
函数实现开始。这个实现简单地检查 MEClientRequest
的类型,并将其转发到对应工具的限价订单簿。它通过访问 ticker_order_book_
容器,使用 MEClientRequest
中的 ticker_id_
字段来找到这个 MEClientRequest
应该对应的正确订单簿实例:
auto processClientRequest(const MEClientRequest *client_request) noexcept {
auto order_book = ticker_order_book_[client_request
->ticker_id_];
对于尝试添加新订单(ClientRequestType::NEW
)的客户端请求,我们调用 MEOrderBook::add()
方法并让它处理该请求:
switch (client_request->type_) {
case ClientRequestType::NEW: {
order_book->add(client_request->client_id_,
client_request->order_id_,
client_request->ticker_id_,
client_request->side_, client_request->price_,
client_request->qty_);
}
break;
类似地,尝试取消现有订单(ClientRequestType::CANCEL
)的客户端请求会被转发到 MEOrderBook::cancel()
方法:
case ClientRequestType::CANCEL: {
order_book->cancel(client_request->client_id_,
client_request->order_id_,
client_request->ticker_id_);
}
break;
default: {
FATAL("Received invalid client-request-type:" +
clientRequestTypeToString(client_request->type_));
}
break;
}
}
我们还将在同一个类中定义一个方法,限价订单簿将使用该方法通过MEClientResponse
消息发布订单响应。它只是将响应写入outgoing_ogw_responses_
无锁队列并推进写入索引。它是通过调用LFQueue::getNextToWriteTo()
方法找到下一个有效的索引来写入MEClientResponse
消息,将数据移动到该槽位,并通过调用LFQueue::updateWriteIndex()
方法更新下一个写入索引来实现的:
auto sendClientResponse(const MEClientResponse *client_response) noexcept {
logger_.log("%:% %() % Sending %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_),
client_response->toString());
auto next_write = outgoing_ogw_responses_
->getNextToWriteTo();
*next_write = std::move(*client_response);
outgoing_ogw_responses_->updateWriteIndex();
}
现在,我们将查看一些与刚才看到的类似的代码,但它用于发布市场数据更新。
发布到市场数据发布器队列
Chapter6/exchange/matcher/matching_engine.h
中的sendMarketUpdate()
方法由限价订单簿使用,通过MEMarketUpdate
结构发布市场数据更新。它只是将数据写入outgoing_md_updates_
无锁队列并推进写入者。它以与我们之前看到完全相同的方式执行此操作——通过调用getNextToWriteTo()
方法,将MEMarketUpdate
消息写入该槽位,并使用updateWriteIndex()
更新下一个写入索引:
auto sendMarketUpdate(const MEMarketUpdate *market_update) noexcept {
logger_.log("%:% %() % Sending %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_),
market_update->toString());
auto next_write = outgoing_md_updates_
->getNextToWriteTo();
*next_write = *market_update;
outgoing_md_updates_->updateWriteIndex();
}
这部分内容到此结束,我们现在已经完成了匹配引擎的实现。在下一小节中,我们将把这些部分组合成交易交换的二进制文件,除了限价订单簿的实现,这是本章我们将讨论的最后一部分。
构建交换应用程序二进制文件
现在,我们可以构建交易交换的二进制文件。我们将实例化匹配引擎对象所需的三个无锁队列,用于订单请求、订单响应和市场更新。我们还将创建MatchingEngine
对象并启动线程,然后二进制文件将永久休眠。由于应用程序进入无限循环,我们还将为该应用程序安装信号处理程序,以捕获外部信号并优雅地退出。请注意,随着我们在本书后续章节中构建订单服务器和市场数据发布器组件,这些组件需要添加到此处,此代码将在本书的后续章节中扩展。该应用程序的代码位于 GitHub 存储库中的Chapter6/exchange/exchange_main.cpp
。
首先,我们添加一些变量,这些变量将作为Logger
对象和MatchingEngine
对象的指针。我们还将添加一个signal_handler()
方法,当终止交换应用程序时将被调用。信号处理程序简单地删除这些对象并退出:
#include <csignal>
#include "matcher/matching_engine.h"
Common::Logger* logger = nullptr;
Exchange::MatchingEngine* matching_engine = nullptr;
void signal_handler(int) {
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(10s);
delete logger; logger = nullptr;
delete matching_engine; matching_engine = nullptr;
std::this_thread::sleep_for(10s);
exit(EXIT_SUCCESS);
}
目前,main()
方法相当简单,直到我们在下一章添加其他组件。它使用 std::signal()
例程安装 signal_handler()
方法以捕获外部 SIGINT
信号。SIGINT
信号是信号值 2,当在 Linux 中按下 Ctrl + C 或向 ClientRequestLFQueue
变量 client_requests
和 ClientResponseLFQueue
变量 client_responses
发送 kill –2 PID
时发送给正在运行的过程。我们还初始化了无锁队列变量 market_updates
,其类型为 MEMarketUpdateLFQueue
,容量为 ME_MAX_MARKET_UPDATES
。main()
方法还使用 Logger
类的实例初始化 logger
变量:
int main(int, char **) {
logger = new Common::Logger("exchange_main.log");
std::signal(SIGINT, signal_handler);
const int sleep_time = 100 * 1000;
Exchange::ClientRequestLFQueue
client_requests(ME_MAX_CLIENT_UPDATES);
Exchange::ClientResponseLFQueue
client_responses(ME_MAX_CLIENT_UPDATES);
Exchange::MEMarketUpdateLFQueue
market_updates(ME_MAX_MARKET_UPDATES);
最后,main()
方法使用我们创建的 MatchingEngine
类的实例初始化 matching_engine
变量,并从前面的代码块中传递它所需的三个无锁队列。然后它调用 start()
方法,以便主匹配引擎线程可以开始执行。此时,main()
方法已完成,因此它进入一个无限循环,其中大部分时间都在睡眠,等待一个将杀死此进程的外部信号:
std::string time_str;
logger->log("%:% %() % Starting Matching Engine...\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str));
matching_engine = new
Exchange::MatchingEngine(&client_requests,
&client_responses, &market_updates);
matching_engine->start();
while (true) {
logger->log("%:% %() % Sleeping for a few
milliseconds..\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str));
usleep(sleep_time * 1000);
}
}
为了便于构建主二进制文件,我们提供了一个脚本,Chapter6/build.sh
,它使用 CMake 和 Ninja 构建此二进制文件。你可能需要更新此脚本以指向系统中的正确二进制文件,或者如果你更喜欢,可以使用不同的构建系统。下一节将提供有关如何运行此 exchange_main
应用程序的一些信息。
运行交易所应用程序二进制文件
运行 exchange_main
应用程序此时只需调用 exchange_main
二进制文件,如下面的代码块所示。我们还会展示你应在终端上看到的输出:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter6$ ./cmake-build-release/exchange_main
Set core affinity for Common/Logger exchange_main.log 139685103920704 to -1
Set core affinity for Common/Logger exchange_matching_engine.log 139684933506624 to -1
Set core affinity for Exchange/MatchingEngine 139684925113920 to -1
如前所述,可以通过发送 SIGINT
信号来停止此进程。此时,它将生成三个日志文件,类似于以下片段中所示。然而,请注意,此时日志文件中没有什么有趣的内容,因为我们只构建了所有需要构建的完整交易生态系统组件中的匹配引擎组件。在下一章的结尾,与市场参与者通信,我们将再次运行此应用程序,并添加额外的组件,输出将更有趣:
exchange_main.log exchange_matching_engine.log
下一节将探讨订单簿的内部工作原理以及它如何处理客户端订单请求并生成订单响应和市场更新。
构建订单簿和匹配订单
本节实现了订单簿功能。记住,订单簿处理来自匹配引擎转发给客户端的订单请求。它检查订单请求类型,更新订单簿,为客户端生成订单响应,并为公共市场数据馈送生成市场数据更新。匹配引擎中所有限价订单的代码都在me_order_book.h
和me_order_book.cpp
源文件中,保存在本书 GitHub 仓库的Chapter6/exchange/matcher/
目录中。
构建内部数据结构
首先,我们将声明限价订单簿的数据成员。我们之前在图 6.1 中展示了一个表示构成限价订单簿的数据结构的图表。限价订单簿包含以下重要数据成员:
-
一个指向
MatchingEngine
父类的matching_engine_
指针变量,用于订单簿发布订单响应和市场数据更新。 -
ClientOrderHashMap
变量,cid_oid_to_order_
,用于按其ClientId
键跟踪OrderHashMap
对象。提醒一下,OrderHashMap
按其OrderId
键跟踪MEOrder
对象。 -
MEOrdersAtPrice
对象的orders_at_price_pool_
内存池变量,用于创建新对象并将死亡对象返回。 -
双向链表的头部分别是出价(
bids_by_price_
)和询问(asks_by_price_
),因为我们按价格级别将订单跟踪为MEOrdersAtPrice
对象列表。 -
一个哈希表,
OrdersAtPriceHashMap
,用于跟踪价格级别的MEOrdersAtPrice
对象,使用级别的价格作为映射中的键。 -
一个
MEOrder
对象的内存池,称为order_pool_
,其中MEOrder
对象从中创建并返回,而不产生动态内存分配。 -
一些次要成员,例如用于此订单簿的
TickerId
,用于跟踪下一个市场数据订单 ID 的OrderId
,一个MEClientResponse
变量(client_response_
),一个MEMarketUpdate
对象(market_update_
),一个用于记录时间的字符串,以及用于日志记录的Logger
对象。
首先,我们包含一些依赖的头文件,并提前声明MatchingEngine
类,因为我们将在尚未完全定义该类型的情况下引用它:
#pragma once
#include "common/types.h"
#include "common/mem_pool.h"
#include "common/logging.h"
#include "order_server/client_response.h"
#include "market_data/market_update.h"
#include "me_order.h"
using namespace Common;
namespace Exchange {
class MatchingEngine;
class MEOrderBook final {
现在,我们将定义数据成员变量,如之前所述:
private:
TickerId ticker_id_ = TickerId_INVALID;
MatchingEngine *matching_engine_ = nullptr;
ClientOrderHashMap cid_oid_to_order_;
MemPool<MEOrdersAtPrice> orders_at_price_pool_;
MEOrdersAtPrice *bids_by_price_ = nullptr;
MEOrdersAtPrice *asks_by_price_ = nullptr;
OrdersAtPriceHashMap price_orders_at_price_;
MemPool<MEOrder> order_pool_;
MEClientResponse client_response_;
MEMarketUpdate market_update_;
OrderId next_market_order_id_ = 1;
std::string time_str_;
Logger *logger_ = nullptr;
在这一点上,我们还将定义之前提到的OrderBookHashMap
类型,它是一个按TickerId
索引的MEOrderBook
对象的std::array
:
typedef std::array<MEOrderBook *, ME_MAX_TICKERS> OrderBookHashMap;
};
}
接下来,让我们展示构造函数和析构函数的直接实现,以及默认构造函数和赋值运算符的样板代码:
#include "me_order_book.h"
#include "matcher/matching_engine.h"
MEOrderBook::MEOrderBook(TickerId ticker_id, Logger *logger, MatchingEngine *matching_engine)
: ticker_id_(ticker_id),
matching_engine_(matching_engine),
orders_at_price_pool_(ME_MAX_PRICE_LEVELS),
order_pool_(ME_MAX_ORDER_IDS), logger_(logger) {
}
MEOrderBook::~MEOrderBook() {
logger_->log("%:% %() % OrderBook\n%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
toString(false, true));
matching_engine_ = nullptr;
bids_by_price_ = asks_by_price_ = nullptr;
for (auto &itr: cid_oid_to_order_) {
itr.fill(nullptr);
}
}
然后,我们在大多数类中添加样板代码以防止意外复制和分配MEOrderBook
对象:
// Deleted default, copy & move constructors and
// assignment-operators.
MEOrderBook() = delete;
MEOrderBook(const MEOrderBook &) = delete;
MEOrderBook(const MEOrderBook &&) = delete;
MEOrderBook &operator=(const MEOrderBook &) = delete;
MEOrderBook &operator=(const MEOrderBook &&) = delete;
在我们继续实现将在订单簿上执行的不同操作之前,让我们先介绍一些简单的方法来生成新的市场订单 ID,将Price
转换为OrdersAtPriceHashMap
中的索引,并在给定Price
时访问OrdersAtPriceHashMap price_orders_at_price_
映射:
namespace Exchange {
class MatchingEngine;
class MEOrderBook final {
private:
generateNewMarketOrderId()
方法很简单;它返回next_market_order_id_
值,并在下次调用此方法时增加它:
auto generateNewMarketOrderId() noexcept -> OrderId {
return next_market_order_id_++;
}
priceToIndex()
方法将Price
参数转换为介于0
和ME_MAX_PRICE_LEVELS-1
之间的索引,然后用于索引价格级别的std::array
:
auto priceToIndex(Price price) const noexcept {
return (price % ME_MAX_PRICE_LEVELS);
}
最后,getOrdersAtPrice()
实用方法通过将提供的Price
转换为索引来索引price_orders_at_price_
的std::array
,使用priceToIndex()
方法,该方法返回MEOrdersAtPrice
对象:
auto getOrdersAtPrice(Price price) const noexcept ->
MEOrdersAtPrice * {
return price_orders_at_price_.at(priceToIndex(price));
}
};
}
接下来的几个小节将详细说明处理新订单请求和现有订单取消请求的重要操作,以及匹配跨越订单簿另一侧现有被动订单的积极订单。我们还将生成并发布订单响应和市场数据更新回匹配引擎。
处理新的被动订单
在订单簿中,我们需要首先执行的重要任务是处理客户订单请求,这些请求希望在市场中下新订单。我们将实现MEOrderBook::add()
方法,这是匹配引擎首先调用的方法。它生成并发送MEClientResponse
,接受新订单,并将其发送给匹配引擎(以发送给发送新订单的客户)。然后,它还会通过调用checkForMatch()
方法检查新订单是否与另一侧的现有被动订单交叉,以及是否完全或部分匹配。如果新订单根本不匹配或部分填充并留下一些数量在订单簿中,MEOrder
将被添加到订单簿中。在这种情况下,它还会为公共市场数据馈送生成MEMarketUpdate
,并将其发送回匹配引擎(由市场数据发布组件发布)。我们将在本节中简要讨论getNextPriority()
、checkForMatch()
和addOrder()
方法,但让我们首先探索MEOrderBook::add()
方法:
auto MEOrderBook::add(ClientId client_id, OrderId client_order_id, TickerId ticker_id, Side side, Price price, Qty qty) noexcept -> void {
它首先生成new_market_order_id_
,用于MEClientResponse
和MEMarketUpdate
。它更新client_response_
数据成员,其中包含此请求的属性,并调用MatchingEngine::sendClientResponse()
方法将响应发布回匹配引擎:
const auto new_market_order_id =
generateNewMarketOrderId();
client_response_ = {ClientResponseType::ACCEPTED,
client_id, ticker_id, client_order_id,
new_market_order_id, side, price, 0, qty};
matching_engine_->sendClientResponse(&client_response_);
接下来,MEOrderBook::add()
方法调用 MEOrderBook::checkForMatch()
方法,该方法检查订单簿的当前状态与刚刚到达的新客户端请求。它检查是否可以做出部分或完全匹配。checkForMatch()
方法(我们很快就会构建)返回匹配事件后剩余的订单数量(如果有)。对于完全没有执行的订单,返回的 leaves_qty
与订单上的原始数量相同。对于部分执行的订单,它是匹配后剩余的数量。对于完全执行的订单,此方法将返回 0
值,并将其分配给 leaves_qty
。我们很快就会看到 checkForMatch()
的完整实现,但现在,让我们使用它:
const auto leaves_qty = checkForMatch(client_id,
client_order_id, ticker_id, side, price, qty,
new_market_order_id);
如果匹配事件后还有剩余数量,我们需要生成一个与这个新订单相对应的市场数据更新,该订单将加入订单簿。为此,MEOrderBook::add()
方法通过调用 MEOrderBook::getNextPriority()
方法来找出这个订单的正确优先级值。它从 order_pool_
内存池中分配一个新的 MEOrder
对象,并为其分配这个订单的属性。然后,它调用 MEOrderBook::addOrder()
方法,实际上在 MEOrdersAtPrice
数据结构中正确的价格水平和优先级添加它。最后,它用市场更新的值填写 market_update_
对象,并调用 MatchingEngine::sendMarketUpdate()
方法将其发布到匹配引擎:
if (LIKELY(leaves_qty)) {
const auto priority = getNextPriority(ticker_id,
price);
auto order = order_pool_.allocate(ticker_id, client_id,
client_order_id, new_market_order_id, side, price,
leaves_qty, priority, nullptr, nullptr);
addOrder(order);
market_update_ = {MarketUpdateType::ADD,
new_market_order_id, ticker_id, side, price,
leaves_qty, priority};
matching_engine_->sendMarketUpdate(&market_update_);
}
}
getNextPriority()
方法相当直接。如果某个价格水平已经存在某个价格,那么它就返回比该价格最后订单高一级的优先级值。如果该价格水平不存在,那么它为该价格水平的第一个订单返回 1
:
auto getNextPriority(Price price) noexcept {
const auto orders_at_price = getOrdersAtPrice(price);
if (!orders_at_price)
return 1lu;
return orders_at_price->first_me_order_->prev_order_
->priority_ + 1;
}
接下来,我们将详细说明如何将新订单添加到限价订单簿。该方法将传递给它的 MEOrder
对象追加到该订单的价格的 MEOrdersAtPrice
条目末尾。如果一个 MEOrdersAtPrice
条目不存在(新的价格水平),它首先分配一个新的条目,使用 addOrdersAtPrice()
方法将新水平添加到簿中,然后追加订单。此外,它还在 ClientOrderHashMap id_oid_to_order_
映射中跟踪 MEOrder
对象,映射从 ClientId
和 OrderId
到 MEOrder
对象:
auto addOrder(MEOrder *order) noexcept {
首先,我们尝试通过调用 getOrdersAtPrice()
方法并保存到 orders_at_price
变量中来检查和获取 MEOrdersAtPrice
,如果存在的话。然后,我们检查是否存在有效的 MEOrdersAtPrice
,这意味着存在一个具有该订单价格和方向的定价水平。如果这样的价格水平不存在,并且这是形成该水平的第一个订单,我们就会从 orders_at_price_pool_
中创建一个新的 MEOrdersAtPrice
,初始化它,并在其上调用 addOrdersAtPrice()
方法:
const auto orders_at_price = getOrdersAtPrice(order
->price_);
if (!orders_at_price) {
order->next_order_ = order->prev_order_ = order;
auto new_orders_at_price =
orders_at_price_pool_.allocate(order->side_,
order->price_, order, nullptr, nullptr);
addOrdersAtPrice(new_orders_at_price);
}
如果存在有效的价格级别,我们将新的订单附加到MEOrder
对象的双向链表的末尾,该链表可通过MEOrdersAtPrice
的first_me_order_
成员访问。然后,我们更新被添加的MEOrder
上的prev_order_
和next_order_
指针以及链表的最后一个元素,之后MEOrder
对象被附加:
else {
auto first_order = (orders_at_price ?
orders_at_price->first_me_order_ : nullptr);
first_order->prev_order_->next_order_ = order;
order->prev_order_ = first_order->prev_order_;
order->next_order_ = first_order;
first_order->prev_order_ = order;
}
最后,我们将此MEOrder
指针添加到cid_oid_to_order_
容器中,该容器是std::array
的std::array
实例的std::array
,首先按订单的client_id_
索引,然后按订单的client_order_id_
索引:
cid_oid_to_order_.at(order->client_id_)
.at(order->client_order_id_) = order;
}
最后,为了完成关于向簿中添加新订单的讨论,我们需要实现addOrdersAtPrice()
方法来向簿中添加新的价格级别。此方法首先将新的MEOrdersAtPrice
条目添加到OrdersAtPriceHashMap price_orders_at_price_
中。然后,它遍历报价或询问价格级别,从最激进的到最不激进的,以找到新价格级别的正确位置。请注意,此实现遍历MEOrdersAtPrice
对象的一侧的双向链表。可能有一种替代实现,通过遍历price_orders_at_price_
哈希图来找到正确的位置。两种实现都是可行的,并且根据价格级别的数量和连续价格之间的距离而有所不同。我们将在本书末尾的优化我们的 C++ 系统性能 *章节中重新讨论这个话题。
addOrdersAtPrice()
方法的第一个任务是将在price_orders_at_price_
哈希图中插入新的MEOrdersAtPrice
,映射从Price
到MEOrdersAtPrice
:
auto addOrdersAtPrice(MEOrdersAtPrice *new_orders_at_price) noexcept {
price_orders_at_price_.at(priceToIndex(
new_orders_at_price->price_)) = new_orders_at_price;
然后,我们需要将其插入到按价格排列的买卖报价的正确位置。我们通过首先将一个best_orders_by_price
变量分配给报价或询问的开始部分,并按价格排序来实现这一点:
const auto best_orders_by_price = (new_orders_at_price->
side_ == Side::BUY ? bids_by_price_ : asks_by_price_);
我们需要处理一个边缘情况,即没有报价或没有询问——也就是说,订单簿的一侧是空的。在这种情况下,我们设置bids_by_price_
或asks_by_price_
成员,它们指向该侧排序列表的头部:
if (UNLIKELY(!best_orders_by_price)) {
(new_orders_at_price->side_ == Side::BUY ?
bids_by_price_ : asks_by_price_) =
new_orders_at_price;
new_orders_at_price->prev_entry_ =
new_orders_at_price->next_entry_ =
new_orders_at_price;
}
否则,我们需要在价格级别的双向链表中找到正确的条目。我们通过遍历报价或询问,直到找到正确的价格级别,在它之前或之后插入新的价格级别来实现这一点。我们在以下target
变量中跟踪新价格级别之前或之后的价格级别,并使用add_after
布尔标志跟踪我们是否需要在目标变量之后或之前插入:
else {
auto target = best_orders_by_price;
bool add_after = ((new_orders_at_price->side_ ==
Side::SELL && new_orders_at_price->price_ >
target->price_) || (new_orders_at_price->side_ ==
Side::BUY && new_orders_at_price->price_ <
target->price_));
if (add_after) {
target = target->next_entry_;
add_after = ((new_orders_at_price->side_ ==
Side::SELL && new_orders_at_price->price_ >
target->price_) || (new_orders_at_price->side_ ==
Side::BUY && new_orders_at_price->price_ <
target->price_));
}
while (add_after && target != best_orders_by_price) {
add_after = ((new_orders_at_price->side_ ==
Side::SELL && new_orders_at_price->price_ >
target->price_) || (new_orders_at_price->side_ ==
Side::BUY && new_orders_at_price->price_ <
target->price_));
if (add_after)
target = target->next_entry_;
}
一旦我们找到了新的MEOrdersAtPrice
条目的正确位置,我们就通过更新target
MEOrdersAtPrice
结构中的prev_entry_
或next_entry_
变量以及被附加的新MEOrdersAtPrice
来附加新的价格级别,如下所示:
if (add_after) { // add new_orders_at_price after
// target.
if (target == best_orders_by_price) {
target = best_orders_by_price->prev_entry_;
}
new_orders_at_price->prev_entry_ = target;
target->next_entry_->prev_entry_ =
new_orders_at_price;
new_orders_at_price->next_entry_ =
target->next_entry_;
target->next_entry_ = new_orders_at_price;
} else { // add new_orders_at_price before target.
new_orders_at_price->prev_entry_ =
target->prev_entry_;
new_orders_at_price->next_entry_ = target;
target->prev_entry_->next_entry_ =
new_orders_at_price;
target->prev_entry_ = new_orders_at_price;
最后,如果我们在一个现有价格级别之前添加新的价格级别,我们需要检查是否将此价格级别添加到前面会改变 bids_by_price_
或 asks_by_price_
变量。记住,这些变量分别跟踪出价或要价的开始——也就是说,最高的出价价格和最低的要价价格。如果我们有一个新的最佳出价/要价价格级别,我们将分别更新 bids_by_price_
或 asks_by_price_
变量:
if ((new_orders_at_price->side_ == Side::BUY &&
new_orders_at_price->price_ > best_orders_by_price
->price_) || new_orders_at_price->side_ ==
Side::SELL && new_orders_at_price->price_ <
best_orders_by_price->price_)) {
target->next_entry_ = (target->next_entry_ ==
best_orders_by_price ? new_orders_at_price :
target->next_entry_);
(new_orders_at_price->side_ == Side::BUY ?
bids_by_price_ : asks_by_price_) =
new_orders_at_price;
}
}
}
}
接下来,我们将讨论处理订单取消请求的源代码。
处理订单取消请求
处理订单取消请求的代码是从撮合引擎转发的。首先,它检查取消请求是否有效,这意味着 ClientId
是有效的,并且取消请求中的 OrderId
与订单簿中的活动订单相对应。如果订单不可取消,它将生成并发布一个 MEClientResponse
消息来表示拒绝的取消请求返回给撮合引擎。如果订单可以取消,它将生成 MEClientResponse
来表示成功的取消尝试,并调用 removeOrder()
方法从限价订单簿中删除订单。我们将在下一个方法之后讨论 removeOrder()
的细节。
我们将跟踪一个 is_cancelable
布尔变量,以确定我们是否能够成功找到并取消客户端的订单。如果 client_id
大于最大可能的客户端 ID 值,则我们无法取消订单。如果客户端 ID 有效,则我们检查 cid_oid_to_order_
容器中的提供的 client_id
和 order_id
值。如果不存在有效的订单,则我们确认订单不可取消:
auto MEOrderBook::cancel(ClientId client_id, OrderId order_id, TickerId ticker_id) noexcept -> void {
auto is_cancelable = (client_id <
cid_oid_to_order_.size());
MEOrder *exchange_order = nullptr;
if (LIKELY(is_cancelable)) {
auto &co_itr = cid_oid_to_order_.at(client_id);
exchange_order = co_itr.at(order_id);
is_cancelable = (exchange_order != nullptr);
}
如果我们确定订单无法取消,我们将生成一个类型为 ClientResponseType::CANCEL_REJECTED
的 MEClientResponse
消息来通知撮合引擎:
if (UNLIKELY(!is_cancelable)) {
client_response_ =
{ClientResponseType::CANCEL_REJECTED, client_id,
ticker_id, order_id, OrderId_INVALID,
Side::INVALID, Price_INVALID, Qty_INVALID,
Qty_INVALID};
}
如果我们成功取消订单,我们将更新 client_response_
成员变量和 market_update_
成员变量中的属性。然后,我们调用 removeOrder()
方法来更新我们的订单簿并从其中删除此订单。最后,我们使用 sendMarketUpdate()
方法将市场更新发送给撮合引擎,并使用 sendClientResponse()
方法将客户端响应发送给撮合引擎:
else {
client_response_ = {ClientResponseType::CANCELED,
client_id, ticker_id, order_id,
exchange_order->market_order_id_,
exchange_order->side_, exchange_order->price_,
Qty_INVALID, exchange_order->qty_};
market_update_ = {MarketUpdateType::CANCEL,
exchange_order->market_order_id_, ticker_id,
exchange_order->side_, exchange_order->price_, 0,
exchange_order->priority_};
removeOrder(exchange_order);
matching_engine_->sendMarketUpdate(&market_update_);
}
matching_engine_->sendClientResponse(&client_response_);
}
接下来,让我们实现 removeOrder()
方法。它首先找到被删除订单所属的 MEOrdersAtPrice
,然后从 MEOrdersAtPrice
中包含的订单列表中找到并删除 MEOrder
。如果被删除的订单是该价格级别的唯一订单,该方法还将调用 removeOrdersAtPrice()
来删除整个价格级别,因为在此删除之后,该价格级别将不再存在。最后,它从 cid_oid_to_order_
哈希表中删除该 MEOrder
的条目,并将释放的 MEOrder
对象返回到 order_pool_
内存池:
auto removeOrder(MEOrder *order) noexcept {
auto orders_at_price = getOrdersAtPrice(order->price_);
if (order->prev_order_ == order) { // only one element.
removeOrdersAtPrice(order->side_, order->price_);
} else { // remove the link.
const auto order_before = order->prev_order_;
const auto order_after = order->next_order_;
order_before->next_order_ = order_after;
order_after->prev_order_ = order_before;
if (orders_at_price->first_me_order_ == order) {
orders_at_price->first_me_order_ = order_after;
}
order->prev_order_ = order->next_order_ = nullptr;
}
cid_oid_to_order_.at(order->client_id_).at(order
->client_order_id_) = nullptr;
order_pool_.deallocate(order);
}
为了结束我们对处理订单取消请求所涉及的任务的讨论,我们将实现 removeOrdersAtPrice()
方法。它从出价或询问侧的 MEOrdersAtPrice
的双链表中查找并删除 MEOrdersAtPrice
。如果被删除的价格条目恰好是该侧订单簿上唯一的 MEOrdersAtPrice
条目,它将双链表的头设置为 nullptr
,表示订单簿的一侧为空。最后,该方法从该价格的价格订单哈希表 price_orders_at_price_
中删除条目,并将释放的 MEOrdersAtPrice
返回到 orders_at_price_pool_
内存池:
auto removeOrdersAtPrice(Side side, Price price) noexcept {
const auto best_orders_by_price = (side == Side::BUY ?
bids_by_price_ : asks_by_price_);
auto orders_at_price = getOrdersAtPrice(price);
if (UNLIKELY(orders_at_price->next_entry_ ==
orders_at_price)) { // empty side of book.
(side == Side::BUY ? bids_by_price_ : asks_by_price_) =
nullptr;
} else {
orders_at_price->prev_entry_->next_entry_ =
orders_at_price->next_entry_;
orders_at_price->next_entry_->prev_entry_ =
orders_at_price->prev_entry_;
if (orders_at_price == best_orders_by_price) {
(side == Side::BUY ? bids_by_price_ : asks_by_price_)
= orders_at_price->next_entry_;
}
Orders_at_price->prev_entry_ = orders_at_price
->next_entry_ = nullptr;
}
price_orders_at_price_.at(priceToIndex(price)) = nullptr;
orders_at_price_pool_.deallocate(orders_at_price);
}
我们需要处理的最后一个操作是一个重要的操作——将侵略性订单与订单簿另一侧的被动订单进行匹配。我们将在下一节中查看该操作的实现。
匹配侵略性订单并更新订单簿
在本小节中,我们将通过展示我们之前遇到的 MEOrderBook::checkForMatch()
方法来实现限价订单簿中的匹配功能。图 6.3 所示的图解展示了在限价订单簿的假设状态下会发生什么。在这里,展示了询问侧的状态,由 MEOrdersAtPrice
表示的被动卖价是 MEOrder
对象,第一个具有 MEOrder
的优先级,在 FIFO 队列中跟随的具有优先级 13,市场订单 ID 为 1400,数量为 10。在这种情况下,一个数量为 25、价格为 117(用蓝色表示)的新买订单将与市场订单 ID 为 1200(用黄色表示)的第一个订单匹配,并完全执行它。然后,它将对市场订单 ID 为 1400(用洋红色表示)的订单的剩余数量 5 进行部分执行,并完成匹配事件。这些步骤在以下图解的算法中展示。
图 6.3 – 限价订单簿中匹配事件的示例
此方法遍历位于新(可能具有侵略性)订单对面的一侧的 MEOrdersAtPrice
对象。它从最具有侵略性到最不具有侵略性的价格水平遍历价格,并且对于每个价格水平,按照先进先出(FIFO)的顺序,从第一个到最后一个匹配该价格水平下包含的 MEOrder
对象。它继续将新订单与另一侧的被动订单进行匹配,从最具有侵略性到最不具有侵略性的价格,并在价格水平的第一到最后一个订单,通过调用 match()
方法。当新侵略性订单没有更多未匹配的数量可以匹配,另一侧剩余的价格水平不再与新订单的价格交叉,或者订单簿的一侧为空时,它停止并返回。此时,它将新订单上剩余的未匹配数量返回给调用者:
auto MEOrderBook::checkForMatch(ClientId client_id, OrderId client_order_id, TickerId ticker_id, Side side, Price price, Qty qty, Qty new_market_order_id) noexcept {
auto leaves_qty = qty;
我们持续迭代所有询问价格水平,从最低价格到最高价格排列,从asks_by_price_
级别开始。对于asks_by_price_
级别,我们从MEOrder
类型指针的first_me_order_
对象开始,按照先进先出(FIFO)的顺序迭代,从最低到最高优先级。对于每个可以与新激进订单匹配的订单,我们调用MEOrder::match()
方法来执行实际匹配。我们继续这样做,直到没有更多的leaves_qty
剩余,asks_by_price_
变量为nullptr
表示空订单簿的一侧,或者剩余的价格水平无法用于匹配新订单:
if (side == Side::BUY) {
while (leaves_qty && asks_by_price_) {
const auto ask_itr = asks_by_price_->first_me_order_;
if (LIKELY(price < ask_itr->price_)) {
break;
}
match(ticker_id, client_id, side, client_order_id,
new_market_order_id, ask_itr, &leaves_qty);
}
}
如果新订单有一侧是卖出,我们将执行与之前描述相同的逻辑,只是我们迭代bids_by_price_
价格水平,这些价格水平从最高买入价格到最低买入价格排列,如下所示:
if (side == Side::SELL) {
while (leaves_qty && bids_by_price_) {
const auto bid_itr = bids_by_price_->first_me_order_;
if (LIKELY(price > bid_itr->price_)) {
break;
}
match(ticker_id, client_id, side, client_order_id,
new_market_order_id, bid_itr, &leaves_qty);
}
}
return leaves_qty;
}
当新激进订单与订单簿另一侧的现有被动订单匹配时,会调用match()
方法。它计算执行数量,这是新订单数量和它将与之匹配的现有被动订单数量的最小值。它从这个执行数量中减去激进订单的剩余数量以及与之匹配的被动订单。它生成两个执行订单响应并发送给匹配引擎——一个发送给发送激进订单的客户,另一个发送给被动订单被执行的客户。它还创建并发布一个类型为MarketUpdateType::TRADE
的市场更新,以通知参与者关于公共市场数据流中的执行情况。最后,它检查这笔交易是否完全执行了被动订单,如果是完全执行,它生成另一个类型为MarketUpdateType::CANCEL
的市场更新,通知参与者被动订单已被移除。如果被动订单只是部分匹配,它将生成一个类型为MarketUpdateType::MODIFY
的市场更新,包含被动限价订单的新剩余数量。
这意味着选择忽略市场数据流中的交易消息的参与者仍然可以准确地构建和维护限价订单簿。理论上,我们可以消除额外的取消或修改市场更新,但这将要求下游市场数据消费者将交易消息应用于他们的订单簿并更新它们。
MEOrderBook::match()
方法接受一些参数来识别客户信息,但关键参数是MEOrder
指针itr
和Qty
指针leaves_qty
。MEOrder
指针代表订单簿中与新订单匹配的订单,而Qty
代表新订单上的剩余数量。这些参数通过指针传递,因为我们将在该方法中直接修改它们,并期望这些更改在调用方法中反映出来:
auto MEOrderBook::match(TickerId ticker_id, ClientId client_id, Side side, OrderId client_order_id, OrderId new_market_order_id, MEOrder* itr, Qty* leaves_qty) noexcept {
我们计算 fill_qty
变量,使其成为账本中存在的被动订单数量和新订单数量的最小值。然后我们使用 fill_qty
减少订单簿中的 leaves_qty
和 MEOrder
对象上的 qty_
成员:
const auto order = itr;
const auto order_qty = order->qty_;
const auto fill_qty = std::min(*leaves_qty, order_qty);
*leaves_qty -= fill_qty;
order->qty_ -= fill_qty;
我们生成一个类型为 ClientResponseType::FILLED
的客户端响应消息,是为发送新订单的客户准备的,并使用 sendClientResponse()
方法将其调度到匹配引擎:
client_response_ = {ClientResponseType::FILLED,
client_id, ticker_id, client_order_id,
new_market_order_id, side, itr->price_, fill_qty,
*leaves_qty};
matching_engine_->sendClientResponse(&client_response_);
我们还生成了一个第二客户端响应消息,类型为 ClientResponseType::FILLED
;这个消息是为订单在订单簿中且被匹配的客户准备的:
client_response_ = {ClientResponseType::FILLED, order
->client_id_, ticker_id, order->client_order_id_,
order->market_order_id_, order->side_, itr->price_,
fill_qty, order->qty_};
matching_engine_->sendClientResponse(&client_response_);
我们还将生成一个类型为 MarketUpdateType::TRADE
的市场更新,并使用 sendMarketUpdate()
方法发布它,通知参与者关于发生的交易交易,并为他们提供 fill_qty
:
market_update_ = {MarketUpdateType::TRADE,
OrderId_INVALID, ticker_id, side, itr->price_,
fill_qty, Priority_INVALID};
matching_engine_->sendMarketUpdate(&market_update_);
最后,我们将为账本中存在的被动客户订单生成一个市场更新。如果这个 MEOrder
上还有剩余数量,那么我们生成一个 MarketUpdateType::MODIFY
消息,并传递该订单上剩余的数量。如果订单完全执行,则生成一个 MarketUpdateType::CANCEL
更新,发布它,并调用 MEOrderBook::removeOrder()
方法从订单簿中移除这个 MEOrder
:
if (!order->qty_) {
market_update_ = {MarketUpdateType::CANCEL,
order->market_order_id_, ticker_id, order->side_,
order->price_, order_qty, Priority_INVALID};
matching_engine_->sendMarketUpdate(&market_update_);
removeOrder(order);
} else {
market_update_ = {MarketUpdateType::MODIFY,
order->market_order_id_, ticker_id, order->side_,
order->price_, order->qty_, order->priority_};
matching_engine_->sendMarketUpdate(&market_update_);
}
}
这结束了我们对处理客户订单请求、更新匹配引擎内部的限价订单簿以及生成和发布订单响应和市场更新的操作的讨论。
摘要
我们在本章中开始了我们的电子交易生态系统的 C++ 实现。我们构建的第一个组件是交易所匹配引擎,该引擎负责接受和回答来自交易所基础设施中订单服务器组件的订单请求。此组件还负责生成和发布市场数据更新到交易所基础设施中的市场数据发布者组件。
首先,我们在我们的匹配引擎和限价订单簿中声明了一些假设。我们还定义了一些基本的 OrderId
,并在 MEOrdersAtPrice
结构中将相同价格下的订单链接在一起。重申我们之前已经讨论过的内容,这些价格级别本身是在一个双向链表和按价格索引的哈希表中维护的。
然后,我们构建了匹配引擎组件,这是一个独立的执行线程,它从订单服务器消费更新,并将响应和市场数据更新发布回订单服务器和市场数据发布者。我们还构建了电子交易交易所的主应用程序二进制文件,我们将在下一章中对其进行增强。
最后,我们详细阐述了构建和更新限价订单簿数据结构的机制。我们讨论了处理新订单请求和订单取消请求所涉及的任务。我们还实现了撮合引擎的功能,以执行新积极订单与现有被动订单之间的实际匹配,这些订单在价格上交叉。匹配事件为参与匹配事件的参与者生成私有执行消息。此外,该事件还生成交易消息和公共市场数据流上的订单删除或修改。
在下一章中,我们将构建市场数据发布者组件,该组件负责消费由撮合引擎生成的市场数据更新,并将其传输到线上供参与者消费。此外,我们还将构建位于电子交易交易所中的订单服务器组件,该组件负责与不同市场参与者订单网关的通信,将请求和响应转发到和从撮合引擎。
第七章:与市场参与者通信
在本章中,我们将构建电子交易交易所中的订单网关组件,该组件负责接受客户端连接、处理请求,并在有更新时向客户端发布关于其订单的响应。公平性、低延迟和低抖动(延迟变化)是这里的重要要求,以促进高频交易参与者。我们还将构建从交易交易所发布市场数据的组件。这些市场数据更新旨在允许客户端构建电子交易交易所持有的所有客户订单的订单簿。当有订单更新和匹配发生时,这些市场更新需要尽快发送出去,因此重点将放在超低延迟性能上。此外,交易所还需要定期为那些掉包或在市场已经开盘后开始参与的市场参与者提供订单簿快照。
在本章中,我们将涵盖以下主题:
-
定义市场数据协议和订单数据协议
-
构建订单网关服务器
-
构建市场数据发布者
-
构建主要交易所应用程序
技术要求
本书的所有代码都可以在本书的 GitHub 仓库github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
中找到。本章的源代码可以在仓库中的Chapter7
目录中找到。
重要的是,你已经阅读并理解了设计我们的交易生态系统章节中展示的电子交易生态系统的设计。我们在本章构建的组件将与我们在构建 C++匹配引擎章节中构建的匹配引擎交互,因此我们假设你熟悉这一点。和之前一样,我们将使用我们在构建低延迟应用的 C++构建块章节中构建的构建块。
定义市场数据协议和订单数据协议
在我们构建交易交换内部发布市场数据更新并接收和响应客户端请求的组件之前,我们需要最终确定协议。该协议需要公开可用,以便想要连接到交易所、处理更新和发送订单请求的市场参与者能够构建他们的软件。该协议是交易所和市场参与者将用于通信的语言。我们将有两个协议——一个用于市场数据更新的格式,另一个用于发送订单请求和接收订单响应的格式。
设计市场数据协议
对于市场数据协议,我们将定义一个内部格式,这是匹配引擎使用的,以及一个公共格式,用于市场参与者。我们在构建匹配引擎章节的定义匹配引擎中的操作和交互部分中看到了内部匹配格式,即MEMarketUpdate
结构。在本节中,我们将定义公共市场数据格式,它将被封装在MDPMarketUpdate
结构中。记住,我们提到市场数据格式可以是几种类型和不同复杂度,例如 FAST 协议或 SBE 协议。对于我们的市场数据格式,我们将使用Chapter7/exchange/market_data/market_update.h
源文件。
在我们查看市场数据协议之前,提醒一下,我们首先在设计我们的交易生态系统章节的理解交易所如何向参与者发布信息部分的设计市场数据发布者子部分中解释了市场数据快照是什么,为什么需要它,以及它是如何使用增量市场数据更新合成的。此外,我们在同一章节的构建市场参与者与交易所的接口部分讨论了快照数据流的更多细节。因此,如果需要复习这些概念,重新访问这些部分将是有益的。但为了重新介绍快照消息,这些消息包含任何给定时间限价订单簿状态的完整信息,并且如果市场参与者需要重新构建完整的限价订单簿,可以使用这些信息。
在我们查看MDPMarketUpdate
结构之前,让我们首先回顾一下我们在上一章中创建的MarketUpdateType
枚举。在本章中,我们将在此处添加几个新的枚举类型——CLEAR
、SNAPSHOT_START
和SNAPSHOT_END
——这些将在以后需要。CLEAR
消息用于通知客户端他们应该在他们的端清除/清空订单簿,SNAPSHOT_START
表示快照消息的开始,而SNAPSHOT_END
表示快照更新中的所有更新都已交付。
更新的枚举列表如下所示:
#pragma once
#include <sstream>
#include "common/types.h"
using namespace Common;
namespace Exchange {
enum class MarketUpdateType : uint8_t {
INVALID = 0,
CLEAR = 1,
ADD = 2,
MODIFY = 3,
CANCEL = 4,
TRADE = 5,
SNAPSHOT_START = 6,
SNAPSHOT_END = 7
};
}
我们的 MDPMarketUpdate
结构相对于 MEMarketUpdate
结构增加了一个重要的字段,即序列号字段。这个 size_t seq_num_
字段是交易所发布的每个市场更新的递增序列号值。对于每个新的市场更新,序列号正好比前一个市场更新大 1。这个序列号字段将由市场数据消费者在市场参与者的交易系统中用来检测市场更新的间隔。记住,对于我们的市场数据发布者,我们将以 UDP 格式发布市场数据,这是一个不可靠的协议。所以,当网络层面出现数据包丢失,或者如果某个参与者的系统丢失了一个数据包,他们可以使用序列号字段来检测这一点。我们再次展示 MEMarketUpdate
的内部格式,以及新的公共 MDPMarketUpdate
格式如下:
#pragma pack(push, 1)
struct MEMarketUpdate {
MarketUpdateType type_ = MarketUpdateType::INVALID;
OrderId order_id_ = OrderId_INVALID;
TickerId ticker_id_ = TickerId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
Priority priority_ = Priority_INVALID;
auto toString() const {
std::stringstream ss;
ss << "MEMarketUpdate"
<< " ["
<< " type:" << marketUpdateTypeToString(type_)
<< " ticker:" << tickerIdToString(ticker_id_)
<< " oid:" << orderIdToString(order_id_)
<< " side:" << sideToString(side_)
<< " qty:" << qtyToString(qty_)
<< " price:" << priceToString(price_)
<< " priority:" << priorityToString(priority_)
<< "]";
return ss.str();
}
};
struct MDPMarketUpdate {
size_t seq_num_ = 0;
MEMarketUpdate me_market_update_;
auto toString() const {
std::stringstream ss;
ss << "MDPMarketUpdate"
<< " ["
<< " seq:" << seq_num_
<< " " << me_market_update_.toString()
<< "]";
return ss.str();
}
};
#pragma pack(pop)
因此,MDPMarketUpdate
简单地是 MEMarketUpdate
,只是在前面添加了一个 seq_num_
字段。在我们完成这个子节之前,我们将定义两个简单的 typedef
,我们将在本章后面用到。我们在上一章中看到了第一个,MEMarketUpdateLFQueue
;新的 MDPMarketUpdateLFQueue
与之类似,代表一个 MDPMarketUpdate
结构的锁免费列队:
typedef Common::LFQueue<Exchange::MEMarketUpdate>
MEMarketUpdateLFQueue;
typedef Common::LFQueue<Exchange::MDPMarketUpdate>
MDPMarketUpdateLFQueue;
这就完成了我们对市场数据协议的设计。接下来,我们将看到订单数据协议的设计。
设计订单数据协议
在这个子节中,我们将设计客户端将用来向交易所发送订单请求并从其接收订单响应的公共订单数据协议,具体来说是订单网关服务器。
首先,我们将看到从市场参与者的订单网关发送到交易所订单网关服务器的消息格式。在 构建 C++ 匹配引擎 章节中,我们已经讨论了 ClientRequestType
枚举、MEClientRequest
结构和匹配引擎使用的 ClientRequestLFQueue
typedef
,在 定义匹配引擎中的操作和交互 部分。MEClientRequest
是匹配引擎使用的内部格式,但 OMClientRequest
是市场参与者在向交易所订单网关服务器发送订单请求时需要使用的格式。与市场数据格式类似,OMClientRequest
有一个序列号字段 seq_num_
,然后是 MEClientRequest
结构。这里的序列号字段与之前的作用类似,确保交易所和客户端的订单网关组件彼此同步。这个结构的代码在 Chapter7/exchange/order_server/client_request.h
文件中:
#pragma once
#include <sstream>
#include "common/types.h"
#include "common/lf_queue.h"
using namespace Common;
namespace Exchange {
#pragma pack(push, 1)
struct OMClientRequest {
size_t seq_num_ = 0;
MEClientRequest me_client_request_;
auto toString() const {
std::stringstream ss;
ss << "OMClientRequest"
<< " ["
<< "seq:" << seq_num_
<< " " << me_client_request_.toString()
<< "]";
return ss.str();
}
};
#pragma pack(pop)
}
我们为交易所的订单网关服务器发送给客户端订单网关组件的响应设计了一个对称的结构。我们在上一章中看到了MEClientResponse
结构,它用于交易交换基础设施内部匹配引擎和订单网关服务器组件之间的内部通信。OMClientResponse
结构是市场参与者用来接收和处理订单响应的公共格式。像之前看到的其他结构一样,有一个用于同步的序列号字段,以及该结构的剩余有效载荷是MEClientResponse
结构。这个结构可以在Chapter7/exchange/order_server/client_response.h
文件中找到:
#pragma once
#include <sstream>
#include "common/types.h"
#include "common/lf_queue.h"
using namespace Common;
namespace Exchange {
#pragma pack(push, 1)
struct OMClientResponse {
size_t seq_num_ = 0;
MEClientResponse me_client_response_;
auto toString() const {
std::stringstream ss;
ss << "OMClientResponse"
<< " ["
<< "seq:" << seq_num_
<< " " << me_client_response_.toString()
<< "]";
return ss.str();
}
};
#pragma pack(pop)
}
本章所需的新结构设计到此结束。接下来,我们将开始讨论订单网关服务器的实现,首先从它如何处理来自市场参与者的客户端请求开始。
构建订单网关服务器
在本节中,我们将开始构建订单网关服务器基础设施,它负责为客户端设置 TCP 服务器以连接到。订单网关服务器还需要按照请求到达的顺序处理来自不同客户端的请求,并将它们转发给匹配引擎。最后,它还需要从匹配引擎接收订单响应,并将它们转发给对应市场参与者的正确 TCP 连接。我们将重新审视订单网关服务器的结构以及它与匹配引擎和市场参与者的交互,如下所述。
图 7.1 – 订单网关服务器及其子组件
为了帮助您回忆,订单网关服务器接收新的 TCP 连接或已建立的 TCP 连接上的客户端请求。然后,这些请求通过一个 FIFO 序列器阶段,以确保请求按照它们到达交易所基础设施的确切顺序进行处理。在上一节中描述的内部匹配引擎格式和公共订单数据格式之间存在转换。在上一章《构建匹配引擎》中,我们已经构建了匹配引擎的通信路径,这是通过无锁队列实现的。关于该组件的设计细节以及它在我们的电子交易生态系统中所起的作用,在《设计我们的交易生态系统》一章中已有详细讨论,特别是在《理解电子交易生态系统的布局》和《理解交易所如何向参与者发布信息》部分。因此,我们强烈建议在构建交易所的订单网关服务器时重新阅读那一章。
首先,我们将构建OrderServer
类,它代表前面图中订单网关服务器组件。OrderServer
的代码位于Chapter7/exchange/order_server/order_server.h
和Chapter7/exchange/order_server/order_server.cpp
文件中。
在网关服务器中定义数据成员的顺序
OrderServer
类有几个重要的数据成员:
-
一个名为
tcp_server_
的变量,它是Common::TCPServer
类的一个实例,将被用来托管一个 TCP 服务器以轮询市场参与者的传入连接,并轮询已建立的 TCP 连接以查看是否有任何连接可以读取数据。 -
一个名为
fifo_sequencer_
的变量,它是FIFOSequencer
类的一个实例,负责确保来自不同 TCP 连接的客户请求按照它们到达的正确顺序进行处理。 -
一个无锁队列变量
outgoing_responses_
,其类型为ClientResponseLFQueue
,通过它接收来自匹配引擎的MEClientResponse
消息,这些消息需要发送给正确的市场参与者。 -
一个大小为
ME_MAX_NUM_CLIENTS
的std::array
cid_tcp_socket_
,包含TCPSocket
对象,它将被用作从客户端 ID 到该客户端TCPSocket
连接的哈希映射。 -
两个大小为
ME_MAX_NUM_CLIENTS
的std::array
,用于跟踪OMClientResponse
和OMClientRequest
消息上的交易所到客户端和客户端到交易所的序列号。这些是cid_next_outgoing_seq_num_
和cid_next_exp_seq_num_
变量。 -
一个布尔变量
run_
,它将被用来启动和停止OrderServer
线程。请注意,它被标记为volatile
,因为它将从不同的线程中访问,并且我们希望在这里防止编译器优化,以确保在多线程环境中的正确功能:#pragma once
#include <functional>
#include "common/thread_utils.h"
#include "common/macros.h"
#include "common/tcp_server.h"
#include "order_server/client_request.h"
#include "order_server/client_response.h"
#include "order_server/fifo_sequencer.h"
namespace Exchange {
class OrderServer {
private:
const std::string iface_;
const int port_ = 0;
ClientResponseLFQueue *outgoing_responses_ = nullptr;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
std::array<size_t, ME_MAX_NUM_CLIENTS> cid_next_outgoing_
seq_num_;
std::array<size_t, ME_MAX_NUM_CLIENTS> cid_next_exp_seq_
num_;
std::array<Common::TCPSocket *, ME_MAX_NUM_CLIENTS> cid_tcp_
socket_;
Common::TCPServer tcp_server_;
FIFOSequencer fifo_sequencer_;
};
}
在我们进入下一个子节之前,还有一个小的声明,即OrderServer
类有以下方法声明,我们将在后续子节中定义它们。这些是与构造函数、析构函数、start()
方法和stop()
方法相对应的方法,但就目前而言,不要担心这些方法的细节;我们很快就会定义它们:
OrderServer(ClientRequestLFQueue *client_requests,
ClientResponseLFQueue *client_responses, const std::string &iface,
int port);
~OrderServer();
auto start() -> void;
auto stop() -> void;
在下一个子节中,我们将初始化和销毁OrderServer
类及其成员变量。
初始化订单网关服务器
这个类的构造函数很简单。我们使用一些基本值初始化三个数组:序列号设置为 1,TCPSocket
设置为nullptr
。我们还将两个回调成员recv_callback_
和recv_finished_callback_
设置为指向recvCallback()
和recvFinishedCallback()
成员函数。我们将在接下来的几个小节中讨论这些回调处理方法。OrderServer
类的构造函数接受两个无锁队列对象的指针:一个用于将MEClientRequest
转发到匹配引擎,另一个用于从匹配引擎接收MEClientResponse
。它还接受一个网络接口和端口号,该端口号用于订单网关服务器监听并接受客户端连接:
#include "order_server.h"
namespace Exchange {
OrderServer::OrderServer(ClientRequestLFQueue *client_requests,
ClientResponseLFQueue *client_responses, const std::string &iface,
int port)
: iface_(iface), port_(port), outgoing_responses_(client_
responses), logger_("exchange_order_server.log"),
tcp_server_(logger_), fifo_sequencer_(client_requests,
&logger_) {
cid_next_outgoing_seq_num_.fill(1);
cid_next_exp_seq_num_.fill(1);
cid_tcp_socket_.fill(nullptr);
tcp_server_.recv_callback_ = this {
recvCallback(socket, rx_time); };
tcp_server_.recv_finished_callback_ = [this]() {
recvFinishedCallback(); };
}
}
我们还将定义一个start()
方法,它将 bool run_ 设置为 true。这是控制主线程运行时间的标志。我们还初始化TCPServer
成员对象,以便在构造函数中提供的接口和端口上开始监听。最后,它创建并启动一个将执行run()
方法的线程,我们将在接下来的几个小节中看到这个方法。现在,我们不会为在这个应用程序中创建的任何线程设置亲和性,但我们将在本书的末尾讨论优化可能性:
auto OrderServer::start() -> void {
run_ = true;
tcp_server_.listen(iface_, port_);
ASSERT(Common::createAndStartThread(-1, "Exchange/OrderServer",
[this]() { run(); }) != nullptr, "Failed to start OrderServer
thread.");
}
我们定义了一个互补的stop()
方法,它只是将run_
标志设置为 false,这将导致run()
方法完成执行(关于这一点我们稍后还会讨论):
auto OrderServer::stop() -> void {
run_ = false;
}
OrderServer
类的析构函数也很简单。它调用stop()
方法指示主线程停止执行,然后等待一段时间,以便线程完成任何挂起的任务:
OrderServer::~OrderServer() {
stop();
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(1s);
}
这就完成了本小节关于这个类初始化的讨论。接下来,我们将研究OrderServer
处理通过 TCP 连接传入的客户端请求所需的功能。
处理传入的客户端请求
在本小节中,我们将讨论我们需要处理传入客户端请求的代码。这些客户端请求通过 TCP 连接接收,并通过TCPServer
(如我们在构造函数中设置的)将这些请求分发给recvCallback()
和recvFinishedCallback()
方法。我们将把这个方法的实现分解成不同的块,以便我们更好地理解它。
这个方法中的第一个代码块检查可用数据的大小是否至少与一个完整的OMClientRequest
结构体的大小一样大。然后它将可用数据分成与OMClientRequest
对象大小相等的块,并遍历可用数据。它将TCPSocket
中的rcv_buffer_
重新解释为OMClientRequest
结构体,并将其保存到request
变量中,该变量是OMClientRequest
指针类型:
auto recvCallback(TCPSocket *socket, Nanos rx_time) noexcept {
logger_.log("%:% %() % Received socket:% len:% rx:%\n", __FILE__,
__LINE__, __FUNCTION__, Common::getCurrentTimeStr(&time_str_),
socket->fd_, socket->next_rcv_valid_index_, rx_
time);
if (socket->next_rcv_valid_index_ >= sizeof(OMClientRequest)) {
size_t i = 0;
for (; i + sizeof(OMClientRequest) <= socket->next_rcv_valid_
index_; i += sizeof(OMClientRequest)) {
auto request = reinterpret_cast<const OMClientRequest *>(socket->rcv_buffer_ + i);
logger_.log("%:% %() % Received %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_),
request->toString());
一旦它获得了需要处理的OMClientRequest
,它将检查这是否是这个客户端的第一个请求。如果是这种情况,那么它将通过将其添加到我们用作哈希表的cid_tcp_socket_
std::array
中来跟踪这个客户端的TCPSocket
实例:
if (UNLIKELY(cid_tcp_socket_[request->me_client_request_.
client_id_] == nullptr)) {
cid_tcp_socket_[request->me_client_request_.client_id_] =
socket;
}
如果对于这个客户端-id 已经在cid_tcp_socket_
容器中存在一个TCPSocket
条目,那么我们将确保之前跟踪的TCPSocket
与当前请求的TCPSocket
匹配。如果不匹配,我们将记录一个错误并跳过处理这个请求:
if (cid_tcp_socket_[request->me_client_request_.client_id_]
!= socket) {
logger_.log("%:% %() % Received ClientRequest from
ClientId:% on different socket:% expected:%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
request->me_client_request_.client_id_,
socket->fd_,
cid_tcp_socket_[request->me_client_request_.
client_id_]->fd_);
continue;
}
接下来,我们将执行一个序列号检查,以确保这个OMClientRequest
上的序列号与我们根据最后收到的消息所期望的序列号完全一致。如果期望的序列号和接收到的序列号之间存在不匹配,那么我们将记录一个错误并忽略这个请求:
auto &next_exp_seq_num = cid_next_exp_seq_num_[request->me_
client_request_.client_id_];
if (request->seq_num_ != next_exp_seq_num) {
logger_.log("%:% %() % Incorrect sequence number.
ClientId:% SeqNum expected:% received:%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
request->me_client_request_.client_id_, next_
exp_seq_num, request->seq_num_);
continue;
}
这里有一个注意事项,在现实设置中,如果交换接收到的请求是在错误的套接字上,或者如果序列号不匹配,它将向客户端发送一个拒绝响应,以通知他们错误。为了简化,这里省略了这一点,但如果需要,添加它并不困难。如果我们已经执行了这个循环的这部分,那么我们将为这个客户端的下一个OMClientRequest
递增下一个预期的序列号,并将这个请求转发到 FIFO 序列器数据成员。这里需要注意的是,我们还转发rx_time
,这是这个 TCP 数据包的软件接收时间,因为 FIFO 序列器需要这些信息来正确地排序请求。我们将在下一小节中讨论 FIFO 序列器如何实现这一点:
++next_exp_seq_num;
fifo_sequencer_.addClientRequest(rx_time, request->me_
client_request_);
}
memcpy(socket->rcv_buffer_, socket->rcv_buffer_ + i, socket-
>next_rcv_valid_index_ - i);
socket->next_rcv_valid_index_ -= i;
}
}
记住,当所有recvCallback()
方法从当前对TCPServer::sendAndRecv()
的调用中分发出去时,会调用recvFinishedCallback()
方法。recvFinishedCallback()
方法指示FIFOSequencer
正确排序它已排队的MEClientRequests
并将它们推送到匹配引擎。当我们在下一小节中讨论FIFOSequencer
的设计和实现时,这种机制将变得清晰:
auto recvFinishedCallback() noexcept {
fifo_sequencer_.sequenceAndPublish();
}
接下来,我们将讨论 FIFO 序列器组件,该组件负责从处理客户端请求的角度维护公平性。它通过确保在不同 TCP 连接上接收到的请求以它们在网关服务器接收到的确切顺序进行处理来实现这一点。
使用 FIFO 序列器公平处理请求
订单网关服务器中的 FIFO 序列器子组件负责确保客户端请求按到达时间顺序处理。这是必要的,因为顺序网关服务器从不同的 TCP 连接中读取和调度客户端请求,这些请求到达的时间不同。让我们首先定义这个类内部的数据成员。FIFO 序列器的代码位于 Chapter7/exchange/order_server/fifo_sequencer.h
源文件中。
定义 FIFO 序列器中的数据成员
首先,我们定义一个常量 ME_MAX_PENDING_REQUESTS
,它表示在网络套接字上所有 TCP 连接中可以同时挂起的最大请求数量。如果顺序网关服务器正忙于其他任务并且没有在非常短的时间内轮询 TCP 连接,那么在这段时间内可能到达的客户端请求可能会在网络套接字级别排队。
FIFO 序列器使用此常量来创建一个大小为 RecvTimeClientRequest
结构的 std::array
。在这个 FIFOSequencer
类中,这个成员变量被命名为 pending_client_requests_
。为了计算 pending_client_requests_
数组中实际挂起的请求条目数量,我们将维护一个 size_t
类型的 pending_size_
变量。
RecvTimeClientRequest
结构有两个成员——recv_time_
,类型为 Nanos
,以及一个 request_
变量,类型为 MEClientRequest
。这个结构捕获了客户端请求以及它在订单网关服务器上的到达时间。我们将按时间对这些进行排序,然后按到达顺序处理它们。为了使排序变得容易,我们将定义一个 <
操作符,如果操作符的左侧(LHS)的客户端请求在操作符的右侧(RHS)的客户端请求之前接收,则返回 true
。
最后,这个类中最后一个重要的成员变量是 incoming_requests_
,它属于 ClientRequestLFQueue
类型,这是 FIFO 序列器用来将 MEClientRequest
发送到匹配引擎的无锁队列:
#pragma once
#include "common/thread_utils.h"
#include "common/macros.h"
#include "order_server/client_request.h"
namespace Exchange {
constexpr size_t ME_MAX_PENDING_REQUESTS = 1024;
class FIFOSequencer {
private:
ClientRequestLFQueue *incoming_requests_ = nullptr;
std::string time_str_;
Logger *logger_ = nullptr;
struct RecvTimeClientRequest {
Nanos recv_time_ = 0;
MEClientRequest request_;
auto operator<(const RecvTimeClientRequest &rhs) const {
return (recv_time_ < rhs.recv_time_);
}
};
std::array<RecvTimeClientRequest, ME_MAX_PENDING_REQUESTS>
pending_client_requests_;
size_t pending_size_ = 0;
};
}
现在,让我们查看源代码以初始化 FIFO 序列器。
初始化 FIFO 序列器
FIFOSequencer
类的构造函数简单明了,易于理解。它如下所示,并初始化 incoming_requests_
ClientRequestLFQueue
和 logger_
,这两个都是通过这个类的构造函数传递给它的:
class FIFOSequencer {
public:
FIFOSequencer(ClientRequestLFQueue *client_requests, Logger
*logger)
: incoming_requests_(client_requests), logger_(logger) {
}
现在,我们将查看 FIFO 序列器中最重要的功能——按接收时间顺序排队客户端请求并发布它们。
按顺序发布客户端请求
我们在之前的子节“处理传入客户端请求”中使用了FIFOSequencer::addClientRequest()
方法。在这里,我们展示了其实现,这相当简单,只需将其添加到pending_client_requests_
的末尾,并将pending_size_
变量递增以表示添加了一个额外的条目。请注意,我们始终期望一次最多只有ME_MAX_PENDING_REQUESTS
,因为我们将其设置为高值。如果这个限制不够,我们有增加数组大小并可能切换到使用RecvTimeClientRequest
对象的MemPool
的选项:
auto addClientRequest(Nanos rx_time, const MEClientRequest
&request) {
if (pending_size_ >= pending_client_requests_.size()) {
FATAL("Too many pending requests");
}
pending_client_requests_.at(pending_size_++) =
std::move(RecvTimeClientRequest{rx_time, request});
}
我们也在之前的子节“处理传入客户端请求”中使用了FIFOSequencer::sequenceAndPublish()
方法。这是FIFOSequencer
类中最重要的方法,并执行以下任务:
-
首先,它按到达时间升序对
pending_client_requests_
容器中的所有RecvTimeClientRequest
条目进行排序。它通过使用std::sort()
算法来实现这一点,该算法反过来使用我们为RecvTimeClientRequest
对象构建的<
运算符来排序容器。这里有一点需要注意:如果元素数量非常大,排序可能会变得耗时,但在这里我们很少期望这种情况发生,因为同时挂起的请求数量预计会相当低。这将是另一个优化领域,但在决定如何改进之前,我们需要在实际中测量我们系统的负载和性能。 -
在排序步骤之后,它将每个
MEClientRequest
条目写入incoming_requests_
LFQueue
,该队列流向匹配引擎。 -
最后,它将
pending_size_
变量重置以标记处理结束,并从方法返回:auto sequenceAndPublish() {
if (UNLIKELY(!pending_size_))
return;
logger_->log("%:% %() % Processing % requests.\n", __
FILE__, __LINE__, __FUNCTION__, Common::getCurrentTimeStr
(&time_str_), pending_size_);
std::sort(pending_client_requests_.begin(), pending_
client_requests_.begin() + pending_size_);
for (size_t i = 0; i < pending_size_; ++i) {
const auto &client_request = pending_client_requests_.
at(i);
logger_->log("%:% %() % Writing RX:% Req:%
to FIFO.\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
client_request.recv_time_, client_request.
request_.toString());
auto next_write = incoming_requests_->getNextToWriteTo();
*next_write = std::move(client_request.request_);
incoming_requests_->updateWriteIndex();
}
pending_size_ = 0;
}
这标志着我们订单网关服务器内部FIFOSequencer
子组件的设计和实现的结束。现在,我们可以回到我们的OrderServer
类的设计,通过添加将客户端响应发送回客户端的功能。
发送客户端响应
在本节中,我们将探讨OrderServer
在run()
方法中执行的两个重要任务。请记住,这个run()
方法是这个类的主要循环,它在我们在“初始化订单网关服务器”子节中创建并启动的线程上运行,具体在start()
方法中。run()
方法执行以下两个主要任务:
-
它在其持有的
TCPServer
对象上调用poll()
方法。请记住,poll()
方法检查并接受新的连接,移除已死连接,并检查是否有任何已建立的 TCP 连接上有数据可用,即客户端请求。 -
它还调用它持有的
TCPServer
对象的sendAndRecv()
方法。sendAndRecv()
方法从每个 TCP 连接中读取数据,并为它们分发回调。sendAndRecv()
调用还会在 TCP 连接上发送任何出站数据,即客户端响应。这个代码块如下所示,应该很容易理解:auto run() noexcept {
logger_.log("%:% %() %\n", __FILE__, __LINE__, __
FUNCTION__, Common::getCurrentTimeStr(&time_str_));
while (run_) {
tcp_server_.poll();
tcp_server_.sendAndRecv();
-
run()
循环还会清空outgoing_responses_
无锁队列,匹配引擎使用这个队列来发送需要分发到正确客户端的MEClientResponse
消息。 -
它遍历
outgoing_responses_
队列中的可用数据,然后对于每个读取的MEClientResponse
,它首先找出正确的出站序列号。这是要发送给该客户端 ID 的OMClientResponse
消息上的序列号。它是通过在cid_next_outgoing_seq_num_
数组中查找这个答案来做到这一点的,我们实际上是将它用作从客户端 ID 到序列号的哈希表:for (auto client_response = outgoing_responses_-
>getNextToRead(); outgoing_responses_->size() &&
client_response; client_response = outgoing_responses_-
>getNextToRead()) {
auto &next_outgoing_seq_num = cid_next_outgoing_seq_
num_[client_response->client_id_];
logger_.log("%:% %() % Processing cid:% seq:% %\n", __
FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
client_response->client_id_, next_
outgoing_seq_num, client_response-
>toString());
-
它还会检查它是否有一个有效的
TCPSocket
用于这个响应目标客户端 ID。它在cid_tcp_socket_
数组中查找这个信息,这是一个从客户端 ID 到TCPSocket
对象的哈希表。 -
然后,它通过调用
TCPSocket::send()
方法,向该客户端 ID 发送一个OMClientResponse
消息到TCPSocket
。它是通过首先发送next_outgoing_seq_num_
值,然后发送由匹配引擎生成的MEClientResponse
消息来实现的。这可能一开始并不明显,但实际上这是在发送一个OMClientResponse
消息,因为OMClientResponse
消息实际上只是一个序列号字段,后面跟着一个MEClientResponse
消息,这正是我们刚才所做的。 -
最后,它更新读取索引和下一个出站消息的序列号,并继续循环:
ASSERT(cid_tcp_socket_[client_response->client_id_] !=
nullptr,
"Dont have a TCPSocket for ClientId:" +
std::to_string(client_response->client_id_));
cid_tcp_socket_[client_response->client_id_]-
>send(&next_outgoing_seq_num, sizeof(next_outgoing_
seq_num));
cid_tcp_socket_[client_response->client_id_]-
>send(client_response, sizeof(MEClientResponse));
outgoing_responses_->updateReadIndex();
++next_outgoing_seq_num;
}
}
}
这就完成了我们电子交易基础设施中订单网关服务器组件的完整设计和实现。接下来,我们将查看发布公共市场数据给参与者的组件。
构建市场数据发布者
我们需要构建的电子交易交易所的最后一个组件是市场数据发布者,这是交易所如何向需要它的任何市场参与者发布公共市场数据更新的方式。回顾市场数据发布者的设计,我们展示了一个图表,说明这个组件如何与匹配引擎通信,并通过 UDP 向市场数据参与者发布,如下所示。
图 7.2 – 市场数据发布者和其子组件
我们想提醒您,市场数据发布者的目的和设计在《设计我们的交易生态系统》章节中进行了详细讨论,具体在《理解电子交易生态系统的布局》和《理解交易所如何向参与者发布信息》部分。我们强烈建议您重新阅读这些部分,以便在我们构建市场数据发布组件时能够跟上进度。
让我们首先通过了解如何从撮合引擎中消费更新并将其发布到 MarketDataPublisher
类来开始。MarketDataPublisher
类的所有源代码都在 Chapter7/exchange/market_data/market_data_publisher.h
和 Chapter7/exchange/market_data/market_data_publisher.cpp
源文件中。
定义市场数据发布者的数据成员
MarketDataPublisher
类有以下重要成员:
-
一个
next_inc_seq_num_
变量,其类型为size_t
,代表将在下一个发出的增量市场数据消息上设置的序列号。我们在《设计我们的交易生态系统》章节中讨论了增量快照市场数据更新的概念,在《理解交易所如何向参与者发布信息》和《构建市场参与者与交易所的接口》部分。 -
一个
outgoing_md_updates_
变量,其类型为MEMarketUpdateLFQueue
,这是一个MEMarketUpdate
消息的无锁队列。我们在《构建 C++ 撮合引擎》章节的《定义撮合引擎中的操作和交互》部分讨论了MEMarketUpdate
结构。这个LFQueue
是撮合引擎发送MEMarketUpdate
消息的方式,然后市场数据发布者通过 UDP 发布这些消息。 -
一个名为
incremental_socket_
的成员,它是一个McastSocket
,用于在增量多播流上发布 UDP 消息。 -
一个
snapshot_synthesizer_
变量,我们将在下一小节中讨论。这个对象将负责从撮合引擎提供的更新中生成限价订单簿的快照,并定期在快照多播流上发布整个订单簿的快照。这在《设计我们的交易生态系统》章节的《理解交易所如何向参与者发布信息》部分进行了讨论,特别是在《设计市场数据发布者》子部分。 -
一个名为
snapshot_md_updates_
的无锁队列实例,其类型为MDPMarketUpdateLFQueue
,这是一个包含MDPMarketUpdate
消息的无锁队列。该队列由市场数据发布线程使用,以将发送到增量流的MDPMarketUpdate
消息发布到SnapshotSynthesizer
组件。这个LFQueue
是必要的,因为SnapshotSynthesizer
在与MarketDataPublisher
不同的线程上运行,这主要是为了确保快照合成和发布过程不会减慢对延迟敏感的MarketDataPublisher
组件: -
MarketDataPublisher
类的最后一个重要成员是run_
布尔变量,它仅用于控制何时启动和停止MarketDataPublisher
线程。由于它可以从不同的线程访问,就像OrderServer
类中的run_
变量一样,因此它也被标记为volatile
:#pragma once
#include <functional>
#include "market_data/snapshot_synthesizer.h"
namespace Exchange {
class MarketDataPublisher {
private:
size_t next_inc_seq_num_ = 1;
MEMarketUpdateLFQueue *outgoing_md_updates_ = nullptr;
MDPMarketUpdateLFQueue snapshot_md_updates_;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
Common::McastSocket incremental_socket_;
SnapshotSynthesizer *snapshot_synthesizer_ = nullptr;
};
}
在下一节中,我们将看到这些数据成员是如何初始化的。
初始化市场数据发布者
在本小节中,我们将探讨如何初始化 MarketDataPublisher
,以及如何启动和停止 MarketDataPublisher
组件。首先,我们将查看构造函数,其表示如下。传递给它的 market_updates
参数是 MEMarketUpdateLFQueue
对象,匹配引擎将在其上发布市场更新。构造函数还接收网络接口和两套 IP 地址和端口号——一套用于增量市场数据流,另一套用于快照市场数据流。在构造函数中,它使用构造函数中传递的参数初始化 outgoing_md_updates_
成员,并将 snapshot_md_updates_
LFQueue
初始化为大小为 ME_MAX_MARKET_UPDATES
,这是我们之前在 设计 C++ 匹配引擎 章节中定义的,在 定义匹配引擎中的操作和交互 部分 中,并在 common/types.h
源文件中可用。它还使用本类的日志文件初始化 logger_
对象,并使用构造函数中提供的增量 IP 地址和端口号初始化 incremental_socket_
变量。最后,它创建一个 SnapshotSynthesizer
对象,并将 snapshot_md_updates_
LFQueue
和快照多播流信息传递给它:
#include "market_data_publisher.h"
namespace Exchange { MarketDataPublisher::MarketDataPublisher(MEMarketUpdateLFQueue
*market_updates, const std::string &iface,
const std::string
&snapshot_ip, int snapshot_
port,
const std::string
&incremental_ip, int
incremental_port)
: outgoing_md_updates_(market_updates), snapshot_md_updates_(ME_
MAX_MARKET_UPDATES),
run_(false), logger_("exchange_market_data_publisher.log"),
incremental_socket_(logger_) {
ASSERT(incremental_socket_.init(incremental_ip, iface,
incremental_port, /*is_listening*/ false) >= 0,
"Unable to create incremental mcast socket. error:" +
std::string(std::strerror(errno)));
snapshot_synthesizer_ = new SnapshotSynthesizer(&snapshot_md_
updates_, iface, snapshot_ip, snapshot_port);
}
我们还提供了一个 start()
方法,如下所示,其功能与我们在 OrderServer
类中看到的 start()
方法类似。首先,它将 run_
标志设置为 true
,然后创建并启动一个新的线程,并将 run()
方法分配给该线程,这将是我们 MarketDataPublisher
组件的主要 run
循环。它还调用 SnapshotSynthesizer
对象的 start()
方法,以便启动 SnapshotSynthesizer
线程:
auto start() {
run_ = true;
ASSERT(Common::createAndStartThread(-1, "Exchange/
MarketDataPublisher", [this]() { run(); }) != nullptr, "Failed
to start MarketData thread.");
snapshot_synthesizer_->start();
}
析构函数相当直观;它调用stop()
方法来停止正在运行的MarketDataPublisher
线程,然后等待一小段时间,让线程完成任何挂起的任务,并删除SnapshotSynthesizer
对象。我们将在析构函数之后立即看到stop()
方法的实现,但它应该不难猜测该方法的样子:
~MarketDataPublisher() {
stop();
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(5s);
delete snapshot_synthesizer_;
snapshot_synthesizer_ = nullptr;
}
最后,正如之前提到的,我们展示stop()
方法。该方法只是将run_
标志设置为false
,并指示SnapshotSynthesizer
线程停止:
auto stop() -> void {
run_ = false;
snapshot_synthesizer_->stop();
}
现在我们已经看到了如何初始化这个类,我们将看看MarketDataPublisher
如何发布订单簿更新,首先是增量更新市场数据通道的更新,然后是快照更新市场数据通道的更新。
发布订单簿更新
MarketDataPublisher
中的主要run()
循环执行几个重要操作,我们将在下面讨论。首先,它通过读取匹配引擎发布的任何新的MEMarketDataUpdates
来清空outgoing_md_updates_
队列。以下代码块显示了这部分内容:
auto MarketDataPublisher::run() noexcept -> void {
logger_.log("%:% %() %\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
while (run_) {
for (auto market_update = outgoing_md_updates_->getNextToRead();
outgoing_md_updates_->size() && market_update; market_
update = outgoing_md_updates_->getNextToRead()) {
logger_.log("%:% %() % Sending seq:% %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_), next_inc_
seq_num_,
market_update->toString().c_str());
一旦它从匹配引擎接收到MEMarketUpdate
消息,它就会继续将其写入incremental_socket_
UDP 套接字。但是,它需要以MDPMarketUpdate
格式写入消息,这只是一个序列号后面跟着一个MEMarketUpdate
消息。正如我们在OrderServer
中看到的那样,它将通过首先写入next_inc_seq_num_
来实现这一点,这是将要发送到增量流的下一个增量序列号,然后写入它从匹配引擎接收到的MEMarketUpdate
。这一逻辑在以下代码块中显示,包括它刚刚从LFQueue
中读取的读取索引增加的行:
incremental_socket_.send(&next_inc_seq_num_, sizeof(next_inc_
seq_num_));
incremental_socket_.send(market_update,
sizeof(MEMarketUpdate));
outgoing_md_updates_->updateReadIndex();
在这里,它还需要执行一个额外的步骤,即将它写入套接字相同的增量更新写入snapshot_md_updates_
LFQueue
,以通知SnapshotSynthesizer
组件关于匹配引擎发送给客户端的新增量更新。以下代码块显示了这部分内容:
auto next_write = snapshot_md_updates_.getNextToWriteTo();
next_write->seq_num_ = next_inc_seq_num_;
next_write->me_market_update_ = *market_update;
snapshot_md_updates_.updateWriteIndex();
最后,它增加增量流序列号跟踪器,以便发送下一个消息,并在incremental_socket_
上调用sendAndRecv()
,以便将消息放入线路上:
++next_inc_seq_num_;
}
incremental_socket_.sendAndRecv();
}
}
}
这就完成了我们需要执行的所有任务,以从匹配引擎消费更新并生成增量市场更新多播流。在下一个子节中,我们将处理市场数据发布者中的最后关键步骤,即综合订单簿快照并定期在快照多播流上发布它们。
综合和发布快照
本节将专注于 SnapshotSynthesizer
类的设计和实现,该类从 MarketDataPublisher
线程中消费增量 MDPMarketDataUpdates
,合成整个订单簿的完整快照,并定期在快照多播流上发布完整的簿快照。SnapshotSynthesizer
的所有源代码可以在 Chapter7/exchange/market_data/snapshot_synthesizer.h
和 Chapter7/exchange/market_data/snapshot_synthesizer.cpp
源文件中找到。
定义快照合成器中的数据成员
让我们先定义 SnapshotSynthesizer
类中的数据成员。其中重要的成员如下所述:
-
首先,
snapshot_md_updates_
是MDPMarketUpdateLFQueue
类型的变量,这是MarketDataPublisher
用于向该组件发布增量MDPMarketUpdates
的方式,我们之前在章节中已经提到过。 -
它还有一个
snapshot_socket_
变量,这是一个McastSocket
,用于将快照市场数据更新发布到快照多播流。 -
其中最重要的数据成员是
ticker_orders_
变量,它是一个大小为ME_MAX_TICKERS
的std::array
,用于表示每个交易工具的簿快照。这个数组中的每个元素都是一个std::array
,包含MEMarketUpdate
指针和一个最大大小为ME_MAX_ORDER_IDS
,用于从OrderId
到对应订单的哈希映射。正如我们之前所做的那样,我们使用第一个std::array
作为从TickerId
到限价订单簿快照的哈希映射。第二个std::array
也是一个从OrderId
到订单信息的哈希映射。我们还将有一个order_pool_
数据成员,它是MEMarketUpdate
对象的MemPool
类型。这个内存池是我们将用于在更新ticker_orders_
容器中的订单簿快照时分配和释放MEMarketUpdate
对象的。 -
我们有两个变量用于跟踪
SnapshotSynthesizer
处理的最后一条增量市场数据更新的信息。第一个是last_inc_seq_num_
变量,用于跟踪它接收到的最后一条增量MDPMarketUpdate
的序列号。第二个是last_snapshot_time_
变量,用于跟踪最后通过 UDP 发布快照的时间,因为该组件将只定期发布所有簿的完整快照。 -
还有一个布尔变量
run_
,它与我们之前构建的OrderServer
和MarketDataPublisher
组件中的run_
变量具有类似的作用。这个变量将用于启动和停止SnapshotSynthesizer
线程,并且由于它将从多个线程访问,因此会被标记为volatile
:#pragma once
#include "common/types.h"
#include "common/thread_utils.h"
#include "common/lf_queue.h"
#include "common/macros.h"
#include "common/mcast_socket.h"
#include "common/mem_pool.h"
#include "common/logging.h"
#include "market_data/market_update.h"
#include "matcher/me_order.h"
using namespace Common;
namespace Exchange {
class SnapshotSynthesizer {
private:
MDPMarketUpdateLFQueue *snapshot_md_updates_ = nullptr;
Logger logger_;
volatile bool run_ = false;
std::string time_str_;
McastSocket snapshot_socket_;
std::array<std::array<MEMarketUpdate *, ME_MAX_ORDER_IDS>,
ME_MAX_TICKERS> ticker_orders_;
size_t last_inc_seq_num_ = 0;
Nanos last_snapshot_time_ = 0;
MemPool<MEMarketUpdate> order_pool_;
};
}
在下一小节中,我们将看到这些变量是如何初始化的,当我们查看 SnapshotSynthesizer
类的初始化过程时。
初始化快照合成器
SnapshotSynthesizer
构造函数接受一个MDPMarketUpdateLFQueue
类型的参数,该参数由MarketDataPublisher
组件传递给它。它还接收网络接口名称和快照 IP 及端口,以表示多播流。构造函数从传递给它的参数初始化snapshot_md_updates_
数据成员,并用新的文件名初始化logger_
。它初始化MEMarketUpdate
MemPool
,使其大小为ME_MAX_ORDER_IDS
。它还初始化snapshot_socket_
,并配置它在该提供的网络接口上发布快照多播 IP 和端口的消息:
#include "snapshot_synthesizer.h"
namespace Exchange {
SnapshotSynthesizer::SnapshotSynthesizer(MDPMarketUpdateLFQueue
*market_updates, const std::string &iface,
const std::string &snapshot_
ip, int snapshot_port)
: snapshot_md_updates_(market_updates), logger_("exchange_
snapshot_synthesizer.log"), snapshot_socket_(logger_), order_
pool_(ME_MAX_ORDER_IDS) {
ASSERT(snapshot_socket_.init(snapshot_ip, iface, snapshot_port,
/*is_listening*/ false) >= 0,
"Unable to create snapshot mcast socket. error:" +
std::string(std::strerror(errno)));
}
我们也在这里添加了一个start()
方法,就像我们之前对其他类所做的那样。这个start()
方法将run_
标志设置为 true,创建并启动一个线程,并将run()
方法分配给该线程:
void SnapshotSynthesizer::start() {
run_ = true;
ASSERT(Common::createAndStartThread(-1, "Exchange/
SnapshotSynthesizer", [this]() { run(); }) != nullptr,
"Failed to start SnapshotSynthesizer thread.");
}
这个类的析构函数非常简单;它只是调用stop()
方法。stop()
方法也非常简单,只是将run_
标志设置为 false,以便run()
方法退出:
SnapshotSynthesizer::~SnapshotSynthesizer() {
stop();
}
void SnapshotSynthesizer::stop() {
run_ = false;
}
接下来,我们将查看SnapshotSynthesizer
的重要部分,它将合成订单簿快照并定期发布快照。
合成订单簿快照
为不同交易工具合成订单簿快照的过程类似于构建OrderBook
。然而,这里的区别在于,快照合成过程只需要维护活订单的最后状态,因此它是一个更简单的容器。我们接下来将要构建的addToSnapshot()
方法每次在向SnapshotSynthesizer
提供新的增量市场数据更新时都会接收一个MDPMarketUpdate
消息。我们将把这个方法分成几个代码块,以便更容易理解。
在第一个代码块中,我们从MDPMarketUpdate
消息中提取MEMarketUpdate
部分,并将其存储在me_market_update
变量中。它还从ticker_orders_ std::array
哈希表中找到对应于该工具的正确TickerId
的MEMarketUpdate
消息的std::array
。然后,我们在MarketUpdateType
的类型上使用 switch case,并单独处理每个这些情况。在我们查看 switch case 下的每个情况之前,让我们先展示addToSnapshot()
方法中描述的初始代码块:
auto SnapshotSynthesizer::addToSnapshot(const MDPMarketUpdate
*market_update) {
const auto &me_market_update = market_update->me_market_update_;
auto *orders = &ticker_orders_.at(me_market_update.ticker_id_);
switch (me_market_update.type_) {
现在,我们将展示switch case
中MarketUpdateType::ADD
情况的实现。为了处理MarketUpdateType::ADD
消息,我们只需将其插入到MEMarketUpdate
std::array
的正确OrderId
位置。我们通过从order_pool_
内存池中分配并使用allocate()
调用,传递MEMarketUpdate
对象以复制字段来创建一个MEMarketUpdate
消息:
case MarketUpdateType::ADD: {
auto order = orders->at(me_market_update.order_id_);
ASSERT(order == nullptr, "Received:" + me_market_update.
toString() + " but order already exists:" + (order ? order-
>toString() : ""));
orders->at(me_market_update.order_id_) = order_pool_.
allocate(me_market_update);
}
break;
MarketUpdateType::MODIFY
的处理方式与MarketUpdateType::ADD
类似。这里的微小差异是我们只更新qty_
和price_
字段,并保持条目上的type_
字段不变:
case MarketUpdateType::MODIFY: {
auto order = orders->at(me_market_update.order_id_);
ASSERT(order != nullptr, "Received:" + me_market_update.
toString() + " but order does not exist.");
ASSERT(order->order_id_ == me_market_update.order_id_,
"Expecting existing order to match new one.");
ASSERT(order->side_ == me_market_update.side_, "Expecting
existing order to match new one.");
order->qty_ = me_market_update.qty_;
order->price_ = me_market_update.price_;
}
break;
MarketUpdateType::CANCEL
类型与MarketUpdateType::ADD
相反。在这里,我们在哈希表中找到MEMarketUpdate
,并对其调用deallocate()
。我们还将在哈希表中的std::array
条目设置为nullptr
,以标记其为已取消或死订单:
case MarketUpdateType::CANCEL: {
auto order = orders->at(me_market_update.order_id_);
ASSERT(order != nullptr, "Received:" + me_market_update.
toString() + " but order does not exist.");
ASSERT(order->order_id_ == me_market_update.order_id_,
"Expecting existing order to match new one.");
ASSERT(order->side_ == me_market_update.side_, "Expecting
existing order to match new one.");
order_pool_.deallocate(order);
orders->at(me_market_update.order_id_) = nullptr;
}
break;
我们不需要对其他枚举值进行任何操作,因此我们忽略它们。我们只需更新我们在增量市场数据流中看到的最后一个序列号,该序列号存储在last_inc_seq_num_
数据成员中:
case MarketUpdateType::SNAPSHOT_START:
case MarketUpdateType::CLEAR:
case MarketUpdateType::SNAPSHOT_END:
case MarketUpdateType::TRADE:
case MarketUpdateType::INVALID:
break;
}
ASSERT(market_update->seq_num_ == last_inc_seq_num_ + 1, "Expected
incremental seq_nums to increase.");
last_inc_seq_num_ = market_update->seq_num_;
}
这就完成了从增量MEMarketUpdate
消息中合成和更新订单簿快照的代码。接下来,我们将查看如何生成和发布完整的快照流。
发布快照
下一个方法——publishSnapshot()
——在我们想要发布订单簿当前状态的完整快照时被调用。在我们查看发布快照消息的代码之前,让我们首先尝试理解包含多个金融工具订单簿完整状态的快照消息的格式和内容。完整的快照消息的格式如下所示:
-
第一个
MDPMarketUpdate
消息是MarketUpdateType::SNAPSHOT_START
类型,seq_num_ = 0
,以标记快照消息的开始。 -
然后,对于每个金融工具,我们发布以下内容:
-
一个
MDPMarketUpdate
消息,消息类型为MarketUpdateType::CLEAR
,指示客户端在应用后续消息之前清除其订单簿 -
对于快照中存在的每个订单,我们发布一个
MDPMarketUpdate
消息,消息类型为MarketUpdateType::ADD
,直到我们发布了所有订单的信息
-
-
最后,我们发布一个
MDPMarketUpdate
消息,消息类型为MarketUpdateType::SNAPSHOT_END
,以标记快照消息的结束。需要注意的是,对于SNAPSHOT_START
和SNAPSHOT_END
消息,我们将OrderId
值设置为用于构建此快照的最后一个增量序列号。市场参与者将使用此序列号来同步快照市场数据流与增量市场数据流。
此设计在以下图中表示,其中快照包含三个金融工具的数据。
图 7.3 – 描述我们的市场数据快照消息布局的图
在考虑了这种格式之后,让我们看看合成和发布我们之前描述的快照消息格式的代码。首先,我们发布MarketUpdateType::SNAPSHOT_START
消息,如下所示:
auto SnapshotSynthesizer::publishSnapshot() {
size_t snapshot_size = 0;
const MDPMarketUpdate start_market_update{snapshot_size++,
{MarketUpdateType::SNAPSHOT_START, last_inc_seq_num_}};
logger_.log("%:% %() % %\n", __FILE__, __LINE__, __FUNCTION__,
getCurrentTimeStr(&time_str_), start_market_update.toString());
snapshot_socket_.send(&start_market_update,
sizeof(MDPMarketUpdate));
然后,我们遍历所有我们将发布快照的金融工具。我们首先为该金融工具发布MDPMarketUpdate
消息,消息类型为MarketUpdateType::CLEAR
:
for (size_t ticker_id = 0; ticker_id < ticker_orders_.size();
++ticker_id) {
const auto &orders = ticker_orders_.at(ticker_id);
MEMarketUpdate me_market_update;
me_market_update.type_ = MarketUpdateType::CLEAR;
me_market_update.ticker_id_ = ticker_id;
const MDPMarketUpdate clear_market_update{snapshot_size++, me_
market_update};
logger_.log("%:% %() % %\n", __FILE__, __LINE__, __FUNCTION__,
getCurrentTimeStr(&time_str_), clear_market_update.toString());
snapshot_socket_.send(&clear_market_update,
sizeof(MDPMarketUpdate));
然后,我们遍历该交易工具的所有订单,并检查实时订单——即没有nullptr
值的条目。对于每个有效订单,我们使用MarketUpdateType::ADD
为该OrderId
发布MDPMarketUpdate
消息:
for (const auto order: orders) {
if (order) {
const MDPMarketUpdate market_update{snapshot_size++, *order};
logger_.log("%:% %() % %\n", __FILE__, __LINE__, __
FUNCTION__, getCurrentTimeStr(&time_str_), market_update.
toString());
snapshot_socket_.send(&market_update, sizeof(MDPMarketUpdate));
snapshot_socket_.sendAndRecv();
}
}
}
最后,我们使用MarketUpdateType::SNAPSHOT_END
类型发布MDPMarketUpdate
消息,以表示本轮快照消息的结束:
const MDPMarketUpdate end_market_update{snapshot_size++, {MarketUpdateType::SNAPSHOT_END, last_inc_seq_num_}};
logger_.log("%:% %() % %\n", __FILE__, __LINE__, __FUNCTION__,
getCurrentTimeStr(&time_str_), end_market_update.toString());
snapshot_socket_.send(&end_market_update,
sizeof(MDPMarketUpdate));
snapshot_socket_.sendAndRecv();
logger_.log("%:% %() % Published snapshot of % orders.\n",
__FILE__, __LINE__, __FUNCTION__, getCurrentTimeStr(&time_str_),
snapshot_size - 1);
}
这就完成了快照流的规划和在publishSnapshot()
方法中发布它的代码。在下一小节中,我们将通过实现将一切串联起来的主要run()
循环来完成对市场数据发布者基础设施中SnapshotSynthesizer
组件的讨论。
运行主循环
请记住,SnapshotSynthesizer
在独立的线程上运行,与MarketDataPublisher
线程分开,以避免对发布增量市场数据流的组件造成延迟。run()
方法是分配给SnapshotSynthesizer
线程的方法。它唯一执行的任务是检查snapshot_md_updates_
无锁队列中的新条目,这是MarketDataPublisher
发送增量MDPMarketUpdate
消息的地方。对于它读取的每个增量MDPMarketUpdate
消息,它调用我们之前构建的addToSnapshot()
方法。此外,它将last_snapshot_time_
变量与从getCurrentTime()
获取的当前时间进行比较,以查看是否已过去一分钟。如果自上次发布快照以来至少过去了一分钟,它将调用publishSnapshot()
方法来发布一个新的快照。它还记住当前时间作为上次发布完整快照的时间:
void SnapshotSynthesizer::run() {
logger_.log("%:% %() %\n", __FILE__, __LINE__, __FUNCTION__,
getCurrentTimeStr(&time_str_));
while (run_) {
for (auto market_update = snapshot_md_updates_->getNextToRead();
snapshot_md_updates_->size() && market_update; market_update =
snapshot_md_updates_->getNextToRead()) {
logger_.log("%:% %() % Processing %\n", __FILE__, __LINE__,
__FUNCTION__, getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
addToSnapshot(market_update);
snapshot_md_updates_->updateReadIndex();
}
if (getCurrentNanos() - last_snapshot_time_ > 60 * NANOS_TO_
SECS) {
last_snapshot_time_ = getCurrentNanos();
publishSnapshot();
}
}
}
}
这标志着SnapshotSynthesizer
以及MarketDataPublisher
组件和我们的完整电子交易交易所基础设施的设计和实现完成。在下一节中,我们将构建主要的电子交易所应用程序,这将把我们在电子交易所方面构建的所有组件串联起来。
构建主要交易所应用程序
在本章的最后一节以及电子交易交易所讨论的最后一节,我们将构建主要交易所应用程序。这将是一个独立的二进制应用程序,它将运行订单网关服务器、匹配引擎和市场数据发布者,并执行以下任务:
-
订单网关服务器接受客户端连接和客户端请求。
-
匹配引擎构建限价订单簿。
-
匹配引擎也执行客户端订单之间的匹配。
-
匹配引擎和订单网关服务器发布客户端响应。
-
匹配引擎和市场数据发布者根据客户端请求发布增量市场数据更新。
-
市场数据发布者还综合并定期发布订单簿的完整快照。
完整的设计在以下图中展示。
图 7.4 – 最终交易交换应用程序及其所有组件
此交易所应用程序的代码位于 Chapter7/exchange/exchange_main.cpp
源文件中。
我们将在全局范围内创建 Logger
、MatchingEngine
、MarketDataPublisher
和 OrderServer
实例。我们还将创建信号处理函数,因为当向应用程序发送 UNIX 信号时,它将被终止。信号处理器清理组件并退出:
#include <csignal>
#include "matcher/matching_engine.h"
#include "market_data/market_data_publisher.h"
#include "order_server/order_server.h"
Common::Logger *logger = nullptr;
Exchange::MatchingEngine *matching_engine = nullptr;
Exchange::MarketDataPublisher *market_data_publisher = nullptr;
Exchange::OrderServer *order_server = nullptr;
void signal_handler(int) {
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(10s);
delete logger;
logger = nullptr;
delete matching_engine;
matching_engine = nullptr;
delete market_data_publisher;
market_data_publisher = nullptr;
delete order_server;
order_server = nullptr;
std::this_thread::sleep_for(10s);
exit(EXIT_SUCCESS);
}
main()
函数初始化日志对象,安装信号处理器,并设置三个无锁队列——client_requests
,类型为 ClientRequestLFQueue
,client_responses
,类型为 ClientResponseLFQueue
,以及 market_updates
,类型为 MEMarketUpdateLFQueue
,以促进三个主要组件之间的通信:
int main(int, char **) {
logger = new Common::Logger("exchange_main.log");
std::signal(SIGINT, signal_handler);
const int sleep_time = 100 * 1000;
Exchange::ClientRequestLFQueue client_requests(ME_MAX_CLIENT_
UPDATES);
Exchange::ClientResponseLFQueue client_responses(ME_MAX_CLIENT_
UPDATES);
Exchange::MEMarketUpdateLFQueue market_updates(ME_MAX_MARKET_
UPDATES);
然后,我们创建并启动 MatchingEngine
组件的实例,并传递三个 LFQueue
对象:
std::string time_str;
logger->log("%:% %() % Starting Matching Engine...\n", __FILE__, __
LINE__, __FUNCTION__, Common::getCurrentTimeStr(&time_str));
matching_engine = new Exchange::MatchingEngine(&client_requests,
&client_responses, &market_updates);
matching_engine->start();
我们还将创建并启动 MarketDataPublisher
实例,并为其提供快照和增量流信息以及 market_updates
LFQueue
对象。
关于本章以及随后的章节中指定的接口、IP 和端口的一个注意事项是,我们随意选择了这些;如果需要,请随意更改它们。这里重要的是,电子交易所和交易客户端使用的市场数据流 IP:port 信息应该匹配,同样,电子交易所和交易客户端使用的订单服务器 IP:port 信息也应该匹配:
const std::string mkt_pub_iface = "lo";
const std::string snap_pub_ip = "233.252.14.1", inc_pub_ip =
"233.252.14.3";
const int snap_pub_port = 20000, inc_pub_port = 20001;
logger->log("%:% %() % Starting Market Data Publisher...\n", __
FILE__, __LINE__, __FUNCTION__, Common::getCurrentTimeStr(&time_
str));
market_data_publisher = new Exchange::MarketDataPublisher(&market_
updates, mkt_pub_iface, snap_pub_ip, snap_pub_port, inc_pub_ip, inc_
pub_port);
market_data_publisher->start();
我们使用 order_server
对象执行类似任务——创建 OrderServer
并在提供订单网关服务器配置信息后启动它:
const std::string order_gw_iface = "lo";
const int order_gw_port = 12345;
logger->log("%:% %() % Starting Order Server...\n", __FILE__, __
LINE__, __FUNCTION__, Common::getCurrentTimeStr(&time_str));
order_server = new Exchange::OrderServer(&client_requests, &client_
responses, order_gw_iface, order_gw_port);
order_server->start();
最后,main()
线程将无限期地休眠,因为三个组件内的线程将从这一点开始运行交易所:
while (true) {
logger->log("%:% %() % Sleeping for a few milliseconds..\n", __
FILE__, __LINE__, __FUNCTION__, Common::getCurrentTimeStr(&time_
str));
usleep(sleep_time * 1000);
}
}
按照以下方式运行应用程序将在屏幕上产生一些最小输出,但大部分输出将记录到我们从三个组件及其子组件创建的日志文件中:
(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter7$ ./cmake-build-release/exchange_main
Set core affinity for Common/Logger exchange_main.log 140329423955712 to -1
Set core affinity for Common/Logger exchange_matching_engine.log 140329253541632 to -1
Set core affinity for Exchange/MatchingEngine 140329245148928 to –1
...
Sun Mar 26 13:58:04 2023 Flushing and closing Logger for exchange_order_server.log
Sun Mar 26 13:58:04 2023 Logger for exchange_order_server.log exiting.
使用 kill –2 PID
命令向 exchange_main
应用程序发送 SIGINT
信号将其终止。我们可以检查日志文件以查看不同组件做了什么。请注意,但目前的输出并不特别有趣。它只是记录了组件被创建和启动。一旦我们为这个交易交易所添加客户端,它们将连接并发送客户端请求,这种输出将包含更多信息:
(base) sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter7$ tail -n 10 *.log
exchange_main.log
文件包含有关不同组件创建的信息,如下所示:
==> exchange_main.log <==
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/exchange_main.cpp:43 main() Sun Mar 26 09:13:49 2023 Starting Matching Engine...
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/exchange_main.cpp:51 main() Sun Mar 26 09:13:51 2023 Starting Market Data Publisher...
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/exchange_main.cpp:58 main() Sun Mar 26 09:13:56 2023 Starting Order Server...
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/exchange_main.cpp:63 main() Sun Mar 26 09:13:58 2023 Sleeping for a few milliseconds..
exchange_market_data_publisher.log
文件创建 UDP 套接字并调用 run()
方法,如下所示:
==> exchange_market_data_publisher.log <==
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/common/socket_utils.cpp:68 createSocket() Sun Mar 26 09:13:52 2023 ip:233.252.14.3 iface:lo port:20001 is_udp:1 is_blocking:0 is_listening:0 ttl:32 SO_time:0
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/market_data/market_data_publisher.cpp:15 run() Sun Mar 26 09:13:54 2023
由于尚未执行匹配操作且未构建订单簿,exchange_matching_engine.log
文件目前没有太多有意义的输出:
==> exchange_matching_engine.log <==
X
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/matcher/me_order_book.cpp:12 ~MEOrderBook() Sun Mar 26 09:15:00 2023 OrderBook
Ticker:7
X
exchange_order_server.log
文件还包含有关TCPServer
的创建和主线程的run()
方法的一些信息:
==> exchange_order_server.log <==
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/common/socket_utils.cpp:68 createSocket() Sun Mar 26 09:13:57 2023 ip:127.0.0.1 iface:lo port:12345 is_udp:0 is_blocking:0 is_listening:1 ttl:0 SO_time:1
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/order_server/order_server.h:25 run() Sun Mar 26 09:13:57 2023
最后,exchange_snapshot_synthesizer.log
文件为不同的交易工具输出一个空快照的消息,因为订单簿中还没有订单:
==> exchange_snapshot_synthesizer.log <==
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/market_data/snapshot_synthesizer.cpp:82 publishSnapshot() Sun Mar 26 09:14:55 2023 MDPMarketUpdate [ seq:2 MEMarketUpdate [ type:CLEAR ticker:1 oid:INVALID side:INVALID qty:INVALID price:INVALID priority:INVALID]]
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/market_data/snapshot_synthesizer.cpp:82 publishSnapshot() Sun Mar 26 09:14:55 2023 MDPMarketUpdate [ seq:3 MEMarketUpdate [ type:CLEAR ticker:2 oid:INVALID side:INVALID qty:INVALID price:INVALID priority:INVALID]]
...
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/market_data/snapshot_synthesizer.cpp:96 publishSnapshot() Sun Mar 26 09:14:55 2023 MDPMarketUpdate [ seq:9 MEMarketUpdate [ type:SNAPSHOT_END ticker:INVALID oid:0 side:INVALID qty:INVALID price:INVALID priority:INVALID]]
/home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter7/exchange/market_data/snapshot_synthesizer.cpp:100 publishSnapshot() Sun Mar 26 09:14:55 2023 Published snapshot of 9 orders.
这就结束了我们对电子交易交易所的讨论、设计和实现。在下一章中,我们将构建客户端端的交易系统。
摘要
本章专门用于构建订单网关服务器和市场数据发布者组件。我们还把我们上一章构建的匹配引擎组件与本章构建的订单网关服务器和市场数据发布者组件结合起来,构建最终的交易交易所主应用程序。
首先,我们定义了交易所将用于在有线上发布数据并供客户端用于编写市场数据消费者应用的公共市场数据协议。我们执行了类似的任务,以订单网关协议,以便客户端应用可以理解它们发送给交易所订单网关服务器的客户端请求的格式,并接收响应。
我们构建了订单网关服务器,其设计在设计我们的交易生态系统章节中确立。我们构建了OrderServer
类,它构建并运行TCPServer
以接受和管理 TCP 客户端连接。我们添加了处理传入客户端请求和发送客户端响应的功能。我们还构建了FIFOSequencer
组件,它负责按接收到的顺序对传入的 TCP 客户端请求进行排序/排序,以保持市场中的公平性。
我们构建的下一个组件是在同一章节中设计的,即设计我们的交易生态系统,这是市场数据发布者。我们构建了MarketDataPublisher
,它从匹配引擎消费市场数据更新,并生成一个多播流,包含增量市场数据更新。我们还添加了SnapshotSynthesizer
组件,它在不同的线程上运行,负责从MarketDataPublisher
消费市场数据更新并合成完整订单簿的快照。这个完整快照由SnapshotSynthesizer
定期在快照多播流上发布。
最后,我们构建了主要的电子交易交易所应用程序,它将我们迄今为止构建的所有交易所端组件连接起来。这将作为支持多个客户端和不同交易工具的中心电子交易交易所,供客户端连接和交易,以及接收市场数据更新。
在下一章中,我们将关注点从交易所基础设施转移到市场参与者基础设施。下一章将专注于连接到订单网关服务器并与它通信的功能,以及接收和处理电子交易所发布的市场数据更新。
第三部分:构建实时 C++算法交易系统
在本部分,我们将开始构建交易客户端的 C++算法交易系统。我们将构建与交易交易所接口的组件,以处理市场数据,并连接到交易所订单网关进行通信。我们还将构建 C++框架,在这个框架上我们将构建市场做市和流动性获取的交易算法。在高频交易领域,参与者会花费大量时间和精力来减少延迟并最大化性能(和利润)。最后,我们将在这个框架中实现市场做市和流动性获取的交易算法,运行整个交易生态系统,并理解所有组件之间的交互。
本部分包含以下章节:
-
第八章**,在 C++中处理市场数据和向交易所发送订单
-
第九章**,构建 C++交易算法构建块
-
第十章**,构建 C++市场做市和流动性获取算法
第八章:使用 C++ 处理市场数据并向交易所发送订单
在本章中,我们将构建客户端的 C++ 系统,该系统从交易交易所接收并处理市场数据更新。我们还将处理创建和读取 UDP 套接字、处理数据包丢失等问题。我们将讨论客户端上订单簿的设计,以跟踪交易所在维护的订单簿。我们还将实现建立和维护与交易交易所 TCP 连接所需的 C++ 组件。我们还将实现从策略向交易所发送订单以及接收和处理订单响应的功能。
在本章中,我们将涵盖以下主题:
-
订阅市场数据和解码市场数据协议
-
从市场数据构建订单簿
-
连接到交易所、发送订单请求和接收响应
技术要求
本书的所有代码都可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
。本章的源代码位于仓库中的 Chapter 8
目录。
您必须阅读并理解章节 设计我们的交易生态系统 中所介绍的电子交易生态系统的设计。我们本章构建的组件将与我们在章节 与市场参与者通信 中构建的电子交易交易所应用程序进行交互,因此我们假设您熟悉该内容。我们将在客户端应用程序的交易引擎组件中构建的限价订单簿几乎与我们在 构建订单簿和匹配订单 部分中 构建 C++ 匹配引擎 章节内构建的订单簿相同。因此,我们假设读者非常熟悉该章节和我们在那里讨论的代码,因为我们将在此章节中引用它。与之前一样,我们将使用我们在 构建低延迟应用程序的 C++ 构建块 章节中构建的构建块。
本书源代码开发环境的规格如下所示。我们提供此环境的详细信息,因为本书中展示的所有 C++ 代码可能并不一定可移植,可能需要在您的环境中进行一些小的修改才能工作:
-
OS –
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
-
GCC –
g++ (Ubuntu
11.3.0-1ubuntu1~22.04.1) 11.3.0
-
CMAKE –
cmake
版本 3.23.2
-
NINJA –
1.10.2
订阅市场数据和解码市场数据协议
在市场参与者交易系统中,我们首先需要构建的是市场数据消费者组件。该组件负责订阅由交易交易所发布的公共市场数据更新多播流。它需要解码交易所生成并由我们之前讨论的公共MDPMarketUpdate
格式生成的市场数据流。由于选择了简单二进制编码(SBE)协议,在我们的应用中解码步骤简单直接,不涉及任何复杂的流解码逻辑。该组件的另一个重要职责是检测增量市场数据流中的数据包丢失,并提供恢复并与市场数据流再次同步的机制。此机制对于在存在非空订单簿后订阅市场数据流(即交易所在接受客户订单后已经开放)的交易系统也是必需的。此外,如果交易应用程序需要在一天中重新启动,这也将是必需的。
我们展示了之前见过的市场数据消费者组件的详细图示。如图 8.1 所示,它消费包含市场数据更新的多播数据,这些更新来自增量流,也可以选择来自快照流。在检查市场数据更新的序列号并可能需要在快照流和增量流之间同步后,它解码市场数据更新。然后,它生成一系列解码并按顺序排列的市场数据更新流,供交易引擎消费,并通过无锁队列发布:
图 8.1 – 市场数据消费者组件及其子组件概述
在我们深入设计市场数据消费者组件的实现之前,我们想提到,该组件的源代码可以在Chapter8/trading/market_data/market_data_consumer.h
源文件和Chapter8/trading/market_data/market_data_consumer.cpp
源文件中找到。接下来,让我们首先定义市场数据消费者组件将需要的内部数据成员。
在市场数据消费者中定义数据成员
我们将要构建的MarketDataConsumer
类将需要以下列表中所示的一些重要数据成员:
-
首先,它需要一个无锁的
incoming_md_updates_
队列实例,该实例为Exchange::MEMarketUpdateLFQueue
类型,这是我们之前定义的。这是为了由MarketDataConsumer
将MEMarketUpdate
消息发布到交易引擎组件。 -
我们将维护一个
next_exp_inc_seq_num_
变量,该变量为size_t
类型,它将用于确保我们按正确顺序处理增量市场数据流中的更新,并检测增量市场数据流中的数据包丢失。 -
我们将有两个多播订阅套接字——
incremental_mcast_socket_
和snapshot_mcast_socket_
,它们都是Common::McastSocket
类型。这些对应于我们将用于订阅和消费增量多播流和快照多播流的套接字。
当需要从快照市场数据流执行恢复/同步操作时,我们需要维护一些额外的数据成员,如下面的项目符号列表所示:
-
首先,我们将存储一个
in_recovery_
布尔标志来表示MarketDataConsumer
是否检测到数据包丢失,并且目前正在尝试使用快照和增量市场数据流进行恢复。 -
由于我们将根据需要加入和离开快照多播流,我们将在
iface_
变量、snapshot_ip_
变量和snapshot_port_
变量中拥有多播流和网络接口信息。这些代表要使用的网络接口、IP 地址和快照多播流的端口。 -
最后,我们定义一个类型来排队消息并按其相应的序列号进行排序。在这里我们将使用
std::map
类型,并对其进行参数化以使用size_t
类型的键(表示更新的序列号),持有Exchange::MEMarketUpdate
对象,并使用typedef
将此类型命名为QueuedMarketUpdates
。我们选择std::map
类型是因为与std::unordered_map
相比,迭代排序键更容易。请注意,std::map
由于多种原因效率不高——其内部数据结构是O(log(N))
并导致动态内存分配等。然而,我们在此例中做出例外,因为快照恢复预计会非常罕见,并且当MarketDataConsumer
类从快照流恢复时,客户端的交易应用程序中的交易通常会被暂停,因为它没有订单簿状态的准确视图。此外,快照流在交易所方面是延迟和节流的,因此快照同步过程本身不需要低延迟。 -
我们将创建两个
QueuedMarketUpdates
类型的实例——snapshot_queued_msgs_
和incremental_queued_msgs_
,一个用于排队快照流中的MEMarketUpdate
消息,另一个用于排队增量流中的MEMarketUpdate
消息。 -
MarketDataConsumer
类也是一个不同的执行线程,因此类似于我们之前看到的类,它有一个run_
布尔标志来控制线程的执行,并且它被标记为volatile
,因为它被不同的线程访问:
#pragma once
#include <functional>
#include <map>
#include "common/thread_utils.h"
#include "common/lf_queue.h"
#include "common/macros.h"
#include "common/mcast_socket.h"
#include "exchange/market_data/market_update.h"
namespace Trading {
class MarketDataConsumer {
private:
size_t next_exp_inc_seq_num_ = 1;
Exchange::MEMarketUpdateLFQueue *incoming_md_updates_ =
nullptr;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
Common::McastSocket incremental_mcast_socket_,
snapshot_mcast_socket_;
bool in_recovery_ = false;
const std::string iface_, snapshot_ip_;
const int snapshot_port_;
typedef std::map<size_t, Exchange::MEMarketUpdate>
QueuedMarketUpdates;
QueuedMarketUpdates snapshot_queued_msgs_,
incremental_queued_msgs_;
};
}
我们将在下一节中初始化 MarketDataConsumer
类及其数据成员。
初始化市场数据消费者
MarketDataConsumer
类的构造函数接受以下参数:
-
一个
client_id
参数,其类型为Common::ClientId
,在此情况下仅用于创建一个唯一的日志文件名,用于初始化此类中Logger logger_
组件。 -
它还期望一个指向名为
market_updates
的MEMarketUpdateLFQueue
无锁队列对象的指针,其中它将发布解码并排序的市场更新。 -
它期望在
iface
参数中提供网络接口名称以及快照和增量市场数据流的地址。这些将通过snapshot_ip
参数、snapshot_port
参数、incremental_ip
参数和incremental_port
参数传递:
#include "market_data_consumer.h"
namespace Trading {
MarketDataConsumer::MarketDataConsumer(Common::ClientId
client_id, Exchange::MEMarketUpdateLFQueue
*market_updates,
const std::string &iface,
const std::string &snapshot_ip, int snapshot_port,
const std::string &incremental_ip, int incremental_port)
: incoming_md_updates_(market_updates), run_(false),
logger_("trading_market_data_consumer_" + std::
to_string(client_id) + ".log"),
incremental_mcast_socket_(logger_),
snapshot_mcast_socket_(logger_),
iface_(iface), snapshot_ip_(snapshot_ip),
snapshot_port_(snapshot_port) {
构造函数执行以下任务:
-
正如我们提到的,构造函数为此类创建一个
Logger
实例,并使用该logger_
对象初始化incremental_mcast_socket_
变量和snapshot_mcast_socket_
变量。它还从传递给它的参数中初始化iface_
、snapshot_ip_
和snapshot_port_
成员。 -
使用
recv_callback()
lambda 方法,它在incremental_mcast_socket_
变量和snapshot_mcast_socket_
变量中初始化recv_callback_
变量。lambda 仅将回调转发到MarketDataConsumer
类中的recvCallback()
成员方法,我们将在后面看到。关键点在于我们期望在增量或快照多播套接字上有数据可用时调用MarketDataConsumer::recvCallback()
方法。 -
构造函数最后一件要做的事情是通过调用
McastSocket::init()
方法完全初始化incremental_mcast_socket_
,该方法在内部创建实际的套接字。它还调用McastSocket::join()
方法来订阅此套接字的多播流。请注意,我们还没有对snapshot_mcast_socket_
做同样的事情。这是在检测到数据包丢失或序列间隙时按需完成的:
auto recv_callback = this {
recvCallback(socket);
};
incremental_mcast_socket_.recv_callback_ =
recv_callback;
ASSERT(incremental_mcast_socket_.init(incremental_ip,
iface, incremental_port, /*is_listening*/ true) >= 0,
"Unable to create incremental mcast socket.
error:" + std::string(std::strerror(errno)));
ASSERT(incremental_mcast_socket_.join(incremental_ip,
iface, incremental_port),
"Join failed on:" + std::to_string
(incremental_mcast_socket_.fd_) + " error:" +
std::string(std::strerror(errno)));
snapshot_mcast_socket_.recv_callback_ = recv_callback;
}
我们添加了一个 start()
方法,类似于我们在交易交易所的其他组件旁边看到的。它将 run_
变量设置为 true
并创建并启动一个线程来执行我们将在后面构建的 MarketDataConsumer::run()
方法:
auto start() {
run_ = true;
ASSERT(Common::createAndStartThread(-1,
"Trading/MarketDataConsumer", [this]() { run(); })
!= nullptr, "Failed to start MarketData
thread.");
}
此类的析构函数简单直接,调用 stop()
方法,该方法仅将 run_
标志设置为 false
以结束 run()
方法的执行:
~MarketDataConsumer() {
stop();
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(5s);
}
auto stop() -> void {
run_ = false;
}
现在我们已经初始化了 MarketDataConsumer
类,我们将首先查看主要的 run()
循环,该循环执行从交易所消费多播流的一个循环。
运行市场数据消费者主循环
对于我们的市场数据消费者组件,run()
方法很简单。它仅在 incremental_mcast_socket_
套接字和 snapshot_mcast_socket_
对象上调用 sendAndRecv()
方法,在我们的情况下,它消费增量或快照通道上接收到的任何附加数据,并调度回调:
auto MarketDataConsumer::run() noexcept -> void {
logger_.log("%:% %() %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_));
while (run_) {
incremental_mcast_socket_.sendAndRecv();
snapshot_mcast_socket_.sendAndRecv();
}
}
下一节处理在recvCallback()
方法中从先前逻辑分发的网络套接字上的可用数据。
处理市场数据更新和处理数据包丢失
本节实现了处理在增量流和快照流上接收到的市场数据更新的重要功能。增量流上的市场更新在整个MarketDataConsumer
组件运行期间接收。然而,只有在检测到增量流上的序列号间隙时,才会从快照流接收和处理数据,这导致MarketDataConsumer
初始化snapshot_mcast_socket_
并订阅快照多播流。记住,在MarketDataConsumer
的构造函数中,我们故意没有像对incremental_mcast_socket_
那样完全初始化snapshot_mcast_socket_
。这里要理解的重要一点是,只有在恢复模式下,我们才会从快照套接字接收数据,否则不会。
recvCallback()
方法中的第一个代码块通过比较接收数据的套接字文件描述符,确定我们正在处理的数据来自增量流还是快照流。在极不可能的边缘情况下,如果我们从快照套接字接收数据但我们不在恢复中,我们简单地记录一个警告,重置套接字接收缓冲区索引,并返回:
auto MarketDataConsumer::recvCallback(McastSocket
*socket) noexcept -> void {
const auto is_snapshot = (socket->fd_ ==
snapshot_mcast_socket_.fd_);
if (UNLIKELY(is_snapshot && !in_recovery_)) {
socket->next_rcv_valid_index_ = 0;
logger_.log("%:% %() % WARN Not expecting snapshot
messages.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
return;
}
否则,我们继续进行,并使用之前看到的相同代码从套接字缓冲区读取Exchange::MDPMarketUpdate
消息。我们遍历socket->rcv_buffer_
缓冲区中的数据,并以Exchange::MDPMarketUpdate
大小为块的大小读取它。这里的目的是尽可能多地读取完整的MDPMarketUpdate
消息,直到我们从缓冲区中读取完所有消息。我们使用reinterpret_cast
将缓冲区中的数据转换为Exchange::MDPMarketUpdate
类型的对象:
if (socket->next_rcv_valid_index_ >= sizeof
(Exchange::MDPMarketUpdate)) {
size_t i = 0;
for (; i + sizeof(Exchange::MDPMarketUpdate) <=
socket->next_rcv_valid_index_; i +=
sizeof(Exchange::MDPMarketUpdate)) {
auto request = reinterpret_cast<const
Exchange::MDPMarketUpdate *>(socket->rcv_buffer_
+ i);
logger_.log("%:% %() % Received % socket len:%
%\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
(is_snapshot ? "snapshot" :
"incremental"), sizeof
(Exchange::MDPMarketUpdate),
request->toString());
对于每个MDPMarketUpdate
消息,我们检查我们刚刚读取的消息中的序列号,以查看是否存在序列号间隙。如果我们检测到序列号间隙或我们已经在恢复中,我们将in_recovery_
成员标志设置为true
:
const bool already_in_recovery = in_recovery_;
in_recovery_ = (already_in_recovery || request->
seq_num_ != next_exp_inc_seq_num_);
首先,我们将看到在恢复模式下处理消息的方式。在下一个代码块中,我们首先检查already_in_recovery_
标志,以确定我们之前是否不在恢复模式,并且是否因为这条消息而刚刚开始恢复。如果我们之前不在恢复模式,并且因为看到了序列号差距而开始恢复,我们将调用startSnapshotSync()
方法,我们很快就会看到这个方法。简而言之,startSnapshotSync()
方法将初始化snapshot_mcast_socket_
对象并订阅快照多播流,但关于这一点我们稍后再说。在恢复模式下,我们调用queueMessage()
方法来存储我们刚刚收到的MDPMarketUpdate
消息。我们保持在恢复模式,并在快照和增量流上排队市场数据更新。我们将这样做,直到我们从快照流中获得完整的簿记快照,以及快照消息之后的所有增量消息,以赶上增量流。我们将在稍后详细介绍这一点,当我们展示checkSnapshotSync()
方法的实际实现时:
if (UNLIKELY(in_recovery_)) {
if (UNLIKELY(!already_in_recovery)) {
logger_.log("%:% %() % Packet drops on %
socket. SeqNum expected:% received:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr
(&time_str_), (is_snapshot ?
"snapshot" : "incremental"),
next_exp_inc_seq_num_,
request->seq_num_);
startSnapshotSync();
}
queueMessage(is_snapshot, request);
}
对于我们不在恢复模式且接收到的消息来自增量市场数据流的情况,我们只需更新next_exp_inc_seq_num_
。这是一个提醒,next_exp_inc_seq_num_
变量跟踪我们期望在下一个增量市场数据更新中出现的下一个序列号。然后我们将MEMarketUpdate
消息写入incoming_md_updates_
无锁队列,该队列将由另一端的交易引擎组件消费:
else if (!is_snapshot) {
logger_.log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr
(&time_str_), request->toString());
++next_exp_inc_seq_num_;
auto next_write = incoming_md_updates_->
getNextToWriteTo();
*next_write = std::move(request->
me_market_update_);
incoming_md_updates_->updateWriteIndex();
}
}
最后,我们将 socket 中rcv_buffer_
缓冲区中剩余的局部数据左移,并更新下一次有效接收索引以供下一次读取:
memcpy(socket->rcv_buffer_, socket->rcv_buffer_ + i,
socket->next_rcv_valid_index_ - i);
socket->next_rcv_valid_index_ -= i;
}
}
这就完成了recvCallback()
方法的实现,我们现在将查看处理快照订阅和同步逻辑的方法。首先,我们研究startSnapshotSync()
方法,正如我们之前提到的,它准备MarketDataConsumer
类在序列号差距上启动快照同步机制。为此任务,我们首先清除两个std::map
容器——snapshot_queued_msgs_
和incremental_queued_msgs_
,我们使用这些容器来排队快照和增量流中的市场更新消息。然后我们使用McastSocket::init()
方法初始化snapshot_mcast_socket_
对象,以便在snapshot_ip_
和snapshot_port_
地址上创建套接字。然后我们调用McastSocket::join()
方法来开始快照市场数据流的组播订阅。记住,对于多播套接字,我们不仅要确保有一个正在读取市场数据的套接字,而且我们还需要发出 IGMP 加入成员网络级消息,以便消息可以流向应用程序,这是通过调用snapshot_mcast_socket_.join()
实现的:
auto MarketDataConsumer::startSnapshotSync() -> void {
snapshot_queued_msgs_.clear();
incremental_queued_msgs_.clear();
ASSERT(snapshot_mcast_socket_.init(snapshot_ip_,
iface_, snapshot_port_, /*is_listening*/ true) >= 0,
"Unable to create snapshot mcast socket. error:"
+ std::string(std::strerror(errno)));
ASSERT(snapshot_mcast_socket_.join(snapshot_ip_,
iface_, snapshot_port_),
"Join failed on:" + std::to_string
(snapshot_mcast_socket_.fd_) + " error:" +
std::string(std::strerror(errno)));
}
下一节处理MarketDataConsumer
组件的一个重要职责,即从快照和增量流中排队市场数据更新,并在需要时进行同步。
与快照流同步
我们需要实现的第一种方法是MarketDataConsumer::queueMessage()
方法,这是我们之前调用的。该方法接收一个MDPMarketUpdate
消息和一个标志,该标志捕获它是否是从快照流或增量流接收到的。
如果消息是通过增量市场数据流发送的,那么它将其添加到incremental_queued_msgs_
std::map
中。如果它是通过快照流接收的,那么首先,它会检查该序列号的市场更新是否已经存在于snapshot_queued_msgs_
容器中。如果该序列号的条目已经存在于容器中,那么这意味着我们正在接收一个新的快照消息周期,并且我们没有能够从上一个快照消息周期中成功恢复。在这种情况下,它会清除snapshot_queued_msgs_
容器,因为我们将不得不从头开始重新启动快照恢复过程。最后,将MEMarketUpdate
消息添加到snapshot_queued_msgs_
容器中:
auto MarketDataConsumer::queueMessage(bool is_snapshot,
const Exchange::
MDPMarketUpdate
*request) {
if (is_snapshot) {
if (snapshot_queued_msgs_.find(request->seq_num_) !=
snapshot_queued_msgs_.end()) {
logger_.log("%:% %() % Packet drops on snapshot
socket. Received for a 2nd time:%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
request->toString());
snapshot_queued_msgs_.clear();
}
snapshot_queued_msgs_[request->seq_num_] = request->
me_market_update_;
} else {
incremental_queued_msgs_[request->seq_num_] =
request->me_market_update_;
}
在将新消息排队到正确的容器后,我们调用checkSnapshotSync()
方法来查看我们是否可以从快照以及我们迄今为止排队的增量消息中成功恢复:
logger_.log("%:% %() % size snapshot:% incremental:% %
=> %\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
snapshot_queued_msgs_.size(),
incremental_queued_msgs_.size(),
request->seq_num_, request->
toString());
checkSnapshotSync();
}
现在,我们将实现MarketDataConsumer
类中最后也是最重要的方法 – checkSnapshotSync()
,该方法检查快照和增量容器中排队的MEMarketUpdate
消息,以查看我们是否可以成功恢复或与快照和增量流同步并赶上:
-
逻辑是排队快照和增量市场数据流上接收到的消息。
-
然后,当我们收到
MarketUpdateType::SNAPSHOT_END
时,我们确保在快照市场数据流中没有丢失任何消息,通过检查快照消息的序列号字段上没有间隙来确认。 -
然后,我们检查增量数据流中排队的市场更新,查看我们是否有消息跟在用于合成这一轮快照消息的最后一个消息之后。我们通过检查增量队列中是否有从
SNAPSHOT_END
消息中OrderId + 1
值开始的序列号的市场更新来完成此操作。 -
最后,我们确保从那个点开始,在增量排队消息中我们没有另一个间隙。
为了更好地理解快照恢复逻辑是如何工作的,我们提供了一个具体的示例,即恢复是可能的,图 8**.2:
图 8.2 – 当恢复可能时快照和增量队列的示例状态
应用我们在 图 8**.2 中刚刚提出的逻辑,我们首先检查 snapshot_queued_msgs_
容器,以确保我们有一个 SNAPSHOT_START
消息和一个 SNAPSHOT_END
消息。我们还通过检查序列号来确保快照消息中没有缺失,序列号从零开始,每条消息递增一。我们找到最后一个序列号,该序列号用于从 SNAPSHOT_END
消息中合成这个快照,并使用该消息中的订单 ID 字段,在这种情况下,设置为 776。
一旦我们确定我们有一个完整的快照消息序列,我们将检查增量市场数据更新队列。所有队列中的增量消息,其序列号小于或等于 checkSnapshotSync()
方法。
首先,我们检查 snapshot_queued_msgs_
容器是否为空。显然,我们无法恢复,因为我们需要一个完整的快照消息周期以及从那时起的所有增量消息来赶上增量流:
auto MarketDataConsumer::checkSnapshotSync() -> void {
if (snapshot_queued_msgs_.empty()) {
return;
}
我们接下来需要检查的是是否存在 MEMarketUpdate
的 MarketUpdateType::SNAPSHOT_START
类型的更新。否则,我们将清空队列并等待下一轮快照消息:
const auto &first_snapshot_msg =
snapshot_queued_msgs_.begin()->second;
if (first_snapshot_msg.type_ != Exchange::
MarketUpdateType::SNAPSHOT_START) {
logger_.log("%:% %() % Returning because have not
seen a SNAPSHOT_START yet.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
snapshot_queued_msgs_.clear();
return;
}
接下来,我们将遍历队列中的快照消息,并确保我们队列中的快照消息没有缺失,通过检查序列号来实现。记住,snapshot_queued_msgs_
容器中的键实际上是来自 MDPMarketUpdate
消息的 seq_num_
字段。如果我们检测到快照消息中的缺失,我们将 have_complete_snapshot
标志设置为 false
并退出循环。我们将快照队列中的每条消息收集到 final_events
容器中,该容器是 MEMarketUpdate
消息的 std::vector
类型,这将是我们从该快照成功恢复后要处理的全部事件的容器:
std::vector<Exchange::MEMarketUpdate> final_events;
auto have_complete_snapshot = true;
size_t next_snapshot_seq = 0;
for (auto &snapshot_itr: snapshot_queued_msgs_) {
logger_.log("%:% %() % % => %\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
snapshot_itr.first,
snapshot_itr.second.toString());
if (snapshot_itr.first != next_snapshot_seq) {
have_complete_snapshot = false;
logger_.log("%:% %() % Detected gap in snapshot
stream expected:% found:% %.\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
next_snapshot_seq,
snapshot_itr.first, snapshot_itr.
second.toString());
break;
}
if (snapshot_itr.second.type_ !=
Exchange::MarketUpdateType::SNAPSHOT_START &&
snapshot_itr.second.type_ !=
Exchange::MarketUpdateType::SNAPSHOT_END)
final_events.push_back(snapshot_itr.second);
++next_snapshot_seq;
}
一旦我们完成循环,我们将检查 have_complete_snapshot
标志,以查看我们是否在快照消息中找到了缺失。如果标志设置为 false
,这意味着我们找到了缺失,我们将清空 snapshot_queued_msgs_
容器并返回,因为我们无法恢复,必须等待下一轮快照消息:
if (!have_complete_snapshot) {
logger_.log("%:% %() % Returning because found gaps
in snapshot stream.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
snapshot_queued_msgs_.clear();
return;
}
假设我们已经走到这一步,我们将从快照消息队列中提取最后一条消息,并确保它是 MarketUpdateType::SNAPSHOT_END
类型,因为我们需要使用该消息中的 order_id_
字段来处理增量消息队列:
const auto &last_snapshot_msg = snapshot_queued_msgs_
.rbegin()->second;
if (last_snapshot_msg.type_ != Exchange::
MarketUpdateType::SNAPSHOT_END) {
logger_.log("%:% %() % Returning because have not
seen a SNAPSHOT_END yet.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
return;
}
现在,我们继续检查队列中的增量消息,看看我们是否可以成功同步。我们定义一个 have_complete_incremental
布尔标志,它将表示我们是否拥有增量流中的所有消息,没有任何缺失。我们还设置 next_exp_inc_seq_num_
成员变量为 last_snapshot_msg.order_id_ + 1
,来自 SNAPSHOT_END
消息:
auto have_complete_incremental = true;
size_t num_incrementals = 0;
next_exp_inc_seq_num_ = last_snapshot_msg.order_id_ + 1;
现在,我们遍历incremental_queued_msgs_
容器中的所有消息。我们丢弃序列号小于我们刚刚分配的next_exp_inc_seq_num_
变量的消息。否则,我们确保增量消息队列中没有差距,通过确保下一个消息的序列号等于next_exp_inc_seq_num_
,并在检测到差距时将have_complete_incremental
标志设置为false
:
for (auto inc_itr = incremental_queued_msgs_.begin();
inc_itr != incremental_queued_msgs_.end(); ++inc_itr) {
logger_.log("%:% %() % Checking next_exp:% vs. seq:%
%.\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
next_exp_inc_seq_num_, inc_itr->first,
inc_itr->second.toString());
if (inc_itr->first < next_exp_inc_seq_num_)
continue;
if (inc_itr->first != next_exp_inc_seq_num_) {
logger_.log("%:% %() % Detected gap in incremental
stream expected:% found:% %.\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
next_exp_inc_seq_num_, inc_itr->
first, inc_itr->second.toString());
have_complete_incremental = false;
break;
}
如果我们没有在增量队列的市场更新消息中检测到差距,我们就像之前一样将其添加到final_events
容器中。同时,我们也增加next_exp_inc_seq_num_
变量,因为如果没有差距,这是我们期望的下一个序列号:
logger_.log("%:% %() % % => %\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
inc_itr->first, inc_itr->second
.toString());
if (inc_itr->second.type_ != Exchange::
MarketUpdateType::SNAPSHOT_START &&
inc_itr->second.type_ != Exchange::
MarketUpdateType::SNAPSHOT_END)
final_events.push_back(inc_itr->second);
++next_exp_inc_seq_num_;
++num_incrementals;
}
在退出循环后,我们检查have_complete_incremental
标志以确保增量更新队列中没有差距。如果我们确实发现了一个差距,我们就清除snapshot_queued_msgs_
容器并返回,因为我们无法成功同步:
if (!have_complete_incremental) {
logger_.log("%:% %() % Returning because have gaps in
queued incrementals.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_));
snapshot_queued_msgs_.clear();
return;
}
在这一点上,我们已经成功恢复,因此我们遍历final_events
容器中的所有MEMarketUpdate
消息,并将它们写入incoming_md_updates_
无锁队列,以便发送到交易引擎组件:
for (const auto &itr: final_events) {
auto next_write = incoming_md_updates_->
getNextToWriteTo();
*next_write = itr;
incoming_md_updates_->updateWriteIndex();
}
最后,我们清除snapshot_queued_msgs_
容器和incremental_queued_msgs_
容器,并将in_recovery_
标志设置为false
,因为我们不再处于恢复模式。最后,我们在snapshot_mcast_socket_
上调用McastSocket::leave()
方法,因为我们不再需要订阅快照流或接收或处理快照消息:
logger_.log("%:% %() % Recovered % snapshot and %
incremental orders.\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
snapshot_queued_msgs_.size() - 2,
num_incrementals);
snapshot_queued_msgs_.clear();
incremental_queued_msgs_.clear();
in_recovery_ = false;
snapshot_mcast_socket_.leave(snapshot_ip_,
snapshot_port_);;
}
使用这种方法,我们已经完成了我们的MarketDataConsumer
组件的设计和实现。接下来,我们将从这些市场数据更新消息开始,讨论在交易引擎内部构建限价订单簿的话题。
从市场数据构建订单簿
在上一节中,我们构建了市场数据消费者组件,该组件订阅市场数据流,在快照流和增量流之间同步,并解码市场数据更新并将它们发布到交易引擎组件。交易引擎组件随后需要处理这些市场数据更新并构建一个类似于匹配引擎构建的限价订单簿,但这是一个比匹配引擎订单簿更为简化的版本。作为提醒,我们在“设计我们的交易生态系统”章节中讨论了这一点,该章节位于“设计低延迟 C++交易算法框架”部分。最后要注意的一点是,我们将重用匹配引擎中订单簿的设计和代码来创建客户端系统中的订单簿。我们将重用我们在“构建 C++匹配引擎”章节中构建的源代码,该章节位于“构建订单簿和匹配订单”部分。现在,让我们开始实现订单簿,我们将称之为MarketOrderBook
,以便于与匹配引擎内部的订单簿区分开来,后者被称为MEOrderBook
。
定义市场订单簿的结构
首先,我们将定义构成MarketOrderBook
数据结构的结构和类型。我们在这里使用的设计与MEOrderBook
类中使用的相同设计,该设计在图 8.3中展示。我们建议回顾订单簿的设计以及“构建 C++匹配引擎”章节中在“设计交易所订单簿”部分提出的不同选择的动机。
每个订单都由一个MarketOrder
结构体表示,它是为我们为匹配引擎构建的MEOrder
结构体的子集。我们还将有一个OrderHashMap
类型,就像我们在匹配引擎中做的那样,它将是一个从OrderId
到这些MarketOrder
对象的哈希表。与匹配引擎中一样,相同价格的订单将保存在一个MarketOrdersAtPrice
结构体中,这将是一个MarketOrder
对象的双向链表。记住,我们需要这个结构来维护所有具有相同价格和买卖方向的订单,并按 FIFO 顺序排列。我们还将构建一个OrdersAtPriceHashMap
映射,就像我们在匹配引擎中做的那样,它将是一个从Price
到这些MarketOrdersAtPrice
对象的哈希表。设计在图 8.3中展示,与我们在匹配引擎中展示的订单簿图类似,但在这个情况下结构不同:
图 8.3 – 市场参与者交易引擎中限价订单簿的架构
我们在下一两个子节中定义的结构体和类型的所有源代码都可以在 Chapter8/trading/strategy/market_order.h
源文件和 Chapter8/trading/strategy/market_order.cpp
源文件中找到。让我们从定义我们将需要的结构体和类型开始,来开始 MarketOrderBook
的实现。
定义 MarketOrder 结构体和 OrderHashMap 类型
首先,我们将定义 MarketOrder
结构体,它代表市场数据流中的一个单个订单。这个结构体包含 OrderId
、Side
、Price
、Qty
和 Priority
属性。它还包含一个 prev_order_
和 next_order_
成员,类型为 MarketOrder
指针,因为我们将会将这些对象链成一个双链表:
#pragma once
#include <array>
#include <sstream>
#include "common/types.h"
using namespace Common;
namespace Trading {
struct MarketOrder {
OrderId order_id_ = OrderId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
Priority priority_ = Priority_INVALID;
MarketOrder *prev_order_ = nullptr;
MarketOrder *next_order_ = nullptr;
构造函数很简单;它只是初始化构造函数中提供的字段:
// only needed for use with MemPool.
MarketOrder() = default;
MarketOrder(OrderId order_id, Side side, Price price,
Qty qty, Priority priority, MarketOrder *prev_order,
MarketOrder *next_order) noexcept
: order_id_(order_id), side_(side), price_(price),
qty_(qty), priority_(priority),
prev_order_(prev_order),
next_order_(next_order) {}
auto toString() const -> std::string;
};
我们还定义了 OrderHashMap
类型,它是一个 std::array
数组,包含 MarketOrder
指针对象,大小为 ME_MAX_ORDER_IDS
,与我们在匹配引擎订单簿中做的方式相同:
typedef std::array<MarketOrder *, ME_MAX_ORDER_IDS> OrderHashMap;
我们将用于日志记录的 toString()
方法是显而易见的:
auto MarketOrder::toString() const -> std::string {
std::stringstream ss;
ss << "MarketOrder" << "["
<< "oid:" << orderIdToString(order_id_) << " "
<< "side:" << sideToString(side_) << " "
<< "price:" << priceToString(price_) << " "
<< "qty:" << qtyToString(qty_) << " "
<< "prio:" << priorityToString(priority_) << " "
<< "prev:" << orderIdToString(prev_order_ ?
prev_order_->order_id_ : OrderId_INVALID) << " "
<< "next:" << orderIdToString(next_order_ ?
next_order_->order_id_ : OrderId_INVALID) << "]";
return ss.str();
}
接下来,我们将定义 MarketOrdersAtPrice
结构体,它包含一个指向 MarketOrder
对象链表的链接。
定义 MarketOrdersAtPrice 结构体和 OrdersAtPriceHashMap 类型
MarketOrdersAtPrice
结构体与我们为匹配 MEOrderBook
引擎构建的 MEOrdersAtPrice
结构体相同。它包含 Side
、Price
以及一个指向 MarketOrder
的 first_mkt_order_
指针,以表示在此价格下 MarketOrder
链表的开始。它还包含两个 MarketOrdersAtPrice
指针,prev_entry_
和 next_entry_
,因为我们将会创建一个由 MarketOrdersAtPrice
对象组成的双链表来表示价格层级:
struct MarketOrdersAtPrice {
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
MarketOrder *first_mkt_order_ = nullptr;
MarketOrdersAtPrice *prev_entry_ = nullptr;
MarketOrdersAtPrice *next_entry_ = nullptr;
这个类的构造函数是显而易见的。它只是用提供的参数初始化数据成员:
MarketOrdersAtPrice() = default;
MarketOrdersAtPrice(Side side, Price price, MarketOrder
*first_mkt_order, MarketOrdersAtPrice *prev_entry,
MarketOrdersAtPrice *next_entry)
: side_(side), price_(price),
first_mkt_order_(first_mkt_order),
prev_entry_(prev_entry),
next_entry_(next_entry) {}
toString()
方法与匹配引擎中的相同,所以我们在这里将跳过重复:
auto toString() const;
};
最后,OrdersAtPriceHashMap
与我们为匹配引擎构建的相同。它表示一个从 Price
到 MarketOrdersAtPrice
指针的哈希表:
typedef std::array<MarketOrdersAtPrice *,
ME_MAX_PRICE_LEVELS> OrdersAtPriceHashMap;
现在,我们可以在下一节中最终实现 MarketOrderBook
类,但在那之前,我们需要定义一个将用于各种组件以构建 最佳买价 卖价(BBO)视图的结构体。
定义 BBO 结构体
最后,我们需要定义另一个结构体来表示在最佳买价和卖价下的总数量。这表示市场上可用的最佳(最激进的)买卖价格,以及那些价格下所有订单数量的总和。这个结构体称为 bid_price_
和 ask_price_
(都是 Price
类型,以表示最佳价格),以及 bid_qty_
和 ask_qty_
以表示这些价格下所有订单的总数量。
BBO 抽象在交易引擎内部的不同组件中被广泛使用。通常,这被需要最佳市场价格和流动性的摘要而不是整个订单簿深度和每个订单详细信息的组件使用。例如,像 RiskManager
这样的组件,它只需要计算开放的 FeatureEngine
、PositionKeeper
、LiquidityTaker
和 MarketMaker
,也使用 BBO 抽象,其中不需要完整的订单簿。
为了便于记录此类对象,我们还将添加一个 toString()
方法:
struct BBO {
Price bid_price_ = Price_INVALID, ask_price_ =
Price_INVALID;
Qty bid_qty_ = Qty_INVALID, ask_qty_ = Qty_INVALID;
auto toString() const {
std::stringstream ss;
ss << "BBO{"
<< qtyToString(bid_qty_) << "@" <<
priceToString(bid_price_)
<< "X"
<< priceToString(ask_price_) << "@" <<
qtyToString(ask_qty_)
<< "}";
return ss.str();
};
};
现在,我们终于可以继续我们的 MarketOrderBook
类的实现。
定义订单簿中的数据成员
要构建 MarketOrderBook
类,我们首先需要定义这个类中的数据成员。这个类的所有源代码都可以在 Chapter8/trading/strategy/market_order_book.h
源文件和 Chapter8/trading/strategy/market_order_book.cpp
源文件中找到。
这个类中的重要数据成员如下:
-
一个
trade_engine_
变量,其类型为TradeEngine
指针类型。我们尚未定义这个类,但将在本章中定义它。目前,它代表的是交易引擎框架的类。我们将通过这个变量与订单簿进行通信。 -
两个内存池,
order_pool_
用于MarketOrder
对象,orders_at_price_pool_
用于MarketOrdersAtPrice
对象,将根据需要分配和释放这些对象。第一个池order_pool_
用于分配和释放MarketOrder
对象。第二个池orders_at_price_pool_
用于分配和释放MarketOrdersAtPrice
对象。记住,一个MemPool
实例与其提供的特定对象类型(作为模板参数提供)绑定在一起。 -
一个
bbo_
变量,其类型为BBO
,将用于在更新时计算并维护订单簿的BBO
视图,并将其提供给任何需要它的组件。 -
一个
oid_to_order_
变量,其类型为OrderHashMap
,将用于通过OrderId
跟踪MarketOrder
对象。 -
一个
price_orders_at_price_
变量,其类型为OrdersAtPriceHashMap
,用于通过Price
跟踪OrdersAtPrice
对象。 -
两个指向
MarketOrdersAtPrice
的指针——bids_by_price_
用于表示按价格排序的双向链表中的出价,asks_by_price_
用于表示按价格排序的双向链表中的要价。 -
最后,一些不太重要的变量,例如用于记录的
ticker_id_
、time_str_
和logger_
:
#pragma once
#include "common/types.h"
#include "common/mem_pool.h"
#include "common/logging.h"
#include "market_order.h"
#include "exchange/market_data/market_update.h"
namespace Trading {
class TradeEngine;
class MarketOrderBook final {
private:
const TickerId ticker_id_;
TradeEngine *trade_engine_ = nullptr;
OrderHashMap oid_to_order_;
MemPool<MarketOrdersAtPrice> orders_at_price_pool_;
MarketOrdersAtPrice *bids_by_price_ = nullptr;
MarketOrdersAtPrice *asks_by_price_ = nullptr;
OrdersAtPriceHashMap price_orders_at_price_;
MemPool<MarketOrder> order_pool_;
BBO bbo_;
std::string time_str_;
Logger *logger_ = nullptr;
};
我们还将定义一个 MarketOrderBookHashMap
类型,它是一个从 TickerId
到 MarketOrderBook
对象的哈希表,大小为 ME_MAX_TICKERS
。这个常量,以及我们将在下一个代码片段中遇到的其它常量,都在 构建 C++ 匹配引擎 章节中的 定义匹配引擎中的操作和交互 部分,以及 定义一些类型和 常量 子部分中定义的:
typedef std::array<MarketOrderBook *, ME_MAX_TICKERS>
MarketOrderBookHashMap;
}
接下来,我们将看到如何初始化 MarketOrderBook
类及其成员变量。
初始化订单簿
在本节中,我们将实现初始化 MarketOrderBook
类及其内部数据成员的代码。构造函数很简单,它接受将要用于记录的 TickerId
和 Logger
实例。它初始化 orders_at_price_pool_
为 MarketOrdersAtPrice
对象的 ME_MAX_PRICE_LEVELS
大小,并将 order_pool_
初始化为 MarketOrder
对象的 ME_MAX_ORDER_IDS
大小:
#include "market_order_book.h"
#include "trade_engine.h"
namespace Trading {
MarketOrderBook::MarketOrderBook(TickerId ticker_id,
Logger *logger)
: ticker_id_(ticker_id),
orders_at_price_pool_(ME_MAX_PRICE_LEVELS),
order_pool_(ME_MAX_ORDER_IDS), logger_(logger) {
}
此类的析构函数只是重置内部数据成员:
MarketOrderBook::~MarketOrderBook() {
logger_->log("%:% %() % OrderBook\n%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
toString(false, true));
trade_engine_ = nullptr;
bids_by_price_ = asks_by_price_ = nullptr;
oid_to_order_.fill(nullptr);
}
还有一个名为 setTradeEngine()
的附加实用方法,这是一个更好的方法来使用 TradeEngine
对象的实例设置 trade_engine_
变量:
auto setTradeEngine(TradeEngine *trade_engine) {
trade_engine_ = trade_engine;
}
既然我们已经看到了如何初始化我们的 MarketOrderBook
类,我们将讨论这个类最重要的功能,即从 TradeEngine
引擎接收到的 MEMarketUpdate
消息中更新订单簿。
处理市场更新和更新订单簿
onMarketUpdate()
方法会与需要处理的 MEMarketUpdate
消息一起被调用。此方法通过作为参数传递的市场更新来更新订单簿。我们将理解源代码以处理这些消息,但我们将逐个代码块地处理 MarketUpdateType
的每个情况。
在我们开始处理实际消息之前,我们首先初始化一个 bid_updated
布尔标志和一个 ask_updated
布尔标志,它们将表示由于这个市场更新,BBO
是否需要更新。我们通过检查我们接收到的市场更新是否对应于 side_ == Side::BUY
和 market_update
的 price_
等于或大于我们从 bids_by_price_->price_
变量获取的当前最佳出价来找出这一点。我们通过在 market_update_->side_
上检查 Side::SELL
并检查 market_update
的 price_
是否小于或等于最佳要价(asks_by_price_->price_
)来做同样的事情:
auto MarketOrderBook::onMarketUpdate(const
Exchange::MEMarketUpdate *market_update) noexcept -> void {
const auto bid_updated = (bids_by_price_ &&
market_update->side_ == Side::BUY && market_update->
price_ >= bids_by_price_->price_);
const auto ask_updated = (asks_by_price_ &&
market_update->side_ == Side::SELL && market_update->
price_ <= asks_by_price_->price_);
首先,我们看到了对 MarketUpdateType::ADD
的处理。我们将分配一个新的 MarketOrder
对象,并在其上调用 addOrder()
方法。这个 addOrder()
方法与我们为匹配引擎构建的 addOrder()
方法相同,但它操作 MarketOrder
和 MarketOrdersAtPrice
对象。我们将在下一小节中简要讨论这个 addOrder()
方法,但我们将不会完全重新实现它,因为我们已经在 Building the C++ Matching Engine 章节中看到了所有细节:
switch (market_update->type_) {
case Exchange::MarketUpdateType::ADD: {
auto order = order_pool_.allocate(market_update->
order_id_, market_update->side_, market_update->
price_,
market_update->qty_, market_update->priority_,
nullptr, nullptr);
addOrder(order);
}
break;
对于 MarketUpdateType::MODIFY
的情况的处理,找到目标修改消息的 MarketOrder
结构。然后,它更新该订单的 qty_
属性:
case Exchange::MarketUpdateType::MODIFY: {
auto order = oid_to_order_.at(market_update->
order_id_);
order->qty_ = market_update->qty_;
}
break;
对于MarketUpdateType::CANCEL
的处理很简单,它会找到取消消息对应的MarketOrder
,然后调用其上的removeOrder()
方法。removeOrder()
方法与我们在构建 C++撮合引擎章节中构建的removeOrder()
方法相同,除了它操作的是MarketOrder
和MarketOrdersAtPrice
对象。同样,我们不会完全重新实现这些方法,因为它们与我们之前看到的是相同的,详细信息可以在那一章和源文件中找到:
case Exchange::MarketUpdateType::CANCEL: {
auto order = oid_to_order_.at(market_update->
order_id_);
removeOrder(order);
}
break;
MarketUpdateType::TRADE
消息不会改变订单簿,因此在这里,我们只需使用onTradeUpdate()
方法将那条交易消息转发回TradeEngine
引擎。这里需要注意的是,在MarketUpdateType::TRADE
的情况下,我们在调用TradeEngine::onTradeUpdate()
方法后直接返回。这是因为交易消息不会更新我们市场数据协议中的订单簿,所以在这个switch case
之后的后续代码不需要执行:
case Exchange::MarketUpdateType::TRADE: {
trade_engine_->onTradeUpdate(market_update, this);
return;
}
break;
MarketOrderBook
类需要处理MarketUpdateType::CLEAR
消息。当订单簿需要被清除,因为我们丢失了一个数据包并且正在从快照流中恢复时,它会接收到这些消息。在这里,它所做的只是释放订单簿中所有有效的MarketOrder
对象,并通过将每个条目设置为nullptr
来清除oid_to_order_
容器。然后,它从bids_by_price_
指针开始迭代双链表,并将每个MarketOrdersAtPrice
对象释放回orders_at_price_pool_
内存池。它对asks_by_price_
链表做同样的处理,最后将bids_by_price_
和asks_by_price_
都设置为nullptr
以表示一个空的订单簿:
case Exchange::MarketUpdateType::CLEAR: {
for (auto &order: oid_to_order_) {
if (order)
order_pool_.deallocate(order);
}
oid_to_order_.fill(nullptr);
if(bids_by_price_) {
for(auto bid = bids_by_price_->next_entry_; bid
!= bids_by_price_; bid = bid->next_entry_)
orders_at_price_pool_.deallocate(bid);
orders_at_price_pool_.deallocate(bids_by_price_);
}
if(asks_by_price_) {
for(auto ask = asks_by_price_->next_entry_; ask
!= asks_by_price_; ask = ask->next_entry_)
orders_at_price_pool_.deallocate(ask);
orders_at_price_pool_.deallocate(asks_by_price_);
}
bids_by_price_ = asks_by_price_ = nullptr;
}
break;
MarketOrderBook
类不需要处理INVALID
、SNAPSHOT_START
和SNAPSHOT_END
的MarketUpdateType
s,因此对于这些消息它不做任何处理:
case Exchange::MarketUpdateType::INVALID:
case Exchange::MarketUpdateType::SNAPSHOT_START:
case Exchange::MarketUpdateType::SNAPSHOT_END:
break;
}
在这一点上,我们将调用updateBBO()
方法,并将我们计算的两个布尔标志bid_updated
和ask_updated
传递给它。我们很快就会看到这个方法的实现,但你现在应该理解它将使用传递给它的两个布尔标志来决定是否需要更新出价或要价BBO
值:
updateBBO(bid_updated, ask_updated);
最后,它通过onOrderBookUpdate()
方法通知TradeEngine
引擎订单簿已被更新,我们将在本章后面讨论该方法,并在下一章中进一步丰富它:
trade_engine_->onOrderBookUpdate(market_update->
ticker_id_, market_update->price_, market_update->
side_);
logger_->log("%:% %() % OrderBook\n%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
toString(false, true));
}
在我们结束这一节之前,让我们看看之前提到的updateBBO()
方法的实现。实现本身相对简单,所以让我们先看看对买入方的处理。一旦我们了解了如何处理买入方,由于处理方式完全相同,理解卖出方将会非常简单。我们首先检查传递给它的update_bid
参数是否为true
。只有在这种情况下,我们才需要更新BBO
对象的买入方。接下来,我们检查bids_by_price_
成员是否不是nullptr
。如果不是有效的,那么我们将bid_price_
变量和bid_qty_
变量设置为无效(分别为Price_INVALID
和Qty_INVALID
),因为这一侧是空的。更有趣的处理是在bids_by_price_
成员有效的情况下。
在这种情况下,我们将bbo_
对象中的bid_price_
成员变量设置为最佳买入价:bids_by_price_->price_
。为了计算bbo_
对象中的bid_qty_
,我们首先将其分配为该价格水平上第一个订单的qty_
值,我们通过bids_by_price_->first_mkt_order_->qty_
值访问它。然后,我们通过跟随next_order_
指针线性迭代该价格水平上的所有订单,直到我们绕回,即next_order_
指向first_mkt_order_
对象。对于我们迭代的每个订单,我们将该订单的qty_
值累加到我们bbo_
对象中的bid_qty_
成员。到此,我们已经完成了对BBO
对象买入方的更新。请注意,这里的线性迭代稍微低效,可以通过在处理MEMarketUpdate
消息本身时跟踪和更新这些值来改进,但我们把这个(简单)练习留给感兴趣的读者:
auto updateBBO(bool update_bid, bool update_ask)
noexcept {
if(update_bid) {
if(bids_by_price_) {
bbo_.bid_price_ = bids_by_price_->price_;
bbo_.bid_qty_ = bids_by_price_->first_mkt_order_-
>qty_;
for(auto order = bids_by_price_->
first_mkt_order_->next_order_; order !=
bids_by_price_->first_mkt_order_; order =
order->next_order_)
bbo_.bid_qty_ += order->qty_;
}
else {
bbo_.bid_price_ = Price_INVALID;
bbo_.bid_qty_ = Qty_INVALID;
}
}
对于BBO
的卖出方处理与之前讨论的买入方处理相同。我们不会重复,但这里是处理方法:
if(update_ask) {
if(asks_by_price_) {
bbo_.ask_price_ = asks_by_price_->price_;
bbo_.ask_qty_ = asks_by_price_->first_mkt_order_-
>qty_;
for(auto order = asks_by_price_->
first_mkt_order_->next_order_; order !=
asks_by_price_->first_mkt_order_; order =
order->next_order_)
bbo_.ask_qty_ += order->qty_;
}
else {
bbo_.ask_price_ = Price_INVALID;
bbo_.ask_qty_ = Qty_INVALID;
}
}
}
这就完成了我们在MarketOrderBook
类中需要的绝大部分功能。在下一小节中,我们将快速回顾一下我们在匹配引擎中为订单簿构建的一些实用方法,并将它们复制到交易引擎的订单簿中。
重新审视订单簿管理的通用实用方法
在构建 C++匹配引擎章节中,我们在构建订单簿和匹配 订单部分构建了MEOrderBook
。
我们在构建内部数据结构子节中解释并实现了priceToIndex()
方法和getOrdersAtPrice()
方法。在我们的MarketOrderBook
类中,我们有相同的方法,但它们操作的是MarketOrdersAtPrice
而不是MEOrdersAtPrice
。我们不会再次讨论或在这里重新实现它们,但提供了这两个方法的签名:
auto priceToIndex(Price price) const noexcept;
auto getOrdersAtPrice(Price price) const noexcept ->
MarketOrdersAtPrice;
在该章节的处理新被动订单子节中,我们解释了逻辑并实现了addOrder()
和addOrdersAtPrice()
方法。同样,对于MarketOrderBook
类,逻辑是相同的,只是它操作的是MarketOrder
结构而不是MEOrder
结构,以及MarketOrdersAtPrice
对象而不是MEOrdersAtPrice
对象。这里展示了MarketOrderBook
类中这两个方法的签名,但我们将跳过重复的解释和源代码,因为它们是相同的:
auto addOrder(MarketOrder *order) noexcept -> void;
auto addOrdersAtPrice(MarketOrdersAtPrice
*new_orders_at_price) noexcept;
类似地,在处理订单取消请求子节中,我们介绍了removeOrder()
和removeOrdersAtPrice()
方法背后的细节。同样,对于我们的MarketOrderBook
类,这些方法的工作方式完全相同,只是它们操作的是MarketOrder
和MarketOrdersAtPrice
结构:
Auto removeOrdersAtPrice(Side side, Price price)
noexcept;
auto removeOrder(MarketOrder *order) noexcept -> void;
这就完成了交易引擎框架内订单簿的设计和实现。接下来,我们需要讨论订单网关基础设施组件,这是TradeEngine
组件将用于与电子交易交易所通信的。
连接到交易所并发送和接收订单流
市场参与者交易基础设施中的订单网关客户端组件通过无锁队列从交易引擎接收订单请求,并通过另一个无锁队列将订单响应发送回交易引擎。它还在交易所基础设施的订单网关服务器上建立了一个 TCP 连接。它将订单请求编码为交易所的订单格式,并通过 TCP 连接发送。它还消耗通过该 TCP 连接发送的订单响应,并将它们从订单数据格式解码。我们再次展示订单网关客户端图,以刷新您对该组件设计的记忆。
图 8.4 – 展示客户端交易基础设施中订单网关客户端组件的图
我们将首先定义该类内部的数据成员来开始实现这个订单网关客户端组件。订单网关客户端组件的所有源代码都在Chapter8/trading/order_gw/order_gateway.h
源文件和Chapter8/trading/order_gw/order_gateway.cpp
源文件中。
定义订单网关客户端的数据成员
OrderGateway
类中的重要数据成员在此描述:
-
两个无锁队列指针。第一个是名为
outgoing_requests_
的ClientRequestLFQueue
类型,这是我们之前定义的LFQueue
实例的MEClientRequest
结构。另一个成员称为incoming_responses_
,它是ClientResponseLFQueue
类型,我们之前也定义了它作为MEClientResponse
结构的LFQueue
实例。这些将由OrderGateway
用于接收订单请求并向TradeEngine
发送订单响应。 -
它还包含一个
tcp_socket_
成员变量,类型为TCPSocket
,这是用于连接到交易所订单网关服务器并发送和接收消息的 TCP 套接字客户端。 -
两个
size_t
变量用于表示序列号。第一个,next_outgoing_seq_num_
,跟踪下一次发送到交易所的OMClientRequest
消息的序列号。第二个,next_exp_seq_num_
,用于检查和验证从交易所接收到的OMClientResponse
消息是否按顺序。 -
一个布尔
run_
标志,它在我们之前看到的所有其他组件中起着类似的作用。它将用于启动和停止OrderGateway
线程的执行,并且因为它被不同的线程访问,所以被标记为volatile
。 -
它还保存网络接口在
iface_
变量中,以及交易所订单网关服务器的 IP 和端口在ip_
和port_
成员变量中。 -
最后,它存储
ClientId
类型的client_id_
变量,以确保通过 TCP 套接字接收到的响应是针对正确客户端的:
#pragma once
#include <functional>
#include "common/thread_utils.h"
#include "common/macros.h"
#include "common/tcp_server.h"
#include "exchange/order_server/client_request.h"
#include "exchange/order_server/client_response.h"
namespace Trading {
class OrderGateway {
private:
const ClientId client_id_;
std::string ip_;
const std::string iface_;
const int port_ = 0;
Exchange::ClientRequestLFQueue *outgoing_requests_ =
nullptr;
Exchange::ClientResponseLFQueue *incoming_responses_ =
nullptr;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
size_t next_outgoing_seq_num_ = 1;
size_t next_exp_seq_num_ = 1;
Common::TCPSocket tcp_socket_;
};
}
在下一节中,我们将初始化这些数据成员以及 OrderGateway
类本身。
初始化订单网关客户端
构造函数接受交易客户端的 client_id
ID,一个指向 ClientRequestsLFQueue
对象的指针(client_requests
),一个指向 ClientResponseLFQueue
对象的指针(client_responses
),以及 TCP 连接的 ip
、port
和接口信息(iface
)。它使用这些参数初始化自己的内部变量,并使用该客户端的订单网关日志文件名初始化 Logger
数据成员(logger_
)。它更新 tcp_socket_
变量中的 TCPSocket
类型的 recv_callback_
成员,以便在数据读取上分发的回调将转到 OrderGateway::recvCallback()
方法。我们将简要地看到该方法的实现:
#include "order_gateway.h"
namespace Trading {
OrderGateway::OrderGateway(ClientId client_id,
Exchange::ClientRequestLFQueue *client_requests,
Exchange::ClientResponseLFQueue *client_responses,
std::string ip, const std::string &iface, int port)
: client_id_(client_id), ip_(ip), iface_(iface),
port_(port), outgoing_requests_(client_requests),
incoming_responses_(client_responses),
logger_("trading_order_gateway_" + std::
to_string(client_id) + ".log"),
tcp_socket_(logger_) {
tcp_socket_.recv_callback_ = this { recvCallback(socket, rx_time); };
}
像我们其他组件的设计一样,我们将添加一个 start()
方法,它将启用 run_
标志并创建和启动一个线程来执行 run()
方法。我们还将初始化我们的 tcp_socket_
成员变量,并将其连接到交易所订单网关服务器的 ip_
和 port_
接口信息:
auto start() {
run_ = true;
ASSERT(tcp_socket_.connect(ip_, iface_, port_, false)
>= 0,
"Unable to connect to ip:" + ip_ + " port:" +
std::to_string(port_) + " on iface:" +
iface_ + " error:" +
std::string(std::strerror(errno)));
ASSERT(Common::createAndStartThread(-1,
"Trading/OrderGateway", [this]() { run(); }) !=
nullptr, "Failed to start OrderGateway
thread.");
}
OrderGateway
类的析构函数调用 stop()
方法来停止 run()
方法的执行,并在返回之前等待一小段时间:
~OrderGateway() {
stop();
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(5s);
}
stop()
方法简单地将run_
标志设置为false
以停止run()
循环的执行:
auto stop() -> void {
run_ = false;
}
现在,我们可以继续处理剩下的两个重要任务:向交易所发送订单请求和接收交易所的订单响应。
向交易所发送订单请求
在本节中,我们将实现run()
方法,这是OrderGateway
类的主要循环。该方法的目标是发送任何准备通过 TCP 套接字发送的客户端请求,读取套接字上可用的任何数据,并调度recv_callback_()
方法。
首先,它调用TCPSocket::sendAndRecv()
方法在建立的 TCP 连接上发送和接收数据:
auto OrderGateway::run() noexcept -> void {
logger_.log("%:% %() %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_));
while (run_) {
tcp_socket_.sendAndRecv();
它还读取outgoing_requests_
LFQueue
上由TradeEngine
引擎发送的任何MEClientRequest
消息,并使用TCPSocket::send()
方法将它们写入tcp_socket_
发送缓冲区。请注意,它需要写入OMClientRequest
消息,这是通过首先写入next_outgoing_seq_num_
字段,然后写入TradeEngine
发送的MEClientRequest
对象来实现的。这是因为我们设计了OMClientRequest
对象,使其成为一个包含size_t seq_num_
字段和随后的MEClientRequest
对象的 struct。我们还为下一个出站套接字消息增加了next_outgoing_seq_num_
实例:
for(auto client_request = outgoing_requests_->
getNextToRead(); client_request; client_request =
outgoing_requests_->getNextToRead()) {
logger_.log("%:% %() % Sending cid:% seq:% %\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
client_id_, next_outgoing_seq_num_,
client_request->toString());
tcp_socket_.send(&next_outgoing_seq_num_,
sizeof(next_outgoing_seq_num_));
tcp_socket_.send(client_request,
sizeof(Exchange::MEClientRequest));
outgoing_requests_->updateReadIndex();
next_outgoing_seq_num_++;
}
}
}
我们将处理接收和处理交易所发送到OrderGateway
建立的 TCP 连接的订单响应的任务。
处理来自交易所的订单响应
当tcp_socket_
上有数据可用时,recvCallback()
方法会被调用,并且在前一节的run()
方法中调用TCPSocket::sendAndRecv()
方法。我们遍历TCPSocket
上的rcv_buffer_
缓冲区,并将数据重新解释为OMClientResponse
消息:
auto OrderGateway::recvCallback(TCPSocket *socket, Nanos
rx_time) noexcept -> void {
logger_.log("%:% %() % Received socket:% len:% %\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_), socket->fd_,
socket->next_rcv_valid_index_, rx_time);
if (socket->next_rcv_valid_index_ >=
sizeof(Exchange::OMClientResponse)) {
size_t i = 0;
for (; i + sizeof(Exchange::OMClientResponse) <=
socket->next_rcv_valid_index_; i +=
sizeof(Exchange::OMClientResponse)) {
auto response = reinterpret_cast<const
Exchange::OMClientResponse *>(socket->rcv_buffer_
+ i);
logger_.log("%:% %() % Received %\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_), response-
>toString());
对于我们刚刚读取到响应变量的OMClientResponse
消息,我们检查以确保响应中的客户端 ID 与OrderGateway
的客户端 ID 匹配,如果不匹配则忽略该响应:
if(response->me_client_response_.client_id_ !=
client_id_) {
logger_.log("%:% %() % ERROR Incorrect client id.
ClientId expected:% received:%.\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_)
, client_id_, response->
me_client_response_.client_id_);
continue;
}
我们还检查OMClientResponse
上的序列号是否与我们期望的一致。如果存在不匹配,我们将记录错误并忽略响应。这里有机会改进错误处理,但为了简单起见,我们只是记录错误并继续:
if(response->seq_num_ != next_exp_seq_num_) {
logger_.log("%:% %() % ERROR Incorrect sequence
number. ClientId:%. SeqNum expected:%
received:%.\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str_)
, client_id_, next_exp_seq_num_,
response->seq_num_);
continue;
}
最后,我们在下一个OMClientResponse
上增加预期的序列号,并将我们刚刚读取的响应写入incoming_responses_
LFQueue
以供TradeEngine
读取。它还更新了rcv_buffer_
缓冲区和我们从其中消耗了一些消息的下一个接收索引到TCPSocket
缓冲区:
++next_exp_seq_num_;
auto next_write = incoming_responses_->
getNextToWriteTo();
*next_write = std::move(response->
me_client_response_);
incoming_responses_->updateWriteIndex();
}
memcpy(socket->rcv_buffer_, socket->rcv_buffer_ + i,
socket->next_rcv_valid_index_ - i);
socket->next_rcv_valid_index_ -= i;
}
}
通过此方法实现,我们已经完成了OrderGateway
组件的设计和实现。这将是本章中构建的所有核心基础设施组件,我们将在下一章总结我们所做的工作。
一个重要的注意事项是,在我们能够构建和运行一个有意义的交易客户端之前,我们需要构建本章中展示的所有组件,以及《构建 C++交易算法构建块》和《构建 C++市场做市和流动性获取算法》章节中的所有组件。由于我们的生态系统由服务器(交易交易所)和客户端(交易客户端)基础设施组成,因此我们需要等待《构建 C++市场做市和流动性获取算法》章节中的《构建和运行主要交易应用》部分,然后我们才能运行完整的生态系统。
摘要
本章致力于构建市场参与者交易系统中的重要核心基础设施组件。首先,我们构建了市场数据消费者组件,该组件负责订阅由交易所生成的多播市场数据流。它需要检测增量市场数据流中市场数据更新的差距,并启动快照恢复和同步机制以重新与增量市场数据流同步。它将交易所发布的数据格式解码为更简单的内部市场数据格式。
交易引擎组件内部的处理订单簿子组件处理它从市场数据消费者接收到的市场数据更新。它从这些更新中构建和更新订单簿数据结构,以便交易引擎能够获得对市场的准确视图。
交易系统内部的订单网关组件与电子交易交易所建立并维护双向 TCP 连接。它从交易引擎接收订单操作请求,并以交易所的订单数据格式将它们发送到交易所。它还接收交易所发送给交易客户端的订单响应,解码它们,并将它们转发给交易引擎。
注意,我们并不在交易客户端的交易系统中拥有我们所需要的一切,也就是说,我们缺少构建和运行交易策略及其相关组件所需的组件。下一章将构建交易策略框架中所需的附加组件。随后的章节将把这些组件连接起来,完成最终的交易应用和完整的交易生态系统。
第九章:构建 C++交易算法的构建块
在本章中,我们将构建构成我们交易应用智能的组件。这些是交易策略将非常依赖以做出决策、发送和管理订单、跟踪和管理位置、损益(PnLs)以及管理风险的组件。交易策略不仅需要跟踪交易损益,因为目标是赚钱,而且这些组件还需要跟踪损益以决定何时停止交易(如果需要的话)。我们将学习如何从市场数据更新中计算复杂特征,根据订单执行和市场更新跟踪交易表现,在市场上发送和管理实时策略订单,以及管理市场风险。在本章中,我们将涵盖以下主题:
-
对执行做出反应和管理位置、损益和风险
-
构建特征引擎和计算复杂特征
-
使用执行、更新位置和损益
-
发送和管理订单
-
计算和管理风险
技术要求
本书的所有代码都可以在本书的 GitHub 仓库中找到,该仓库地址为github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
。本章的源代码位于该仓库的Chapter9
目录中。
你必须阅读并理解在第章设计我们的交易生态系统中介绍的电子交易生态系统的设计,特别是设计一个低延迟 C++交易算法框架部分。和之前一样,我们将使用我们在第章为低延迟应用构建 C++构建块中构建的构建块。
本书源代码开发环境的规格在此展示。我们提供了这个环境的详细信息,因为本书中展示的所有 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++交易算法框架部分讨论了这些组件的需求。我们已经实现了一个主要组件——限价订单簿,但在这个部分,我们将构建我们需要的其余组件,即以下内容:
-
一个
FeatureEngine
,它将被用来计算驱动交易策略决策的简单和复杂特征/信号 -
一个
PositionKeeper
,它将接收执行并计算重要的指标,如头寸、损益、交易量等 -
一个
OrderManager
,它将被策略用来发送订单、管理它们并在有更新时更新这些订单 -
一个
RiskManager
来计算和检查交易策略试图承担的市场风险以及它已经实现的风险
下面的图示显示了所有这些组件的拓扑结构以及它们如何相互交互。如果您需要刷新对这些组件存在的原因、它们的目的、它们如何相互交互以及它们如何设计的记忆,请回顾第章设计我们的交易生态系统,并查看设计低延迟 C++交易算法框架部分的子节。
图 9.1 – 交易引擎内部的子组件
现在,让我们开始实施这些组件的工作,从下一子节的功能引擎开始。但在我们这样做之前,我们需要为Side
枚举添加两个额外的方法,这将使我们的源代码在以后变得更加简单。这两个方法都可以在Chapter9/common/types.h
头文件中找到。
我们将添加的第一个方法是sideToIndex()
方法,它将Side
值转换为可以用于索引数组的索引。这将允许我们维护由Side
值索引的不同类型的对象数组。实现很简单——我们只需将side
类型转换为size_t
类型并加 1,以考虑到Side::SELL
的值为-1,并且有效索引从 0 开始:
inline constexpr auto sideToIndex(Side side) noexcept {
return static_cast<size_t>(side) + 1;
}
我们还将定义一个sideToValue()
方法,它将Side
值转换为Side::BUY
的 1 或Side::SELL
的-1。这将在我们计算头寸和损益时有所帮助,我们将在本节稍后看到:
inline constexpr auto sideToValue(Side side) noexcept {
return static_cast<int>(side);
}
现在我们已经处理了这些额外的功能,我们可以开始计算功能引擎。
构建特征和计算复杂特征
在本节中,我们将构建一个功能引擎的最小版本。我们将只计算两个简单的特征——一个(市场价格)基于订单簿价格顶部和数量计算公平市场价格,另一个(激进交易量比)计算交易量与订单簿顶部数量的比较大小。我们将使用这些特征值来驱动本章后面的市场制作和流动性获取交易算法。我们构建的FeatureEngine
类的源代码可以在 GitHub 上的Chapter9/trading/strategy/feature_engine.h
文件中找到。我们在第章设计我们的交易生态系统中讨论了该组件的详细信息,在设计低延迟 C++交易算法框架部分。
在功能引擎中定义数据成员
首先,我们需要声明FeatureEngine
类并在其中定义数据成员。首先,我们将包含所需的头文件并定义一个表示无效或未初始化特征值的常量哨兵值。这被称为Feature_INVALID
,如下所示:
#pragma once
#include "common/macros.h"
#include "common/logging.h"
using namespace Common;
namespace Trading {
constexpr auto Feature_INVALID =
std::numeric_limits<double>::quiet_NaN();
我们的FeatureEngine
类是基础的,并且包含两个重要的double
类型数据成员——一个用于计算和存储公平市场价格值,mkt_price_
,另一个用于计算和存储激进交易数量比率特征值,agg_trade_qty_ratio_
。它还存储了一个指向Logger
对象的指针(logger_
),用于日志记录:
class FeatureEngine {
private:
std::string time_str_;
Common::Logger *logger_ = nullptr;
double mkt_price_ = Feature_INVALID,
agg_trade_qty_ratio_ = Feature_INVALID;
};
接下来,我们将看看如何初始化这个类,因为我们已经用Feature_INVALID
值初始化了两个特征变量。
初始化特征引擎
这个类的构造函数接受一个Logger
对象并初始化logger_
数据成员——仅此而已:
FeatureEngine(Common::Logger *logger)
: logger_(logger) {
}
在这里,我们将展示两个获取方法——getMktPrice()
和getAggTradeQtyRatio()
——以获取FeatureEngine
类负责计算的两个特征值:
auto getMktPrice() const noexcept {
return mkt_price_;
}
auto getAggTradeQtyRatio() const noexcept {
return agg_trade_qty_ratio_;
}
在接下来的两个子节中,我们将看到这个组件如何处理订单簿更新和交易事件,并更新特征值。
在订单簿变化上计算特征
FeatureEngine
类期望在订单簿更新时调用onOrderBookUpdate()
方法。首先,它使用MarketOrderBook::getBBO()
提取 BBO。提醒一下,如果它们有效,则mkt_price_
值。公平市场价格被公式化为订单簿数量加权价格,(bid_price * ask_qty + ask_price * bid_qty) / (bid_qty + ask_qty)
。请注意,这只是公平市场价格的一个单一公式;在特征工程中要记住的重要事情是没有单一的正确公式。鼓励您制定一个公平市场价格或其他任何您希望在将来使用的特征值的版本。我们在这里使用的公式试图在买方订单多于卖方订单时将公平市场价格移向出价,在卖方订单多于买方订单时将其移向要价:
auto onOrderBookUpdate(TickerId ticker_id, Price price,
Side side, MarketOrderBook* book) noexcept -> void {
const auto bbo = book->getBBO();
if(LIKELY(bbo->bid_price_ != Price_INVALID && bbo-
>ask_price_ != Price_INVALID)) {
mkt_price_ = (bbo->bid_price_ * bbo->ask_qty_ +
bbo->ask_price_ * bbo->bid_qty_) /
static_cast<double>(bbo->bid_qty_ + bbo->
ask_qty_);
}
logger_->log("%:% %() % ticker:% price:% side:% mkt-
price:% agg-trade-ratio:%\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
ticker_id, Common::priceToString
(price).c_str(),
Common::sideToString(side).c_str(),
mkt_price_, agg_trade_qty_ratio_);
}
下一个子节将计算另一个特征,我们将称之为激进交易数量比率,用于将交易数量作为订单簿价格水平数量的分数来计算。
在交易事件上计算特征
FeatureEngine
预期在市场数据流中出现交易事件时调用 onTradeUpdate()
方法。正如我们之前所看到的,它获取 BBO 并检查价格是否有效,然后计算 agg_trade_qty_ratio_
特征,该特征是交易量与交易所攻击的 BBO 数量的比率。正如我们之前提到的关于该特征,没有单一正确的特征公式 – 这只是我们现在使用的公式;希望你在未来会添加自己的公式。这个公式试图衡量交易攻击者相对于攻击者交易的 BBO 侧可用的流动性的大小。我们只是试图通过我们计算的这个特征量化交易压力。正如我们之前提到的,还有许多其他可能的公式:
auto onTradeUpdate(const Exchange::MEMarketUpdate
*market_update, MarketOrderBook* book) noexcept -> void {
const auto bbo = book->getBBO();
if(LIKELY(bbo->bid_price_ != Price_INVALID && bbo->
ask_price_ != Price_INVALID)) {
agg_trade_qty_ratio_ = static_cast<double>
(market_update->qty_) / (market_update->side_ ==
Side::BUY ? bbo->ask_qty_ : bbo->bid_qty_);
}
logger_->log("%:% %() % % mkt-price:% agg-trade-ratio
:%\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
market_update->toString().c_str(),
mkt_price_, agg_trade_qty_ratio_);
}
那就是本书中 FeatureEngine
的全部实现。在下一节中,我们将学习如何处理执行情况,并使用这些信息来更新头寸和 PnL。
使用执行情况更新头寸和 PnL
现在,我们将构建一个 PositionKeeper
类,该类将负责处理策略订单的执行情况,并计算和跟踪头寸和 PnL。该组件被策略以及风险管理者用于计算不同目的的头寸和 PnL。PositionKeeper
类的所有源代码位于 GitHub 上的 Chapter9/trading/strategy/position_keeper.h
文件中。在我们构建管理所有交易工具头寸的 PositionKeeper
类之前,我们需要构建一个也存在于同一源文件中的 PositionInfo
结构体。PositionInfo
结构体是用于管理单个交易工具的头寸和 PnL 的低级结构体;我们将在接下来的几个小节中更详细地介绍它。我们在 第 章 设计我们的交易生态系统 的 设计低延迟 C++交易算法框架 部分中讨论了该组件的细节。
在 PositionInfo
中声明数据成员
首先,我们必须指定 position_keeper.h
文件所需的 include
文件,如下所示:
#pragma once
#include "common/macros.h"
#include "common/types.h"
#include "common/logging.h"
#include "exchange/order_server/client_response.h"
#include "market_order_book.h"
using namespace Common;
PositionInfo
结构体内的数据成员在源代码中呈现。重要的数据成员如下:
-
一个
position_
变量,其类型为int32_t
,用于表示当前头寸。这可以是正数、负数或 0。 -
三个
double
类型的值 –real_pnl_
、unreal_pnl_
和total_pnl_
– 分别用于跟踪已关闭头寸的已实现或已关闭的 PnL(real_pnl_
)、当前开放头寸的未实现或开放 PnL(unreal_pnl_
),以及总 PnL,这是两个值的总和(total_pnl_
)。已实现 PnL 仅在发生额外订单执行时才会改变;如果存在非零头寸且市场价格发生变化,未实现 PnL 即使没有订单执行也可能改变。 -
一个足够大的
std::array
,其大小足以容纳买方和卖方的条目。这个数组将使用sideToIndex(Side::BUY)
和sideToIndex(Side::SELL)
值进行索引。这个open_vwap_
std::array
变量跟踪在存在开放多头(正)或空头(负)持仓时每侧的价格和执行数量的乘积。我们需要这个来通过比较开放多头或空头持仓的加权平均价格(VWAP)与当前市场价格来计算未实现盈亏。 -
一个
volume_
变量,其类型为Qty
,用于跟踪已执行的总数量。 -
一个指向名为
bbo_
的BBO
对象的const
指针变量,该变量将在市场更新时用于获取更新的订单簿顶端价格:
namespace Trading {
struct PositionInfo {
int32_t position_ = 0;
double real_pnl_ = 0, unreal_pnl_ = 0, total_pnl_ = 0;
std::array<double, sideToIndex(Side::MAX) + 1>
open_vwap_;
Qty volume_ = 0;
const BBO *bbo_ = nullptr;
我们还将为此结构添加一个简单的toString()
方法来将此结构的实例转换为字符串:
auto toString() const {
std::stringstream ss;
ss << "Position{"
<< "pos:" << position_
<< " u-pnl:" << unreal_pnl_
<< " r-pnl:" << real_pnl_
<< " t-pnl:" << total_pnl_
<< " vol:" << qtyToString(volume_)
<< " vwaps:[" << (position_ ? open_vwap_
.at(sideToIndex(Side::BUY)) / std::abs
(position_) : 0)
<< "X" << (position_ ? open_vwap_
.at(sideToIndex(Side::SELL)) / std::abs
(position_) : 0)
<< "] "
<< (bbo_ ? bbo_->toString() : "") << "}";
return ss.str();
}
接下来,我们需要处理订单执行情况,并根据这些执行情况更新持仓和盈亏(PnL)。
在PositionInfo
中处理订单执行
当一个交易策略的订单被执行时,PositionKeeper
需要更新跟踪执行交易工具的持仓和盈亏。它是通过向PositionInfo::addFill()
方法提供与订单执行对应的MEClientResponse
消息来做到这一点的。我们将在本小节中构建它。
在我们查看PositionInfo::addFill()
方法的实现源代码之前,我们将查看一个示例,说明如何更新实现和未实现盈亏的算法。这将帮助你轻松理解实现源代码。我们将跟踪在几个假设的执行过程中假设交易工具的不同变量的演变。在我们的表格中,我们将显示以下变量作为列:
-
position – old:这是处理当前执行消息之前的持仓。
-
position – new:这将是在处理当前执行消息后的新持仓。
-
open_vwap – BUY:这是仅针对买入执行的执行价格和执行数量的乘积之和。
-
open_vwap – SELL:这是仅针对卖出执行的执行价格和执行数量的乘积之和。
-
VWAP – BUY:这是当前多头/正持仓的实际 VWAP,以价格单位表示,而不是价格乘以数量。
-
VWAP – SELL:这是当前空头/负持仓的实际 VWAP,以价格单位表示,而不是价格乘以数量。
-
PnL – real:这是处理此执行后的实现盈亏。
-
PnL – unreal:这是处理此执行后的开放持仓的未实现盈亏。
假设我们得到一个以 100.0 的价格买入 10 个单位的执行,我们必须更新open_vwap
、BUY
侧的VWAP
以及新的持仓,如下所示。目前不需要对未实现盈亏进行更改:
position | open_vwap | VWAP | PnL |
---|---|---|---|
旧 | 新 | BUY | SELL |
0 | 10 | 1000.0 | 0.0 |
假设我们再次以 90.0 的价格执行买入 10 的操作。我们的旧仓位是 10,新仓位将是 20。BUY
(买入)的open_vwap
属性现在将 10 * 90 加到之前的 1,000 上,变成 1,900。开放多头/正仓位的VWAP
列是 95,可以通过将 1,900(BUY
open_vwap
)除以 20(新仓位)来计算。我们通过使用 95 的VWAP
和最新的执行价格 90,将-5 的差额乘以 20 的仓位来计算未实现损益,得到-100。由于我们的多头/正仓位的VWAP
高于当前市场价格(由最新执行价格表示),我们有一个负的未实现损益:
仓位 | open_vwap | VWAP | PnL |
---|---|---|---|
旧 | 新 | BUY | SELL |
10 | 20 | 1900.0 | 0.0 |
现在,假设我们执行了一次以 92 的价格卖出 10 的卖出操作。我们的旧仓位从 20 减少到 10。由于这是一次卖出操作,我们的open_vwap
和VWAP
在BUY
(买入)方面没有变化。由于我们关闭了 10 个多头/正仓位,我们将有一些已实现损益(PnL),而剩余的 10 个多头/正仓位将基于这次最新执行的执行价格有一些未实现损益。已实现损益是通过使用 92 的卖出执行价格、多头/正仓位的VWAP
属性(95)以及 10 的执行数量来计算的,得到已实现损益为(92 - 95) * 10 = -30。在这种情况下,未实现损益也是相同的,因为还剩下多头/正仓位 10:
仓位 | open_vwap | VWAP | PnL |
---|---|---|---|
旧 | 新 | BUY | SELL |
20 | 10 | 1900.0 | 0.0 |
现在,假设我们收到另一个以 97 的价格卖出 20 的卖出执行。这将导致我们的仓位从 10 翻转到-10(注意,我们将open_vwap
和VWAP
的BUY
(买入)方面设置为 0)。由于-10 的仓位和 97 的执行价格,SELL
(卖出)方面的open_vwap
属性变为 970。我们关闭了之前的 10 仓位,该仓位在 95 的VWAP
下以 97 的价格卖出。由于我们以高于我们多头/正仓位VWAP
属性的价格卖出,我们获得了(97 - 95) * 10 = 20 的利润,加上之前的已实现损益-30,最终得到realized
(已实现)损益为-10。这里的未实现损益为 0,因为 97 的VWAP
与当前的执行价格 97 相同:
仓位 | open_vwap | VWAP | PnL |
---|---|---|---|
旧 | 新 | BUY | SELL |
10 | -10 | 0.0 | 970.0 |
假设我们得到另一个以 94 价格卖出 20 的卖出执行。在这里,空头/负头寸从 -10 增加到 -30。open_vwap
属性在 SELL
方面通过将 (20 * 94) 加到之前的值 970 上进行更新,得到 2,850。我们的空头头寸的 VWAP
属性更新为 95,通过将 open_vwap
属性 2,850 除以头寸 30 得到 95。由于头寸增加且没有减少或关闭,实际 PnL 没有变化。未实现 PnL 使用这次新执行上的执行价格 94,将其与 VWAP
属性 95 进行比较,并使用新的头寸 -30 得到 (95 - 94) * 30 = 30:
位置 | 开盘 VWAP | VWAP | PnL |
---|---|---|---|
旧值 | 新值 | 买入 | 卖出 |
-10 | -30 | 0.0 | 2850.0 |
假设还有一次以 90 价格卖出 10 的卖出执行。空头/负头寸从 -30 增加到 -40。我们将新执行的价格和数量乘积(10 * 90)加到之前的 open_vwap
属性的 SELL
2,850 上,得到 3,750。实际 VWAP
的空头头寸从 95 变为 93.75,这是通过将这个 3,750 的值除以新的头寸 40 得到的。实际 PnL 没有变化,因为头寸增加了,但没有减少或关闭。未实现 PnL 使用 (93.75 - 90) * 40 = 150 进行更新:
位置 | 开盘 VWAP | VWAP | PnL |
---|---|---|---|
旧值 | 新值 | 买入 | 卖出 |
-30 | -40 | 0.0 | 3750.0 |
最后,假设我们收到一个以 88 价格买入 40 的买入执行。这次执行将平掉我们的空头/负头寸 -40,因此新的头寸将为 0。未实现 PnL 将为 0,由于没有开放头寸,open_vwap
和 VWAP
属性对于双方都将为 0。实际 PnL 使用之前的 VWAP
属性、执行价格和 40 的头寸进行更新,所以 (93.75 - 88) * 40 = 230。这加到之前实现的 PnL -10 上,得到最终实现的 PnL 为 220:
位置 | 开盘 VWAP | VWAP | PnL |
---|---|---|---|
旧值 | 新值 | 买入 | 卖出 |
-40 | 0 | 0.0 | 0.0 |
现在,我们可以继续讨论这个算法的实现细节。
我们必须做的第一件事是初始化几个局部变量。在这里,old_position
变量保存更新前的 current position_
值。side_index
和 opp_side_index
使用 sideToIndex()
方法找到 open_vwap_
数组中对应执行方向和执行方向相反方向的索引。我们还必须初始化一个 side_value
变量,对于买入执行将是 +1,对于卖出执行将是 -1:
auto addFill(const Exchange::MEClientResponse
*client_response, Logger *logger) noexcept {
const auto old_position = position_;
const auto side_index = sideToIndex(client_response->
side_);
const auto opp_side_index = sideToIndex
(client_response->side_ == Side::BUY ? Side::SELL :
Side::BUY);
const auto side_value = sideToValue(client_response->
side_);
现在,我们必须使用这个响应中的执行数量(exec_qty_
)和我们初始化的side_value
变量来更新position_
变量。我们还必须通过将新的执行数量添加到其中来更新volume_
成员。当我们收到一个买进订单的执行时,我们的头寸会增加;相反,当我们收到一个卖出订单的执行时,我们的头寸会减少。当我们的头寸为正,也就是所谓的多头头寸时,价格上涨时我们获利,价格下跌时我们亏损。当我们的头寸为负,也就是所谓的空头头寸时,价格下跌时我们获利,价格上涨时我们亏损:
position_ += client_response->exec_qty_ * side_value;
volume_ += client_response->exec_qty_;
对于我们来说,下一个重要的步骤是更新open_vwap_
条目的std::array
变量。我们将检查在此执行之前我们是否处于平衡状态(头寸为 0)并使用此执行开立新头寸,或者如果我们已经有一个开立的头寸并且我们得到了增加该头寸的执行。在这种情况下,我们将简单地使用side_index
来索引正确的方向来更新open_vwap_
变量。由于open_vwap_
跟踪执行价格和在该价格下执行的数量乘积,我们可以简单地乘以此执行的价格和执行数量,并将其添加到现有的总和,如下所示:
if (old_position * sideToValue(client_response->
side_) >= 0) { // opened / increased position.
open_vwap_[side_index] += (client_response->price_
* client_response->exec_qty_);
}
现在,我们需要处理我们之前已经有一个未平仓头寸的情况。这次最近的执行会减少或平掉头寸。在这种情况下,我们需要使用执行方向的相反方向的open_vwap_
条目来更新已实现 PnL(real_pnl_
)。有一点需要理解的是,只有当未平仓头寸减少或关闭时,已实现 PnL 才会更新,因为在这种情况下,我们已经买进和卖出了一定数量的资产。另一种思考方式是,我们可以将部分买进数量与部分卖出数量相匹配,从而创建一对买进和卖出交易。在这种情况下,我们已经平仓了至少部分头寸。在前一种情况下,即我们要么开了一个新头寸,要么增加了一个已经开头的头寸,我们没有一对买进和卖出交易可以匹配,因此我们不需要更新已实现 PnL。
首先,我们将计算一个opp_side_vwap
值,这是另一侧所有执行的加权平均价格,使用open_vwap_
条目为opp_side_index
,并使用此执行之前的old_position
的绝对值来归一化。记住,open_vwap_
变量命名不佳;它跟踪执行价格和数量的乘积,而不仅仅是价格,所以通过除以由old_position
表示的数量来得到实际的 VWAP。然后,我们将使用在opp_side_vwap
中计算的 VWAP 和新的position_
值的绝对值来更新opp_side_index
的open_vwap_
条目。
我们可以通过找到执行数量(exec_qty_
)的最小数量值和old_position
的绝对值来更新real_pnl_
值。我们必须将这个值乘以当前执行消息的价格(price_
)和opp_side_vwap
之间的差值。最后,我们需要将这个乘积乘以opp_side_value
来考虑是否盈利(以低于卖出 VWAP 的价格买入或以高于买入 VWAP 的价格卖出)或亏损(以高于卖出 VWAP 的价格买入或以低于买入 VWAP 的价格卖出):
else { // decreased position.
const auto opp_side_vwap = open_vwap_
[opp_side_index] / std::abs(old_position);
open_vwap_[opp_side_index] = opp_side_vwap * std::
abs(position_);
real_pnl_ += std::min
(static_cast<int32_t>(client_response->
exec_qty_), std::abs(old_position)) *
(opp_side_vwap - client_response->
price_) * sideToValue
(client_response->side_);
如果这次执行导致头寸翻转,即从多头头寸变为空头头寸或反之,我们需要处理一个边缘情况。这种情况可能发生在,例如,我们持有一定数量的多头/正值头寸,并收到一个数量大于该头寸的卖出执行。相反,这也可能发生在我们持有一定数量的空头/负值头寸,并收到一个数量大于该头寸的买入执行。在这些情况中,我们从一个正/多头头寸变为负/空头头寸,或者从一个负/空头头寸变为正/多头头寸。在这种情况下,我们可以简单地重置对应于相反侧的open_vwap_
值为 0,并重置执行侧(以及因此新头寸侧)的open_vwap_
值,使其成为最新执行价格和当前position_
绝对值的乘积:
if (position_ * old_position < 0) { // flipped
position to opposite sign.
open_vwap_[side_index] = (client_response->price_
* std::abs(position_));
open_vwap_[opp_side_index] = 0;
}
}
最后,我们将通过更新未实现损益(unreal_pnl_
)值来结束PositionInfo::addFill()
方法的封装。我们现在处于平衡状态(position_ == 0
)的情况很简单——我们重置两边的open_vwap_
变量并将unreal_pnl_
设置为 0,因为没有开放头寸意味着没有unreal_pnl_
:
if (!position_) { // flat
open_vwap_[sideToIndex(Side::BUY)] = open_vwap_
[sideToIndex(Side::SELL)] = 0;
unreal_pnl_ = 0;
}
如果在此执行后我们仍然有一个开放的position_
,那么我们可以通过将position_
的绝对值乘以当前执行价格与从position_
侧的open_vwap_
条目计算出的 VWAP 之间的差值来计算unreal_pnl_
值:
else {
if (position_ > 0)
unreal_pnl_ =
(client_response->price_ - open_vwap_
[sideToIndex(Side::BUY)] / std::abs
(position_)) *
std::abs(position_);
else
unreal_pnl_ =
(open_vwap_[sideToIndex(Side::SELL)] / std::
abs(position_) - client_response->price_) *
std::abs(position_);
}
最后,total_pnl_
只是real_pnl_
和unreal_pnl_
的总和,如前所述:
total_pnl_ = unreal_pnl_ + real_pnl_;
std::string time_str;
logger->log("%:% %() % % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str),
toString(), client_response->
toString().c_str());
}
我们需要添加到PositionInfo
中的最后一块功能是处理市场价格的变动以及更新任何开放头寸的未实现损益(PnL)。我们将在下一小节中探讨这个功能。
处理PositionInfo
中的订单簿变动
当市场更新导致我们构建的订单簿发生变化时,我们需要更新未实现和总盈亏(PnL)值。PositionInfo::updateBBO()
方法由 PositionKeeper
类在收到市场更新时调用,这会导致订单簿发生变化。我们提供与更新过的交易工具对应的 BBO
对象,在 updateBBO()
方法中。我们将此方法中提供的 bbo
参数保存在我们的 PositionInfo
结构体中的 bbo_
数据成员中。此方法仅在 position_
不为零且提供的 BBO 上的要价和卖价有效时才有意义。这是我们首先需要检查的事项:
auto updateBBO(const BBO *bbo, Logger *logger) noexcept {
std::string time_str;
bbo_ = bbo;
if (position_ && bbo->bid_price_ != Price_INVALID &&
bbo->ask_price_ != Price_INVALID) {
如果我们需要更新未实现 PnL,我们可以使用 BBO 价格的中值,我们可以计算并保存在 mid_price
变量中,如下所示:
const auto mid_price = (bbo->bid_price_ + bbo->
ask_price_) * 0.5;
之后,我们可以使用之前子节中看到的相同逻辑来更新 unreal_pnl_
,只是我们使用 mid_price
值而不是执行价格。让我们解释一下为什么即使我们没有额外的执行,我们也要更新未实现 PnL。假设我们有一个从假设价格为 100 的执行中获得的多头头寸。在这个时候,初始未实现 PnL 是 0。让我们还假设,在未来,市场价格(由我们的 mid_price
变量表示)上涨到 110。在这种情况下,我们的实现 PnL 没有改变,因为我们没有执行任何卖出订单。然而,我们的未实现 PnL 增加了,因为我们如果决定平仓多头头寸,我们会以大约等于 mid_price
的价格获得执行。这就是为什么我们在市场价格变化时更新未实现 PnL,即使没有执行额外的订单。此外,请注意,实现 PnL 捕获买卖执行对的 PnL,因此在这里不需要更新,因为没有额外的执行:
if (position_ > 0)
unreal_pnl_ =
(mid_price – open_vwap_
[sideToIndex(Side::BUY)] / std::
abs(position_)) *
std::abs(position_);
else
unreal_pnl_ =
(open_vwap_[sideToIndex(Side::SELL)] / std::
abs(position_) – mid_price) *
std::abs(position_);
最后,我们必须更新 total_pnl_
数据成员,并在它自上次更改以来发生变化时记录它:
const auto old_total_pnl = total_pnl_;
total_pnl_ = unreal_pnl_ + real_pnl_;
if (total_pnl_ != old_total_pnl)
logger->log("%:% %() % % %\n", __FILE__, __LINE__
, __FUNCTION__, Common::
getCurrentTimeStr(&time_str),
toString(), bbo_->toString());
}
}
这就完成了我们对 PositionInfo
结构体所需的所有功能。我们现在将讨论 PositionKeeper
类,我们将使用它来管理整个交易引擎中所有交易工具的头寸和 PnL。
设计 PositionKeeper
PositionKeeper
类管理交易引擎中所有交易工具的头寸和 PnL。PositionKeeper
类包含一个 std::array
的 PositionInfo
对象,并且足够大,可以容纳 ME_MAX_TICKERS
数量的对象:
class PositionKeeper {
private:
std::string time_str_;
Common::Logger *logger_ = nullptr;
std::array<PositionInfo, ME_MAX_TICKERS>
ticker_position_;
};
我们将添加一个获取并返回提供 TickerId
的 PositionInfo
实例的获取器方法 getPositionInfo()
:
auto getPositionInfo(TickerId ticker_id) const noexcept {
return &(ticker_position_.at(ticker_id));
}
我们还将添加一个简单的 toString()
方法,我们将在稍后用于日志记录:
auto toString() const {
double total_pnl = 0;
Qty total_vol = 0;
std::stringstream ss;
for(TickerId i = 0; i < ticker_position_.size(); ++i) {
ss << "TickerId:" << tickerIdToString(i) << " " <<
ticker_position_.at(i).toString() << "\n";
total_pnl += ticker_position_.at(i).total_pnl_;
total_vol += ticker_position_.at(i).volume_;
}
ss << "Total PnL:" << total_pnl << " Vol:" <<
total_vol << "\n";
return ss.str();
}
初始化此类对象很简单,我们将在下一节中讨论。
初始化 PositionKeeper
PositionKeeper
构造函数接受一个 Logger
对象,并用该参数初始化 logger_
数据成员,如下所示:
PositionKeeper(Common::Logger *logger)
: logger_(logger) {
}
接下来,我们将看到如何在 PositionKeeper
类中处理订单执行和 BBO
的变化,并将其转发到正确的 PositionInfo
对象。
在 PositionKeeper
中处理订单执行和市场更新
PositionKeeper::addFill()
方法处理订单执行,其实现很简单。它只是简单地调用正确的 PositionInfo::addFill()
方法在 TickerId
的 PositionInfo
对象上,如下所示:
auto addFill(const Exchange::MEClientResponse
*client_response) noexcept {
ticker_position_.at(client_response->
ticker_id_).addFill(client_response, logger_);
}
PositionKeeper::updateBBO()
方法处理由于市场更新和订单簿中相应的变化而导致的 BBO
变化。它还简单地调用正确的 PositionInfo::updateBBO()
方法在 TickerId
的 PositionInfo
对象上,如下所示:
auto updateBBO(TickerId ticker_id, const BBO *bbo)
noexcept {
ticker_position_.at(ticker_id).updateBBO(bbo,
logger_);
}
这就完成了我们 PositionKeeper
类中所需的所有设计和实现。在下一节中,我们将构建一个订单管理类,该类将被交易策略用于在更高层次上管理它们的订单。
发送和管理订单
在 第 章 设计我们的交易生态系统 中,我们讨论了交易系统订单管理组件(设计低延迟 C++交易算法框架 部分)的目的。在本节中,我们将实现一个 OrderManager
类来封装这个类内部的订单管理逻辑,从而使交易策略能够轻松管理它们的订单。在我们构建 OrderManager
类本身之前,我们需要定义一个基本构建块,称为 OMOrder
结构。
定义 OMOrder 结构及其相关类型
在本节的第一小节中,我们将定义一些将在 OrderManager
类及其子组件中使用的枚举和类型。本小节的所有源代码都在 GitHub 上的 Chapter9/trading/strategy/om_order.h
源文件中。
首先,我们必须提供 om_order.h
文件所需的 include
文件:
#pragma once
#include <array>
#include <sstream>
#include "common/types.h"
using namespace Common;
namespace Trading {
现在,我们必须声明一个 OMOrderState
枚举,它将用于跟踪订单管理器中策略订单(OMOrder
)的状态。这些状态代表 OMOrder
的状态,如下所述:
-
INVALID
状态表示无效的订单状态。 -
PENDING_NEW
状态表示由OrderManager
发送的新订单尚未被电子交易交易所接受。 -
当我们收到交易所的响应以表示接受时,订单从
PENDING_NEW
状态变为LIVE
状态。 -
与
PENDING_NEW
状态类似,PENDING_CANCEL
状态表示订单已被发送至交易所进行取消,但交易所尚未处理或尚未收到响应。 -
DEAD
状态表示不存在的订单——它可能尚未发送或已完全执行或成功取消:
enum class OMOrderState : int8_t {
INVALID = 0,
PENDING_NEW = 1,
LIVE = 2,
PENDING_CANCEL = 3,
DEAD = 4
};
我们还必须添加一个方法,将OMOrderState
枚举转换为字符串,用于日志记录,如下所示:
inline auto OMOrderStateToString(OMOrderState side) ->
std::string {
switch (side) {
case OMOrderState::PENDING_NEW:
return "PENDING_NEW";
case OMOrderState::LIVE:
return "LIVE";
case OMOrderState::PENDING_CANCEL:
return "PENDING_CANCEL";
case OMOrderState::DEAD:
return "DEAD";
case OMOrderState::INVALID:
return "INVALID";
}
return "UNKNOWN";
}
现在,我们可以定义OMOrder
结构,它具有以下关键字段:
-
一个
ticker_id_
变量,其类型为TickerId
,用于表示此订单是为哪个交易工具 -
一个
order_id_
变量,其类型为OrderId
,这是分配给此订单对象的唯一订单 ID -
一个
side_
变量,用于存储此订单的Side
属性 -
订单的
Price
存储在price_
数据成员中 -
此订单的实时或请求的
Qty
存储在qty_
变量中 -
一个
order_state_
变量,其类型为OMOrderState
,这是我们之前定义的,用于表示OMOrder
的当前状态:
struct OMOrder {
TickerId ticker_id_ = TickerId_INVALID;
OrderId order_id_ = OrderId_INVALID;
Side side_ = Side::INVALID;
Price price_ = Price_INVALID;
Qty qty_ = Qty_INVALID;
OMOrderState order_state_ = OMOrderState::INVALID;
我们还必须添加一个toString()
方法,将OMOrder
对象转换为字符串,用于日志记录:
auto toString() const {
std::stringstream ss;
ss << "OMOrder" << "["
<< "tid:" << tickerIdToString(ticker_id_) << " "
<< "oid:" << orderIdToString(order_id_) << " "
<< "side:" << sideToString(side_) << " "
<< "price:" << priceToString(price_) << " "
<< "qty:" << qtyToString(qty_) << " "
<< "state:" << OMOrderStateToString(order_state_)
<< "]";
return ss.str();
}
};
在这里,我们定义一个OMOrderSideHashMap
类型定义,表示OMOrder
对象的std::array
,并指示此数组的容量足够大,可以容纳买方和卖方的条目。OMOrderSideHashMap
类型的对象将通过sideToIndex(Side::BUY)
和sideToIndex(Side::SELL)
索引进行索引:
typedef std::array<OMOrder, sideToIndex(Side::MAX) + 1>
OMOrderSideHashMap;
我们还必须定义一个OMOrderTickerSideHashMap
,它只是另一个足够大的std::array
,可以容纳所有交易工具——即ME_MAX_TICKERS
大小:
typedef std::array<OMOrderSideHashMap, ME_MAX_TICKERS>
OMOrderTickerSideHashMap;
现在,我们可以构建订单管理器类,该类用于管理交易策略的OMOrder
对象。
设计 OrderManager 类
我们简化的订单管理器将代表交易策略管理OMOrder
对象。为了保持简单,我们的OrderManager
类将允许最多在买方和卖方各有一个订单。我们将在本节中查看此实现的详细信息。OrderManager
类的所有代码都可以在Chapter9/trading/strategy/order_manager.h
和Chapter9/trading/strategy/order_manager.cpp
源文件中找到。
定义 OrderManager 中的数据成员
我们需要定义属于我们OrderManager
类的数据成员。但在我们这样做之前,在下面的代码块中,我们提供了需要在order_manager.h
源文件中包含的头文件。我们还必须声明TradeEngine
类,因为我们将在本类中引用它,但想避免循环依赖问题:
#pragma once
#include "common/macros.h"
#include "common/logging.h"
#include "exchange/order_server/client_response.h"
#include "om_order.h"
#include "risk_manager.h"
using namespace Common;
namespace Trading {
class TradeEngine;
现在,我们可以在OrderManager
类中设计内部数据成员。关键成员如下:
-
一个
trade_engine_
变量。这是一个指向TradeEngine
对象的指针。我们将使用它来存储使用此订单管理器的父TradeEngine
实例。 -
存储在
risk_manager_
成员变量中的RiskManager
对象的常量引用。这将用于执行交易前风险检查——即在新订单发送到交易所之前执行的风险检查。 -
一个
ticker_side_order_
变量,其类型为OMOrderTickerSideHashMap
,用于保存每个交易工具的(一个买入和一个卖出)OMOrder
对象对。这将被用作一个哈希表,首先按我们想要发送订单的仪器的TickerId
值索引,然后按sideToIndex(Side::BUY)
或sideToIndex(Side::SELL)
值索引来管理买入或卖出订单。 -
从
1
开始的新的唯一订单 ID,我们将使用简单的next_order_id_
变量(OrderId
类型)来生成:
class OrderManager {
private:
TradeEngine *trade_engine_ = nullptr;
const RiskManager& risk_manager_;
std::string time_str_;
Common::Logger *logger_ = nullptr;
OMOrderTickerSideHashMap ticker_side_order_;
OrderId next_order_id_ = 1;
};
}
那就是OrderManager
类内部的所有数据。在下一小节中,我们将学习如何初始化这些成员以及OrderManager
类本身。
初始化 OrderManager
初始化OrderManager
很简单。除了在类定义本身中初始化的内容外,我们还必须初始化trade_engine_
、risk_manager_
和logger_
数据成员,我们期望它们通过构造函数参数传递:
OrderManager(Common::Logger *logger, TradeEngine
*trade_engine, RiskManager& risk_manager)
: trade_engine_(trade_engine),
risk_manager_(risk_manager), logger_(logger) {
}
如此所示,我们必须添加一个简单的便利函数,我们可以在我们的OrderManager
实现中使用它,称为getOMOrderSideHashMap()
。这个函数简单地返回提供的TickerId
的OMOrderSideHashMap
实例:
auto getOMOrderSideHashMap(TickerId ticker_id) const {
return &(ticker_side_order_.at(ticker_id));
}
接下来,我们可以继续到OrderManager
中的一个重要任务——发送新订单。
从 OrderManager 发送新订单
OrderManager::newOrder()
方法是我们订单管理类中的底层方法。它需要一个指向OMOrder
对象的指针,该对象将发送这个新订单。它还需要设置新发送的订单上的TickerId
、Price
、Side
和Qty
属性:
auto OrderManager::newOrder(OMOrder *order, TickerId
ticker_id, Price price, Side side, Qty qty) noexcept -> void {
它创建一个ClientRequestType::NEW
类型的MEClientRequest
结构,并填充通过参数传递的属性,将OrderId
设置为next_order_id_
,将ClientId
设置为TradeEngine
的客户 ID,这可以通过调用clientId()
方法获得。它还调用TradeEngine::sendClientRequest()
并提供它刚刚初始化的MEClientRequest
对象(new_request
):
const Exchange::MEClientRequest
new_request{Exchange::ClientRequestType::NEW,
trade_engine_->clientId(), ticker_id,
next_order_id_, side, price, qty};
trade_engine_->sendClientRequest(&new_request);
最后,它更新在方法参数中提供的OMOrder
对象指针,并将新发送的订单上设置的属性分配给它。请注意,此OMOrder
的状态被设置为OMOrderState::PENDING_NEW
,因为它将很快被发送出去,但只有在交易所接受它并且我们收到响应后才会生效。它还增加next_order_id_
变量以保持任何可能稍后发送的新订单的唯一性:
*order = {ticker_id, next_order_id_, side, price, qty,
OMOrderState::PENDING_NEW};
++next_order_id_;
logger_->log("%:% %() % Sent new order % for %\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
new_request.toString().c_str(), order->
toString().c_str());
}
我们将很快看到newOrder()
方法是从哪里被调用的,但在那之前,让我们看看取消订单的互补任务。
取消来自 OrderManager 的订单
OrderManager::cancelOrder()
是我们订单管理类中的底层方法,它将被用来向由OrderManager
管理的实时订单发送取消请求。它只接受一个参数,即将要发送取消请求的OMOrder
对象:
auto OrderManager::cancelOrder(OMOrder *order) noexcept
-> void {
与 newOrder()
方法类似,我们必须初始化一个 MEClientRequest
类型的 client_request
对象,并将其属性从传递到方法中的 OMOrder
对象中填充。它调用 TradeEngine::sendClientRequest()
方法来发送取消请求。需要理解的一点是,next_order_id_
成员变量仅用于为新出订单请求生成新的订单 ID。取消现有订单不会改变 next_order_id_
变量,如下面的代码块所示。在我们的设计中,next_order_id_
在每次我们发送 ClientRequestType::NEW
类型的 MEClientRequest
时都会顺序递增。理论上,我们可以在下一个新订单请求中重用刚刚取消的订单的 order_id_
值,但这需要我们跟踪空闲订单 ID,这也不是特别困难。这只是我们做出的一个设计选择,但如果你愿意,可以修改这个方案并跟踪空闲订单 ID:
const Exchange::MEClientRequest cancel_request
{Exchange::ClientRequestType::CANCEL, trade_engine_->
clientId(),
order->ticker_id_, order->order_id_, order->side_,
order->price_,
order->qty_};
trade_engine_->sendClientRequest(&cancel_request);
最后,我们必须更新 OMOrder
对象的 order_state_
值为 OMOrderState::PENDING_CANCEL
,以表示已发送取消请求:
order->order_state_ = OMOrderState::PENDING_CANCEL;
logger_->log("%:% %() % Sent cancel % for %\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
cancel_request.toString().c_str(), order->
toString().c_str());
}
之前我们提到,newOrder()
和 cancelOrder()
是 OrderManager
类中的低级方法。使用 OrderManager
的交易策略不会直接调用这些方法;相反,它们将通过调用 OrderManager::moveOrders()
方法让 OrderManager
来管理订单。我们将在下一小节中构建这个方法。
添加方法以简化订单管理
在我们构建 moveOrders()
方法之前,我们将构建一个更多用于 OrderManager
的低级方法。这个方法称为 moveOrder()
,它管理单个订单,根据提供的参数发送新订单或取消现有订单。这个方法最重要的参数是一个指向 OMOrder
对象的指针。它还接受 TickerId
、Price
、Side
和 Qty
参数。这个方法的目的确保传递给它的 OMOrder
对象被放置或替换为提供的 price
、side
和 qty
参数。这涉及到取消现有订单(如果它不是指定的价格)和/或使用指定的 price
和 qty
参数放置新订单的组合:
auto moveOrder(OMOrder *order, TickerId ticker_id,
Price price, Side side, Qty qty) noexcept {
这个方法决定采取的操作取决于传递给它的 OMOrder
对象的当前 order_state_
。我们将逐个通过不同的 OMOrderState
情况,从 OMOrderState::LIVE
开始。如果 OMOrder
对象已经是活跃的/有效的,它会检查 price
参数是否与订单的 price_
属性匹配。如果不是这种情况,它将调用 OrderManager::cancelOrder()
方法来取消这个订单,并在下一次迭代中替换它:
switch (order->order_state_) {
case OMOrderState::LIVE: {
if(order->price_ != price || order->qty_ != qty)
cancelOrder(order);
}
break;
对于订单处于INVALID
或DEAD
状态的情况,这意味着在市场上不活跃,我们将使用我们之前构建的OrderManager::newOrder()
方法来放置订单。但需要通过调用RiskManager::checkPreTradeRisk()
方法并与RiskManager
确认此操作是否允许,并将我们希望发送的订单的TickerId
、Side
和Qty
属性传递给它。此时,应该清楚为什么这被称为预交易风险——我们在执行操作/交易之前检查我们是否可以执行此操作。我们将很快讨论RiskManager
的设计和实现,以及checkPreTradeRisk()
方法。目前,你需要知道的是,如果风险检查通过,它返回RiskCheckResult::ALLOWED
枚举值,如果风险检查失败,则返回不同的值——也就是说,操作/交易是不允许的。在下面的代码块中,我们只有在checkPreTradeRisk()
方法返回RiskCheckResult::ALLOWED
时才通过调用newOrder()
方法发送订单。最后,这里我们使用riskCheckResultToString()
方法记录一个错误消息,如果风险检查失败。我们很快就会介绍这一点:
case OMOrderState::INVALID:
case OMOrderState::DEAD: {
if(LIKELY(price != Price_INVALID)) {
const auto risk_result = risk_manager_
.checkPreTradeRisk(ticker_id, side, qty);
if(LIKELY(risk_result == RiskCheckResult
::ALLOWED))
newOrder(order, ticker_id, price, side, qty);
else
logger_->log("%:% %() % Ticker:% Side:% Qty:%
RiskCheckResult:%\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_
str_),
tickerIdToString(ticker_id),
sideToString(side),
qtyToString(qty),
riskCheckResultToString
(risk_result));
}
}
break;
对于OMOrder
对象的order_state_
为PENDING_NEW
或PENDING_CANCEL
的情况,我们什么都不做,因为我们正在等待电子交易交换的响应,然后才能继续操作:
case OMOrderState::PENDING_NEW:
case OMOrderState::PENDING_CANCEL:
break;
}
}
现在,我们已经拥有了构建OrderManager::moveOrders()
方法所需的所有组件。这是交易策略用来生成和管理所需订单的主要方法。它接受一些参数——工具的TickerId
参数,购买订单的Price
参数的bid_price
,卖出订单的Price
参数的ask_price
,以及一个Qty
类型的clip
参数,这将代表购买和卖出订单的数量。我们将在定义 TradeEngineCfg 结构小节中看到这个clip
参数的来源,在计算和管理风险部分。目前,请注意,术语clip
来自枪械弹药的clip
术语,在我们的交易策略的上下文中,它表示我们的交易策略可以发送的每个订单的大小。我们将看到这个参数被用来设置新订单请求的大小。这只是我们选择的变量名称;它也可以是trade_size
、order_size
等等。
在这里需要注意的是,如果为 bid_price
或 ask_price
传递 Price_INVALID
的价格值,将会导致订单被取消——也就是说,订单将只在买方或卖方一侧存在,而不是在两侧都存在。这是因为 moveOrder()
方法会在 OMOrder
上的价格与传递给方法的价格不匹配时取消订单。并且因为任何在市场中的活跃 OMOrder
(OMOrderState::LIVE
) 都将有一个有效的价格,而不是 Price_INVALID
,这个检查将评估为真,从而导致订单被取消。还有一点需要注意,目前,我们支持买方和卖方订单的单个 clip
值,但很容易扩展以使买方订单和卖方订单有不同的数量。此方法的实现非常简单——通过使用 ticker_id
值索引 ticker_side_order_
容器,并使用 sideToIndex(Side::BUY)
值索引它来获取买方订单 (bid_order
)。然后,它在这个 bid_order
上调用 OrderManager::moveOrder()
方法,并传递 bid_price
参数作为价格,传递 clip
参数作为数量。对于卖方订单 (ask_order
),我们做同样的事情,除了使用 sideToIndex(Side::SELL)
和 ask_price
作为卖方侧:
auto moveOrders(TickerId ticker_id, Price bid_price,
Price ask_price, Qty clip) noexcept {
auto bid_order =
&(ticker_side_order_.at(ticker_id)
.at(sideToIndex(Side::BUY)));
moveOrder(bid_order, ticker_id, bid_price, Side::BUY,
clip);
auto ask_order = &(ticker_side_order_
.at(ticker_id).at(sideToIndex(Side::SELL)));
moveOrder(ask_order, ticker_id, ask_price, Side::
SELL, clip);
}
我们需要向我们的 OrderManager
类添加一个最终的功能,即处理传入的订单响应。我们将在下一个子节中处理这个问题。
处理订单更新和更新订单
在我们可以结束对 OrderManager
实现的讨论之前,我们需要添加一些代码来处理以 MEClientResponse
消息形式传入的订单响应。我们将在这里构建的 OrderManager::onOrderUpdate()
方法期望被调用并传递一个 MEClientResponse
对象:
auto onOrderUpdate(const Exchange::MEClientResponse
*client_response) noexcept -> void {
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
首先,我们必须获取这个 MEClientResponse
消息针对的 OMOrder
对象。我们可以通过使用 client_response
中的 ticker_id_
字段访问 ticker_side_order_
容器,并通过使用 sideToIndex()
方法将 client_response
消息中的 side_
字段转换为索引来实现这一点。这在上面的代码块中显示:
auto order = &(ticker_side_order_.at(client_response
->ticker_id_).at(sideToIndex(client_response
->side_)));
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
order->toString().c_str());
我们将更新之前获取的 OMOrder
对象,但这取决于我们收到的 MEClientResponse
类型。在 ClientResponseType::ACCEPTED
的情况下,我们只需要将此 OMOrder
对象的 order_state_
成员设置为 OMOrderState::LIVE
,以将其标记为已接受并在市场上活跃:
switch (client_response->type_) {
case Exchange::ClientResponseType::ACCEPTED: {
order->order_state_ = OMOrderState::LIVE;
}
break;
如果响应类型是 ClientResponseType::CANCELED
,那么我们只需将 OMOrder
的 order_state_
变量更新为 OMOrderState::DEAD
,因为它在市场上不再活跃:
case Exchange::ClientResponseType::CANCELED: {
order->order_state_ = OMOrderState::DEAD;
}
break;
如果MEClientResponse
是ClientResponseType::FILLED
类型,这是为了表示执行,我们将更新OMOrder
上的qty_
字段为新的leaves_qty_
。这反映了市场上仍然存在的实时数量。我们还需要检查如果qty_
字段(以及因此client_response
上的leaves_qty_
字段)为 0,意味着订单已完全执行,该订单在市场上不再活跃。如果是这样,我们必须将order_state_
设置为OMOrderState::DEAD
:
case Exchange::ClientResponseType::FILLED: {
order->qty_ = client_response->leaves_qty_;
if(!order->qty_)
order->order_state_ = OMOrderState::DEAD;
}
break;
我们忽略了CANCEL_REJECTED
和INVALID
的ClientResponseType
枚举值,因为没有任何需要采取的操作:
case Exchange::ClientResponseType::CANCEL_REJECTED:
case Exchange::ClientResponseType::INVALID: {
}
break;
}
}
这标志着我们对OrderManager
组件的讨论、设计和实现的结束。然而,我们在OrderManager
类的实现中引用并使用了RiskManager
,而没有讨论其所有细节。我们将在下一节中这样做。
计算和管理风险
在我们可以构建我们的交易策略之前,我们还需要构建的最终组件是RiskManager
。RiskManager
组件通过交易策略使用的相同OrderManager
实例跟踪交易策略在市场上的活跃订单数量。它还使用PositionKeeper
实例跟踪头寸和已实现和未实现的盈亏,该实例跟踪交易策略的头寸和盈亏。它检查策略是否保持在分配的风险限制内。如果交易策略超过了其风险限制,例如,如果它损失的钱超过了允许的金额,试图发送超过允许的订单,或者构建超过允许的仓位,它将阻止其交易。为了使我们的RiskManager
保持简单,我们将在客户端交易系统中对每个交易工具的最大允许订单大小、最大允许仓位和最大允许损失实现风险检查。我们的RiskManager
的源代码可以在Chapter9/trading/strategy/risk_manager.h
和Chapter9/trading/strategy/risk_manager.cpp
源文件中找到。首先,我们将声明一个枚举和一个RiskInfo
结构体。我们在第章设计我们的交易生态系统中的设计低延迟 C++交易算法框架部分讨论了该组件的详细信息。
定义 RiskCfg 结构体
首先,我们将定义一个包含风险配置的结构体。这被称为RiskCfg
结构体,并在Chapter9/common/types.h
头文件中定义。风险配置包含以下参数:
-
Qty
类型的max_order_size_
成员。它表示策略允许发送的最大允许订单大小。 -
Qty
类型的max_position_
成员变量。它表示策略允许构建的最大仓位。 -
double
类型的max_loss_
变量。这是在交易策略被关闭以进一步交易之前允许的最大损失。
我们还必须为结构体添加一个toString()
方法,以便进行日志记录:
struct RiskCfg {
Qty max_order_size_ = 0;
Qty max_position_ = 0;
double max_loss_ = 0;
auto toString() const {
std::stringstream ss;
ss << "RiskCfg{"
<< "max-order-size:" <<
qtyToString(max_order_size_) << " "
<< "max-position:" << qtyToString(max_position_)
<< " "
<< "max-loss:" << max_loss_
<< "}";
return ss.str();
}
};
在下一节中,我们将定义另一个配置结构。这个结构将用于配置 TradeEngine
。
定义 TradeEngineCfg 结构
首先,我们必须定义一个结构来封装 TradeEngine
配置。我们将称之为 TradeEngineCfg
。这是我们用作高级 TradeEngine
配置的内容,并在 Chapter9/common/types.h
头文件中定义。它有以下重要的数据成员:
-
Qty
类型的clip_
成员。这是交易策略将用作它们发送的订单数量的。 -
double
类型的threshold_
成员。这将由交易策略使用,并将用于特征值,以决定是否需要做出交易决策。 -
最后一个成员是一个
RiskCfg
类型的risk_cfg_
变量。我们之前定义了这个变量,以便它可以保存风险配置。
同样,我们还必须定义一个 toString()
方法,将这些对象转换为字符串以便进行日志记录。这里描述的所有代码都可以在以下代码块中看到:
struct TradeEngineCfg {
Qty clip_ = 0;
double threshold_ = 0;
RiskCfg risk_cfg_;
auto toString() const {
std::stringstream ss;
ss << "TradeEngineCfg{"
<< "clip:" << qtyToString(clip_) << " "
<< "thresh:" << threshold_ << " "
<< "risk:" << risk_cfg_.toString()
<< "}";
return ss.str();
}
};
我们在这里定义的 TradeEngineCfgHashMap
类型是一个 std::array
,包含这些 TradeEngineCfg
对象,并且足够大,可以容纳所有可能的 TickerId
值 (ME_MAX_TICKERS
):
typedef std::array<TradeEngineCfg, ME_MAX_TICKERS>
TradeEngineCfgHashMap;
现在,我们需要定义一个类型来表示风险检查的结果——RiskCheckResult
枚举。
声明 RiskCheckResult 枚举
首先,我们将正式声明之前遇到的 RiskCheckResult
枚举。但在我们这样做之前,让我们看看在 risk_manager.h
头文件中我们需要包含的文件。我们还需要提前声明之前构建的 OrderManager
类,这样我们就可以使用它而不会遇到循环头文件依赖问题:
#pragma once
#include "common/macros.h"
#include "common/logging.h"
#include "position_keeper.h"
#include "om_order.h"
using namespace Common;
namespace Trading {
class OrderManager;
RiskCheckResult
枚举用于封装 RiskManager
中风险检查结果的信息。让我们更详细地看看这些值:
-
INVALID
表示一个无效的哨兵值。 -
ORDER_TOO_LARGE
表示风险检查失败,因为我们试图发送的订单数量将超过允许的最大订单数量限制。 -
POSITION_TOO_LARGE
表示当前头寸加上我们试图发送的订单数量可能会使我们超过在RiskManager
中配置的最大头寸限制。 -
LOSS_TOO_LARGE
枚举表示风险检查失败的事实,因为交易策略的总损失(已实现损失加上未实现损失)超过了在RiskManager
中允许的值。 -
ALLOWED
枚举是一个表示所有风险检查都成功通过的值。如前所述,这是唯一允许交易策略向交易所发送额外订单的值:
enum class RiskCheckResult : int8_t {
INVALID = 0,
ORDER_TOO_LARGE = 1,
POSITION_TOO_LARGE = 2,
LOSS_TOO_LARGE = 3,
ALLOWED = 4
};
我们还将添加一个 riskCheckResultToString()
方法,将这些枚举转换为字符串,以便进行日志记录:
inline auto riskCheckResultToString(RiskCheckResult
result) {
switch (result) {
case RiskCheckResult::INVALID:
return "INVALID";
case RiskCheckResult::ORDER_TOO_LARGE:
return "ORDER_TOO_LARGE";
case RiskCheckResult::POSITION_TOO_LARGE:
return "POSITION_TOO_LARGE";
case RiskCheckResult::LOSS_TOO_LARGE:
return "LOSS_TOO_LARGE";
case RiskCheckResult::ALLOWED:
return "ALLOWED";
}
return "";
}
在下一节中,我们将定义基本的RiskInfo
结构体,它包含我们执行单个交易工具风险检查所需的信息。
定义 RiskInfo 结构
如前所述,RiskInfo
结构体包含执行单个交易工具风险检查所需的信息。RiskManager
类维护和管理一个RiskInfo
对象容器。RiskInfo
结构体需要以下重要的数据成员:
-
一个指向
PositionInfo
的const
指针position_info_
。这将用于获取交易工具的位置和 PnL 信息。 -
一个
RiskCfg
类型的对象risk_cfg_
,用于保存此工具配置的风险限制。这些限制将被检查:
struct RiskInfo {
const PositionInfo *position_info_ = nullptr;
RiskCfg risk_cfg_;
让我们为这个类添加一个toString()
方法,用于日志记录:
auto toString() const {
std::stringstream ss;
ss << "RiskInfo" << "["
<< "pos:" << position_info_->toString() << " "
<< risk_cfg_.toString()
<< "]";
return ss.str();
}
最后,我们必须定义一个TickerRiskInfoHashMap
类型,它是一个大小为ME_MAX_TICKERS
的RiskInfo
对象std::array
。我们将使用它作为TickerId
到RiskInfo
对象的哈希表:
typedef std::array<RiskInfo, ME_MAX_TICKERS>
TickerRiskInfoHashMap;
接下来,我们将查看checkPreTradeRisk()
方法的实现,该方法执行实际的风险检查。
在 RiskInfo 中执行风险检查
checkPreTradeRisk()
方法接受一个Side
参数和一个Qty
参数,并根据风险检查是否因某些原因通过或失败返回一个RiskCheckResult
枚举值:
auto checkPreTradeRisk(Side side, Qty qty) const
noexcept {
首先,它检查传递给方法的qty
参数是否大于RiskCfg
对象(risk_cfg_
)中的max_order_size_
成员。如果是这种情况,风险检查失败,并返回RiskCheckResult::ORDER_TOO_LARGE
枚举值:
if (UNLIKELY(qty > risk_cfg_.max_order_size_))
return RiskCheckResult::ORDER_TOO_LARGE;
然后,它检查当前position_
(它从position_info_
数据成员中获取),加上我们想要发送的额外qty
,是否超过了RiskCfg
对象(risk_cfg_
)中允许的最大max_position_
限制。注意,它在这里使用sideToValue(side)
方法来正确计算如果执行这个新的qty
,位置可能是什么,然后使用std::abs()
方法来正确地与max_position_
参数进行比较。在失败的情况下,它通过返回RiskCheckResult::POSITION_TOO_LARGE
方法来表示错误:
if (UNLIKELY(std::abs(position_info_->position_ +
sideToValue(side) * static_cast<int32_t>(qty)) >
static_cast<int32_t>(risk_cfg_.max_position_)))
return RiskCheckResult::POSITION_TOO_LARGE;
最后,它检查我们RiskManager
中的最后一个风险指标,即总损失。它将position_info_
中的total_pnl_
与risk_cfg_
配置中的max_loss_
参数进行比较。如果损失超过允许的最大损失,它将返回一个RiskCheckResult::LOSS_TOO_LARGE
枚举值:
if (UNLIKELY(position_info_->total_pnl_ <
risk_cfg_.max_loss_))
return RiskCheckResult::LOSS_TOO_LARGE;
最后,如果所有风险检查都成功通过,它将返回RiskCheckResult::ALLOWED
值:
return RiskCheckResult::ALLOWED;
}
这个重要的方法完成了RiskInfo
结构体的设计和实现。现在,我们可以开始构建RiskManager
类,该类被我们之前提到的其他组件使用。
设计 RiskManager 中的数据成员
现在,我们将设计我们的RiskManager
,首先定义构成这个类的数据成员。关键成员是一个ticker_risk_
变量,其类型为TickerRiskInfoHashMap
,并持有RiskInfo
对象。我们之前已经定义了它:
class RiskManager {
private:
std::string time_str_;
Common::Logger *logger_ = nullptr;
TickerRiskInfoHashMap ticker_risk_;
};
接下来,我们将学习如何初始化RiskManager
类。
初始化我们的RiskManager
类
RiskManager
构造函数期望一个Logger
对象,一个指向PositionKeeper
对象的指针,以及一个指向TradeEngineCfgHashMap
类型(ticker_cfg
)对象的引用,该对象持有风险配置。它初始化logger_
成员变量,并将PositionKeeper
中的PositionInfo
对象(getPositionInfo()
)和TradeEngineCfgHashMap
中的RiskCfg
对象(risk_cfg_
)存储在TickerRiskInfoHashMap
数据成员(ticker_risk_
)中:
RiskManager::RiskManager(Common::Logger *logger, const
PositionKeeper *position_keeper, const
TradeEngineCfgHashMap &ticker_cfg)
: logger_(logger) {
for (TickerId i = 0; i < ME_MAX_TICKERS; ++i) {
ticker_risk_.at(i).position_info_ = position_keeper
->getPositionInfo(i);
ticker_risk_.at(i).risk_cfg_ =
ticker_cfg[i].risk_cfg_;
}
}
接下来,我们将实现RiskManager
需要执行的最终任务——执行风险检查。
在RiskManager
中执行风险检查
给定一个工具的TickerId
,以及我们期望发送的订单的Side
和Qty
,在RiskManager
中对其进行风险检查是直接的。它只是获取与该工具对应的正确RiskInfo
对象,调用RiskInfo::checkPreTradeRisk()
方法,并返回该方法返回的值:
auto checkPreTradeRisk(TickerId ticker_id, Side side,
Qty qty) const noexcept {
return ticker_risk_.at(ticker_id)
.checkPreTradeRisk(side, qty);
}
这就完成了我们对RiskManager
组件的设计和实现,以及在我们开始组装它们并构建我们的交易策略之前所需的所有组件。我们将在下一章开始介绍这一点。
一个重要的注意事项是,在我们能够构建和运行一个有意义的交易客户端之前,我们需要构建本章中展示的所有组件,以及构建 C++市场做市和流动性获取算法章节中的所有组件。由于我们的生态系统由服务器(交易交易所)和客户端(交易客户端)基础设施组成,我们将在构建 C++市场做市和流动性获取算法章节,构建和运行主要交易应用程序部分之前等待,然后我们才能运行完整的生态系统。
摘要
在本章中,我们的主要关注点是增强市场参与者交易系统的智能和复杂性。首先,我们讨论了我们的市场做市和流动性获取交易策略。我们讨论了这些策略背后的动机,它们如何在市场中寻求利润,以及这些算法的交易动态。
我们实现了构成我们交易策略智能的重要组件。第一个是特征引擎,它用于从市场数据中计算交易特征/信号,以便它们可以被交易策略用来做出明智的交易决策。接下来是持仓管理器,它负责跟踪交易策略的持仓和盈亏,随着策略在市场中的订单被执行。然后,我们研究了订单管理器组件,它负责发送和管理市场中的实时订单,以简化交易策略的实施。风险管理器是我们考虑的最后一个,也可能是最重要的组件,因为它负责跟踪和调节交易算法目前所承担的风险,以及它试图承担的任何额外风险。
现在我们已经将所有重要组件集中在一起,在下一章中,我们将构建我们的市场做市策略,以在市场中提供被动流动性。然后,我们将构建流动性获取的交易算法,以发送积极的订单并在市场中发起和管理头寸。最后,我们将构建我们的交易引擎框架,它将容纳所有必要的组件,并构建和驱动我们构建的交易算法。通过这样做,我们将完成我们的电子交易生态系统。
第十章:构建 C++市场做市和流动性获取算法
在本章中,我们将在前几章构建的所有组件之上实现一个 C++市场做市算法。此市场做市算法将连接到我们之前构建的交易交易所并发送订单。此外,我们将在相同的交易引擎框架中实现一个 C++流动性获取算法。此流动性获取算法也将连接到交易交易所并发送订单。
本章将涵盖以下主题:
-
理解我们的交易算法的行为
-
管理订单簿中提供的被动流动性
-
主动开仓和平仓
-
构建交易引擎框架
-
构建和运行主要交易应用程序
技术要求
本书的所有代码都可以在 GitHub 仓库中找到,仓库地址为github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
。本章的源代码位于仓库中的Chapter10
目录。
重要的是,您已经阅读并理解了设计我们的交易生态系统章节中介绍的电子交易生态系统的设计,特别是设计低延迟 C++交易算法框架部分。还预期您对前两个章节——在 C++中处理市场数据和向交易所发送订单和构建 C++交易算法构建块相当熟悉,因为我们将在此章中使用我们在那两个章节中构建的每一个组件。
本书源代码开发环境的规格在此展示。我们提供此环境的详细信息,因为本书中展示的所有 C++代码并不一定可移植,可能需要在您的环境中进行一些小的修改才能工作:
-
OS:
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 GNU/Linux
-
GCC:
g++ (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
-
CMake:
cmake version 3.23.2
-
Ninja:
1.10.2
理解我们的交易算法的行为
在本节中,我们将讨论我们将在此章中构建的两个交易策略(市场做市交易策略和流动性获取交易策略)的行为和背后的动机的一些额外细节。通过为每个策略提供一个假设示例,我们还将尝试理解策略订单流机制,并在本章末尾将我们的 C++系统中的这些交易策略实现时,进一步加深我们的理解。
理解市场做市交易算法
做市交易策略旨在通过试图捕捉价差来获利,这仅仅意味着在市场上以最佳报价价格被动且快速地买入,并以最佳要价价格被动地卖出(或先卖出后买入)。做市策略的盈利能力取决于交易工具的价差、策略在一段时间内可以执行的买卖交易数量,以及买卖交易之间的市场价格变动幅度。应该清楚的是,做市策略只会与其他跨越价差并发出积极买卖订单的策略进行交易,这是我们所说的流动性获取交易策略。这意味着做市交易策略很少期望与其他做市交易策略进行交易,因为所有此类策略都寻求被动地执行其订单。为了实现这一点,做市交易策略会在订单簿中发送和管理被动限价订单,并试图使用智能来修改这些订单的价格,从而提高其执行效率和尽可能频繁地成功捕捉价差的可能性。在下一个小节中,我们将讨论一个假设的例子,说明做市交易策略将如何管理其订单。
用示例检查做市机制
在本小节中,我们将讨论在假设市场条件下我们的做市交易策略将如何表现。这将有助于加强你对做市算法行为方式的理解。在我们这样做之前,让我们先尝试理解以下表格。
该表格展示了市场订单簿的一种状态,称为价格水平聚合订单簿。这个术语的含义是,同一侧和同一价格的所有订单都被分组/聚合到一个单一的价格水平中,因此如果有 12 个订单在报价侧,价格都相同(10.21),总数量为 2,500,它们可以表示为一个单独的条目。这如下所示,以及类似地按下一个买入价格水平 10.20 和要价水平 10.22 和 10.23 的价格进行分组。
图 10.1 – 一个订单簿快照,按价格水平聚合
在前面的图表中,列的含义如下(从左到右):
-
我们的 MM 策略报价:这代表我们的做市(MM)策略在这个价格水平上的买入订单数量,在这种情况下是没有
-
市场报价订单数量:这代表组成这个价格水平的市场买入订单数量
-
市场报价数量:在这个价格水平上所有买入订单数量的总和
-
市场报价价:这代表了这个报价价位的报价价格
-
市场卖出价格:这代表这个卖出价格水平的价格
-
市场卖出数量:这个价格水平上所有卖出订单数量的总和
-
市场卖出订单数量:这代表构成这个价格水平的卖出订单的数量
-
我们的 MM 策略的卖出报价:这代表我们的 MM 策略在这个价格水平上的卖出订单数量,在这种情况下是没有
现在,让我们假设我们的 MM 策略在市场处于我们这里描述的状态时开始运行。我们也假设,对于这个例子,我们的策略将发送一个被动买入订单和一个被动卖出订单,每个订单的数量为 100 股。让我们说,策略决定以价格 10.21 和 10.22 分别加入最佳买入价格水平和最佳卖出价格水平。它是通过发送数量为 100 的单一买入订单和单一卖出订单来做到这一点的。以下图表表示这一事件,图中用金色突出显示的块表示由于这一行动而发生变化的事物。
图 10.2 – 当我们的 MM 订单在市场两边加入时的事件
最后,让我们考虑一个最后的场景,假设在 10.21 的最佳出价上的订单要么因为交易事件而完全执行并被移除,要么仅仅被拥有它们的市参与者取消。如果数量下降足够大,让我们假设我们的 MM 交易策略也决定不在该价格水平上存在。在策略决定将其最佳出价订单从当前价格水平移动到一价格水平之外的价格之前,价格聚合订单簿的状态如下,即从价格 10.21 到 10.20:
图 10.3 – 当我们的 MM 订单决定将其出价移动到一价格水平之外时价格水平簿的状态
这个决定可能是由广泛的因素造成的,这取决于策略及其特性。然而,对于这个例子,让我们提供一个简单的直观想法 – 与愿意以 10.22 卖出的人数(6,500 股)相比,较少的人愿意以 10.21 购买(总共只有 600 股)。你可能会得出结论,也许不再明智尝试在 10.21 购买,或者公平的市场价格可能是在 10.21,你希望尝试以略低于这个价格的价格购买。下一个图表显示了 MM 策略决定取消其在 10.21 的买入订单并将其买入订单重新定位到 10.20 时价格水平簿的状态。
图 10.4 – 当我们的 MM 策略将其出价从 10.21 的价格重新定位到 10.20 时的事件
本小节的讨论旨在提高你对简单 MM 策略机制的认知,在下一小节中,我们将继续探讨流动性获取交易算法。
理解流动性获取交易算法
在许多方面,流动性获取交易算法与 MM 算法相反。它不是向簿中发送被动订单并等待它们被动执行,而是在需要时发送积极订单以执行交易。从这一意义上说,它跨越了价差(发送积极订单执行),而不是像 MM 策略那样试图捕捉价差。这种策略押注于正确判断市场方向——也就是说,当它认为价格将进一步上涨时,它会积极买入;当它认为价格将进一步下跌时,它会积极卖出。关于这种交易算法的便利之处在于,由于它不需要始终在订单簿中保持需要管理的活跃订单,因此订单管理非常简单。另一种理解方式是,当策略需要执行交易时,它会向订单簿发送一个订单,几乎立即执行,然后从订单管理角度来看就完成了。关于这种交易算法的不便之处在于,预测市场方向极其困难,但我们将不会深入探讨这一点,因为这不是本书的重点。在下一小节中,我们将像对 MM 策略那样理解这种策略的交易机制。
通过示例检查流动性获取机制
再次,让我们看看 MM 部分讨论的价格水平聚合视图的订单簿,假设初始状态如图所示,这也是 MM 示例的相同初始状态。
图 10.5 – 对于一个假设示例,在给定时间点价格水平簿的状态
让我们假设,在这个例子中,我们的流动性获取策略具有一个特征,该特征试图跟随非常大的交易方向。这意味着,如果市场发生一个非常大的交易事件,我们的流动性获取算法将决定与这个交易事件采取相同的方向。因此,如果发生一个非常大的买入交易,我们的流动性获取算法将积极买入,如果发生一个非常大的卖出交易,我们的流动性获取算法将积极卖出。正如之前提到的,这只是一个示例特征;在实践中,不同的流动性获取算法将有许多这样的特征,这些特征将决定是否进行交易。对于我们的简单流动性获取算法示例,我们将使用市场中的大额积极交易这一特征。
为了理解这看起来是什么样子,让我们假设,在给定的价格水平簿记的先前状态下,一个 2,200 单位的非常大的卖出执行订单击中了 10.21 的买入价格水平,在此之前该价格水平的总数量为 2,500。这个事件在以下图表中显示,其中绿色箭头代表市场数据中的交易积极方。
图 10.6 – 一个大额卖出积极方引起交易事件的事件
这个交易事件将导致最佳买入数量从 2,500 减少到 300 – 即,通过交易积极方的数量。此外,让我们假设,我们的流动性获取策略观察到 2,200 单位的较大交易,并决定在 10.21 的价格发送一个积极卖出订单。让我们还假设,像 MM 策略一样,我们的流动性获取策略也发送了一个 100 单位的卖出订单。这个事件在以下图表中显示。
图 10.7 – 我们的流动性获取算法在价格为 10.21 时发送的 100 单位积极卖出订单的事件
这就结束了我们作为交易系统的一部分所寻求构建的两个交易策略的理论讨论。我们将在接下来的几节中了解它们在框架中的实际实现,但首先,我们需要为这些策略构建一些额外的构建块,这将在下一节中完成。
添加一个枚举来定义算法类型
我们将在Chapter10/common/types.h
头文件中定义一个AlgoType
枚举,它有以下有效值 – MAKER
代表 MM,TAKER
代表流动性获取,RANDOM
代表我们之前构建的随机交易策略。我们还有INVALID
和MAX
值:
enum class AlgoType : int8_t {
INVALID = 0,
RANDOM = 1,
MAKER = 2,
TAKER = 3,
MAX = 4
};
我们将添加一个标准的algoTypeToString()
方法,用于将AlgoType
类型转换为字符串,如下所示:
inline auto algoTypeToString(AlgoType type) -> std::string {
switch (type) {
case AlgoType::RANDOM:
return "RANDOM";
case AlgoType::MAKER:
return "MAKER";
case AlgoType::TAKER:
return "TAKER";
case AlgoType::INVALID:
return "INVALID";
case AlgoType::MAX:
return "MAX";
}
return "UNKNOWN";
}
我们将在下一个代码块中构建的 stringToAlgoType()
方法,它解析一个字符串并将其转换为 AlgoType
枚举值。它是通过遍历所有可能的 AlgoType
枚举值,并将字符串参数与对那个 AlgoType
枚举值调用 algoTypeToString()
的输出进行比较来完成的。如果字符串表示形式匹配,则返回 algo_type
枚举:
inline auto stringToAlgoType(const std::string &str) ->
AlgoType {
for (auto i = static_cast<int>(AlgoType::INVALID); i <=
static_cast<int>(AlgoType::MAX); ++i) {
const auto algo_type = static_cast<AlgoType>(i);
if (algoTypeToString(algo_type) == str)
return algo_type;
}
return AlgoType::INVALID;
}
接下来,我们将继续构建支持我们的交易策略所需的不同构建块。
管理订单簿中提供的被动流动性
到目前为止,我们已经拥有了开始构建我们的交易策略所需的所有子组件。我们将构建的第一个策略将是 MM 算法,该算法发送的订单预计将被动地停留在订单簿中。我们已经在本章前面讨论了这种交易算法的细节,因此在本节中,我们将专注于 C++ 实现。此 MarketMaker
交易算法的所有源代码都可以在 Chapter10/trading/strategy/market_maker.h
和 Chapter10/trading/strategy/market_maker.cpp
源文件中找到。
在 MarketMaker 算法中定义数据成员
首先,我们需要定义构成 MarketMaker
类的数据成员。关键成员如下:
-
一个名为
feature_engine_
的常量FeatureEngine
对象的指针,我们将使用它来获取公平市场价格,使用我们之前看到的FeatureEngine::getMktPrice()
方法。 -
一个指向名为
order_manager_
的OrderManager
对象的指针,该对象将用于管理此策略发送的被动订单 -
一个
ticker_cfg_
变量,其类型为常量TradeEngineCfgHashMap
,用于存储此算法将交易的多种交易工具的交易参数
让我们检查类定义,从 market_maker.h
头文件中需要的 include
文件开始:
#pragma once
#include "common/macros.h"
#include "common/logging.h"
#include "order_manager.h"
#include "feature_engine.h"
using namespace Common;
现在,在下一个代码块中,我们可以定义 MarketMaker
类和上述数据成员:
namespace Trading {
class MarketMaker {
private:
const FeatureEngine *feature_engine_ = nullptr;
OrderManager *order_manager_ = nullptr;
std::string time_str_;
Common::Logger *logger_ = nullptr;
const TradeEngineCfgHashMap ticker_cfg_;
};
}
下一节将定义构造函数以初始化此 MarketMaker
类的一个实例。
初始化 MarketMaker 算法
在 market_maker.cpp
文件中实现的构造函数将在下一个代码块中展示。构造函数在构造函数中接受一些参数:
-
一个
Logger
对象,它将被保存在logger_
成员变量中,并用于日志记录目的。 -
一个指向
TradeEngine
对象的指针,该对象将用于将父TradeEngine
实例中的algoOnOrderBookUpdate
、algoOnTradeUpdate
和algoOnOrderUpdate
回调绑定到MarketMaker
对象中的相应方法。这样,当TradeEngine
接收到回调时,MarketMaker
交易策略可以接收并处理这些回调。 -
一个指向常量
FeatureEngine
对象的指针,该对象将被存储在feature_engine_
数据成员中,并用于提取算法所需的特征值,如之前所述。 -
一个指向
OrderManager
对象的指针,该对象将用于管理此策略的订单,构造函数将简单地保存在order_manager_
数据成员中。 -
对一个常量
TradeEngineCfgHashMap
的引用,该常量将被保存在ticker_cfg_
成员中,并用于做出交易决策,因为它包含了交易参数:#include "market_maker.h"
#include "trade_engine.h"
namespace Trading {
MarketMaker::MarketMaker(Common::Logger *logger,
TradeEngine *trade_engine, const FeatureEngine
*feature_engine,
OrderManager *order_manager, const
TradeEngineCfgHashMap &ticker_cfg)
: feature_engine_(feature_engine),
order_manager_(order_manager),
logger_(logger),
ticker_cfg_(ticker_cfg) {
如前所述,并如图所示,我们将使用 lambda 方法覆盖 TradeEngine::algoOnOrderBookUpdate()
、TradeEngine::algoOnTradeUpdate()
和 TradeEngine::algoOnOrderUpdate()
方法,分别将它们转发到 MarketMaker::onOrderBookUpdate()
、MarketMaker::onTradeUpdate()
和 MarketMaker::onOrderUpdate()
方法:
trade_engine->algoOnOrderBookUpdate_ = this {
onOrderBookUpdate(ticker_id, price, side, book);
};
trade_engine->algoOnTradeUpdate_ = this {
onTradeUpdate(market_update, book); };
trade_engine->algoOnOrderUpdate_ = this { onOrderUpdate(client_response); };
}
}
下一个子节处理 MarketMaker
交易算法中最重要的任务——处理订单簿更新并对其做出反应发送订单。
处理订单簿更新和交易事件
MarketMaker::onOrderBookUpdate()
方法通过 TradeEngine::algoOnOrderBookUpdate_
std::function
成员变量被 TradeEngine
调用。这是 MarketMaker
交易策略根据其希望其买入和卖出订单的价格做出交易决策的地方:
auto onOrderBookUpdate(TickerId ticker_id, Price price,
Side side, const MarketOrderBook *book) noexcept -> void {
logger_->log("%:% %() % ticker:% price:% side:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
ticker_id, Common::
priceToString(price).c_str(),
Common::sideToString(side).c_str());
它使用 getBBO()
方法从 w
获取 BBO
并将其保存在 bbo
变量中。我们还获取市场数量加权的 BBO
价格并将其保存在 fair_price
变量中:
const auto bbo = book->getBBO();
const auto fair_price = feature_engine_->
getMktPrice();
我们对 bbo
和 fair_price
中的最佳 bid_price_
和 ask_price_
值进行合理性检查,以确保价格不是 Price_INVALID
且特征值不是 Feature_INVALID
。只有当这是 true
时,我们才会采取任何行动;否则,我们可能会在无效的特征上采取行动或以无效的价格发送订单:
if (LIKELY(bbo->bid_price_ != Price_INVALID && bbo->
ask_price_ != Price_INVALID && fair_price !=
Feature_INVALID)) {
logger_->log("%:% %() % % fair-price:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
bbo->toString().c_str(), fair_price);
我们从 ticker_cfg_
容器中获取并保存 clip
数量,这将是发送给交易所的被动订单的数量。我们还提取并保存 threshold
值,我们将使用它来决定发送买入和卖出订单的价格:
const auto clip = ticker_cfg_.at(ticker_id).clip_;
const auto threshold =
ticker_cfg_.at(ticker_id).threshold_;
我们初始化两个价格变量,bid_price
和 ask_price
,分别代表我们的买入和卖出订单的价格。如果从 FeatureEngine::getMktPrice()
方法计算出的 fair_price
与市场买入价格之间的差异超过 threshold
值,则将 bid_price
设置为最佳买入价格。否则,将 bid_price
设置为一个低于最佳市场买入价格的价格。我们使用相同的逻辑计算 ask_price
– 如果从公平价格到最佳卖出价格的差异超过阈值,则使用最佳卖出价格,否则使用更高的价格。背后的动机很简单;当我们认为公平价格高于最佳买入价格时,我们愿意以最佳买入价格买入,预期价格会上涨。当我们认为公平价格低于最佳卖出价格时,我们愿意以最佳卖出价格卖出,预期价格会下跌:
const auto bid_price = bbo->bid_price_ -
(fair_price - bbo->bid_price_ >= threshold ? 0 :
1);
const auto ask_price = bbo->ask_price_ + (bbo->
ask_price_ - fair_price >= threshold ? 0 : 1);
我们使用在前面的代码块 a 中计算的 bid_price
和 ask_price
变量,并将它们传递给 OrderManager::moveOrders()
方法,以将订单移动到所需的价格:
order_manager_->moveOrders(ticker_id, bid_price,
ask_price, clip);
}
}
当有交易事件时,MarketMaker
交易算法不做任何事情,只是记录它接收到的交易消息,如下所示:
auto onTradeUpdate(const Exchange::MEMarketUpdate
*market_update, MarketOrderBook * /* book */)
noexcept -> void {
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
}
我们还有一个任务来完成 MarketMaker
交易策略——处理其订单的更新,这将在下一小节中讨论。
处理 MarketMaker 算法中的订单更新
对于 MarketMaker
交易算法的订单更新处理很简单;它只是将 MEClientResponse
消息转发到它用于管理订单的 order_manager_
成员。这是通过调用我们之前实现的 OrderManager::onOrderUpdate()
方法来实现的:
auto onOrderUpdate(const Exchange::MEClientResponse
*client_response) noexcept -> void {
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
order_manager_->onOrderUpdate(client_response);
}
这就完成了我们对 MM 交易算法的实现。在下一节中,我们将处理我们将在这本书中构建的另一种类型的交易策略——一个流动性获取算法。
主动开仓和关仓
在本节中,我们将构建一个流动性获取算法,其行为在本章的第一节中已介绍。这种交易策略不会像 MM 算法那样发送被动订单;相反,它发送与订单簿中休息的流动性进行交易的主动订单。LiquidityTaker
算法的源代码位于 Chapter10/trading/strategy/liquidity_taker.h
和 Chapter10/trading/strategy/liquidity_taker.cpp
源文件中。首先,我们将在下一小节中定义构成 LiquidityTaker
类的数据成员。
定义 LiquidityTaker 算法的数据成员
LiquidityTaker
交易策略具有与我们在上一节中构建的 MarketMaker
算法相同的数据成员。在我们描述数据成员本身之前,我们将展示需要在 liquidity_taker.h
源文件中包含的头文件:
#pragma once
#include "common/macros.h"
#include "common/logging.h"
#include "order_manager.h"
#include "feature_engine.h"
using namespace Common;
现在,我们可以定义数据成员,它们与 MM 算法中相同。LiquidityTaker
类有一个 feature_engine_
成员,它是一个指向 FeatureEngine
对象的常量指针,一个指向 OrderManager
对象的 order_manager_
指针,以及一个常量 ticker_cfg_
成员,其类型为 TradeEngineCfgHashMap
。这些成员与在 MarketMaker
类中的用途相同;feature_engine_
用于提取主动交易与订单簿顶部数量的比率。order_manager_
对象用于发送和管理此交易策略的订单。最后,ticker_cfg_
对象持有此算法将用于做出交易决策并向交易所发送订单的交易参数:
namespace Trading {
class LiquidityTaker {
private:
const FeatureEngine *feature_engine_ = nullptr;
OrderManager *order_manager_ = nullptr;
std::string time_str_;
Common::Logger *logger_ = nullptr;
const TradeEngineCfgHashMap ticker_cfg_;
};
}
在下一节中,我们将看到如何初始化一个 LiquidityTaker
对象。
初始化 LiquidityTaker 交易算法
LiquidityTaker
类的初始化与MarketMaker
类的初始化相同。构造函数期望以下参数——一个Logger
对象,此算法运行的TradeEngine
对象,一个用于计算特征的FeatureEngine
对象,一个用于管理此交易策略订单的OrderManager
对象,以及包含此策略交易参数的TradeEngineCfgHashMap
对象:
#include "liquidity_taker.h"
#include "trade_engine.h"
namespace Trading {
LiquidityTaker::LiquidityTaker(Common::Logger *logger,
TradeEngine *trade_engine, FeatureEngine
*feature_engine,
OrderManager *order_manager,
const TradeEngineCfgHashMap &ticker_cfg):
feature_engine_(feature_engine),
order_manager_(order_manager), logger_(logger),
ticker_cfg_(ticker_cfg) {
此构造函数还覆盖了TradeEngine
对象中的回调,包括订单簿更新、交易事件以及算法订单(如MarketMaker
算法)的更新。TradeEngine
中的std::function
成员algoOnOrderBookUpdate_
、algoOnTradeUpdate_
和algoOnOrderUpdate_
分别通过 lambda 方法绑定到LiquidityTaker
内部的onOrderBookUpdate
、onTradeUpdate
和onOrderUpdate
方法,如下所示(以及我们之前看到的):
trade_engine->algoOnOrderBookUpdate_ = this {
onOrderBookUpdate(ticker_id, price, side, book);
};
trade_engine->algoOnTradeUpdate_ = this {
onTradeUpdate(market_update, book); };
trade_engine->algoOnOrderUpdate_ = this { onOrderUpdate(client_response); };
}
}
接下来,我们将讨论处理由于市场数据事件而导致的交易事件和订单簿更新的代码。
处理交易事件和订单簿更新
对于MarketMaker
交易策略,我们看到了它只在订单簿更新时做出交易决策,而在交易更新时则不采取任何行动。LiquidityTaker
策略则相反——它在onTradeUpdate()
方法中做出交易决策,而在onOrderBookUpdate()
方法中不采取任何行动。我们将在下一个代码块中首先查看LiquidityTaker::onTradeUpdate()
方法的实现:
auto onTradeUpdate(const Exchange::MEMarketUpdate
*market_update, MarketOrderBook *book) noexcept -> void {
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
我们将使用getBBO()
方法在bbo
局部变量中获取并保存BBO
。对于这个交易策略,我们将通过调用FeatureEngine::getAggTradeQtyRatio()
方法从特征引擎中获取侵略性交易数量比特征,并将其保存到agg_qty_ratio
变量中:
const auto bbo = book->getBBO();
const auto agg_qty_ratio = feature_engine_->
getAggTradeQtyRatio();
正如我们之前看到的,在我们决定采取订单操作之前,我们将检查bid_price_
、ask_price_
和agg_qty_ratio
是否为有效值:
if (LIKELY(bbo->bid_price_ != Price_INVALID && bbo->
ask_price_ != Price_INVALID && agg_qty_ratio !=
Feature_INVALID)) {
logger_->log("%:% %() % % agg-qty-ratio:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
bbo->toString().c_str(),
agg_qty_ratio);
如果有效性检查通过,我们首先需要从ticker_cfg_
对象中获取clip_
成员,用于这个交易消息的TickerId
,如下面的代码块所示,并将其保存到clip
局部变量中。同样,我们将从ticker_cfg_
配置对象中获取并保存该TickerId
的threshold_
成员:
const auto clip = ticker_cfg_.at(market_update->
ticker_id_).clip_;
const auto threshold = ticker_cfg_
.at(market_update->ticker_id_).threshold_;
为了决定我们是否为这个算法发送或调整活跃订单,我们将检查agg_qty_ratio
是否超过了我们之前获取的阈值:
if (agg_qty_ratio >= threshold) {
要使用OrderManager::moveOrders()
方法发送订单,我们将检查激进的交易是买入交易还是卖出交易。如果是买入交易,我们将发送一个激进的买入订单以在最佳BBO
ask_price_
处获取流动性,并指定一个Price_INVALID
的卖出价格,不发送任何卖出订单。相反,如果它是卖出交易,并且我们想要发送一个激进的卖出订单以获取流动性,我们将在BBO
对象中指定一个bid_price_
的卖出价格,并通过指定一个Price_INVALID
的买入价格不发送任何买入订单。请记住,这种交易策略通过激进地一次发送一个买入或卖出订单来在市场中确定方向,而不是像MarketMaker
算法那样同时发送两者:
if (market_update->side_ == Side::BUY)
order_manager_->moveOrders(market_update->
ticker_id_, bbo->ask_price_, Price_INVALID,
clip);
else
order_manager_->moveOrders(market_update->
ticker_id_, Price_INVALID, bbo->bid_price_,
clip);
}
}
}
如前所述,并在以下代码块中所示,此LiquidityTaker
交易策略在onOrderBookUpdate()
方法中不对订单更新采取任何行动:
auto onOrderBookUpdate(TickerId ticker_id, Price price,
Side side, MarketOrderBook *) noexcept -> void {
logger_->log("%:% %() % ticker:% price:% side:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
ticker_id, Common::
priceToString(price).c_str(),
Common::sideToString(side).c_str());
}
与LiquidityTaker
相关的下一个结论性部分增加了对策略订单的订单更新处理。
在LiquidityTaker
算法中处理订单更新
如以下代码块所示,LiquidityTaker::onOrderUpdate()
方法与MarketMaker::onOrderUpdate()
方法有相同的实现,只是简单地使用OrderManager::onOrderUpdate()
方法将订单更新转发给订单管理器。
auto onOrderUpdate(const Exchange::MEClientResponse
*client_response) noexcept -> void {
logger_->log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
order_manager_->onOrderUpdate(client_response);
}
这就完成了我们对LiquidityTaker
交易策略的实现。在下一节中,我们将讨论构建我们交易应用的最终形式,以便我们可以在我们的电子交易生态系统中构建和运行这些实际的交易策略。
构建交易引擎框架
在本节中,我们将构建TradeEngine
类中的交易引擎框架。这个框架将我们构建的所有不同组件连接在一起——包括OrderGateway
、MarketDataConsumer
、MarketOrderBook
、FeatureEngine
、PositionKeeper
、OrderManager
、RiskManager
、MarketMaker
和LiquidityTaker
组件。为了提醒交易引擎组件,我们在此展示所有子组件的图示。我们已经构建了所有子组件;现在,我们将构建这些子组件存在的交易引擎框架。
图 10.8 – 客户交易系统中的交易引擎组件
我们将像往常一样,从这个节开始定义我们类的数据成员。所有基本TradeEngine
框架的源代码都在Chapter10/trading/strategy/trade_engine.h
和Chapter10/trading/strategy/trade_engine.cpp
源文件中。
定义交易引擎的数据成员
在我们定义TradeEngine
类的数据成员之前,我们展示trade_engine.h
源文件需要包含的头文件:
#pragma once
#include <functional>
#include "common/thread_utils.h"
#include "common/time_utils.h"
#include "common/lf_queue.h"
#include "common/macros.h"
#include "common/logging.h"
#include "exchange/order_server/client_request.h"
#include "exchange/order_server/client_response.h"
#include "exchange/market_data/market_update.h"
#include "market_order_book.h"
#include "feature_engine.h"
#include "position_keeper.h"
#include "order_manager.h"
#include "risk_manager.h"
#include "market_maker.h"
#include "liquidity_taker.h"
TradeEngine
类需要以下基本数据成员:
-
它有一个类型为
ClientId
的client_id_
变量,用于表示唯一的交易应用程序实例 -
我们创建了一个类型为
MarketOrderBookHashMap
的ticker_order_book_
实例,提醒一下,这是一个MarketOrderBook
对象的std::array
,用于表示从TickerId
到MarketOrderBook
的哈希映射,针对该工具。 -
我们有三个无锁队列用于接收市场数据更新、发送订单请求和接收来自
MarketDataConsumer
和OrderGateway
组件的订单响应。我们使用incoming_md_updates_
变量接收市场数据更新,它是指向类型MEMarketUpdateLFQueue
(MEMarketUpdate
消息的LFQueue
)的指针。我们使用outgoing_ogw_requests_
变量发送客户端订单请求,它是指向类型ClientRequestLFQueue
(MEClientRequest
消息的LFQueue
)的指针。我们使用incoming_ogw_responses_
变量接收客户端订单响应,它是指向类型ClientResponseLFQueue
(MEClientResponse
消息的LFQueue
)的指针。 -
我们有通常的布尔变量
run_
,它将控制主TradeEngine
线程的执行,并标记为volatile
。 -
我们有一个类型为
Nanos
的last_event_time_
变量,用于跟踪收到交易所最后一条消息的时间 -
我们还将有一个名为
logger_
的Logger
变量,为TradeEngine
创建一个日志文件:
namespace Trading {
class TradeEngine {
private:
const ClientId client_id_;
MarketOrderBookHashMap ticker_order_book_;
Exchange::ClientRequestLFQueue *outgoing_ogw_requests_
= nullptr;
Exchange::ClientResponseLFQueue
*incoming_ogw_responses_ = nullptr;
Exchange::MEMarketUpdateLFQueue *incoming_md_updates_ =
nullptr;
Nanos last_event_time_ = 0;
volatile bool run_ = false;
std::string time_str_;
Logger logger_;
我们还需要上一章中每个组件的实例,具体如下:
-
一个类型为
FeatureEngine
的变量feature_engine_
,用于计算复杂特征值 -
一个类型为
PositionKeeper
的position_keeper_
变量,用于跟踪交易策略的头寸和盈亏(PnLs),即从我们的交易中赚取或亏损的资金 -
一个名为
order_manager_
的OrderManager
实例,它将被交易策略用于发送和管理实时订单 -
一个名为
risk_manager_
的RiskManager
对象,用于管理交易策略的风险 -
一个指向
MarketMaker
对象的指针mm_algo_
,如果我们将TradeEngine
配置为运行 MM 交易算法,它将被初始化 -
类似地,一个指向
LiquidityTaker
对象的指针taker_algo_
,如果我们将TradeEngine
配置为运行流动性获取交易策略,它将被初始化:
FeatureEngine feature_engine_;
PositionKeeper position_keeper_;
OrderManager order_manager_;
RiskManager risk_manager_;
MarketMaker *mm_algo_ = nullptr;
LiquidityTaker *taker_algo_ = nullptr;
我们还将添加三个 std::function
成员变量,TradeEngine
将使用这些变量将市场数据和订单更新转发到它实例化的交易策略。具体说明如下:
-
algoOnOrderBookUpdate_
std::function
与TradeEngine::onOrderBookUpdate()
方法的签名相同,并用于将订单簿更新转发到交易策略 -
algoOnTradeUpdate_
std::function
与TradeEngine::onTradeUpdate()
方法的签名相同,并用于将交易事件转发到交易策略 -
algoOnOrderUpdate_
std::function
与TradeEngine::onOrderUpdate()
方法的签名相同,并用于将订单更新/响应转发到交易策略:
std::function<void(TickerId ticker_id, Price price,
Side side, MarketOrderBook *book)>
algoOnOrderBookUpdate_;
std::function<void(const Exchange::MEMarketUpdate
*market_update, MarketOrderBook *book)>
algoOnTradeUpdate_;
std::function<void(const Exchange::MEClientResponse
*client_response)> algoOnOrderUpdate_;
为了默认初始化这三个 std::function
数据成员,我们将创建三个新方法,这些方法只是记录它们接收到的参数。它们在此处显示:
auto defaultAlgoOnOrderBookUpdate(TickerId ticker_id,
Price price, Side side, MarketOrderBook *) noexcept
-> void {
logger_.log("%:% %() % ticker:% price:% side:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
ticker_id, Common::
priceToString(price).c_str(),
Common::sideToString(side).c_str());
}
auto defaultAlgoOnTradeUpdate(const
Exchange::MEMarketUpdate *market_update,
MarketOrderBook *) noexcept -> void {
logger_.log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
}
auto defaultAlgoOnOrderUpdate(const
Exchange::MEClientResponse *client_response) noexcept
-> void {
logger_.log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
}
接下来,我们将讨论初始化 TradeEngine
类及其成员变量的某些方法的代码。
初始化交易引擎
TradeEngine
类的构造函数需要一个 ClientId
参数来标识客户端订单请求中使用的交易应用程序。它还需要指向三种类型的 LFQueue
的指针 – ClientRequestLFQueue
、ClientResponseLFQueue
和 MEMarketUpdateLFQueue
,分别用于初始化 outgoing_ogw_requests_
、incoming_ogw_responses_
和 incoming_md_updates_
数据成员。它还需要一个 algo_type
参数,其类型为 AlgoType
,用于指定交易策略的类型,以及一个 ticker_cfg
参数,其类型为对 const TradeEngineCfgHashMap
的引用,其中包含风险管理器和交易策略的配置参数。
构造函数还使用日志文件初始化 Logger logger_
成员变量,并为每个可能的 TickerId
值创建一个 MarketOrderBook
组件,将它们保存在 ticker_order_book_
容器中。它对每个 MarketOrderBook
组件调用 setTradeEngine()
方法,以便在 TradeEngine
中接收来自簿的回调。我们还初始化了对应于交易子组件的数据成员 – feature_engine_
、position_keeper_
、order_manager_
和 risk_manager_
:
TradeEngine::TradeEngine(Common::ClientId client_id,
AlgoType algo_type,
const TradeEngineCfgHashMap &ticker_cfg,
Exchange::ClientRequestLFQueue *client_requests,
Exchange::ClientResponseLFQueue *client_responses,
Exchange::MEMarketUpdateLFQueue *market_updates)
: client_id_(client_id),
outgoing_ogw_requests_(client_requests),
incoming_ogw_responses_(client_responses),
incoming_md_updates_(market_updates),
logger_("trading_engine_" + std::
to_string(client_id) + ".log"),
feature_engine_(&logger_),
position_keeper_(&logger_),
order_manager_(&logger_, this, risk_manager_),
risk_manager_(&logger_, &position_keeper_,
ticker_cfg) {
for (size_t i = 0; i < ticker_order_book_.size(); ++i) {
ticker_order_book_[i] = new MarketOrderBook(i, &logger_);
ticker_order_book_[i]->setTradeEngine(this);
}
在构造函数的主体中,除了我们之前创建的订单簿之外,我们还将我们的新 std::function
成员 – algoOnOrderBookUpdate_
、algoOnTradeUpdate_
和 algoOnOrderUpdate_
– 初始化为默认值 – defaultAlgoOnOrderBookUpdate()
、defaultAlgoOnTradeUpdate()
和 defaultAlgoOnOrderUpdate()
方法:
algoOnOrderBookUpdate_ = this {
defaultAlgoOnOrderBookUpdate(ticker_id, price, side,
book);
};
algoOnTradeUpdate_ = this { defaultAlgoOnTradeUpdate(market_update,
book); };
algoOnOrderUpdate_ = this {
defaultAlgoOnOrderUpdate(client_response); };
最后,我们将初始化一个交易策略实例,它可以是类型为 MarketMaker
的 mm_algo_
或类型为 LiquidityTaker
的 taker_algo_
交易策略。此初始化过程如下所示;请记住,MarketMaker
或 LiquidityTaker
对象将更新/覆盖成员变量 – algoOnOrderBookUpdate_
、algoOnTradeUpdate_
和 algoOnOrderUpdate_
– 以指向它们自己的方法实现:
if (algo_type == AlgoType::MAKER) {
mm_algo_ = new MarketMaker(&logger_, this,
&feature_engine_, &order_manager_, ticker_cfg);
} else if (algo_type == AlgoType::TAKER) {
taker_algo_ = new LiquidityTaker(&logger_, this,
&feature_engine_, &order_manager_, ticker_cfg);
}
for (TickerId i = 0; i < ticker_cfg.size(); ++i) {
logger_.log("%:% %() % Initialized % Ticker:% %.\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
algoTypeToString(algo_type), i,
ticker_cfg.at(i).toString());
}
}
我们有一个 start()
方法,正如我们在其他组件中看到的那样。再次强调,它将 run_
标志设置为 true
以允许 run()
方法执行,并创建并启动一个线程来执行 run()
方法:
auto start() -> void {
run_ = true;
ASSERT(Common::createAndStartThread(-1,
"Trading/TradeEngine", [this] { run(); }) !=
nullptr, "Failed to start TradeEngine thread.");
}
析构函数对变量进行了一些简单的反初始化。首先,它将 run_
标志设置为 false
,稍作等待以让主线程退出,然后继续删除每个 MarketOrderBook
实例,清空 ticker_order_book_
容器,并最终重置它持有的 LFQueue
指针。它还删除了对应于交易策略的 mm_algo_
和 taker_algo_
成员:
TradeEngine::~TradeEngine() {
run_ = false;
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(1s);
delete mm_algo_; mm_algo_ = nullptr;
delete taker_algo_; taker_algo_ = nullptr;
for (auto &order_book: ticker_order_book_) {
delete order_book;
order_book = nullptr;
}
outgoing_ogw_requests_ = nullptr;
incoming_ogw_responses_ = nullptr;
incoming_md_updates_ = nullptr;
}
这个类熟悉的 stop()
方法首先等待直到所有来自 incoming_ogw_responses_
和 incoming_md_updates_
LFQueue
对象的 MEClientResponse
和 MEMarketUpdate
消息都被清空。然后,它将 run_
标志重置以停止主 run()
线程并从函数返回:
auto stop() -> void {
while(incoming_ogw_responses_->size() ||
incoming_md_updates_->size()) {
logger_.log("%:% %() % Sleeping till all updates
are consumed ogw-size:% md-size:%\n", __FILE__,
__LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
incoming_ogw_responses_->size(),
incoming_md_updates_->size());
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(10ms);
}
logger_.log("%:% %() % POSITIONS\n%\n", __FILE__,
__LINE__, __FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
position_keeper_.toString());
run_ = false;
}
我们将添加到这个基本框架中的下一个方法旨在用于向交易所发送 MEClientRequest
消息。
发送客户端请求
交易引擎框架中的 sendClientRequest()
方法非常简单。它接收一个 MEClientRequest
对象,并将其简单地写入 outgoing_ogw_requests_
无锁队列,以便 OrderGateway
组件可以取走并发送到交易交易所:
auto TradeEngine::sendClientRequest(const
Exchange::MEClientRequest *client_request) noexcept ->
void {
logger_.log("%:% %() % Sending %\n", __FILE__,
__LINE__, __FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_request->toString().c_str());
auto next_write = outgoing_ogw_requests_->
getNextToWriteTo();
*next_write = std::move(*client_request);
outgoing_ogw_requests_->updateWriteIndex();
}
下一个子节将展示主要的 run()
循环,并展示我们如何处理来自交易所的传入数据。
处理市场数据更新和客户端响应
TradeEngine
的主线程执行 run()
方法,该方法简单地检查传入的数据 LFQueue
并读取和处理任何可用的更新。
首先,我们检查并清空 incoming_ogw_responses_
队列。对于我们在其中读取的每个 MEClientResponse
消息,我们调用 TradeEngine::onOrderUpdate()
方法,并将来自 OrderGateway
的响应消息传递给它:
auto TradeEngine::run() noexcept -> void {
logger_.log("%:% %() %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_));
while (run_) {
for (auto client_response = incoming_ogw_responses_->
getNextToRead(); client_response; client_response =
incoming_ogw_responses_->getNextToRead()) {
logger_.log("%:% %() % Processing %\n", __FILE__,
__LINE__, __FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
onOrderUpdate(client_response);
incoming_ogw_responses_->updateReadIndex();
last_event_time_ = Common::getCurrentNanos();
}
我们使用 incoming_md_updates_
无锁队列执行类似任务。我们读取任何可用的 MEMarketUpdate
消息,并通过调用 MarketOrderBook::onMarketUpdate()
方法并将市场更新传递给它,将它们传递给正确的 MarketOrderBook
实例:
for (auto market_update = incoming_md_updates_->
getNextToRead(); market_update; market_update =
incoming_md_updates_->getNextToRead()) {
logger_.log("%:% %() % Processing %\n", __FILE__,
__LINE__, __FUNCTION__, Common::
getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
ASSERT(market_update->ticker_id_ <
ticker_order_book_.size(),
"Unknown ticker-id on update:" +
market_update->toString());
ticker_order_book_[market_update->ticker_id_]->
onMarketUpdate(market_update);
incoming_md_updates_->updateReadIndex();
last_event_time_ = Common::getCurrentNanos();
}
}
}
注意,在前面两个代码块中,当我们成功读取和分发市场数据更新或订单响应时,我们会更新 last_event_time_
变量以跟踪事件的时间,正如我们在本节前面所描述的。在下一个子节中,我们将看到一些小的杂项占位符方法。
处理订单簿、交易和订单响应更新
TradeEngine::onOrderBookUpdate()
方法执行几个任务。首先,它从 MarketOrderBook
获取 BBO
,它通过调用 MarketOrderBook::getBBO()
方法在方法参数中接收。它将更新的 BBO
提供给 position_keeper_
和 feature_engine_
数据成员。对于 FeatureEngine
成员,它调用 FeatureEngine::onOrderBookUpdate()
方法来通知特征引擎更新其特征值。该方法还需要调用 algoOnOrderBookUpdate_()
以便交易策略可以接收关于订单簿更新的通知:
auto TradeEngine::onOrderBookUpdate(TickerId ticker_id,
Price price, Side side, MarketOrderBook *book) noexcept
-> void {
logger_.log("%:% %() % ticker:% price:% side:%\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
ticker_id, Common::priceToString
(price).c_str(),
Common::sideToString(side).c_str());
const auto bbo = book->getBBO();
position_keeper_.updateBBO(ticker_id, bbo);
feature_engine_.onOrderBookUpdate(ticker_id, price,
side, book);
algoOnOrderBookUpdate_(ticker_id, price, side, book);
}
在交易事件上被调用的 TradeEngine::onTradeUpdate()
方法也执行几个任务,这些任务类似于我们刚才看到的 onOrderBookUpdate()
方法中的任务。它通过调用 onTradeUpdate()
方法将交易事件传递给 FeatureEngine
,以便特征引擎可以更新其计算的特征值。它还通过调用 algoOnTradeUpdate_()
std::function
成员将交易事件传递给交易策略:
auto TradeEngine::onTradeUpdate(const
Exchange::MEMarketUpdate *market_update,
MarketOrderBook *book) noexcept -> void {
logger_.log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_),
market_update->toString().c_str());
feature_engine_.onTradeUpdate(market_update, book);
algoOnTradeUpdate_(market_update, book);
}
最后,TradeEngine::onOrderUpdate()
执行两件事。它检查 MEClientResponse
是否对应于执行(ClientResponseType::FILLED
),并调用 PositionKeeper::addFill()
方法来更新头寸和 PnLs。它还调用 algoOnOrderUpdate_()
std::function
成员函数,以便交易策略可以处理 MEClientResponse
:
auto TradeEngine::onOrderUpdate(const
Exchange::MEClientResponse *client_response) noexcept
-> void {
logger_.log("%:% %() % %\n", __FILE__, __LINE__,
__FUNCTION__, Common::getCurrentTimeStr(&time_str_),
client_response->toString().c_str());
if (UNLIKELY(client_response->type_ ==
Exchange::ClientResponseType::FILLED))
position_keeper_.addFill(client_response);
algoOnOrderUpdate_(client_response);
}
现在,我们可以通过定义我们所需的杂项方法来总结下一小节中 TradeEngine
框架的设计和实现。
添加一些杂项方法
本节定义了 TradeEngine
类的一些杂项方法。第一个方法 initLastEventTime()
简单地将 last_event_time_
变量初始化为当前时间,该时间通过调用 getCurrentNanos()
方法获得:
auto initLastEventTime() {
last_event_time_ = Common::getCurrentNanos();
}
silentSeconds()
方法返回自上次收到事件以来经过的时间(以秒为单位):
auto silentSeconds() {
return (Common::getCurrentNanos() - last_event_time_)
/ NANOS_TO_SECS;
}
clientId()
方法是一个简单的获取器方法,它返回此 TradeEngine
实例的 client_id_
:
auto clientId() const {
return client_id_;
}
这就完成了我们交易引擎框架的设计和实现。在下一节中,我们将构建主要交易应用程序的二进制文件。
构建和运行主要交易应用程序
在本章的最后部分,我们将最终使用本章以及前两章中构建的所有组件来构建主要交易应用程序。首先,我们将讨论 trading_main
二进制应用程序的实现,该应用程序结合了 MarketDataConsumer
、OrderGateway
、MarketOrderBook
和 TradeEngine
组件。之后,我们将运行我们的完整电子交易生态系统——电子交易交易所(Communicating with Market Participants 章节中的 exchange_main
应用程序)以及一些市场参与者实例(我们将构建的 trading_main
应用程序)。
构建主要交易应用程序
现在,让我们构建可执行的 trading_main
二进制文件,该文件将在市场参与者的交易系统中初始化并运行所有组件。此应用程序的源代码位于 Chapter10/trading/trading_main.cpp
源文件中。
首先,我们将包含必要的头文件并创建一些基本变量来表示我们需要的不同组件。具体来说,我们将有一个 Logger
对象指针用于日志记录,一个 TradeEngine
对象指针用于基本交易引擎框架,一个 MarketDataConsumer
对象指针用于消费市场数据,以及一个 OrderGateway
对象指针用于连接并与交易所的订单服务器通信:
#include <csignal>
#include "strategy/trade_engine.h"
#include "order_gw/order_gateway.h"
#include "market_data/market_data_consumer.h"
#include "common/logging.h"
Common::Logger *logger = nullptr;
Trading::TradeEngine *trade_engine = nullptr;
Trading::MarketDataConsumer *market_data_consumer = nullptr;
Trading::OrderGateway *order_gateway = nullptr;
现在,我们开始入口点——main()
方法。在命令行中,我们将接受以下形式的参数——trading_main CLIENT_ID ALGO_TYPE [CLIP_1 THRESH_1 MAX_ORDER_SIZE_1 MAX_POS_1 MAX_LOSS_1] [CLIP_2 THRESH_2 MAX_ORDER_SIZE_2 MAX_POS_2 MAX_LOSS_2] …
第一个参数代表此交易应用实例的ClientId
。我们还将接受AlgoType
作为第二个参数,以及每个TickerId
对应的每个交易算法实例的配置作为剩余参数。我们将通过调用srand()
方法并传递client_id
来为这个特定实例生成随机数:
int main(int argc, char **argv) {
const Common::ClientId client_id = atoi(argv[1]);
srand(client_id);
我们将提取AlgoType
,如下所示:
const auto algo_type = stringToAlgoType(argv[2]);
我们还将从剩余的命令行参数中初始化一个类型为TradeEngineCfgHashMap
的对象,如下面的代码块所示:
TradeEngineCfgHashMap ticker_cfg;
size_t next_ticker_id = 0;
for (int i = 3; i < argc; i += 5, ++next_ticker_id) {
ticker_cfg.at(next_ticker_id) =
{static_cast<Qty>(std::atoi(argv[i])),
std::atof(argv[i + 1]),
{static_cast<Qty>(std:
:atoi(argv[i + 2])),
static_cast<Qty>(std:
:atoi(argv[i + 3])),
std::atof(argv[i +
4])}};
}
我们将初始化我们之前声明的组件变量——Logger
、client_requests
LFQueue
、client_responses
LFQueue
和market_updates
LFQueue
。我们还将定义一个sleep_time
变量并将其设置为 20 微秒。我们将在发送给交易交换的OrderGatewayServer
组件的连续订单请求之间使用这个值进行暂停,仅在随机交易策略中:
logger = new Common::Logger("trading_main_" +
std::to_string(client_id) + ".log");
const int sleep_time = 20 * 1000;
Exchange::ClientRequestLFQueue
client_requests(ME_MAX_CLIENT_UPDATES);
Exchange::ClientResponseLFQueue
client_responses(ME_MAX_CLIENT_UPDATES);
Exchange::MEMarketUpdateLFQueue
market_updates(ME_MAX_MARKET_UPDATES);
std::string time_str;
我们将首先初始化并启动TradeEngine
组件。我们将传递client_id
、algo_type
、ticker_cfg
对象中的策略配置以及TradeEngine
构造函数所需的锁-free 队列。然后我们调用start()
方法,让主线程开始执行,如下面的代码块所示:
logger->log("%:% %() % Starting Trade Engine...\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str));
trade_engine = new Trading::TradeEngine(client_id,
algo_type,ticker_cfg,&client_requests,
&client_responses,&market_updates);
trade_engine->start();
我们接下来通过传递exchange_main
的OrderGateway
服务器组件的 IP 和端口信息来对OrderGateway
组件执行类似的初始化。我们还传递client_requests
和client_responses
LFQueue
变量,以便从其中消费MEClientRequest
消息并将MEClientResponse
消息写入,然后我们在主线程上使用start()
:
const std::string order_gw_ip = "127.0.0.1";
const std::string order_gw_iface = "lo";
const int order_gw_port = 12345;
logger->log("%:% %() % Starting Order Gateway...\n",
__FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str));
order_gateway = new Trading::OrderGateway(client_id,
&client_requests, &client_responses, order_gw_ip,
order_gw_iface, order_gw_port);
order_gateway->start();
最后,我们初始化并启动MarketDataConsumer
组件。它需要快照流和增量流的 IP 和端口信息,这些流是交易所的MarketDataPublisher
发布市场数据的地方。它还需要market_updates
LFQueue
变量,它将写入解码后的市场数据更新。最后,由于所有组件都已准备就绪,我们将启动market_data_consumer
以便我们可以处理任何可用的市场数据更新:
const std::string mkt_data_iface = "lo";
const std::string snapshot_ip = "233.252.14.1";
const int snapshot_port = 20000;
const std::string incremental_ip = "233.252.14.3";
const int incremental_port = 20001;
logger->log("%:% %() % Starting Market Data
Consumer...\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str));
market_data_consumer = new
Trading::MarketDataConsumer(client_id, &market_updates,
mkt_data_iface, snapshot_ip, snapshot_port,
incremental_ip, incremental_port);
market_data_consumer->start();
现在,我们几乎准备好向交易所发送订单了;我们首先需要完成几个更小的任务。首先,main()
应用程序将短暂休眠,以便我们刚刚在各个组件中创建并启动的线程可以运行几秒钟:
usleep(10 * 1000 * 1000);
我们还将通过调用TradeEngine::initLastEventTime()
方法初始化TradeEngine
中的第一个事件时间。我们故意延迟了这个成员的初始化,直到我们准备好开始交易:
trade_engine->initLastEventTime();
如果AlgoType
是AlgoType::RANDOM
,我们将在这里实现交易逻辑,因为它非常简单。首先,我们将检查algo_type
变量,并根据algo_type
参数指定的随机交易策略进行分支:
if (algo_type == AlgoType::RANDOM) {
对于这个随机交易算法,我们将创建一个独特的起始 OrderId
值,该值仅适用于此交易应用程序的实例,使用我们从命令行参数接收的 client_id
:
Common::OrderId order_id = client_id * 1000;
由于在我们的当前测试设置中,我们使用随机价格、数量和方向发送订单,因此我们将为每个工具初始化一个随机参考价格,我们将在这个参考价格值周围随机发送订单。我们这样做纯粹是为了让不同的交易工具具有略微不同且随机的价格订单。每个工具的随机参考价格存储在 ticker_base_price
变量中。我们还将创建 std::vector
的 MEClientRequest
消息来存储我们发送给交易所的订单请求。我们还将为其中一些订单发送取消请求以练习该功能;因此,我们将它们保存起来以备取消时使用:
std::vector<Exchange::MEClientRequest>
client_requests_vec;
std::array<Price, ME_MAX_TICKERS> ticker_base_price;
for(size_t i = 0; i < ME_MAX_TICKERS; ++i)
ticker_base_price[i] = (rand() % 100) + 100;
现在,我们可以开始向交易所发送一些订单,但在开始之前,我们将初始化 TradeEngine
的 last_event_time_
变量:
trade_engine->initLastEventTime();
在下面的循环中,该循环执行 10,000 次,我们将执行以下任务。
我们将随机选择一个 TickerId
,生成一个接近该工具的 ticker_base_price
参考价格值的随机 Price
,生成一个随机 Qty
,并为即将发送的订单生成一个随机 Side
:
for (size_t i = 0; i < 10000; ++i) {
const Common::TickerId ticker_id = rand() %
Common::ME_MAX_TICKERS;
const Price price = ticker_base_price[ticker_id] +
(rand() % 10) + 1;
const Qty qty = 1 + (rand() % 100) + 1;
const Side side = (rand() % 2 ? Common::Side::BUY :
Common::Side::SELL);
我们将创建一个类型为 ClientRequestType::NEW
的 MEClientRequest
消息,并带有这些属性,然后通过 sendClientRequest()
方法调用将其传递给 TradeEngine
。发送订单请求后,我们将暂停 sleep_time
(20 微秒),并且我们还将把刚刚发送的 MEClientRequest
消息保存在 client_requests_vec
容器中:
Exchange::MEClientRequest
new_request{Exchange::ClientRequestType::NEW,
client_id, ticker_id, order_id++, side, price,
qty};
trade_engine->sendClientRequest(&new_request);
usleep(sleep_time);
client_requests_vec.push_back(new_request);
暂停之后,我们从客户请求容器中随机选择一个我们发送的客户请求。我们将请求类型更改为 ClientRequestType::CANCEL
并将其发送到 TradeEngine
。然后,我们再次暂停并继续循环迭代:
const auto cxl_index = rand() %
client_requests_vec.size();
auto cxl_request = client_requests_vec[cxl_index];
cxl_request.type_ =
Exchange::ClientRequestType::CANCEL;
trade_engine->sendClientRequest(&cxl_request);
usleep(sleep_time);
}
}
在发送完所有订单流后,我们等待直到我们遇到一个 60 秒的周期,在此期间 TradeEngine
没有收到任何市场更新和订单响应。这是一种简单的方法来检测由于此客户端或任何其他交易客户端连接到交易所而没有市场活动的情况:
while (trade_engine->silentSeconds() < 60) {
logger->log("%:% %() % Waiting till no activity, been
silent for % seconds...\n", __FILE__, __LINE__,
__FUNCTION__,
Common::getCurrentTimeStr(&time_str),
trade_engine->silentSeconds());
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(10s);
}
在一段不活跃期后,此应用程序退出。我们首先停止每个组件并暂停一段时间,然后反初始化并退出应用程序:
trade_engine->stop();
market_data_consumer->stop();
order_gateway->stop();
using namespace std::literals::chrono_literals;
std::this_thread::sleep_for(10s);
delete logger;
logger = nullptr;
delete trade_engine;
trade_engine = nullptr;
delete market_data_consumer;
market_data_consumer = nullptr;
delete order_gateway;
order_gateway = nullptr;
std::this_thread::sleep_for(10s);
exit(EXIT_SUCCESS);
}
这标志着 trading_main
应用程序的实现完成。我们在 Chapter10/scripts/build.sh
中包含了一个构建脚本,该脚本使用 CMake 和 Ninja 构建库和 trading_main
应用程序,以及我们之前构建的 exchange_main
应用程序。您将不得不编辑此脚本以指向您系统上的正确二进制文件,或者如果您希望,切换到不同的构建系统。scripts/build.sh
脚本预计将从 Chapter10
的 root
目录运行,它只是配置构建文件,在这种情况下使用 Ninja
,并清理和重建发布和调试版本的构建。我们想澄清,选择 Ninja
是完全随机的;我们构建和运行系统不依赖于任何 Ninja
特定的东西。构建过程在 Chapter10/cmake-build-release
和 Chapter10/cmake-build-debug
目录中生成二进制文件。运行交易二进制的脚本使用 Chapter10/cmake-build-release
目录中的二进制文件。
运行最终的交易生态系统
我们终于到了可以运行整个电子交易生态系统的地步,虽然现在承认的是使用随机交易策略。我们将展示两个脚本——一个是 Chapter10/scripts/run_clients.sh
,该脚本配置为启动具有客户端 ID 1 到 5 的五个 trading_main
应用程序实例。第二个脚本是 Chapter10/scripts/run_exchange_and_clients.sh
,它首先使用 build.sh
脚本构建库和二进制文件。然后,它启动 exchange_main
应用程序,并继续使用 run_clients.sh
脚本启动交易客户端实例。最后,它等待所有交易客户端实例完成执行,然后终止交易所实例,并退出。
我们将不会查看完整的 run_clients.sh
脚本,但这里展示了一个创建 MarketMaker
算法的第一个交易客户端的示例:
./cmake-build-release/trading_main 1 MAKER 100 0.6 150 300 -100 60 0.6 150 300 -100 150 0.5 250 600 -100 200 0.4 500 3000 -100 1000 0.9 5000 4000 -100 300 0.8 1500 3000 -100 50 0.7 150 300 -100 100 0.3 250 300 -100 &
在这个脚本中,客户端 ID 1 和 2 是 MM 交易算法,客户端 ID 3 和 4 是流动性获取交易算法,最后一个客户端 ID,5,是一个随机交易算法。随机交易算法实例的存在是为了模拟由于任何原因而由市场其他参与者进行的所有交易。我们这样做是因为,在我们的生态系统中,我们只运行五个交易客户端(由于工作站上的资源有限)。然而,我们鼓励那些拥有更多 CPU 资源的人尽可能多地运行系统可以处理的交易客户端。请记住,在实践中,市场是由来自数千(如果不是更多)市场参与者的订单和交易组成的。
首先,我们有构建过程的输出,这是通过运行 scripts/run_exchange_and_clients.sh
脚本生成的,该脚本内部调用 scripts/build.sh
脚本来首先构建所有内容。请注意,您需要处于如这里所示的 Chapter10
根目录中,以便此脚本能够正确运行:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter10$ bash scripts/run_exchange_and_clients.sh
...
-- Build files have been written to: /home/sghosh/Building-Low-Latency-Applications-with-CPP/Chapter10/cmake-build-release
...
[36/37] Linking CXX executable trading_main
[37/37] Linking CXX executable exchange_main
然后,我们有exchange_main
应用程序启动的输出:
-----------------------------------------
Starting Exchange...
-----------------------------------------
Set core affinity for Common/Logger exchange_main.log 140716464399936 to -1
Set core affinity for Common/Logger exchange_matching_engine.log 140716293985856 to -1
...
然后,生成trading_main
实例启动的输出:
-----------------------------------------
Starting TradingClient 1...
-----------------------------------------
Set core affinity for Common/Logger trading_main_1.log 139636947019328 to -1
...
-----------------------------------------
Starting TradingClient 5...
-----------------------------------------
Set core affinity for Common/Logger trading_main_5.log 139837285852736 to -1
...
最后,我们有关闭的交易客户端的输出,然后交易所退出:
Set core affinity for Trading/MarketDataConsumer 139836325348928 to –1
...
Thu Apr 6 12:37:04 2023 Flushing and closing Logger for trading_main_1.log
...
Thu Apr 6 12:37:21 2023 Logger for trading_order_gateway_5.log exiting.
-----------------------------------------
Stopping Exchange...
-----------------------------------------
...
Thu Apr 6 12:38:09 2023 Logger for exchange_order_server.log exiting.
注意,这只是在屏幕上显示的输出。有趣细节在日志文件中,我们将在下一小节中检查和讨论。
另一个重要的注意事项是,exchange_main
应用程序有 10 个线程,每个trading_main
应用程序有 8 个线程。这些线程中的许多是Logger
线程(exchange_main
有五个,trading_main
有四个),以及main()
方法的线程(exchange_main
和trading_main
各有一个),它们不是 CPU 密集型,大部分运行时间都在休眠。最佳的设置需要整个生态系统很多核心,这在用于电子交易的生产级交易服务器上是常见的。在这些生产级交易服务器上,我们可以为剩余的每个关键线程分配一个 CPU 核心(exchange_main
有四个,trading_main
有三个)。由于我们不确定运行在哪个服务器上,所以我们故意避免为这些线程设置亲和性。如果您的系统 CPU 和/或内存资源有限,我们的建议是在run_clients.sh
脚本中减少启动的交易客户端数量。
检查运行输出
在本节结论中,我们将查看运行run_exchange_and_clients.sh
脚本生成的日志文件。我们知道在本章中运行的交易策略并不有趣,因为它向交易所发送随机订单,但日志文件中有一些重要的观察结果。运行run_exchange_and_clients.sh
脚本应该生成类似于以下日志文件:
exchange_main.log exchange_market_data_publisher.log exchange_matching_engine.log exchange_order_server.log exchange_snapshot_synthesizer.log
trading_engine_1.log trading_main_1.log trading_market_data_consumer_1.log trading_order_gateway_1.log
… trading_order_gateway_5.log
要理解和跟踪事件,我们的建议是关联我们从各个组件和子组件的Logger::log()
调用生成的日志行,然后在日志文件中找到它们。
作为示例,让我们跟随一个客户端向交易所发送订单、接收请求、生成客户端响应和该订单请求的市场更新的路径。假设,在这个例子中,我们想找到OrderId=1445和MarketOrderId=53的路径;该订单遵循的路径如下所示,从日志文件中可以看出。请注意,这只是一个从这次特定运行生成的示例,可能无法重现;这里的目的是了解如何跟踪我们生态系统中的事件:
-
MEClientRequest
新订单由trading_main
实例的TradeEngine
组件发送,该实例的ClientId
为 1:trading_engine_5.log:trade_engine.cpp:33 sendClientRequest() Thu Apr 6 12:26:47 2023 Sending MEClientRequest [type:NEW client:1 ticker:0 oid:1445 side:BUY qty:10 price:184]
-
OrderGateway
组件从无锁队列中获取该请求,并通过 TCP 连接将其发送到交易所,如下所示:trading_order_gateway_5.log:order_gateway.cpp:19 run() Thu Apr 6 12:26:47 2023 Sending cid:1 seq:891 MEClientRequest [type:NEW client:1 ticker:0 oid:1445 side:BUY qty:10 price:184]
-
exchange_main
应用程序内部的OrderServer
组件从TCPServer
套接字接收它,如下所示:exchange_order_server.log:order_server.h:55 recvCallback() Thu Apr 6 12:26:47 2023 Received OMClientRequest [seq:891 MEClientRequest [type:NEW client:1 ticker:0 oid:1445 side:BUY qty:10 price:184]]
-
OrderServer
组件内部的FifoSequencer
子组件根据软件接收时间对客户端订单请求 (MEClientRequest
) 进行排序,并将其发布到MatchingEngine
的无锁队列中:exchange_order_server.log:fifo_sequencer.h:38 sequenceAndPublish() Thu Apr 6 12:26:47 2023 Writing RX:1680802007777361000 Req:MEClientRequest [type:NEW client:1 ticker:0 oid:1445 side:BUY qty:10 price:184] to FIFO.
-
MatchingEngine
组件最终从LFQueue
接收此请求并处理它,如下面的日志文件所示:exchange_matching_engine.log:matching_engine.h:66 run() Thu Apr 6 12:26:47 2023 Processing MEClientRequest [type:NEW client:1 ticker:0 oid:1445 side:BUY qty:10 price:184]
-
作为对收到的订单请求的响应,
MatchingEngine
组件生成一个MEClientResponse
消息,该消息由OrderServer
组件发布给客户端:exchange_matching_engine.log:matching_engine.h:48 sendClientResponse() Thu Apr 6 12:26:47 2023 Sending MEClientResponse [type:ACCEPTED client:1 ticker:0 coid:1445 moid:53 side:BUY exec_qty:0 leaves_qty:10 price:184]
-
与添加到限价订单簿中的新订单相对应,
MatchingEngine
也生成一个MEMarketUpdate
消息,如下所示。这是为了让MarketDataPublisher
组件发布并更新它维护的快照:exchange_matching_engine.log:matching_engine.h:55 sendMarketUpdate() Thu Apr 6 12:26:47 2023 Sending MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]
-
OrderServer
组件从LFQueue
中获取MEClientResponse
消息,并向正确的 TCP 连接上的客户端发送OMClientResponse
消息:exchange_order_server.log:order_server.h:32 run() Thu Apr 6 12:26:47 2023 Processing cid:1 seq:1343 MEClientResponse [type:ACCEPTED client:1 ticker:0 coid:1445 moid:53 side:BUY exec_qty:0 leaves_qty:10 price:184]
-
MarketDataPublisher
组件接收由MatchingEngine
发送的MEMarketUpdate
消息,并在增量市场数据多播流上发送MDPMarketUpdate
消息:exchange_market_data_publisher.log:market_data_publisher.cpp:19 run() Thu Apr 6 12:26:47 2023 Sending seq:902 MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]
-
MarketDataPublisher
组件内部的SnapshotSynthesizer
子组件也接收这个增量MEMarketUpdate
消息,并将其添加到它维护的快照中:exchange_snapshot_synthesizer.log:snapshot_synthesizer.cpp:107 run() Thu Apr 6 12:26:47 2023 Processing MDPMarketUpdate [ seq:902 MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]]
-
在某个时刻,
SnapshotSynthesizer
在快照多播市场数据流上发布MDPMarketUpdate
消息的快照,包括这个市场更新:exchange_snapshot_synthesizer.log:snapshot_synthesizer.cpp:88 publishSnapshot() Thu Apr 6 12:27:40 2023 MDPMarketUpdate [ seq:7 MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]]
-
trading_main
应用程序内部的OrderGateway
组件接收来自连接到交易所的 TCPSocket 的订单请求的OMClientResponse
响应:trading_order_gateway_5.log:order_gateway.cpp:37 recvCallback() Thu Apr 6 12:26:47 2023 Received OMClientResponse [seq:1343 MEClientResponse [type:ACCEPTED client:1 ticker:0 coid:1445 moid:53 side:BUY exec_qty:0 leaves_qty:10 price:184]]
-
trading_main
应用程序内部的MarketDataConsumer
组件接收增量市场数据流上的MDPMarketUpdate
消息:trading_market_data_consumer_5.log:market_data_consumer.cpp:177 recvCallback() Thu Apr 6 12:26:47 2023 Received incremental socket len:42 MDPMarketUpdate [ seq:902 MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]]
trading_market_data_consumer_5.log:market_data_consumer.cpp:193 recvCallback() Thu Apr 6 12:26:47 2023 MDPMarketUpdate [ seq:902 MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]]
-
TradeEngine
组件最终从OrderGateway
组件通过无锁队列接收MEClientResponse
消息。它还通过onOrderUpdate()
回调转发MEClientResponse
消息:trading_engine_5.log:trade_engine.cpp:44 run() Thu Apr 6 12:26:47 2023 Processing MEClientResponse [type:ACCEPTED client:1 ticker:0 coid:1445 moid:53 side:BUY exec_qty:0 leaves_qty:10 price:184]
trading_engine_5.log:trade_engine.cpp:75 onOrderUpdate() Thu Apr 6 12:26:47 2023 MEClientResponse [type:ACCEPTED client:1 ticker:0 coid:1445 moid:53 side:BUY exec_qty:0 leaves_qty:10 price:184]
-
TradeEngine
也接收MEMarketUpdate
消息,更新MarketOrderBook
,并反过来从TradeEngine
中的订单簿接收onOrderBookUpdate()
:trading_engine_5.log:trade_engine.cpp:52 run() Thu Apr 6 12:26:47 2023 Processing MEMarketUpdate [ type:ADD ticker:0 oid:53 side:BUY qty:10 price:184 priority:2]
trading_engine_5.log:trade_engine.cpp:64 onOrderBookUpdate() Thu Apr 6 12:26:47 2023 ticker:0 price:184 side:BUY
希望这个例子能让你对我们交易生态系统中不同组件的功能有更深入的了解。这也应该作为如何调查我们电子交易宇宙中各种应用、组件和子组件中不同事件的例子。
现在,让我们关注由我们的其他组件生成的条目——FeatureEngine
、RiskManager
、PositionKeeper
和 OrderManager
——以及策略——MarketMaker
和 LiquidityTaker
算法:
-
以下日志行显示了
FeatureEngine
在订单簿更新或市场数据中出现新的交易事件时更新的特征值:trading_engine_1.log:feature_engine.h:23 onOrderBookUpdate() Thu May 11 16:10:45 2023 ticker:7 price:152 side:BUY mkt-price:152.394 agg-trade-ratio:0.0994475
trading_engine_1.log:feature_engine.h:34 onTradeUpdate() Thu May 11 16:10:45 2023 MEMarketUpdate [ type:TRADE ticker:1 oid:INVALID side:SELL qty:50 price:170 priority:INVALID] mkt-price:170.071 agg-trade-ratio:1
trading_engine_1.log:feature_engine.h:23 onOrderBookUpdate() Thu May 11 16:10:45 2023 ticker:2 price:119 side:SELL mkt-price:115.299 agg-trade-ratio:0.262712
trading_engine_1.log:feature_engine.h:34 onTradeUpdate() Thu May 11 16:10:45 2023 MEMarketUpdate [ type:TRADE ticker:3 oid:INVALID side:BUY qty:18 price:180 priority:INVALID] mkt-price:115.299 agg-trade-ratio:0.00628931
trading_engine_1.log:feature_engine.h:23 onOrderBookUpdate() Thu May 11 16:10:45 2023 ticker:3 price:180 side:SELL mkt-price:178.716 agg-trade-ratio:0.00628931
trading_engine_1.log:feature_engine.h:34 onTradeUpdate() Thu May 11 16:10:45 2023 MEMarketUpdate [ type:TRADE ticker:3 oid:INVALID side:BUY qty:30 price:180 priority:INVALID] mkt-price:178.716 agg-trade-ratio:0.0105485
-
以下日志行对应于
PositionKeeper
在BBO
变化或处理额外执行时更新:trading_engine_1.log:position_keeper.h:75 addFill() Thu May 11 16:10:38 2023 Position{pos:476 u-pnl:-120.715 r-pnl:6248.71 t-pnl:6128 vol:8654 vwaps:[114.254X0] BBO{21@115X116@296}} MEClientResponse [type:FILLED client:1 ticker:2 coid:962 moid:1384 side:BUY exec_qty:25 leaves_qty:102 price:114]
trading_engine_1.log:position_keeper.h:98 updateBBO() Thu May 11 16:10:42 2023 Position{pos:194 u-pnl:15.8965 r-pnl:311.103 t-pnl:327 vol:802 vwaps:[180.918X0] BBO{730@180X182@100}} BBO{730@180X182@100}
trading_engine_1.log:position_keeper.h:75 addFill() Thu May 11 16:10:42 2023 Position{pos:392 u-pnl:688.98 r-pnl:6435.02 t-pnl:7124 vol:8782 vwaps:[114.242X0] BBO{44@114X116@150}} MEClientResponse [type:FILLED client:1 ticker:2 coid:970 moid:1394 side:SELL exec_qty:83 leaves_qty:44 price:116]
trading_engine_1.log:position_keeper.h:98 updateBBO() Thu May 11 16:10:44 2023 Position{pos:373 u-pnl:282.585 r-pnl:6468.41 t-pnl:6751 vol:8801 vwaps:[114.242X0] BBO{19@114X116@131}} BBO{19@114X116@131}
-
由于我们在“构建 C++ 交易算法构建块”章节中讨论的几个原因,
RiskManager
的失败在日志文件中显示如下:trading_engine_1.log:order_manager.h:69 moveOrder() Thu May 11 16:10:41 2023 Ticker:1 Side:BUY Qty:60 RiskCheckResult:POSITION_TOO_LARGE
trading_engine_1.log:order_manager.h:69 moveOrder() Thu May 11 16:10:41 2023 Ticker:4 Side:SELL Qty:1000 RiskCheckResult:LOSS_TOO_LARGE
trading_engine_1.log:order_manager.h:69 moveOrder() Thu May 11 16:10:42 2023 Ticker:2 Side:BUY Qty:150 RiskCheckResult:POSITION_TOO_LARGE
-
在日志文件中,
OrderManager
的事件如下所示,因为尝试发送订单请求并处理响应:trading_engine_1.log:order_manager.h:26 onOrderUpdate() Thu May 11 16:10:36 2023 OMOrder[tid:6 oid:965 side:SELL price:125 qty:15 state:PENDING_CANCEL]
trading_engine_1.log:order_manager.cpp:13 newOrder() Thu May 11 16:10:37 2023 Sent new order MEClientRequest [type:NEW client:1 ticker:6 oid:966 side:SELL qty:50 price:126] for OMOrder[tid:6 oid:966 side:SELL price:126 qty:50 state:PENDING_NEW]
trading_engine_1.log:order_manager.h:23 onOrderUpdate() Thu May 11 16:10:37 2023 MEClientResponse [type:ACCEPTED client:1 ticker:6 coid:966 moid:1806 side:SELL exec_qty:0 leaves_qty:50 price:126]
trading_engine_1.log:order_manager.h:26 onOrderUpdate() Thu May 11 16:10:37 2023 OMOrder[tid:6 oid:966 side:SELL price:126 qty:50 state:PENDING_NEW]
trading_engine_1.log:order_manager.cpp:26 cancelOrder() Thu May 11 16:10:37 2023 Sent cancel MEClientRequest [type:CANCEL client:1 ticker:1 oid:927 side:SELL qty:60 price:170] for OMOrder[tid:1 oid:927 side:SELL price:170 qty:60 state:PENDING_CANCEL]
trading_engine_1.log:order_manager.h:23 onOrderUpdate() Thu May 11 16:10:37 2023 MEClientResponse [type:CANCELED client:1 ticker:1 coid:927 moid:1826 side:SELL exec_qty:INVALID leaves_qty:60 price:170]
-
LiquidityTaker
交易策略中的事件如下所示。这些对应于订单簿更新、交易事件和策略订单的更新:trading_engine_1.log:liquidity_taker.h:19 onOrderBookUpdate() Thu May 11 16:07:48 2023 ticker:4 price:183 side:SELL
trading_engine_1.log:liquidity_taker.h:19 onOrderBookUpdate() Thu May 11 16:07:48 2023 ticker:7 price:153 side:BUY
trading_engine_1.log:liquidity_taker.h:25 onTradeUpdate() Thu May 11 16:07:48 2023 MEMarketUpdate [ type:TRADE ticker:7 oid:INVALID side:SELL qty:90 price:154 priority:INVALID]
trading_engine_1.log:liquidity_taker.h:32 onTradeUpdate() Thu May 11 16:07:48 2023 BBO{368@154X155@2095} agg-qty-ratio:0.244565
trading_engine_1.log:liquidity_taker.h:19 onOrderBookUpdate() Thu May 11 16:07:48 2023 ticker:7 price:154 side:BUY
trading_engine_1.log:liquidity_taker.h:49 onOrderUpdate() Thu May 11 16:07:48 2023 MEClientResponse [type:FILLED client:3 ticker:7 coid:202 moid:792 side:BUY exec_qty:90 leaves_qty:183 price:154]
trading_engine_1.log:liquidity_taker.h:19 onOrderBookUpdate() Thu May 11 16:07:48 2023 ticker:0 price:180 side:BUY
-
同样,
MarketMaker
交易算法中的事件在日志文件中显示,如下所示:trading_engine_1.log:market_maker.h:47 onOrderUpdate() Thu May 11 16:06:12 2023 MEClientResponse [type:FILLED client:1 ticker:5 coid:418 moid:552 side:BUY exec_qty:62 leaves_qty:160 price:137]
trading_engine_1.log:market_maker.h:42 onTradeUpdate() Thu May 11 16:06:12 2023 MEMarketUpdate [ type:TRADE ticker:3 oid:INVALID side:BUY qty:47 price:180 priority:INVALID]
trading_engine_1.log:market_maker.h:19 onOrderBookUpdate() Thu May 11 16:06:12 2023 ticker:3 price:180 side:SELL
trading_engine_1.log:market_maker.h:27 onOrderBookUpdate() Thu May 11 16:06:12 2023 BBO{2759@178X180@2409} fair-price:179.068
trading_engine_1.log:market_maker.h:19 onOrderBookUpdate() Thu May 11 16:06:12 2023 ticker:0 price:183 side:SELL
trading_engine_1.log:market_maker.h:27 onOrderBookUpdate() Thu May 11 16:06:12 2023 BBO{4395@181X182@534} fair-price:181.892
trading_engine_1.log:market_maker.h:42 onTradeUpdate() Thu May 11 16:06:12 2023 MEMarketUpdate [ type:TRADE ticker:5 oid:INVALID side:SELL qty:62 price:137 priority:INVALID]
trading_engine_1.log:market_maker.h:19 onOrderBookUpdate() Thu May 11 16:06:12 2023 ticker:5 price:137 side:BUY
我们鼓励您更详细地检查各种日志文件,以了解不同组件中发生的处理过程以及我们整个电子交易生态系统的运作方式。
摘要
本章重点介绍了使用我们在前两章中构建的所有组件,并利用它们构建我们的智能交易策略——MM 交易策略和流动性获取交易算法。我们花了一些时间理解这两个交易算法的理论、动机和行为,并辅以一些示例。
在接下来的两节中,我们实现了 C++ MM 交易算法,该算法管理被动订单,以及流动性获取算法,该算法向市场发送积极订单。
然后,我们构建了交易引擎框架,该框架将市场数据消费者、订单网关、功能引擎、仓位保持者、订单管理器和风险管理器以及两个交易算法结合在一起。这个框架是我们用来将这些组件连接起来并促进流入和流出数据流以及交易智能的框架。
最后,我们构建了主要的交易应用程序,trading_main
,这是市场参与者侧 exchange_main
应用程序的补充。然后我们运行了几个不同的交易应用程序实例,以在我们的生态系统中运行随机交易算法、MM 算法和流动性获取算法。我们检查了在电子交易生态系统运行时生成的日志文件,因为不同的交易客户端系统和策略通过电子交易所相互交互。
在下一章中,我们将添加一个仪表系统来衡量我们整个电子交易生态系统的性能。我们在这本书中提到,优化某物的第一步是衡量系统和其组件的性能,我们将在下一章开始这样做。
第四部分:分析和改进性能
在本部分,我们将测量我们交易生态系统中所有不同 C++组件的性能。我们将分别分析不同组件的延迟配置文件,以及测量端到端往返路径的性能。从那里,我们将讨论进一步的优化技术,并观察我们 C++优化努力的成效。我们还将讨论可以对我们的电子交易生态系统进行的某些未来改进。
本部分包含以下章节:
-
第十一章,添加仪表和测量性能
-
第十二章,分析和优化我们的 C++系统性能
第十一章:添加性能测量工具和测量性能
在本章中,我们将添加一个系统来测量本书中构建的 C++组件的性能。我们将测量我们在第二部分中构建的交易交易所系统的延迟以及在前一节中构建的客户端交易系统的延迟。最后,我们将通过运行前一节中构建的不同算法来测量和分析端到端系统的性能。在本章中,我们将涵盖以下主题:
-
添加一个用于测量系统性能的仪器系统
-
测量交易所的延迟
-
测量交易引擎的延迟
-
使用新的仪器系统运行整个生态系统
技术要求
本书的所有代码都可以在 GitHub 仓库github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
中找到。本章的源代码位于仓库中的Chapter11
目录。
本章依赖于许多前面的章节,因为我们将会测量电子交易生态系统中所有不同组件和子组件的性能。因此,我们期望您熟悉我们迄今为止构建的代码库,特别是构建 C++匹配引擎、与市场参与者通信、在 C++中处理市场数据和向交易所发送订单、构建 C++交易算法构建块以及最终构建 C++市场做市和流动性获取算法章节。
本书源代码开发环境的规格如下所示。我们提供此环境的详细信息,因为本书中展示的所有 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
-
GCC –
g++ (Ubuntu
11.3.0-1ubuntu1~22.04.1) 11.3.0
-
CMake –
cmake
版本 3.23.2
-
Ninja –
1.10.2
添加一个用于测量系统性能的仪器系统
我们需要解决的首要任务是添加一些实用方法,这些方法将作为我们性能测量系统的基石。这些方法旨在用于测量在同一服务器上运行的进程的内部组件和子组件的延迟。这些方法也旨在用于测量不同组件之间的延迟,实际上这些组件可能不在同一服务器上,例如交易交易所和交易客户端,它们位于不同的服务器上。然而,请注意,在本书中,为了简化,我们在同一服务器上运行交易交易所和交易客户端。现在,让我们从下一节开始添加这些工具。
使用 RDTSC 添加性能测量工具
我们添加的第一个性能测量工具并不直接测量时间本身,而是测量代码库中两个位置之间经过的 CPU 时钟周期数。这是通过读取 rdtsc
的值来实现的,以获取并返回这个值,它以两个 32 位值的格式返回这个值,我们将这些值转换成一个单一的 64 位值。如果我们不关心将它们转换为时间单位,我们可以直接使用这些 rdtsc
值来测量/比较性能。另一种选择是将这个 rdtsc
值转换为时间单位,这是通过将这个值除以系统的时钟频率来实现的,该频率指定为每秒的 CPU 时钟周期数。例如,在我的系统中,CPU 时钟频率大约为 2.6 GHz,如下所示:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ cat /proc/cpuinfo | grep MHz
cpu MHz : 2645.048
cpu MHz : 2645.035
cpu MHz : 2645.033
cpu MHz : 2645.050
这意味着如果我们测量代码块执行前后 rdtsc
值,并且这两个值之间的差是 26 个时钟周期,在我的系统中,这相当于大约 26 / 2.6 = 10 纳秒的执行时间。我们将在本节最后的小节中进一步讨论这个问题,理解实践中测量系统的一些问题。因此,不再拖延,让我们看看这个测量实现的代码。本节的所有代码都可以在 Chapter11/common/perf_utils.h
源文件中找到。
首先,我们实现了一个 rdtsc()
C++ 方法,该方法内部调用 rdtsc
汇编指令,并为其提供两个变量 lo
和 hi
,以读取构成最终 rdtsc
值的低位和高位 32 位。__asm__
指令告诉编译器其后的内容是一个汇编指令。__volatile__
指令存在是为了防止编译器优化指令,以确保每次调用时都执行原样指令,从而确保我们每次都读取 TSC 寄存器。我们将输出保存到 lo
和 hi
变量中,最后,通过位移操作,从它们中创建一个 64 位值并返回:
#pragma once
namespace Common {
inline auto rdtsc() noexcept {
unsigned int lo, hi;
__asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
return ((uint64_t) hi << 32) | lo;
}
}
接下来,我们将定义一个简单的预处理器宏 START_MEASURE
,它接受一个名为 TAG
的参数,它所做的只是创建一个具有该名称的变量,并将我们刚刚构建的 rdtsc()
方法的值保存在其中。换句话说,这个宏只是创建一个具有提供的名称的变量,并将 rdtsc()
值保存在其中:
#define START_MEASURE(TAG) const auto TAG = Common::rdtsc()
我们定义另一个互补的宏END_MEASURE
,它接受一个名为TAG
的参数以及一个名为LOGGER
的参数,它期望LOGGER
是Common::Logger
类型。它使用我们之前构建的rdtsc()
方法进行另一个测量,并使用LOGGER
对象记录两个之间的差异。这个代码块被包含在do {} while(false)
循环中(没有终止的分号),是为了确保编译器在调用此方法时捕获缺少的分号。换句话说,END_MEASURE(example, logger_);
是一个有效的用法,但END_MEASURE(example, logger_)
(缺少分号)会导致编译错误,以保持与START_MEASURE
的对称性。这并不是严格必要的,只是我们的一种偏好:
#define END_MEASURE(TAG, LOGGER) \
do { \
const auto end = Common::rdtsc(); \
LOGGER.log("% RDTSC "#TAG" %\n",
Common::getCurrentTimeStr(&time_str_), (end -
TAG)); \
} while(false)
最后,我们将定义一个名为TTT_MEASURE
的类似宏,它接受类似的参数(即,TAG
和LOGGER
),这个宏简单地记录当前时间(以纳秒为单位),它是通过调用我们之前看到的Common::getCurrentNanos()
方法来获取的:
#define TTT_MEASURE(TAG, LOGGER) \
do { \
const auto TAG = Common::getCurrentNanos(); \
LOGGER.log("% TTT "#TAG" %\n", Common::
getCurrentTimeStr(&time_str_), TAG); \
} while(false)
我们将在本章中使用这些宏,但在我们这样做之前,我们需要对我们之前构建并已经看到许多用途的时间工具做一些小的修改。
更新我们之前的时间工具
在本节中,我们将对时间工具Common::getCurrentTimeStr()
方法进行一些小的修改,以使输出更具有信息性和粒度。这里的目的是改变我们之前的输出,它看起来像这样:
onMarketUpdate() Sat Jun 3 09:46:34 2023 MEMarketUpdate
我们希望将其更改为这种格式,该格式从输出中删除日期和年份,并将时间输出从只有秒更改为有秒和纳秒以增加粒度:
onMarketUpdate() 09:46:34.645778416 MEMarketUpdate
这将帮助我们更仔细地检查、排序和分析在同一秒内发生的事件。这些更改可以在Chapter11/common/time_utils.h
源文件中找到。
注意,另一个更改是包含包含我们在上一节中构建的测量方法的perf_utils.h
头文件:
#include "perf_utils.h"
我们在设计时间工具部分的为低延迟应用构建 C++构建块章节中看到了这一点。我们调用std::chrono::system_clock::now()
来提取当前的time_point
值并将其保存到clock
变量中。我们还使用std::chrono::system_clock::to_time_t()
方法从它中提取并保存time_t
对象到time
变量中,如下所示:
namespace Common {
inline auto& getCurrentTimeStr(std::string* time_str) {
const auto clock = std::chrono::system_clock::now();
const auto time = std::chrono::
system_clock::to_time_t(clock);
我们使用sprintf()
、ctime()
和之前看到的std::chrono::duration_cast<std::chrono::nanoseconds>(clock.time_since_epoch()).count()
方法组合来提取和格式化当前时间,格式为HH:MM:SS.nnnnnnnnn
。最后,我们将它赋值给std::string
类型的time_str
对象,该对象作为参数传递给此方法,并返回它:
char nanos_str[24];
sprintf(nanos_str, "%.8s.%09ld", ctime(&time) + 11,
std::chrono::duration_cast<std::chrono::nanoseconds>
(clock.time_since_epoch()).count() % NANOS_TO_SECS);
time_str->assign(nanos_str);
return *time_str;
}
}
在我们继续使用这些新方法之前,我们将讨论一些关于实践中测量性能的更多要点。
理解实践中测量系统的一些问题
在本节中,我们将讨论在实践中进行性能测量时的一些重要考虑因素。这一点很重要,因为性能测量并不总是像看起来那么简单,它需要你理解一些细微差别。
由于仪器而增加的开销
我们关于实践性能测量的第一个关键点是,重要的是要考虑测量系统本身并不是零延迟。这意味着将仪器添加到关键代码路径中会增加一些额外的延迟。确保仪器系统/例程本身相对于它所测量的系统延迟非常低是非常重要的。一个假设的例子是,如果我们正在测量需要几微秒的事情,我们需要确保测量例程只需要几纳秒,以避免增加过多的开销。我们能够选择使用rdtsc()
来测量性能的一个原因是因为它比调用诸如std::chrono::system_clock::now()
或clock_gettime()
这样的方法要快得多。这给了我们在测量具有极低延迟的代码块时使用rdtsc()
的选择,以添加最小的开销。
理解 RDTSC 的限制和可靠性
第二个关键点是关于rdtsc()
,它并不总是非常便携,并且根据平台可能会有可靠性问题。当将rdtsc()
转换为时间单位时,另一个考虑因素是系统中的 CPU 时钟频率可能从核心到核心有所不同,使用静态 CPU 时钟频率进行转换并不总是准确的。
设置正确的测量环境
关于在交易服务器上测量性能的第三个要点是,需要进行大量的调整以方便准确测量。这包括禁用中断、确保不必要的进程没有运行、确保 NUMA 设置正确、调整 CPU 电源设置、设置 CPU 隔离、将线程固定到特定核心等技术。在电子交易方面讨论所有这些考虑因素超出了本书的范围,也不是本书的重点。我们只想提到,在性能测量方面还有其他考虑因素。我们建议感兴趣的读者参考书籍《开发高频交易系统:从 C++或 Java 基础知识开始,学习如何从头实现高频交易》,该书讨论了高频电子交易(HFT)的具体考虑因素。
现在,我们可以继续使用本节中构建的性能测量系统,在我们的电子交易生态系统中,从下一节中的交易所开始。
在交易所测量延迟
首先,我们将向电子交易交易所侧的组件添加仪器——市场数据发布者、撮合引擎和订单服务器。我们的性能测量方法将包括两种形式;在我们查看代码之前,让我们先了解这些。
理解如何测量内部组件
第一种方法是测量内部组件的延迟——例如,调用Exchange::MatchingEngine::processClientRequest()
方法需要多长时间,或者调用Exchange::MEOrderBook::add()
方法需要多长时间?对于这些情况,我们将使用一对START_MEASURE()
和END_MEASURE()
宏,它们反过来使用rdtsc()
方法来测量此类调用的性能。这里没有任何阻止我们使用TTT_MEASURE()
宏代替rdtsc()
或作为补充的地方。但我们将使用rdtsc()
来测量这些,为了提供如何使用两种不同仪器系统的示例。此外,我们使用这样的理由:对像我们之前提到的那种函数的调用应该非常快,可能最好使用开销较低的rdtsc()
方法。我们将采取的完整内部测量列表将在下面列出,但感兴趣的读者应使用类似的技术根据需要添加更多测量点。我们将在稍后看到如何测量这些熟悉方法的代码,但现在,我们将测量在交易所侧的方法如下:
-
Common::McastSocket::send()
-
Exchange::MEOrderBook::add()
-
Exchange::MEOrderBook::cancel()
-
Exchange::MatchingEngine::processClientRequest()
-
Exchange::MEOrderBook::removeOrder()
-
Exchange::MEOrderBook::match()
-
Exchange::MEOrderBook::match()
-
Exchange::MEOrderBook::checkForMatch()
-
Exchange::MEOrderBook::addOrder()
-
Exchange::MEOrderBook::removeOrder()
-
Common::TCPSocket::send()
-
Exchange::FIFOSequencer::addClientRequest()
-
Exchange::FIFOSequencer::sequenceAndPublish()
接下来,让我们退后几步,了解在电子交易交易所我们将对哪些高级位置/跳数进行时间戳记录。
理解在交易所的关键跳数
除了测量内部组件的功能外,交易所可以记录性能数据,特别是时戳,以追踪事件(客户端请求)如何通过不同的组件和子组件传播。我们指的是追踪,通常还包括发布指标,例如订单何时到达订单服务器,何时到达撮合引擎,响应请求何时离开订单服务器,对应请求的市场更新何时离开市场数据发布者,等等。通过记录这些指标,交易所可以了解和调查其在不同市场/负载条件下的性能,跟踪每个参与者的性能,等等。通过将这些指标发布给市场参与者,参与者可以了解和调查自己的性能,并考虑改进的方法。
在我们的电子交易交易所中,我们将记录以下事件的时戳:
-
T1_OrderServer_TCP_read
– 客户请求首次在OrderServer
中的 TCP 套接字中被读取的时间 -
T2_OrderServer_LFQueue_write
– 客户请求被写入连接到MatchingEngine
的LFQueue
的时间 -
T3_MatchingEngine_LFQueue_read
–MatchingEngine
从LFQueue
读取客户端请求的时间 -
T4_MatchingEngine_LFQueue_write
– 市场更新被写入连接到MarketDataPublisher
的LFQueue
的时间 -
T4t_MatchingEngine_LFQueue_write
– 客户端响应被写入连接到OrderServer
的LFQueue
的时间 -
T5_MarketDataPublisher_LFQueue_read
– 从LFQueue
读取市场更新的时间 -
T5t_OrderServer_LFQueue_read
– 从LFQueue
读取客户端响应的时间 -
T6_MarketDataPublisher_UDP_write
– 市场更新被写入MarketDataPublisher
中的 UDP 套接字的时间 -
T6t_OrderServer_TCP_write
– 客户端响应被写入OrderServer
中的 TCP 套接字的时间
这些时戳的确切位置在以下图中显示:
图 11.1 – 带有时戳关键跳转的电子交易交易所拓扑结构
现在,从下一节开始,我们可以开始查看我们需要添加到这两种测量形式的代码更改。
测量市场数据发布者内部的延迟
首先,我们将向市场数据发布者添加性能测量和时戳代码。为了简洁起见,我们只展示我们进行这些更改的代码块,而不是包括整个源文件或大型代码块中的源代码。所有更改以及与市场数据发布者相关的完整、更新后的源代码都在Chapter11/exchange/market_data/market_data_publisher.cpp
源文件中。
首先,在MarketDataPublisher::run()
方法中,我们将在从outgoing_md_updates_
LFQueue
读取后立即使用带有T5_MarketDataPublisher_LFQueue_read
标记的TTT_MEASURE
宏添加时间戳,如下所示:
auto MarketDataPublisher::run() noexcept -> void {
...
while (run_) {
for (auto market_update = outgoing_md_updates_->
getNextToRead();
outgoing_md_updates_->size() && market_update;
market_update = outgoing_md_updates_->
getNextToRead()) {
TTT_MEASURE(T5_MarketDataPublisher_LFQueue_read,
logger_);
接下来,我们将使用START_MEASURE
和END_MEASURE
宏以及Exchange_McastSocket_send
标记来测量在incremental_socket_
上调用MCastSocket::send()
所需的时间:
START_MEASURE(Exchange_McastSocket_send);
incremental_socket_.send(&next_inc_seq_num_,
sizeof(next_inc_seq_num_));
incremental_socket_.send(market_update,
sizeof(MEMarketUpdate));
END_MEASURE(Exchange_McastSocket_send, logger_);
最后,在套接字写入完成后,我们将使用带有T6_MarketDataPublisher_UDP_write
标记的TTT_MEASURE
宏再次获取时间戳:
outgoing_md_updates_->updateReadIndex();
TTT_MEASURE(T6_MarketDataPublisher_UDP_write,
logger_);
...
接下来,让我们看看OrderServer
组件在性能测量和时间戳方面的更改。
测量订单服务器内部的延迟
所有用于性能测量和时间戳的更改,以及OrderServer
的完整源代码,都在Chapter11/exchange/order_server/order_server.h
源文件中。和之前一样,我们将只展示更改所做的最小代码块,以节省篇幅并避免重复。
首先,我们将修改OrderServer::run()
方法,在从outgoing_responses_
LFQueue
读取条目后立即进行。我们使用带有T5t_OrderServer_LFQueue_read
标记的TTT_MEASURE
宏,如下所示:
auto run() noexcept {
...
while (run_) {
...
for (auto client_response = outgoing_responses_->
getNextToRead(); outgoing_responses_->size() &&
client_response; client_response =
outgoing_responses_->getNextToRead()) {
TTT_MEASURE(T5t_OrderServer_LFQueue_read,
logger_);
接下来,我们将使用带有START_MEASURE
和END_MEASURE
宏以及Exchange_TCPSocket_send
标记来测量对TCPSocket::send()
方法的调用。请注意,我们测量的是发送完整客户端响应消息的调用,在我们的实现中,这会导致对TCPSocket::send()
方法的两次调用:
START_MEASURE(Exchange_TCPSocket_send);
cid_tcp_socket_[client_response->client_id_]->
send(&next_outgoing_seq_num,
sizeof(next_outgoing_seq_num));
cid_tcp_socket_[client_response->client_id_]->
send(client_response,
sizeof(MEClientResponse));
END_MEASURE(Exchange_TCPSocket_send, logger_);
最后,在 TCP 套接字发送操作完成后,我们将使用带有T6t_OrderServer_TCP_write
标记的TTT_MEASURE
方法再次获取时间戳:
outgoing_responses_->updateReadIndex();
TTT_MEASURE(T6t_OrderServer_TCP_write, logger_);
...
下一个更改集在OrderServer::recvCallback()
方法中。当我们刚进入该方法时,我们使用带有T1_OrderServer_TCP_read
标记的TTT_MEASURE
宏获取时间戳:
auto recvCallback(TCPSocket *socket, Nanos rx_time)
noexcept {
TTT_MEASURE(T1_OrderServer_TCP_read, logger_);
...
最后,在本方法结束时,我们将使用带有Exchange_FIFOSequencer_addClientRequest
标记的START_MEASURE
和END_MEASURE
宏来测量对FIFOSequencer::addClientRequest()
的调用:
START_MEASURE(Exchange_FIFOSequencer_addClientRequest);
fifo_sequencer_.addClientRequest(rx_time,
request->me_client_request_);
END_MEASURE(Exchange_FIFOSequencer_
addClientRequest, logger_);
...
最后,对于OrderServer
,我们需要更新OrderServer::recvFinishedCallback()
方法。我们使用带有Exchange_FIFOSequencer_sequenceAndPublish
标记的START_MEASURE
和END_MEASURE
宏来测量对FIFOSequencer::sequenceAndPublish()
方法的调用:
auto recvFinishedCallback() noexcept {
START_MEASURE(Exchange_FIFOSequencer_sequenceAndPublis)
;
fifo_sequencer_.sequenceAndPublish();
END_MEASURE(Exchange_FIFOSequencer_
sequenceAndPublish, logger_);
}
在下一个子节中,我们将向FIFOSequencer
子组件添加仪器。
测量 FIFOSequencer 内部的延迟
所有仪器更改以及FIFOSequencer
子组件的完整、更新后的源代码都可以在Chapter11/exchange/order_server/fifo_sequencer.h
源文件中找到。我们将做出的唯一更改是在FIFOSequencer::sequenceAndPublish()
方法中。在这里,我们只是在将客户端请求写入incoming_requests_
LFQueue
后添加时间戳,我们通过使用TTT_MEASURE
宏和T2_OrderServer_LFQueue_write
标签值来完成此操作,如下所示:
auto sequenceAndPublish() {
for (size_t i = 0; i < pending_size_; ++i) {
...
auto next_write = incoming_requests_->
getNextToWriteTo();
*next_write = std::move(client_request.request_);
incoming_requests_->updateWriteIndex();
TTT_MEASURE(T2_OrderServer_LFQueue_write,
(*logger_));
...
接下来,我们将继续对核心匹配引擎组件及其子组件添加仪器和时间戳的任务。
测量匹配引擎和订单簿内部的延迟
首先,我们将更新MatchingEngine
;所有更改以及MatchingEngine
的完整、更新后的源代码都可以在Chapter11/exchange/matcher/matching_engine.h
源文件中找到。
在MatchingEngine::processClientRequest()
方法中,我们将测量MEOrderBook::add()
和MEOrderBook::cancel()
方法所需的时间。首先,我们使用START_MEASURE
和END_MEASURE
宏以及Exchange_MEOrderBook_add
标签来展示MEOrderBook::add()
方法的更改,如下所示:
auto processClientRequest(const MEClientRequest
*client_request) noexcept {
...
switch (client_request->type_) {
case ClientRequestType::NEW: {
START_MEASURE(Exchange_MEOrderBook_add);
order_book->add(client_request->client_id_,
client_request->order_id_, client_request->
ticker_id_,
client_request->side_,
client_request->price_,
client_request->qty_);
END_MEASURE(Exchange_MEOrderBook_add, logger_);
...
然后,我们展示了使用START_MEASURE
和END_MEASURE
宏以及Exchange_MEOrderBook_cancel
标签对MEOrderBook::cancel()
方法的更改,如下所示:
case ClientRequestType::CANCEL: {
START_MEASURE(Exchange_MEOrderBook_cancel);
order_book->cancel(client_request->client_id_,
client_request->order_id_, client_request->
ticker_id_);
END_MEASURE(Exchange_MEOrderBook_cancel,
logger_);
...
我们需要更新的下一个方法是MatchingEngine::sendClientResponse()
。我们将在将客户端响应写入outgoing_ogw_responses_
LFQueue
后立即使用TTT_MEASURE
宏和T4t_MatchingEngine_LFQueue_write
标签,如下所示:
auto sendClientResponse(const MEClientResponse
*client_response) noexcept {
...
auto next_write = outgoing_ogw_responses_->
getNextToWriteTo();
*next_write = std::move(*client_response);
outgoing_ogw_responses_->updateWriteIndex();
TTT_MEASURE(T4t_MatchingEngine_LFQueue_write,
logger_);
}
我们还需要通过使用TTT_MEASURE
宏和T4_MatchingEngine_LFQueue_write
标签,在将市场更新写入outgoing_md_updates_
LFQueue
后添加时间戳来更新MatchingEngine::sendMarketUpdate()
方法:
auto sendMarketUpdate(const MEMarketUpdate
*market_update) noexcept {
...
auto next_write = outgoing_md_updates_->
getNextToWriteTo();
*next_write = *market_update;
outgoing_md_updates_->updateWriteIndex();
TTT_MEASURE(T4_MatchingEngine_LFQueue_write,
logger_);
}
我们需要在MatchingEngine
中更新的最后一个方法是run()
方法本身。我们在从incoming_requests_
LFQueue
读取后立即获取时间戳,使用TTT_MEASURE
宏和T3_MatchingEngine_LFQueue_read
标签,如下所示:
auto run() noexcept {
while (run_) {
const auto me_client_request = incoming_requests_->
getNextToRead();
if (LIKELY(me_client_request)) {
TTT_MEASURE(T3_MatchingEngine_LFQueue_read,
logger_);
接下来,我们使用START_MEASURE
和END_MEASURE
宏以及Exchange_MatchingEngine_processClientRequest
标签来测量对MatchingEngine::processClientRequest()
方法的调用,如下所示:
START_MEASURE(Exchange_MatchingEngine_processClientRequest); processClientRequest(me_client_request);
END_MEASURE(Exchange_MatchingEngine_processClientRequest,
logger_);
...
我们需要在交易所侧更新的最后一个组件是MatchingEngine
中的MEOrderBook
子组件。
测量 MEOrderBook 内部的延迟
我们将在本小节中讨论对MEOrderBook
组件的仪器更改,这些更改可以在Chapter11/exchange/matcher/me_order_book.cpp
源文件中找到。
我们将更新的第一个方法是MEOrderBook::match()
。我们希望使用START_MEASURE
和END_MEASURE
宏以及Exchange_MEOrderBook_removeOrder
标签来测量对MEOrderBook::removeOrder()
的调用,如下所示:
auto MEOrderBook::match(TickerId ticker_id, ClientId
client_id, Side side, OrderId client_order_id, OrderId
new_market_order_id, MEOrder* itr, Qty* leaves_qty)
noexcept {
...
if (!order->qty_) {
...
START_MEASURE(Exchange_MEOrderBook_removeOrder);
removeOrder(order);
END_MEASURE(Exchange_MEOrderBook_removeOrder,
(*logger_));
...
我们还需要更新MEOrderBook::checkForMatch()
方法来测量对MEOrderBook::match()
的调用。我们使用带有Exchange_MEOrderBook_match
标记的START_MEASURE
和END_MEASURE
宏来对执行的两个分支进行测量,如下所示:
auto MEOrderBook::checkForMatch(ClientId client_id,
OrderId client_order_id, TickerId ticker_id, Side side,
Price price, Qty qty, Qty new_market_order_id)
noexcept {
...
if (side == Side::BUY) {
while (leaves_qty && asks_by_price_) {
...
START_MEASURE(Exchange_MEOrderBook_match);
match(ticker_id, client_id, side, client_order_id,
new_market_order_id, ask_itr, &leaves_qty);
END_MEASURE(Exchange_MEOrderBook_match,
(*logger_));
}
}
if (side == Side::SELL) {
while (leaves_qty && bids_by_price_) {
...
START_MEASURE(Exchange_MEOrderBook_match);
match(ticker_id, client_id, side, client_order_id,
new_market_order_id, bid_itr, &leaves_qty);
END_MEASURE(Exchange_MEOrderBook_match,
(*logger_));
}
}
...
我们将在MEOrderBook::add()
方法中添加额外的仪表化来测量几个不同的调用。第一个是调用MEOrderBook::checkForMatch()
,我们将使用Exchange_MEOrderBook_checkForMatch
标记:
auto MEOrderBook::add(ClientId client_id, OrderId
client_order_id, TickerId ticker_id, Side side, Price
price, Qty qty) noexcept -> void {
...
START_MEASURE(Exchange_MEOrderBook_checkForMatch);
const auto leaves_qty = checkForMatch(client_id,
client_order_id, ticker_id, side, price, qty,
new_market_order_id);
END_MEASURE(Exchange_MEOrderBook_checkForMatch,
(*logger_));
…
下一个是调用MEOrderBook::addOrder()
,我们将使用Exchange_MEOrderBook_addOrder
标记:
START_MEASURE(Exchange_MEOrderBook_addOrder);
addOrder(order);
END_MEASURE(Exchange_MEOrderBook_addOrder,
(*logger_));
...
我们需要添加更多细粒度仪表化的最后一个MEOrderBook
方法是cancel()
方法。在这个方法中,我们想要测量对MEOrderBook::removeOrder()
方法的调用,如下所示,使用START_MEASURE
和END_MEASURE
宏以及Exchange_MEOrderBook_removeOrder
标记:
auto MEOrderBook::cancel(ClientId client_id, OrderId
order_id, TickerId ticker_id) noexcept -> void {
...
START_MEASURE(Exchange_MEOrderBook_removeOrder);
removeOrder(exchange_order);
END_MEASURE(Exchange_MEOrderBook_removeOrder,
(*logger_));
...
这就完成了我们希望在电子交易所一侧添加的所有测量,在下一节中,我们将添加另一侧的类似仪表化:即交易客户端系统。
测量交易引擎中的延迟
在本节中,我们将专注于向交易客户端系统添加性能测量和时间戳——包括市场数据消费者、订单网关、交易引擎及其子组件。在这里,我们也将测量内部组件的性能,并添加时间戳以帮助进行更高层次的分析,包括事件传入和传出的延迟。
理解如何进行内部测量
测量交易客户端系统内部组件性能的动机和方法与交易所方面的方法相同。我们将采取的完整内部测量列表如下,但感兴趣的读者应使用类似的技术根据需要添加更多测量点。我们很快将看到如何测量这些熟悉方法的代码,但到目前为止,我们将测量客户端侧的方法如下:
-
Trading::MarketDataConsumer::recvCallback()
-
Common::TCPSocket::send()
-
Trading::OrderGateway::recvCallback()
-
Trading::OrderManager::moveOrders()
-
Trading::OrderManager::onOrderUpdate()
-
Trading::OrderManager::moveOrders()
-
Trading::OrderManager::onOrderUpdate()
-
Trading::MarketOrderBook::addOrder()
-
Trading::MarketOrderBook::removeOrder()
-
Trading::MarketOrderBook::updateBBO()
-
Trading::OrderManager::cancelOrder()
-
Trading::RiskManager::checkPreTradeRisk()
-
Trading::OrderManager::newOrder()
-
Trading::OrderManager::moveOrder()
-
Trading::OrderManager::moveOrder()
-
Trading::PositionKeeper::updateBBO()
-
Trading::FeatureEngine::onOrderBookUpdate()
-
Trading::TradeEngine::algoOnOrderBookUpdate()
-
Trading::FeatureEngine::onTradeUpdate()
-
Trading::TradeEngine::algoOnTradeUpdate()
-
Trading::PositionKeeper::addFill()
-
Trading::TradeEngine::algoOnOrderUpdate()
正如我们在电子交易交易所中所做的那样,我们将理解在交易客户端系统中我们将要时间戳的关键跳转。
理解交易客户端系统中的关键跳转
市场参与者也有类似的原因对每个组件和子组件的事件流进行时间戳记录。通过记录和分析这些事件的时序,参与者可以寻求改进他们的系统,以及分析如何增加盈利能力。
在我们的电子交易客户端系统中,我们将记录以下事件的时戳:
-
T7_MarketDataConsumer_UDP_read
– 从MarketDataConsumer
中的 UDP 套接字读取市场数据更新的时间 -
T7t_OrderGateway_TCP_read
– 从OrderGateway
中的 TCP 套接字读取客户端响应的时间 -
T8_MarketDataConsumer_LFQueue_write
– 将市场数据更新写入连接到TradeEngine
的LFQueue
的时间 -
T8t_OrderGateway_LFQueue_write
– 客户端响应写入连接到TradeEngine
的LFQueue
的时间 -
T9_TradeEngine_LFQueue_read
– 从MarketDataConsumer
的LFQueue
读取市场数据更新的时间 -
T9t_TradeEngine_LFQueue_read
– 从OrderGateway
读取客户端响应的时间 -
T10_TradeEngine_LFQueue_write
– 客户端请求写入连接到OrderGateway
的LFQueue
的时间 -
T11_OrderGateway_LFQueue_read
–OrderGateway
从TradeEngine
的LFQueue
读取客户端请求的时间 -
T12_OrderGateway_TCP_write
–OrderGateway
将客户端请求写入 TCP 套接字的时间
这些时戳的确切位置在以下图中显示:
图 11.2 – 电子交易客户端系统的拓扑结构以及要时间戳的关键跳转
现在,从下一节开始,我们可以开始查看我们需要添加到这两种测量形式的代码更改。
测量市场数据消费者内部的延迟
我们将从MarketDataConsumer
组件开始,正如我们之前讨论的那样,我们在这里只展示代码的更改,并省略重复完整的源代码。这些更改以及完整的源代码都位于Chapter11/trading/market_data/market_data_consumer.cpp
源文件中。
我们首先获取的时戳是在进入MarketDataConsumer::recvCallback()
时,我们使用TTT_MEASURE
宏和T7_MarketDataConsumer_UDP_read
标记:
auto MarketDataConsumer::recvCallback(McastSocket
*socket) noexcept -> void {
TTT_MEASURE(T7_MarketDataConsumer_UDP_read, logger_);
我们还将使用带有Trading_MarketDataConsumer_recvCallback
标记的START_MEASURE
和END_MEASURE
宏将整个方法包围起来,以测量整个方法的延迟:
START_MEASURE(Trading_MarketDataConsumer_recvCallback);
...
END_MEASURE(Trading_MarketDataConsumer_recvCallback,
logger_);
}
我们将在将解码后的市场更新写入incoming_md_updates_
LFQueue
之后立即添加时间戳,使用TTT_MEASURE
宏和T8_MarketDataConsumer_LFQueue_write
标记:
auto next_write = incoming_md_updates_->
getNextToWriteTo();
*next_write = std::move(request->
me_market_update_);
incoming_md_updates_->updateWriteIndex();
TTT_MEASURE(T8_MarketDataConsumer_LFQueue_write,
logger_);
在下一节中,我们将继续为 OrderGateway
组件添加性能测量。
测量订单网关内部的延迟
在本小节中,我们将更新 OrderGateway
组件;所有更改和更新的完整源代码可在 Chapter11/trading/order_gw/order_gateway.cpp
源文件中找到。
我们将首先更新的是 OrderGateway::run()
方法,我们记录的第一个时间戳是在从 outgoing_requests_
LFQueue
读取客户端请求时。我们通过使用 TTT_MEASURE
宏和 T11_OrderGateway_LFQueue_read
标签来完成这项工作:
auto OrderGateway::run() noexcept -> void {
...
for(auto client_request = outgoing_requests_->
getNextToRead(); client_request; client_request =
outgoing_requests_->getNextToRead()) {
TTT_MEASURE(T11_OrderGateway_LFQueue_read,
logger_);
我们接下来要测量的将是执行 Common::TCPSocket::send()
方法所需的时间,正如以下所示,我们使用 Trading_TCPSocket_send
标签来完成这项工作:
START_MEASURE(Trading_TCPSocket_send);
tcp_socket_.send(&next_outgoing_seq_num_,
sizeof(next_outgoing_seq_num_));
tcp_socket_.send(client_request,
sizeof(Exchange::MEClientRequest));
END_MEASURE(Trading_TCPSocket_send, logger_);
最后,我们也在 TCPSocket::send()
完成后立即使用 TTT_MEASURE
宏和 T12_OrderGateway_TCP_write
标签进行时间戳记录:
outgoing_requests_->updateReadIndex();
TTT_MEASURE(T12_OrderGateway_TCP_write, logger_);
在 OrderGateway
组件中,我们将更新的下一个方法是 recvCallback()
方法。一旦进入 recvCallback()
方法,我们就使用 TTT_MEASURE
宏和 T7t_OrderGateway_TCP_read
标签来记录一个时间戳:
auto OrderGateway::recvCallback(TCPSocket *socket, Nanos
rx_time) noexcept -> void {
TTT_MEASURE(T7t_OrderGateway_TCP_read, logger_);
与 MarketDataConsumer::recvCallback()
类似,我们将使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_OrderGateway_recvCallback
标签将整个 OrderGateway::recvCallback()
方法括起来:
START_MEASURE(Trading_OrderGateway_recvCallback);
...
END_MEASURE(Trading_OrderGateway_recvCallback,
logger_);
}
我们还在将客户端响应写入 incoming_responses_
LFQueue
后立即使用 TTT_MEASURE
宏和 T8t_OrderGateway_LFQueue_write
标签记录一个时间戳:
auto next_write = incoming_responses_->
getNextToWriteTo();
*next_write = std::move(response->
me_client_response_);
incoming_responses_->updateWriteIndex();
TTT_MEASURE(T8t_OrderGateway_LFQueue_write,
logger_);
在本节的下一和最后的小节中,我们将向交易引擎及其所有子组件添加仪表代码。
测量交易引擎内部的延迟
首先,我们将从更新 TradeEngine
类本身开始,这个更改及其完整的更新源代码可以在 Chapter11/trading/strategy/trade_engine.cpp
源文件中找到。
我们列表中的下一个方法是 TradeEngine::sendClientRequest()
方法,在这里,我们在将客户端请求写入 outgoing_ogw_requests_
LFQueue
后使用 T10_TradeEngine_LFQueue_write
标签记录一个时间戳:
auto TradeEngine::sendClientRequest(const
Exchange::MEClientRequest *client_request) noexcept -> void {
auto next_write = outgoing_ogw_requests_->
getNextToWriteTo();
*next_write = std::move(*client_request);
outgoing_ogw_requests_->updateWriteIndex();
TTT_MEASURE(T10_TradeEngine_LFQueue_write, logger_);
我们列表中的下一个方法是 TradeEngine::run()
方法,其中第一个任务是读取来自 incoming_ogw_responses_
LFQueue
的客户端响应后立即记录一个时间戳,使用 TTT_MEASURE
宏和 T9t_TradeEngine_LFQueue_read
标签:
auto TradeEngine::run() noexcept -> void {
while (run_) {
for (auto client_response = incoming_ogw_responses_->
getNextToRead(); client_response; client_response =
incoming_ogw_responses_->getNextToRead()) {
TTT_MEASURE(T9t_TradeEngine_LFQueue_read, logger_);
我们还将使用 T9_TradeEngine_LFQueue_read
标签在从 incoming_md_updates_
LFQueue
读取市场更新后立即进行时间戳测量:
for (auto market_update = incoming_md_updates_->
getNextToRead(); market_update; market_update =
incoming_md_updates_->getNextToRead()) {
TTT_MEASURE(T9_TradeEngine_LFQueue_read, logger_);
我们接下来需要更新的下一个方法是 TradeEngine::onOrderBookUpdate()
方法,我们将首先使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_PositionKeeper_updateBBO
标签来测量对 PositionKeeper::updateBBO()
的调用:
auto TradeEngine::onOrderBookUpdate(TickerId ticker_id,
Price price, Side side, MarketOrderBook *book) noexcept
-> void {
...
START_MEASURE(Trading_PositionKeeper_updateBBO);
position_keeper_.updateBBO(ticker_id, bbo);
END_MEASURE(Trading_PositionKeeper_updateBBO, logger_);
我们还需要测量对 FeatureEngine::onOrderBookUpdate()
方法的调用,我们使用 Trading_FeatureEngine_onOrderBookUpdate
标签:
START_MEASURE(Trading_FeatureEngine_onOrderBookUpdate);
feature_engine_.onOrderBookUpdate(ticker_id, price,
side, book);
END_MEASURE(Trading_FeatureEngine_onOrderBookUpdate,
logger_);
我们还需要测量对 TradeEngine::algoOnOrderBookUpdate_
std::function
的调用,该调用在 MarketMaker
或 LiquidityTaker
算法实例中调用 onOrderBookUpdate()
。我们使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_TradeEngine_algoOnOrderBookUpdate_
标签:
START_MEASURE(Trading_TradeEngine_algoOnOrderBookUpdate_);
algoOnOrderBookUpdate_(ticker_id, price, side, book);
END_MEASURE(Trading_TradeEngine_algoOnOrderBookUpdate_,
logger_);
下一个方法是 TradeEngine::onTradeUpdate()
方法。在这里,我们首先测量的调用是 FeatureEngine::onTradeUpdate()
,我们将其分配给 Trading_FeatureEngine_onTradeUpdate
标签:
auto TradeEngine::onTradeUpdate(const
Exchange::MEMarketUpdate *market_update,
MarketOrderBook *book) noexcept -> void {
...
START_MEASURE(Trading_FeatureEngine_onTradeUpdate);
feature_engine_.onTradeUpdate(market_update, book);
END_MEASURE(Trading_FeatureEngine_onTradeUpdate,
logger_);
我们将要测量的另一个调用是使用 TradeEngine::algoOnTradeUpdate_
标准函数的调用,它将将其转发到 MarketMaker
或 LiquidityTaker
实例。我们使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_TradeEngine_algoOnTradeUpdate_
标签:
START_MEASURE(Trading_TradeEngine_algoOnTradeUpdate_);
algoOnTradeUpdate_(market_update, book);
END_MEASURE(Trading_TradeEngine_algoOnTradeUpdate_,
logger_);
我们最后要添加探针的方法是 TradeEngine::onOrderUpdate()
。在这里,我们首先测量的函数调用将是使用 Trading_PositionKeeper_addFill
标签对 PositionKeeper::addFill()
的调用:
auto TradeEngine::onOrderUpdate(const
Exchange::MEClientResponse *client_response) noexcept -
> void {
if (UNLIKELY(client_response->type_ ==
Exchange::ClientResponseType::FILLED)) {
START_MEASURE(Trading_PositionKeeper_addFill);
position_keeper_.addFill(client_response);
END_MEASURE(Trading_PositionKeeper_addFill, logger_);
}
最后,我们在 algoOnOrderUpdate_
std::function
对象的调用周围添加 START_MEASURE
和 END_MEASURE
宏以及 Trading_TradeEngine_algoOnOrderUpdate_
标签:
START_MEASURE(Trading_TradeEngine_algoOnOrderUpdate_);
algoOnOrderUpdate_(client_response);
END_MEASURE(Trading_TradeEngine_algoOnOrderUpdate_,
logger_);
我们将在 TradeEngine
内部相互协作的每个子组件中添加一些内部测量代码,首先从 OrderManager
组件开始。
在 OrderManager 内部测量延迟
在本小节中,我们关注的重点是向 OrderManager
中添加性能测量的更改,所有代码都可以在 Chapter11/trading/strategy/order_manager.h
源文件中找到。
首先,我们将向 OrderManager::moveOrder()
方法添加测量。我们将首先测量使用 Trading_OrderManager_cancelOrder
标签对 OrderManager::cancelOrder()
方法的调用:
auto moveOrder(OMOrder *order, TickerId ticker_id,
Price price, Side side, Qty qty) noexcept {
switch (order->order_state_) {
case OMOrderState::LIVE: {
if(order->price_ != price) {
START_MEASURE(Trading_OrderManager_cancelOrder);
cancelOrder(order);
END_MEASURE(Trading_OrderManager_cancelOrder,
(*logger_));
我们还将测量对 RiskManager
组件的调用,特别是对 checkPreTradeRisk()
调用的测量。我们将在风险检查周围使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_RiskManager_checkPreTradeRisk
标签,如下所示:
case OMOrderState::DEAD: {
if(LIKELY(price != Price_INVALID)) {
START_MEASURE(Trading_RiskManager_checkPreTrade
Risk);
const auto risk_result =
risk_manager_.checkPreTradeRisk(ticker_id,
side, qty);
END_MEASURE(Trading_RiskManager_checkPreTradeRi
sk,
(*logger_));
另一个需要测量的指标是当风险检查成功时对 OrderManager::newOrder()
的调用,我们将为这个测量分配 Trading_OrderManager_newOrder
标签,如下所示:
if(LIKELY(risk_result ==
RiskCheckResult::ALLOWED)) {
START_MEASURE(Trading_OrderManager_newOrder);
newOrder(order, ticker_id, price, side, qty);
END_MEASURE(Trading_OrderManager_newOrder,
(*logger_));
我们将在 OrderManager
中添加测量的另一个方法是 moveOrders()
方法,在那里我们将使用 START_MEASURE
和 END_MEASURE
以及 Trading_OrderManager_moveOrder
标签包围对 OrderManager::moveOrder()
的调用:
auto moveOrders(TickerId ticker_id, Price bid_price,
Price ask_price, Qty clip) noexcept {
...
START_MEASURE(Trading_OrderManager_moveOrder);
moveOrder(bid_order, ticker_id, bid_price,
Side::BUY, clip);
END_MEASURE(Trading_OrderManager_moveOrder,
(*logger_));
...
START_MEASURE(Trading_OrderManager_moveOrder);
moveOrder(ask_order, ticker_id, ask_price,
Side::SELL, clip);
END_MEASURE(Trading_OrderManager_moveOrder,
(*logger_));
我们需要更新的下一个 TradeEngine
类的子组件是 MarketOrderBook
。
在 MarketOrderBook 内部测量延迟
MarketOrderBook
的更改和完整源代码可以在 Chapter11/trading/strategy/market_order_book.cpp
源文件中找到。
首先,在 MarketOrderBook::onMarketUpdate()
方法以及 MarketUpdateType::ADD
消息的情况中,我们将测量对 MarketOrderBook::addOrder()
的调用。这通常是通过使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_MarketOrderBook_addOrder
标签来实现的:
auto MarketOrderBook::onMarketUpdate(const
Exchange::MEMarketUpdate *market_update) noexcept ->
void {
...
switch (market_update->type_) {
case Exchange::MarketUpdateType::ADD: {
auto order = order_pool_.allocate(market_update->
order_id_, market_update->side_, market_update->
price_,
market_update->
qty_,
market_update-
>priority_, nullptr,
nullptr);
START_MEASURE(Trading_MarketOrderBook_addOrder);
addOrder(order);
END_MEASURE(Trading_MarketOrderBook_addOrder,
(*logger_));
为了测量在 MarketUpdateType::CANCEL
情况下对 MarketOrderBook::removeOrder()
的调用,我们将使用 Trading_MarketOrderBook_removeOrder
标签在 START_MEASURE
和 END_MEASURE
宏中:
case Exchange::MarketUpdateType::CANCEL: {
auto order = oid_to_order_.at(market_update-
>order_id_);
START_MEASURE(Trading_MarketOrderBook_removeOrder);
removeOrder(order);
END_MEASURE(Trading_MarketOrderBook_removeOrder,
(*logger_));
最后,我们将在对 MarketOrderBook::updateBBO()
的调用周围添加测量,并将其分配给 Trading_MarketOrderBook_updateBBO
标签:
START_MEASURE(Trading_MarketOrderBook_updateBBO);
updateBBO(bid_updated, ask_updated);
END_MEASURE(Trading_MarketOrderBook_updateBBO,
(*logger_));
下一个要测量的组件是其中一个交易算法——LiquidityTaker
算法。
测量 LiquidityTaker
算法内部的延迟
我们在这里讨论的更改以及完整源代码都位于 Chapter11/trading/strategy/liquidity_taker.h
源文件中。
我们的第一项测量是在 LiquidityTaker
类的 onTradeUpdate()
方法中。当信号启动交易时,我们测量对 OrderManager::moveOrders()
的调用,并将其分配给 OrderManager_moveOrders
标签,如下所示:
auto onTradeUpdate(const Exchange::MEMarketUpdate
*market_update, MarketOrderBook *book) noexcept ->
void {
...
if (agg_qty_ratio >= threshold) {
START_MEASURE(OrderManager_moveOrders);
if (market_update->side_ == Side::BUY)
order_manager_->moveOrders(market_update->
ticker_id_, bbo->ask_price_, Price_INVALID,
clip);
else
order_manager_->moveOrders(market_update->
ticker_id_, Price_INVALID, bbo->bid_price_,
clip);
END_MEASURE(OrderManager_moveOrders, (*logger_));
}
我们想要测量的另一个调用是在 onOrderUpdate()
方法中,我们使用 START_MEASURE
和 END_MEASURE
宏以及 Trading_OrderManager_onOrderUpdate
标签来测量对 OrderManager::onOrderUpdate()
的调用:
auto onOrderUpdate(const Exchange::MEClientResponse
*client_response) noexcept -> void {
START_MEASURE(Trading_OrderManager_onOrderUpdate);
order_manager_->onOrderUpdate(client_response);
END_MEASURE(Trading_OrderManager_onOrderUpdate,
(*logger_));
最后,我们来到了本章的最后一个组件,更新 MarketMaker
算法。
测量 MarketMaker
算法内部的延迟
MarketMaker
的更改和完整源代码位于 Chapter11/trading/strategy/market_maker.h
源文件中。
MarketMaker::onOrderBookUpdate()
方法包含了调用 OrderManager::moveOrders()
,这是我们接下来在代码块中使用 Trading_OrderManager_moveOrders
标签所测量的内容:
auto onOrderBookUpdate(TickerId ticker_id, Price price,
Side side, const MarketOrderBook *book) noexcept ->
void {
...
START_MEASURE(Trading_OrderManager_moveOrders);
order_manager_->moveOrders(ticker_id, bid_price,
ask_price, clip);
END_MEASURE(Trading_OrderManager_moveOrders,
(*logger_));
另一个方法,MarketMaker::onOrderUpdate()
,包含了调用 OrderManager::onOrderUpdate()
,我们也会对其进行测量,并将其分配给 Trading_OrderManager_onOrderUpdate
标签:
auto onOrderUpdate(const Exchange::MEClientResponse
*client_response) noexcept -> void {
...
START_MEASURE(Trading_OrderManager_onOrderUpdate);
order_manager_->onOrderUpdate(client_response);
END_MEASURE(Trading_OrderManager_onOrderUpdate,
(*logger_));
这总结了我们在整个电子交易生态系统中进行的所有性能测量和时间戳相关更改。我们将很快查看如何运行经过我们迄今为止所做的所有更改的生态系统,以及我们在日志文件中找到的差异。
使用新的仪器系统运行整个生态系统
运行更新后的电子交易生态系统与之前相同,通过运行以下脚本启动:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ bash scripts/run_exchange_and_clients.sh
一旦新的生态系统运行完成,你可以注意到如下性能测量日志条目,这是针对 RDTSC 测量的:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ grep Exchange_MEOrderBook_match *.log
exchange_matching_engine.log:02:42:59.980424597 RDTSC Exchange_MEOrderBook_match 205247
exchange_matching_engine.log:02:43:00.022326352 RDTSC Exchange_MEOrderBook_match 216239
对于 RDTSC 测量,也存在如下条目:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ grep Trading_MarketOrderBook_addOrder *.log
trading_engine_1.log:02:44:18.894251975 RDTSC Trading_MarketOrderBook_addOrder 204
trading_engine_1.log:02:44:18.904221378 RDTSC Trading_MarketOrderBook_addOrder 971
对于 TTT 测量,也存在如下条目:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ grep T6_MarketDataPublisher_UDP_write *.log
exchange_market_data_publisher.log:02:40:13.596201293 TTT T6_MarketDataPublisher_UDP_write 1685864413596201240
exchange_market_data_publisher.log:02:40:13.624236967 TTT T6_MarketDataPublisher_UDP_write 1685864413624236907
对于 TTT 测量,也存在如下条目:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter11$ grep T8t_OrderGateway_LFQueue_write *.log
trading_order_gateway_1.log:02:40:14.524401434 TTT T8t_OrderGateway_LFQueue_write 1685864414524401386
trading_order_gateway_1.log:02:40:14.524425862 TTT T8t_OrderGateway_LFQueue_write 1685864414524425811
我们将在下一章重新审视这些性能数据,但现在我们已经完成了这一章。
摘要
本章完全致力于衡量我们电子交易生态系统的性能。首先,我们构建了一个系统来衡量和比较执行任何任意代码块所引起的延迟。我们还构建了一个系统,在发生显著事件时生成纳秒级的时间戳。我们还讨论了这些系统设计的动机以及在使用这些性能测量技术时需要注意的各种重要点。
下一节致力于理解电子交易所端各个组件和子组件中性能测量的设计和动机。然后,我们更新了交易所中的所有源代码,以添加性能测量和时间戳代码。
在我们完成电子交易所内性能测量的讨论和实施后,我们在交易系统中进行了类似的测量。最后,我们通过运行这个更新的生态系统并观察性能测量系统的新日志条目来结束本章。
在下一章和最后一章中,我们将详细分析这些性能数据,讨论我们的发现,并讨论如何优化性能。
第十二章:分析和优化我们的 C++系统性能
在本章中,我们将基于上一章中添加的测量结果,即添加仪表和测量性能,分析我们电子交易生态系统的性能。通过分析我们基于此分析开发的关于交易系统性能的见解,我们将了解在潜在的性能瓶颈方面应该关注哪些领域,以及我们可以改进哪些领域。我们将讨论优化我们的 C++交易生态系统的技巧和技术。最后,我们将思考我们电子交易生态系统的未来,以及未来可以进行的改进。
在本章中,我们将涵盖以下主题:
-
分析我们交易生态系统的性能
-
讨论优化我们的 C++交易系统的技巧和技术
-
思考我们交易生态系统的未来
技术要求
本书的所有代码都可以在本书的 GitHub 仓库github.com/PacktPublishing/Building-Low-Latency-Applications-with-CPP
中找到。本章的源代码位于仓库中的Chapter12
目录。
由于这是本书的最后一章,我们将讨论提高整个电子交易生态系统性能的技巧以及未来的改进,我们希望您已经阅读了所有前面的章节。
本书源代码开发环境的规格如下所示。我们展示了这个环境的详细信息,因为本书中展示的所有 C++代码可能并不一定可移植,可能需要在您的环境中进行一些小的修改才能工作:
-
操作系统:
Linux 5.19.0-41-generic #42~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC 周二 4 月 18 17:40:00 UTC 2 x86_64 x86_64
x86_64 GNU/Linux
-
GCC:
g++ (Ubuntu 11.3.0-1ubuntu1~22.04.1) 11.3.0
-
CMake:
cmake version 3.23.2
-
Ninja:
1.10.2
此外,对于那些有兴趣运行本章包含的可选 Python Jupyter 笔记本的用户,以下环境被使用。我们不会讨论 Python、Jupyter 以及这些库的安装过程,并假设您会自己解决这些问题:
-----
Python 3.10.6 (main, Mar 10 2023, 10:55:28) [GCC 11.3.0]
Linux-5.19.0-43-generic-x86_64-with-glibc2.35
-----
IPython 8.13.2
jupyter_client 8.2.0
jupyter_core 5.3.0
notebook 6.5.4
-----
hvplot 0.8.3
numpy 1.24.3
pandas 2.0.1
plotly 5.14.1
-----
分析我们交易生态系统的性能
在我们分析电子交易生态系统的性能之前,让我们快速回顾一下上一章中添加的测量指标。
重新审视我们测量的延迟
我们添加了两种测量形式。第一种测量内部组件的性能,第二种在系统的关键点生成时间戳。
第一种形式,它测量内部组件的延迟,在调用不同函数前后生成RDTSC
值的差异,并生成如下日志条目:
exchange_order_server.log:18:48:29.452140238 RDTSC Exchange_FIFOSequencer_addClientRequest 26
trading_engine_1.log:18:48:29.480664387 RDTSC Trading_FeatureEngine_onOrderBookUpdate 39272
trading_engine_1.log:18:48:29.480584410 RDTSC Trading_MarketOrderBook_addOrder 176
trading_engine_1.log:18:48:29.480712854 RDTSC Trading_OrderManager_moveOrder 32
trading_engine_1.log:18:48:29.254832602 RDTSC Trading_PositionKeeper_addFill 94350
trading_engine_1.log:18:48:29.480492650 RDTSC Trading_RiskManager_checkPreTradeRisk 1036
...
第二种形式,它测量交易生态系统中关键点的延迟,生成绝对时间戳值,并生成如下日志条目:
trading_engine_1.log:18:48:29.440526826 TTT T10_TradeEngine_LFQueue_write 1686008909440526763
exchange_order_server.log:18:48:29.452087295 TTT T1_OrderServer_TCP_read 1686008909452087219
exchange_market_data_publisher.log:18:48:29.467680305 TTT T5_MarketDataPublisher_LFQueue_read 1686008909467680251
trading_market_data_consumer_1.log:18:48:29.478030090 TTT T8_MarketDataConsumer_LFQueue_write 1686008909478029956
trading_engine_1.log:18:48:29.480552551 TTT T9_TradeEngine_LFQueue_read 1686008909480552495
...
现在,让我们继续分析这些延迟测量值。
分析性能
为了分析这些性能指标,我们构建了一个 Python Jupyter 笔记本,它位于Chapter12/notebooks/perf_analysis.ipynb
。请注意,由于这是一本关于 C++和低延迟应用的书籍,我们不会讨论这个笔记本中的源代码,而是描述分析过程。运行笔记本是可选的,所以我们还包含了一个包含此分析结果的 HTML 文件,它位于Chapter12/notebooks/perf_analysis.html
。要运行这个笔记本,你首先必须从Chapter12
根目录(其中存在日志文件)使用以下命令启动jupyter notebook
服务器:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter12$ jupyter notebook
...
To access the notebook, open this file in a browser:
file:///home/sghosh/.local/share/jupyter/runtime/nbserver-182382-open.html
Or copy and paste one of these URLs:
http://localhost:8888/?token=d28e3bd3b1f8109b12afe1210ae8c494c7 7a4128e23bdae7
or http://127.0.0.1:8888/?token=d28e3bd3b1f8109b12afe1210ae8c494c7 7a4128e23bdae7
如果你的浏览器还没有打开这个笔记本的网页,你可以复制并粘贴你收到的 URL,导航到并打开notebooks/perf_analysis.ipynb
笔记本。请注意,前面的地址只是这个特定运行的示例;你将收到一个不同的地址,你应该使用。一旦打开笔记本,你可以使用Cell | Run All,或者在你的笔记本实例中显示的最近似等效方式来运行它,如下面的截图所示。
图 12.1 – perf_analysis.ipynb 笔记本的截图
由于我们不会讨论这个笔记本的细节,我们将简要描述其中执行的分析。这个笔记本按照以下顺序执行以下步骤:
-
首先,它在当前工作目录中查找运行电子交易生态系统的日志文件。具体来说,它查找来自交易交易所的日志文件;在这个笔记本的情况下,我们查找
ClientId=1
的交易客户端的日志文件。 -
它打开每个日志文件,并查找包含
RDTSC
和TTT
标记的日志条目,以找到与我们在上一章中讨论并在前一小节中回顾的测量值相对应的日志条目。 -
然后,它创建了两个
pandas
DataFrame
实例,包含从日志文件中提取的每个测量值。 -
对于对应于内部函数测量的测量条目,这些条目被标记为
RDTSC
令牌,我们生成了这些测量的散点图以及这些图的滚动平均值(以平滑整体延迟测量)。这里的一个关键点是,日志文件中的测量值代表RDTSC
值的差异,即函数调用所经过的 CPU 周期数。在本笔记本中,我们使用 2.6 GHz 的常数因子将 CPU 周期转换为纳秒,这是针对我们系统的特定值,并且会根据您的硬件而有所不同;它需要调整。我们将在下一小节中查看这些图的一些示例。 -
对于对应于我们电子交易生态系统关键位置的时间戳的测量条目,这些条目被标记为
TTT
令牌,我们也生成了散点图和滚动平均值图。这里的区别在于我们显示了从一个跳到另一个跳的传输时间。例如,我们将绘制从T1_OrderServer_TCP_read
跳到T2_OrderServer_LFQueue_write
跳的时间,从T2_OrderServer_LFQueue_write
跳到T3_MatchingEngine_LFQueue_read
跳,从T3_MatchingEngine_LFQueue_read
跳到T4_MatchingEngine_LFQueue_write
跳,等等。
这些在交易所侧的跨跳传输在以下图中展示。
图 12.2 – 电子交易所不同跳之间的数据流
这些在交易客户端侧的跨跳传输在以下图中展示。
图 12.3 – 电子交易客户端不同跳之间的数据流
在下一小节中,我们将观察来自两组(RDTSC
和TTT
)的一些不同延迟指标的分布,并看看我们能从中学习到什么。
理解分析输出
在本节中,我们将展示我们在上一章中添加并使用上一小节中展示的笔记本分析的测量子集的延迟分布。我们的目标是深入了解我们生态系统中不同组件和子组件的性能。首先,我们将在下一小节中从内部函数调用的延迟的几个示例开始。需要注意的是,为了简洁起见,我们将展示和讨论本章 Python 笔记本中所有性能图的一个子集。另外,请注意,这些图没有按照任何特定的顺序排列;我们只是挑选了一些更有趣的图,并将所有可能的图都留在了笔记本中供您进一步检查。
观察内部函数调用的延迟
本章中我们首先展示的性能图表是在交易交易所内部匹配引擎中调用Exchange::MEOrderBook::removeOrder()
方法的延迟分布。它呈现如下,但我们的关键结论是,这是一个表现非常好的函数;也就是说,最小和最大延迟在 0.4 到 3.5 微秒的紧密范围内,平均值相对稳定在 1 到 1.5 微秒的范围内。当然,有可能使其更快,但现在这似乎表现良好,并且具有低性能延迟;在尝试进一步优化之前,我们应该评估此方法是否是瓶颈。
图 12.4 – MEOrderBook 中 removeOrder()方法在匹配引擎中的延迟分布
下一个图表展示了Exchange::FIFOSequencer::sequenceAndPublish()
方法的延迟分布。这个实例更有趣,因为在这里我们看到,尽管这个方法在 90 微秒范围内具有较低的平均延迟,但它经历了许多延迟峰值,峰值达到 500 到 1,200 微秒。这种行为将导致OrderServer
组件在处理客户端订单请求时的性能出现抖动,这是我们可能需要调查的问题。
图 12.5 – FIFOSequencer 中 sequenceAndPublish()方法在匹配引擎中的延迟分布
下一个图表显示了Trading::PositionKeeper::addFill()
方法的另一个有趣的延迟值分布。在这种情况下,平均性能延迟稳定在 50 微秒左右。然而,在15:28:00到15:29:00之间,有几个延迟峰值需要进一步观察。与图 12.4相比,这里的差异在于那里的峰值分布均匀,但在这个案例中,似乎有一个小的峰值区域。
图 12.6 – PositionKeeper 中 addFill()方法在交易引擎中的延迟分布
我们通过展示另一个图表来结束本小节,这次是Trading::PositionKeeper::updateBBO()
方法的图表,它更新了开放头寸的损益。这是一个表现良好的方法,平均性能延迟为 10 微秒,似乎有很多测量值接近 0 微秒,这与图 12.3略有不同,在那里最小延迟值从未显著接近 0。
图 12.7 – 交易引擎 PositionKeeper 中 updateBBO()方法的延迟分布
在下一小节中,我们将查看一些类似的例子,但这次是关于我们生态系统中不同跳之间的延迟。
观察生态系统中跳之间的延迟
我们将要查看的第一个图表是交易客户端的OrderGateway
组件将客户端请求写入 TCP 套接字(T12
)到交易所的OrderServer
组件从 TCP 套接字读取该客户端请求(T1
)的时间差。这代表了 TCP 连接上从交易客户端到交易交易所的网络传输时间。在这种情况下,平均延迟大约在 15 到 20 微秒之间,分布是均匀的。
图 12.8 – T12_OrderGateway_TCP_write 和 T1_OrderServer_TCP_read 跳之间的延迟分布
下一个图表显示了市场数据更新的网络传输时间分布,从市场数据更新由MarketDataPublisher
(T6
)写入 UDP 套接字到由MarketDataConsumer
(T7
)从 UDP 套接字读取它们的时间。如图所示,这个测量的延迟似乎有很大的变化;然而,这个路径的整体延迟比 TCP 路径要低。
图 12.9 – T6_MarketDataPublisher_UDP_write 和 T7_MarketDataConsumer_UDP_read 跳之间的延迟分布
下一个图表显示了从MarketDataConsumer
从 UDP 套接字(T7
)读取市场更新到市场更新写入连接到TradeEngine
的LFQueue
(T8
)的时间的延迟分布。与平均性能大约为 100 微秒相比,这个路径的延迟出现了巨大的峰值(高达 2,000 微秒),因此这是我们需要调查的。
图 12.10 – T7_MarketDataConsumer_UDP_read 和 T8_MarketDataConsumer_LFQueue_write 跳之间的延迟分布
下一个图表显示了MatchingEngine
从连接到OrderServer
的LFQueue
(T3
)读取客户端请求到MatchingEngine
处理它并将客户端响应写回LFQueue
到OrderServer
(T4t
)之间的延迟分布。这个路径似乎也经历了大的延迟峰值,应该进行调查。
图 12.11 – T3_MatchingEngine_LFQueue_read 和 T4t_MatchingEngine_LFQueue_write 跳数之间的延迟分布
本节专门用于分析我们生态系统中的不同延迟测量。在下一节中,我们将讨论一些我们可以用来优化我们电子交易生态系统中不同组件的设计和实现的技巧和技术。
讨论优化我们的 C++交易系统的技巧和技术
在本节中,我们将展示一些我们可以优化我们的 C++交易生态系统的可能区域。请注意,这些只是一些示例,还有很多其他可能性,但我们将让您去衡量和发现这些低效之处,以及进一步改进它们。为了重申我们之前提到过几次的内容,您应该使用我们在上一章“添加仪表和测量性能”中学到的所有知识来衡量您系统各个部分的性能。您应该使用本章中讨论的方法来分析它们,并使用我们在“从低延迟应用程序的角度探索 C++概念”这一章节中讨论的 C++讨论来进一步改进它们。现在,让我们讨论一些改进的区域。我们试图将这些区域从最少到最多工作量进行排列。
优化发布构建
第一建议是尝试优化我们为系统运行的发布构建。我们可以在代码本身中做的简单事情是,从发布二进制文件中移除对ASSERT()
的调用。背后的动机是移除这个宏在我们代码库中任何使用的地方引入的额外if
条件。然而,这可能是危险的,因为我们可能会允许异常条件通过。最佳折衷方案是从安全的地方移除此宏在关键代码路径上的使用。
另一个建议是减少发布构建中的日志记录。我们已经付出了相当大的努力来使日志记录高效且低延迟。此外,完全消除日志记录是不明智的,因为它会使故障排除变得困难,甚至不可能。然而,日志记录不是免费的,因此我们应该尽可能减少发布构建中的关键路径上的日志记录。
如我们在此处所建议的,执行优化的最常见方法,仅适用于发布构建,是定义 NDEBUG(无调试)预处理器标志,并在我们的代码库中检查其存在。如果定义了此标志,我们将构建发布版本并跳过非必要代码,例如断言和日志记录。
这里展示了MemoryPool::deallocate()
方法的示例:
auto deallocate(const T *elem) noexcept {
const auto elem_index = (reinterpret_cast<const
ObjectBlock *>(elem) - &store_[0]);
#if !defined(NDEBUG)
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));
#endif
store_[elem_index].is_free_ = true;
}
这里展示了FIFOSequencer::sequenceAndPublish()
方法的另一个示例:
auto sequenceAndPublish() {
...
#if !defined(NDEBUG)
logger_->log("%:% %() % Processing % requests.\n",
__FILE__, __LINE__, __FUNCTION__, Common::
getCurrentTimeStr(&time_str_), pending_size_);
#endif
...
for (size_t i = 0; i < pending_size_; ++i) {
const auto &client_request =
pending_client_requests_.at(i);
#if !defined(NDEBUG)
logger_->log("%:% %() % Writing RX:% Req:% to
FIFO.\n", __FILE__, __LINE__, __FUNCTION__,
Common::getCurrentTimeStr(&time_str_),
client_request.recv_time_,
client_request.request_.toString());
#endif
...
}
另一个需要考虑的问题是,实际记录的条目是否可以以更优的方法输出。例如,Common::getCurrentTimeStr()
,在当前代码库状态下的每条日志记录中都会被调用,它相当昂贵。这是因为它使用sprintf()
执行字符串格式化操作,这就像大多数字符串格式化操作一样昂贵。在这里,我们有一个优化,在发布构建中,我们可以输出一个简单的整数来表示时间,而不是格式化的字符串,虽然更易读,但效率更低。
让我们继续探讨下一个可能的优化领域——管理线程亲和性。
正确设置线程亲和性
到目前为止,在创建和启动线程的所有实例中,我们都在调用Common::createAndStartThread()
方法时传递了core_id
参数为-1
;也就是说,线程没有被固定在任何特定的核心上。这是故意为之,因为我们之前提到过,exchange_main
应用程序实例创建并运行 10 个线程,而每个trading_main
应用程序实例创建并运行 8 个线程。除非你在生产级交易服务器上执行这本书的源代码,否则不太可能有太多的 CPU 核心。例如,我们的系统只有四个核心。然而,在实践中,以下性能关键线程将各自分配一个 CPU 核心。我们接下来将展示一个核心分配的示例;然而,这会因服务器而异,也可能取决于 NUMA 架构——但这超出了本书的范围。请注意,这些名称指的是我们在name
字符串参数中传递给方法的名称:
-
core_id
=0 :Exchange/MarketDataPublisher
-
core_id
=1 :Exchange/MatchingEngine
-
core_id
=2 :Exchange/OrderServer
-
core_id
=3 :Trading/MarketDataConsumer
-
core_id
=4 :Trading/OrderGateway
-
core_id
=5 :Trading/TradeEngine
-
任何额外的性能关键线程将以类似的方式分配剩余的核心 ID
剩余的非关键线程以及服务器上运行的任何 Linux 进程都将分配一组 CPU 核心来运行,而不进行任何亲和性设置。具体来说,在我们的系统中,它们将是以下非关键线程:
-
core_id
=-1 :Exchange/SnapshotSynthesizer
-
core_id
=-1 :Common/Logger exchange_main.log
-
core_id
=-1 :Common/Logger exchange_matching_engine.log
-
core_id
=-1 :Common/Logger exchange_market_data_publisher.log
-
core_id
=-1 :Common/Logger exchange_snapshot_synthesizer.log
-
core_id
=-1 :Common/Logger exchange_order_server.log
-
core_id
=-1 :Common/Logger trading_main_1.log
-
core_id
=-1 :Common/Logger trading_engine_1.log
-
core_id
=-1 :Common/Logger trading_order_gateway_1.log
-
core_id
=-1 :Common/Logger trading_market_data_consumer_1.log
-
任何其他非关键线程也会被分配核心 ID -1,即这些线程不会被固定在任何一个特定的 CPU 核心上
注意一个额外的细节:为了使此设置尽可能优化,我们需要确保 Linux 进程调度器不将任何操作系统进程分配给被关键线程使用的 CPU 核心。在 Linux 上,这是通过使用isolcpus
内核参数实现的,我们在此不详细讨论。isolcpus
参数告诉进程调度器在决定在哪里调度进程时忽略哪些核心。
优化字符串的 Logger
我们有机会优化Logger
类以更好地处理char*
类型的参数。记住,我们记录char*
参数的实现是通过迭代地对每个字符调用Logger::pushValue(const char value)
方法,如下所示:
auto pushValue(const char *value) noexcept {
while (*value) {
pushValue(*value);
++value;
}
}
这里的一个选择是向LogType
枚举中引入一个新的枚举值。让我们称它为STRING
,如下所示:
enum class LogType : int8_t {
...
DOUBLE = 8,
STRING = 9
};
我们将更新LogElement
类型,使其具有固定大小的char*
数组,如下所示。我们故意对数组的尺寸保持模糊,因为这是伪代码,我们更关注设计和想法,而不是实现细节:
struct LogElement {
LogType type_ = LogType::CHAR;
union {
...
double d;
char str[SOME_SIZE];
} u_;
};
最后,更新Logger::pushValue(const char *value)
和Logger::flushQueue()
,以便以字符块的形式复制和写入字符串,而不是一次写入一个字符。
消除 std::function 实例的使用
在我们的代码库中,我们在几个地方使用了std::function<>
函数包装器,如下所示:
-
Common::McastSocket
:std::function<void(McastSocket *s)> recv_callback_;
-
Common::TCPServer
:std::function<void(TCPSocket *s, Nanos rx_time)> recv_callback_;
std::function<void()> recv_finished_callback_;
-
Common::TCPSocket
:std::function<void(TCPSocket *s, Nanos rx_time)> recv_callback_;
-
Trading::TradeEngine
:std::function<void(TickerId ticker_id, Price price, Side side, MarketOrderBook *book)> algoOnOrderBookUpdate_;
std::function<void(const Exchange::MEMarketUpdate *market_update, MarketOrderBook *book)> algoOnTradeUpdate_;
std::function<void(const Exchange::MEClientResponse *client_response)> algoOnOrderUpdate_;
通过这些对象调用函数比直接调用函数慢,并且这些调用会带来与virtual
函数相似的成本。使用std::function<>
对象调用方法的这种机制可以用模板来替换。为了刷新您对间接调用函数的缺点,请重新阅读章节从低延迟应用程序的角度探索 C++概念,特别是调用函数高效部分的避免函数指针子部分。此外,请重新阅读同一章节中的使用编译时多态部分,回顾我们代码库中std::function<>
实例的讨论,但我们鼓励有兴趣的人尝试进行这种改进。
检查这些优化的影响
我们无法详细调查每一个优化机会,但在完成本节之前,我们将讨论本节中提到的两个优化的细节。首先,让我们讨论对用于记录字符串的Logger
类的优化实现及其影响。
基准测试 Logger 字符串优化
为了实现日志记录器的字符串优化,我们将改变之前讨论过的 pushValue()
方法,用于 char*
参数。为了简洁起见,我们不会查看完整的类,我们将在 Chapter12/common/opt_logging.h
源文件中实现的备用 OptLogger
类中查看。这里显示的是最重要的更改,但请参阅完整的源文件以查看其他一些小的更改:
auto pushValue(const char *value) noexcept {
LogElement l{LogType::STRING, {.s = {}}};
strncpy(l.u_.s, value, sizeof(l.u_.s) - 1);
pushValue(l);
}
为了对这个进行基准测试并与原始的 Logger
实现进行比较,我们将创建一个简单的独立二进制文件,称为 logger_benchmark
。我们这样做是为了能够在受控环境中检查性能影响。请记住,运行完整的交易生态系统会引入许多变量,包括进程和线程的数量、网络活动、交易活动等,这可能会很难正确评估 Logger
优化的影响。这个基准测试应用程序的源代码可以在 Chapter12/benchmarks/logger_benchmark.cpp
源文件中找到。在查看结果之前,让我们快速查看这个源文件的实现。
首先,我们将包含与原始 Logger
和新 OptLogger
类对应的头文件:
#include "common/logging.h"
#include "common/opt_logging.h"
接下来,我们将定义一个 random_string()
方法,它简单地生成指定长度的随机字符串。我们将使用这个方法来生成随机字符串,以便两个日志记录器进行记录,比较字符串方面的性能差异。这使用了 charset()
lambda 方法,该方法返回一个随机的字母数字字符(0-9,a-z 或 A-Z)。然后,它使用 std::generate_n()
方法通过重复调用 charset()
lambda 方法来生成一个长度由长度参数指定的 std::string
:
std::string random_string(size_t length) {
auto randchar = []() -> char {
const char charset[] =
"0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz";
const size_t max_index = (sizeof(charset) - 1);
return charset[rand() % max_index];
};
std::string str(length, 0);
std::generate_n(str.begin(), length, randchar);
return str;
}
接下来,我们将定义一个 benchmarkLogging()
方法,它接受一个模板参数 T
,它期望它是一个我们在这里比较的两个日志记录器之一的实例。它运行一个循环 100,000 次,并使用我们之前构建的 random_string()
方法和日志记录器的 log()
方法来记录 100,000 个随机字符串。对于 log()
方法的每次调用,它使用我们在上一章中构建的 Common::rdtsc()
方法记录并累加时钟周期的差异。最后,它通过将每个 RDTSC 差异的和除以循环次数来返回平均时钟周期数:
template<typename T>
size_t benchmarkLogging(T *logger) {
constexpr size_t loop_count = 100000;
size_t total_rdtsc = 0;
for (size_t i = 0; i < loop_count; ++i) {
const auto s = random_string(128);
const auto start = Common::rdtsc();
logger->log("%\n", s);
total_rdtsc += (Common::rdtsc() - start);
}
return (total_rdtsc / loop_count);
}
现在,我们终于可以构建 main()
方法了,它相当简单。它创建了一个旧日志记录器的实例 – Common::Logger()
,然后调用其上的 benchmarkLogging()
方法,并将平均时钟周期数输出到屏幕。然后,它再次执行完全相同的事情,只是这次它使用的是新的日志记录器 – OptCommon::OptLogger()
:
int main(int, char **) {
using namespace std::literals::chrono_literals;
{
Common::Logger logger("logger_benchmark_original.log");
const auto cycles = benchmarkLogging(&logger);
std::cout << "ORIGINAL LOGGER " << cycles << " CLOCK
CYCLES PER OPERATION." << std::endl;
std::this_thread::sleep_for(10s);
}
{
OptCommon::OptLogger opt_logger
("logger_benchmark_optimized.log");
const auto cycles = benchmarkLogging(&opt_logger);
std::cout << "OPTIMIZED LOGGER " << cycles << " CLOCK
CYCLES PER OPERATION." << std::endl;
std::this_thread::sleep_for(10s);
}
exit(EXIT_SUCCESS);
}
这个二进制文件可以使用之前的相同脚本构建,即从Chapter12
根目录运行scripts/build.sh
。要运行二进制文件,您可以直接从命令行调用它,如下所示,并且,在输出中,您将看到以下两行显示基准测试的结果:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter12$ ./cmake-build-release/logger_benchmark
ORIGINAL LOGGER 25757 CLOCK CYCLES PER OPERATION.
OPTIMIZED LOGGER 466 CLOCK CYCLES PER OPERATION.
注意,每次运行的输出可能会有所不同,您得到的结果可能会因系统依赖性而不同,但优化所加快的速度应该与我们所展示的相似。在这种情况下,我们的优化努力似乎使字符串的log()
方法加快了大约 50 倍。接下来,让我们看看我们之前讨论的优化技巧的另一个示例,即优化发布构建的二进制文件。
基准测试发布构建优化
为了基准测试从发布构建中省略非必要代码的示例,我们选择了MemPool
类。请注意,这个原则适用于我们构建的所有组件,但我们随意选择了一个来限制我们讨论的范围。类似于我们对Logger
类所做的那样,我们创建了一个新的类,称为OptMemPool
,您可以在Chapter12/common/opt_mem_pool.h
源文件中找到它。与MemPool
类相比,这个文件的主要变化是,对ASSERT()
的调用仅针对非发布构建进行编译。这通过检查NDEBUG
预处理器标志来实现,如下面的两个示例所示。您可以在我们之前提到的文件中查看完整的源代码:
template<typename... Args>
T *allocate(Args... args) noexcept {
auto obj_block = &(store_[next_free_index_]);
#if !defined(NDEBUG)
ASSERT(obj_block->is_free_, "Expected free
ObjectBlock at index:" + std::to_string
(next_free_index_));
#endif
...
}
auto deallocate(const T *elem) noexcept {
const auto elem_index = (reinterpret_cast<const
ObjectBlock *>(elem) - &store_[0]);
#if !defined(NDEBUG)
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));
#endif
...
}
为了基准测试这个优化,我们将构建一个release_benchmark
二进制文件,其代码可在Chapter12/benchmarks/release_benchmark.cpp
源文件中找到。首先,让我们看看我们需要包含的头文件,最重要的是mem_pool.h
和opt_mem_pool.h
文件。由于内存池存储结构,我们将使用Exchange::MDPMarketUpdate
作为示例,因此我们也包含market_update.h
头文件:
#include "common/mem_pool.h"
#include "common/opt_mem_pool.h"
#include "common/perf_utils.h"
#include "exchange/market_data/market_update.h"
类似于我们对logger_benchmark.cpp
文件所做的那样,我们将创建一个benchmarkMemPool()
方法,它接受一个模板参数T
,并期望它是我们比较的两个内存池之一。在这个方法中,我们首先使用allocate()
方法从内存池中分配并保存 256 个MDPMarketUpdate
对象。然后,我们将使用deallocate()
方法释放这些对象并将它们返回到内存池中。我们将运行这个循环 100,000 次,以在多次迭代中找到一个可靠的平均值。我们将测量并累加每次调用allocate()
和deallocate()
所花费的时钟周期,就像我们在日志基准测试中所做的那样。最后,我们将通过将时钟周期总和除以循环计数来返回平均时钟周期:
template<typename T>
size_t benchmarkMemPool(T *mem_pool) {
constexpr size_t loop_count = 100000;
size_t total_rdtsc = 0;
std::array<Exchange::MDPMarketUpdate*, 256>
allocated_objs;
for (size_t i = 0; i < loop_count; ++i) {
for(size_t j = 0; j < allocated_objs.size(); ++j) {
const auto start = Common::rdtsc();
allocated_objs[j] = mem_pool->allocate();
total_rdtsc += (Common::rdtsc() - start);
}
for(size_t j = 0; j < allocated_objs.size(); ++j) {
const auto start = Common::rdtsc();
mem_pool->deallocate(allocated_objs[j]);
total_rdtsc += (Common::rdtsc() - start);
}
}
return (total_rdtsc / (loop_count *
allocated_objs.size()));
}
最后,我们构建了main()
方法,这同样非常简单。它调用了两次benchmarkMemPool()
方法,一次是用Common::MemPool
类型的对象,另一次是用OptCommon::OptMemPool
类型的对象,并输出了allocate()
和deallocate()
方法的平均时钟周期数:
int main(int, char **) {
{
Common::MemPool<Exchange::MDPMarketUpdate>
mem_pool(512);
const auto cycles = benchmarkMemPool(&mem_pool);
std::cout << "ORIGINAL MEMPOOL " << cycles << " CLOCK
CYCLES PER OPERATION." << std::endl;
}
{
OptCommon::OptMemPool<Exchange::MDPMarketUpdate>
opt_mem_pool(512);
const auto cycles = benchmarkMemPool(&opt_mem_pool);
std::cout << "OPTIMIZED MEMPOOL " << cycles << " CLOCK
CYCLES PER OPERATION." << std::endl;
}
exit(EXIT_SUCCESS);
}
构建这个基准二进制的过程保持不变,所以我们将不会重复它。运行二进制文件将产生类似于以下内容的结果:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter12$ ./cmake-build-release/release_benchmark
ORIGINAL MEMPOOL 343 CLOCK CYCLES PER OPERATION.
OPTIMIZED MEMPOOL 44 CLOCK CYCLES PER OPERATION.
在这种情况下,我们的优化努力使得allocate()
和deallocate()
方法的速度提高了大约 7 到 8 倍。
在本节中,我们介绍并解释了我们电子交易生态系统中的优化领域/想法的一个子集。我们的目标是让你了解这些优化领域可能的样子,以及如何以优化性能为目标来处理它们。在下一节中,我们将讨论一些更多可以对我们电子交易生态系统进行的未来改进和增强。
思考我们交易生态系统的未来
在我们结束这一章节和这本书之前,我们将讨论一些可能的增强我们的电子交易生态系统的方案。在前一节中,我们讨论了一些可以优化的例子,对于那些希望最大化我们在这本书中构建的电子交易系统性能的人来说。在本节中,我们将讨论一些如何增强这个生态系统的例子,不一定是为了减少延迟,而是为了让系统更加功能丰富并增加功能。
动态增长容器
我们在这本书中构建并使用了一些容器,如下所示:
-
无锁队列 -
LFQueue
- 被用于多个组件,用于各种对象类型,例如MEMarketUpdate
、MDPMarketUpdate
、MEClientRequest
和MEClientResponse
-
内存池 -
MemPool
- 被用于多种对象类型,例如MEMarketUpdate
、MEOrder
、MEOrdersAtPrice
、MarketOrder
和MarketOrdersAtPrice
的实例
在所有这些情况下,我们都假设了一个安全的最大值。在实践中,这仍然使我们面临在某种情况下可能超过这些限制并陷入麻烦的可能性。我们可以对这个系统进行的一个增强是改进我们对这种不太可能边缘情况的处理。
一个选择是在遇到LFQueue
已满或MemPool
内存不足的场景时失败/退出。另一个选择是在这种不太可能的事件中回退到动态内存分配或一个次级低效的容器;也就是说,在我们耗尽内存或容器空间的情况下,我们将变得低效和缓慢,但我们将继续运行直到问题解决。另一个选择是使这些容器具有灵活性,在需要时可以扩展,尽管在需要时扩展这些容器的任务将非常缓慢,因为在实践中我们并不期望遇到那种情况。
增长和增强哈希映射
在本书中,我们假设一个安全的上限,在许多上下文中使用std::array
作为哈希映射。例如,通过假设有效的TickerId
值在 0 和ME_MAX_TICKERS
之间,我们使用了大小为ME_MAX_TICKERS
的std::array
实例作为以TickerId
为键的哈希映射。类似的设计也用于TradeEngineCfgHashMap
、OrderHashMap
、ClientOrderHashMap
、OrdersAtPriceHashMap
、OrderBookHashMap
、MarketOrderBookHashMap
和OMOrderTickerSideHashMap
等容器。虽然在实际应用中,其中一些可以继续存在,也就是说,可以决定并使用有效的合理上限,但对于其中的一些,这种设计将无法优雅地扩展。
可用的哈希映射实现有多种——std::unordered_map
、absl::flat_hash_map
、boost::
哈希映射、emhash7::HashMap
、folly::AtomicHashmap
、robin_hood::unordered_map
、tsl::hopscotch_map
以及更多。此外,通常会对这些容器进行优化和调整,以便在特定的使用场景下表现最佳。我们将把这个任务留给那些对此感兴趣的人,即探索这些实现并决定哪些可以替换我们系统中基于std::array
的哈希映射。
为了演示一个例子,我们将替换掉匹配引擎构建和维护的基于std::array
的订单簿中的哈希映射(MEOrderBook
),用std::unordered_map
哈希映射。然后我们将对这两种实现进行基准测试,看看有多大差别。按照我们在本章前面进行的基准测试所使用的相同模式,我们将引入一个新的MEOrderBook
类,名为UnorderedMapMEOrderBook
,其中唯一的区别是使用std::unordered_map
容器而不是std::array
容器。这个新类的所有源代码都可在Chapter12/exchange/matcher/unordered_map_me_order_book.h
和Chapter12/exchange/matcher/unordered_map_me_order_book.cpp
源文件中找到。为了简洁起见,我们不会在这里重复整个类的实现,但我们将讨论重要的更改。第一个重要且明显的变化是在unordered_map_me_order_book.h
头文件中包含了unordered_map
头文件:
#include <unordered_map>
我们将cid_oid_to_order_
数据成员更改为std::unordered_map<ClientId, std::unordered_map<OrderId, MEOrder *>>
,而不是ClientOrderHashMap
,它是std::array<OrderHashMap, ME_MAX_NUM_CLIENTS>
的typedef
。这个数据成员是一个从ClientId
到OrderId
再到MEOrder
对象的哈希表。记住,ClientOrderHashMap
实际上是一个哈希表的哈希表,即一个其元素也是std::array
对象的std::array
。我们更改的另一个数据成员是price_orders_at_price_
成员,我们将其更改为std::unordered_map<Price, MEOrdersAtPrice *>
,而不是OrdersAtPriceHashMap
类型。这个数据成员是一个从Price
到MEOrdersAtPrice
对象的哈希表。如果你忘记了MEOrder
和MEOrdersAtPrice
是什么,请回顾章节“构建 C++撮合引擎”中的“定义撮合引擎中的操作和交互”部分的“设计交易所订单簿”子部分。以下是这些更改的示例:
namespace Exchange {
class UnorderedMapMEOrderBook final {
private:
...
std::unordered_map<ClientId, std::
unordered_map<OrderId, MEOrder *>> cid_oid_to_order_;
std::unordered_map<Price, MEOrdersAtPrice *>
price_orders_at_price_;
...
};
}
我们需要从析构函数中移除以下行,因为fill()
方法不适用于std::unordered_map
对象:
MEOrderBook::~MEOrderBook() {
…
for (auto &itr: cid_oid_to_order_) {
itr.fill(nullptr);
}
}
在访问这些修改后的容器方面,我们将对cid_oid_to_order_
和price_orders_at_price_
的std::array::at()
方法调用替换为std::unordered_map::operator[]
方法。以下是cid_oid_to_order_
的这些更改:
auto removeOrder(MEOrder *order) noexcept {
...
cid_oid_to_order_[order->client_id_][order->
client_order_id_] = nullptr;
order_pool_.deallocate(order);
}
auto addOrder(MEOrder *order) noexcept {
...
cid_oid_to_order_[order->client_id_][order->
client_order_id_] = order;
}
auto UnorderedMapMEOrderBook::cancel(ClientId client_id,
OrderId order_id, TickerId ticker_id) noexcept -> void {
auto is_cancelable = (client_id <
cid_oid_to_order_.size());
MEOrder *exchange_order = nullptr;
if (LIKELY(is_cancelable)) {
auto &co_itr = cid_oid_to_order_[client_id];
exchange_order = co_itr[order_id];
is_cancelable = (exchange_order != nullptr);
}
...
}
我们需要在访问price_orders_at_price_
容器的地方进行类似的更改,如下所示:
auto getOrdersAtPrice(Price price) const noexcept ->
MEOrdersAtPrice * {
if(price_orders_at_price_.find(priceToIndex(price))
== price_orders_at_price_.end())
return nullptr;
return price_orders_at_price_
.at(priceToIndex(price));
}
auto addOrdersAtPrice(MEOrdersAtPrice
*new_orders_at_price) noexcept {
price_orders_at_price_
[priceToIndex(new_orders_at_price->price_)] =
new_orders_at_price;
...
}
auto removeOrdersAtPrice(Side side, Price price)
noexcept {
...
price_orders_at_price_[priceToIndex(price)] =
nullptr;
orders_at_price_pool_.deallocate(orders_at_price);
}
最后,我们展示了hash_benchmark
二进制文件来衡量这些更改带来的性能差异。这个二进制文件的源代码可以在Chapter12/benchmarks/hash_benchmark.cpp
源文件中找到。首先,我们包含以下头文件,并定义一个全局的loop_count
变量,就像我们在之前的基准测试中所做的那样:
#include "matcher/matching_engine.h"
#include "matcher/unordered_map_me_order_book.h"
static constexpr size_t loop_count = 100000;
如我们之前所做的那样,我们将定义一个benchmarkHashMap()
方法,它接受一个模板参数T
,以表示MEOrderBook
或UnorderedMapMEOrderBook
。它还接受一个Exchange::MEClientRequest
消息的向量,这些消息将在基准测试中处理。实际的处理相当简单。它检查MEClientRequest
的类型,然后对ClientRequestType::NEW
调用add()
方法,对ClientRequestType::CANCEL
调用cancel()
方法。我们使用Common::rdtsc()
来测量并汇总每个这些调用所消耗的时钟周期,然后在方法结束时返回平均值:
template<typename T>
size_t benchmarkHashMap(T *order_book, const
std::vector<Exchange::MEClientRequest>& client_requests) {
size_t total_rdtsc = 0;
for (size_t i = 0; i < loop_count; ++i) {
const auto& client_request = client_requests[i];
switch (client_request.type_) {
case Exchange::ClientRequestType::NEW: {
const auto start = Common::rdtsc();
order_book->add(client_request.client_id_,
client_request.order_id_,
client_request.ticker_id_,
client_request.side_,
client_request.price_,
client_request.qty_);
total_rdtsc += (Common::rdtsc() - start);
}
break;
case Exchange::ClientRequestType::CANCEL: {
const auto start = Common::rdtsc();
order_book->cancel(client_request.client_id_,
client_request.order_id_,
client_request.ticker_id_);
total_rdtsc += (Common::rdtsc() - start);
}
break;
default:
break;
}
}
return (total_rdtsc / (loop_count * 2));
}
现在,我们可以看看main()
方法。我们需要Logger
和MatchingEngine
对象来创建MEOrderBook
或UnorderedMapMEOrderBook
对象,但为了创建MatchingEngine
对象,我们需要三个无锁队列,正如我们在exchange_main
二进制文件的实现中所看到的。因此,我们创建了这些对象,即使我们并没有测量这些组件的性能:
int main(int, char **) {
srand(0);
Common::Logger logger("hash_benchmark.log");
Exchange::ClientRequestLFQueue
client_requests(ME_MAX_CLIENT_UPDATES);
Exchange::ClientResponseLFQueue
client_responses(ME_MAX_CLIENT_UPDATES);
Exchange::MEMarketUpdateLFQueue
market_updates(ME_MAX_MARKET_UPDATES);
auto matching_engine = new Exchange::
MatchingEngine(&client_requests, &client_responses,
&market_updates);
接下来,我们将创建一个包含 100,000 个(loop_count
) MEClientRequest
对象的向量,这些对象将包括新订单请求以及取消这些订单的请求。我们在trading_main
应用程序中已经看到了类似的代码,用于随机交易算法:
Common::OrderId order_id = 1000;
std::vector<Exchange::MEClientRequest>
client_requests_vec;
Price base_price = (rand() % 100) + 100;
while (client_requests_vec.size() < loop_count) {
const Price price = base_price + (rand() % 10) + 1;
const Qty qty = 1 + (rand() % 100) + 1;
const Side side = (rand() % 2 ? Common::Side::BUY :
Common::Side::SELL);
Exchange::MEClientRequest new_request
{Exchange::ClientRequestType::NEW, 0, 0, order_id++,
side, price, qty};
client_requests_vec.push_back(new_request);
const auto cxl_index = rand() %
client_requests_vec.size();
auto cxl_request = client_requests_vec[cxl_index];
cxl_request.type_ =
Exchange::ClientRequestType::CANCEL;
client_requests_vec.push_back(cxl_request);
}
最后,我们通过调用benchmarkHashMap()
方法两次来结束main()
方法 – 一次使用MEOrderBook
的实例,一次使用UnorderedMapMEOrderBook
的实例,如下所示:
{
auto me_order_book = new Exchange::MEOrderBook(0,
&logger, matching_engine);
const auto cycles = benchmarkHashMap(me_order_book,
client_requests_vec);
std::cout << "ARRAY HASHMAP " << cycles << " CLOCK
CYCLES PER OPERATION." << std::endl;
}
{
auto me_order_book = new Exchange::
UnorderedMapMEOrderBook(0, &logger, matching_engine);
const auto cycles = benchmarkHashMap(me_order_book,
client_requests_vec);
std::cout << "UNORDERED-MAP HASHMAP " << cycles << "
CLOCK CYCLES PER OPERATION." << std::endl;
}
exit(EXIT_SUCCESS);
}
构建此应用程序的过程保持不变,即通过从Chapter12
根目录调用scripts/build.sh
脚本来完成。通过调用hash_benchmark
二进制文件来运行应用程序,将产生类似于这里所示的结果,独立运行之间存在一些差异,并且取决于系统:
sghosh@sghosh-ThinkPad-X1-Carbon-3rd:~/Building-Low-Latency-Applications-with-CPP/Chapter12$ ./cmake-build-release/hash_benchmark
Set core affinity for Common/Logger hash_benchmark.log 140327631447616 to -1
Set core affinity for Common/Logger exchange_matching_engine.log 140327461033536 to -1
ARRAY HASHMAP 142650 CLOCK CYCLES PER OPERATION.
UNORDERED-MAP HASHMAP 152457 CLOCK CYCLES PER OPERATION.
根据这次运行的输出,看起来从std::array
哈希表实现切换到std::unordered_map
哈希表实现,为MEOrderBook
的add()
和cancel()
性能增加了大约 6 到 7%的额外开销。
优化快照消息
在我们为交易交易所的MarketDataPublisher
组件设计的快照消息中,START_SNAPSHOT
和END_SNAPSHOT
消息之间的完整快照周期包含了所有交易工具的快照,如图所示(我们之前已经见过)。在我们的SnapshotSynthesizer
中,所有交易工具的完整快照每 60 秒发布一次。这意味着,如果每个这些交易工具的订单簿有很多订单,那么每 60 秒,快照多播通道上的网络流量会有一个巨大的峰值,随后在剩余的 60 秒内保持沉默。
图 12.12 – 当前快照消息的组成
如果我们改变设计,使得这些快照更加均匀地分布,并且每个快照周期只包含对应于单个TickerId
的快照消息,这将是一个改进。作为一个简单的例子,我们不是每 60 秒发送 6 个工具的快照消息周期,而是可以发送包含单个工具信息的 6 个快照,并且这些快照之间间隔 10 秒。这个假设性的建议在以下图中表示。
图 12.13 – 优化快照消息格式的建议
在这个新的提案中,正如我们提到的,由于完整快照是分时分布的,因此网络流量峰值较少。这导致在交易客户端系统的MarketDataConsumer
组件的快照多播流中丢失数据包的可能性降低。这也使得客户端系统更快地同步或赶上每个交易工具的快照流,因为它不需要在标记某些工具为恢复之前等待所有交易工具的完整快照。
在订单协议中添加认证和拒绝消息
我们现在的电子交易交易所没有用户认证的概念,并且缺少很多错误检查和处理。这意味着它不会检查客户端是否使用正确的凭据登录并且是否有权交易他们试图交易的工具。此外,如果ClientId
和TCPSocket
实例不匹配,或者在客户端发送的ClientRequest
消息中存在序列号差距,我们在Exchange::OrderServer
中会静默忽略它。这在上面的代码块中显示,该代码块来自exchange/order_server/order_server.h
源文件,我们已经详细讨论过:
auto recvCallback(TCPSocket *socket, Nanos rx_time) noexcept {
...
if (socket->next_rcv_valid_index_ >= sizeof(OMClientRequest)) {
...
if (cid_tcp_socket_[request-> me_client_request_.client_id_] != socket) {
...
continue;
}
auto &next_exp_seq_num = cid_next_exp_seq_num_[request-> me_client_request_.client_id_];
if (request->seq_num_ != next_exp_seq_num) {
...
continue;
}
...
}
...
}
}
静默忽略这些错误并不理想,因为客户端没有收到这些错误的通知。对此工作流程的改进是在ClientResponse
消息协议中添加一个拒绝消息,OrderServer
组件可以使用它来通知客户端这些错误。此增强是在我们建议改进订单协议以方便交易客户端认证之外进行的。
在订单协议中支持修改消息
我们当前对ClientRequest
消息的订单协议仅支持ClientRequestType::NEW
和ClientRequestType::CANCEL
请求。对此协议的改进之一是添加一个ClientRequestType::MODIFY
消息类型,以便客户端交易系统可以修改其订单的价格或数量属性。我们需要更新交易所侧的OrderServer
、MatchingEngine
、MEOrderBook
和其他组件,以及交易客户端侧的OrderGateway
、OrderManager
、MarketMaker
、TradeEngine
和其他组件。
增强trade engine
组件
交易引擎有几个可以改进和/或增强的组件。在本节中,我们为每个组件提供了简要的改进描述,以及可能的未来增强。
将风险指标添加到 RiskManager
在第设计我们的交易生态系统章的理解风险管理系统部分,我们描述了几种不同的风险指标。RiskManager
仅使用这些风险指标的一小部分构建,可以通过添加额外的风险措施来增强,如该部分所述。
增强OrderManager
OrderManager
极其简单构建——它支持每侧最多一个活跃订单,也就是说,最多一个买入订单和一个卖出订单。显然,这是一个极其简化的版本,OrderManager
可以增强以支持更复杂的订单管理。
丰富 FeatureEngine
FeatureEngine
配置了两个硬编码的特征。它可以大量丰富以支持复杂特征配置、多种类型特征的库、这些特征之间的复杂交互等。
提升交易算法
本书中的 LiquidityTaker
和 MarketMaker
也是对现实交易策略的极其简单表示。这些可以在许多方面进行增强/改进——包括特征组合、订单管理、高效执行等方面的改进。
这结束了我们对电子交易生态系统未来增强可能性的讨论。
摘要
本章的第一部分专注于分析我们在上一章中添加到电子交易系统中的延迟指标。我们讨论了内部函数的几个延迟测量示例,以及系统关键跳转之间的几个延迟测量示例。目标是了解不同情况下延迟的分布,以便您了解如何识别和调查潜在问题或优化机会的区域。
在本章的第二部分,我们讨论了一些关于如何接近潜在性能优化可能性的技巧和技术。我们提供了一些可以改进的示例,并讨论了当前设计中的性能问题及其解决方案。
在结论部分,我们描述了本书中构建的电子交易生态系统的未来路线图。我们讨论了几个可以丰富以构建更成熟电子交易生态系统的不同组件、子组件和工作流程。
本书讨论的关于在 C++ 中开发的对延迟敏感的应用程序的方法和原则应指导您的旅程。我们构建的完整端到端电子交易生态系统是一个低延迟应用的典范,并希望提供了一个从零开始构建低延迟应用的优秀实践示例。希望这一章通过为您提供分析性能和迭代改进系统的工具,增加了您的经验。我们祝愿您在继续您的低延迟应用开发旅程中一切顺利!