Scala 高性能指南(全)

原文:zh.annas-archive.org/md5/609946227dfd851ddfaf4dcef50c09bc

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Scala 是一种大胆的编程语言,它在 JVM 上融合了面向对象和函数式编程概念。Scala 从相对的默默无闻发展成为一个开发健壮和可维护 JVM 应用程序的首选。然而,没有深入了解语言及其提供的先进特性,编写高性能应用程序仍然是一个挑战。

自 2011 年以来,我们一直使用 Scala 来解决具有严格性能要求的复杂商业挑战。在《Scala 高性能编程》中,我们分享了多年来学到的经验和在编写软件时应用的技巧。本书中,我们探讨了语言及其工具和广泛使用的库生态系统。

我们编写这本书的目标是帮助您理解语言为您提供的选项。我们赋予您收集所需信息的能力,以便在软件系统中做出明智的设计和实现决策。我们不是给您一些 Scala 示例代码然后让您去探索,而是帮助您学习如何钓鱼,并为您提供编写更函数式和更高效软件的工具。在这个过程中,我们通过构建类似现实世界问题的商业问题来激发技术讨论。我们希望通过阅读这本书,您能够欣赏 Scala 的力量,并找到编写更函数式和更高效应用程序的工具。

本书涵盖的内容

第一章, 性能之路,介绍了性能的概念以及这个主题的重要术语。

第二章, 在 JVM 上测量性能,详细介绍了在 JVM 上可用于测量和评估性能的工具,包括 JMH 和 Flight Recorder。

第三章, 释放 Scala 性能,提供了一次对各种技术和模式的引导之旅,以利用语言特性并提高程序性能。

第四章, 探索集合 API,讨论了由标准 Scala 库提供的各种集合抽象。在本章中,我们重点关注即时评估的集合。

第五章, 懒集合和事件源,是一个高级章节,讨论了两种类型的懒序列:视图和流。我们还简要概述了事件源范式。

第六章, Scala 中的并发,讨论了编写健壮并发代码的重要性。我们深入探讨了标准库提供的 Future API,并介绍了来自 Scalaz 库的 Task 抽象。

第七章, 针对性能的架构,这一最后一章结合了之前涵盖的主题的更深入知识,并探讨了 CRDTs 作为分布式系统的构建块。本章还探讨了使用免费单子进行负载控制策略,以构建在高吞吐量情况下具有有限延迟特性的系统。

你需要为此书准备的内容

为了运行所有代码示例,您应该在您的操作系统上安装 Java 开发工具包(JDK)版本 8 或更高版本。本书讨论了 Oracle HotSpot JVM,并展示了包含在 Oracle JDK 中的工具。您还应该从www.scala-sbt.org/download.html获取sbt(写作时版本为 0.13.11)的最新版本。

本书面向的对象

您应该具备 Scala 编程语言的基本知识,对基本的函数式编程概念有所了解,并具有编写生产级 JVM 软件的经验。我们建议 Scala 和函数式编程新手在阅读本书之前花些时间研究其他资源,以便更好地利用本书。两个优秀的以 Scala 为中心的资源是《Scala 编程》,Artima Press和《Scala 函数式编程》,Manning Publications。前者最适合那些希望首先理解语言然后是函数式编程范式的强大面向对象 Java 程序员。后者重点在于函数式编程范式,而对语言特定结构的关注较少。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL 和用户输入将如下所示:“-XX:+FlightRecorderOptions接受一个名为settings的参数,默认情况下,它指向$JAVA_HOME/jre/lib/jfr/default.jfc。”

代码块设置如下:

def sum(l: List[Int]): Int = l match {

case Nil => 0

case x :: xs => x + sum(xs)

}

任何命令行输入或输出将如下所示:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.ThroughputBenchmark

src/main/resources/historical_data 250000'

新术语和重要词汇将以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,将以如下形式出现在文本中:“让我们从代码标签组的概览标签开始。”

注意

警告或重要提示将以如下框内显示。

小贴士

小贴士和技巧将以如下形式出现。

读者反馈

我们欢迎读者的反馈。请告诉我们您对本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中受益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

使用您的电子邮件地址和密码登录或注册我们的网站。

将鼠标指针悬停在顶部的支持选项卡上。

点击代码下载 & 错误清单。

在搜索框中输入书籍名称。

选择您想要下载代码文件的书籍。

从下拉菜单中选择您购买本书的来源。

点击代码下载。

您也可以通过点击 Packt Publishing 网站书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。

一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:

WinRAR / 7-Zip for Windows

Zipeg / iZip / UnRarX for Mac

7-Zip / PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Scala-High-Performance-Programming。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为github.com/PacktPublishing/。请查看它们!

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/ScalaHighPerformanceProgramming_ColorImages.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这个问题,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误表部分。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书名。所需信息将出现在勘误表部分。

侵权

互联网上对版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送邮件至 copyright@packtpub.com 并附上疑似侵权材料的链接与我们联系。

我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。

询问

如果您在这本书的任何方面遇到问题,您可以给我们发送邮件至 questions@packtpub.com,我们将尽力解决问题。

第一章:性能之路

我们欢迎你踏上学习实用方法使用 Scala 编程语言和函数式编程范式的旅程,以编写性能和高效的软件。函数式编程概念,如纯函数和高级函数、引用透明性和不可变性,是理想化的工程品质。它们使我们能够编写可组合的元素、可维护的软件,以及易于理解和推理的代码。然而,尽管功能编程具有所有这些优点,但它往往被错误地与性能下降和低效的代码联系起来。我们的目标是说服你相反的观点!本书探讨了如何利用函数式编程、Scala 语言的功能、Scala 标准库和 Scala 生态系统来编写性能软件。

Scala 是一种静态和强类型语言,它试图优雅地融合函数式和面向对象范式。在过去几年中,它因其吸引力和实用性而越来越受欢迎,成为在函数式范式下编写生产级软件的选择。Scala 代码编译成字节码,在Java 虚拟机(JVM)上运行,该虚拟机具有广泛理解的运行时环境,可配置,并提供出色的工具来检查和调试正确性和性能问题。一个额外的优势是 Scala 与 Java 的出色互操作性,这允许你使用所有现有的 Java 库。虽然 Scala 编译器和 JVM 不断得到改进,并且已经生成良好的优化字节码,但实现性能目标的责任仍然在你,即开发者身上。

在深入探讨 Scala 和 JVM 的具体细节之前,让我们首先培养对所追求的圣杯——性能——的直觉。在本章中,我们将介绍与编程语言无关的性能基础知识。我们将展示并解释本书中使用的术语和概念。

尤其是以下主题:

定义性能

总结性能

收集度量

我们还将介绍我们的案例研究,这是一个基于现实世界问题的虚构应用程序,它将帮助我们说明后面将要介绍的技巧和模式。

定义性能

一个性能词汇表为你提供了一种评估手头问题类型的方法,并常常帮助你找到解决方案。尤其是在时间紧迫的情况下,强烈的直觉和纪律性的策略是解决性能问题的资产。

让我们从形成对“性能”这一术语的共同理解开始。这个术语用于定性或定量地评估实现目标的能力。当前的目标可能会有很大的不同。然而,作为一名专业的软件开发人员,目标最终会与商业目标相联系。与您的业务团队合作,确定业务领域性能敏感性至关重要。对于一个面向消费者的购物网站,就同时在线应用用户数量和可接受的请求响应时间达成一致是相关的。在一家金融交易公司,交易延迟可能是最重要的,因为速度是竞争优势。同时,也要考虑到非功能性需求,例如“交易执行永远不会丢失”,这是由于行业法规和外部审计。这些领域约束也会影响您软件的性能特征。构建一个清晰且达成共识的领域图景是至关重要的第一步。如果您无法定义这些约束,就无法提供可接受的解决方案。

注意

收集需求是本书范围之外的一个复杂话题。如果您想深入了解这个话题,我们推荐 Gojko Adzic 的两本书:《影响映射:用软件产品和项目产生重大影响》(www.amazon.com/Impact-Mapping-software-products-projects-ebook/dp/B009KWDKVA)和《五十个快速提高您的用户故事的想法》(www.amazon.com/Fifty-Quick-Ideas-Improve-Stories-ebook/dp/B00OGT2U7M)。

性能软件

设计性能良好的软件是我们作为软件工程师的一个目标。思考这个目标会导致一个常见的问题:“什么样的性能才算足够好?”我们使用“性能良好”这个术语来描述满足“足够好”的最小可接受阈值的性能。我们的目标是达到并尽可能超过可接受性能的最小阈值。考虑这一点:如果没有一个关于可接受性能的协议标准,那么定义性能良好的软件在定义上就是不可能的!这个陈述说明了定义期望结果作为编写性能良好软件的先决条件的压倒性重要性。

注意

请花点时间思考一下性能对您领域含义。您是否在维护符合您定义的性能良好的软件时遇到过困难?考虑一下您用来解决性能困境的策略。哪些是有效的,哪些是无效的?随着您阅读本书的进展,请记住这一点,以便您可以检查哪些技术可以帮助您更有效地达到您对性能的定义。

硬件资源

为了定义高性能软件的标准,我们必须扩展性能词汇。首先,了解你环境中的资源。我们使用“资源”这个术语来涵盖你的软件运行所需的所有基础设施。参考以下资源清单,它列出了在进行任何性能调优练习之前你应该收集的资源:

硬件类型:物理或虚拟化

CPU:

核心数量

L1, L2, 和 L3 缓存大小

NUMA 区域

RAM(例如,16 GB)

网络连接评级(例如,1GbE 或 10GbE)

操作系统和内核版本

内核设置(例如,TCP 套接字接收缓冲区大小)

JVM 版本

列出资源清单迫使你考虑你操作环境的性能和限制。

注意

核心优化方面的优秀资源包括 Red Hat 性能调优指南(goo.gl/gDS5mY)以及布伦丹·格雷格(www.brendangregg.com/linuxperf.html)的演示和教程。

延迟和吞吐量

延迟和吞吐量定义了两种性能类型,这些类型通常用于建立高性能软件的标准。以下德国高速公路的照片,就像以下照片,是培养对这些类型性能直觉的好方法:

Autobahn 帮助我们思考延迟和吞吐量。(图片来源维基媒体,en.wikipedia.org/wiki/Highway#/media/File:Blick_auf_A_2_bei_Rastst%C3%A4tte_Lehrter_See_(2009).jpg。许可 Creative Commons CC BY-SA 3.0)

延迟描述了观察到的过程完成所需的时间量。在这里,过程是一辆车在高速公路的单车道上行驶。如果高速公路没有拥堵,那么汽车能够快速行驶在高速公路上。这被描述为低延迟过程。如果高速公路拥堵,行程时间增加,这被描述为高延迟或潜伏过程。这个类比也捕捉了你可控的性能优化。你可以想象将一个昂贵的算法从多项式时间优化到线性执行时间,类似于改善高速公路的质量或汽车的轮胎以减少路面摩擦。摩擦的减少使得汽车能够以更低的延迟穿越高速公路。在实践中,延迟性能目标通常以你业务域可容忍的最大延迟来定义。

吞吐量定义了完成一个过程观察到的速率。使用高速公路的类比,单位时间内从 A 点到 B 点行驶的汽车数量是高速公路的吞吐量。例如,如果有三个车道,汽车在每个车道上以均匀的速度行驶,那么吞吐量就是:(在观察期间从 A 点到 B 点行驶的每个车道的汽车数量)* 3。归纳推理可能会暗示吞吐量和延迟之间存在强烈的负相关关系。也就是说,随着延迟的增加,吞吐量会减少。实际上,有许多情况下这种推理并不成立。在我们继续扩展性能词汇以更好地理解为什么会发生这种情况时,请记住这一点。在实践中,吞吐量通常定义为你的软件每秒可以支持的最多事务数。在这里,一个事务意味着你领域中的一个工作单元(例如,处理的订单或执行的交易)。

注意

回想一下你最近遇到的性能问题,你会如何描述它们?你遇到了延迟还是吞吐量问题?你的解决方案是否在降低延迟的同时提高了吞吐量?

瓶颈

一个瓶颈指的是系统中最慢的部分。按照定义,所有系统,包括调优良好的系统,都有一个瓶颈,因为总有一个处理步骤被测量为最慢的。请注意,延迟瓶颈可能不是吞吐量瓶颈。也就是说,可能同时存在多种类型的瓶颈。这是说明为什么理解你是在对抗吞吐量还是延迟性能问题很重要的另一个例子。通过识别你系统瓶颈的过程,为你提供一个有针对性的焦点来攻击你的性能难题。

从个人经验来看,我们看到了当操作环境清单被忽视时,时间是如何被浪费的。有一次,在我们为一个高吞吐量实时竞价(RTB)平台工作的时候,我们追查了一个多天的吞吐量问题,但都没有成功解决。在启动了一个 RTB 平台之后,我们开始优化以提高更高的请求吞吐量目标,因为请求吞吐量是我们行业的一个竞争优势。我们的业务团队将每秒 40,000 次请求(RPS)增加到 75,000 RPS 视为一个重要的里程碑。我们的调整工作一直稳定在约 60,000 RPS。这真是一个令人挠头的问题,因为系统似乎并没有耗尽系统资源。CPU 利用率远低于 100%,而之前增加堆空间实验并没有带来改进。

当我们意识到系统是在 AWS 上部署的,并且默认的网络连接配置为 1 千兆以太网时,我们有了“啊哈!”的时刻。系统处理的请求大约为每个请求 2KB。我们进行了一些基本的算术运算,以确定理论上的最大吞吐量率。1 千兆等于 125,000 千字节。125,000 千字节除以每个请求 2 千字节等于理论上的最大 RPS 为 62,500。这个算术运算通过使用名为 iPerf 的工具进行网络吞吐量测试得到了证实。果然,我们已经达到了我们的网络连接极限!

总结性能

我们正确地定义了一些关于性能的主要概念,即延迟和吞吐量,但我们仍然缺乏一个具体的量化测量方法。继续我们的例子,即汽车在高速公路上行驶,我们希望找到一种方法来回答“从 A 点到 B 点我应该期望多长时间的驾驶?”这个问题。第一步是在多次旅行中测量我们的旅行时间以收集经验信息。

下表列出了我们的观察结果。我们仍然需要一种方法来解释这些数据点并总结我们的测量结果以给出答案:

观察到的旅行

旅行时间(分钟)

旅行 1

28

旅行 2

37

旅行 3

17

旅行 4

38

旅行 5

18

平均值的问题

一个常见的错误是依赖于平均值来衡量系统的性能。算术平均值很容易计算。这是所有收集到的值的总和除以值的数量。使用之前的数据点样本,我们可以推断出平均来说,我们应该期望驾驶时间大约为 27 分钟。在这个简单的例子中,很容易看出为什么平均值是一个如此糟糕的选择。在我们的五个观察值中,只有第 1 次旅行接近我们的平均值,而其他所有旅行都相当不同。平均值的基本问题在于它是一个有损的汇总统计量。当从一系列观察值移动到平均值时,信息会丢失,因为不可能在单个数据点中保留原始观察的所有特征。

为了说明平均值如何丢失信息,考虑以下三个代表处理对 Web 服务请求所需测量延迟的数据集:

在第一个数据集中,有四个请求需要 280 毫秒到 305 毫秒的时间才能完成。将这些延迟与第二个数据集中的延迟进行比较,如下所示:

第二个数据集显示了更易变的延迟混合。你更愿意将第一个还是第二个服务部署到你的生产环境中?为了增加更多的多样性,显示了第三个数据集,如下所示:

尽管这些数据集的分布差异很大,但平均值都是相同的,等于 292 毫秒!想象一下,你必须维护代表数据集 1 的网络服务,目标是确保 75% 的客户在 300 毫秒内收到响应。从数据集 3 中计算平均值会给你一种你正在达到目标的印象,而实际上只有一半的客户实际上体验到了足够快的响应(ID 为 1 和 2 的请求)。

百分位数拯救了困境

上次讨论中的关键词是“分布”。测量系统性能的分布是确保你理解系统行为的最稳健的方法。如果我们认为平均值不是一个有效的选择来考虑我们测量的分布,那么我们需要找到一个不同的工具。在统计学领域,百分位数符合我们解释观察值分布的标准。百分位数是一个测量值,表示在观察值组中,给定百分比的观察值落入的值。让我们用一个例子来使这个定义更具体。回到我们的网络服务示例,假设我们观察到以下延迟:

请求

毫秒延迟

请求 1

10

请求 2

12

请求 3

13

请求 4

13

请求 5

9

请求 6

27

请求 7

12

请求 8

7

请求 9

75

请求 10

80

第 20 个百分位数定义为表示所有观察值 20% 的观察值。由于有十个观察值,我们想要找到表示两个观察值的值。在这个例子中,第 20 个百分位数的延迟是 9 毫秒,因为有两个值(即,总观察值的 20%)小于或等于 10 毫秒(9 毫秒和 7 毫秒)。将这个延迟与第 90 个百分位数进行对比。表示 90% 的观察值的值:75 毫秒(因为十个观察值中有九个小于或等于 75 毫秒)。

当平均值隐藏了我们的测量值的分布时,百分位数为我们提供了更深入的洞察,并突出了尾部观察值(接近第 100 个百分位数的观察值)经历了极端的延迟。

如果你记得本节的开头,我们试图回答的问题是,“从 A 点到 B 点的驾驶时间应该是多长?”在花了一些时间探索可用的工具之后,我们意识到原始问题并不是我们真正感兴趣的。一个更实际的问题是,“90% 的汽车从 A 点到 B 点需要多长时间?”

收集测量值

我们的性能测量工具包已经充满了有用的信息。我们定义了一个共同词汇表来讨论和探索性能。我们还达成了一项实用的方法来总结性能。我们旅程的下一步是回答问题:“为了总结它们,我如何收集性能测量数据?”本节介绍了收集测量的技术。在下一章中,我们将更深入地探讨,专注于从 Scala 代码中收集数据。我们将向你展示如何使用旨在与 JVM 一起工作并更好地理解你的程序的各种工具和库。

使用基准测试来衡量性能

基准测试是一种黑盒测量方式。基准测试通过提交各种类型的负载作为输入,并测量延迟和吞吐量作为系统输出,来评估整个系统的性能。例如,假设我们正在开发一个典型的购物车 Web 应用程序。为了基准测试这个应用程序,我们可以编写一个简单的 HTTP 客户端来查询我们的服务并记录完成请求所需的时间。这个客户端可以用来发送每秒增加的请求数量,并输出记录的响应时间摘要。

存在多种类型的基准测试,用以回答关于你系统不同问题的答案。你可以回放历史生产数据,以确保在处理实际负载时,你的应用程序能够达到预期的性能目标。负载和压力测试基准测试可以识别你应用程序的故障点,并在长时间内接收异常高负载时测试其鲁棒性。

基准测试也是比较同一应用程序不同版本并检测性能回归或确认改进的强大工具。通过针对你代码的两个版本执行相同的基准测试,你实际上可以证明你最近的变化带来了更好的性能。

尽管基准测试非常有用,但它们并不提供关于软件每个部分如何表现的信息;因此,它们是黑盒测试。基准测试不能帮助我们识别瓶颈或确定系统哪个部分应该改进以获得更好的整体性能。为了深入了解黑盒,我们转向分析。

分析以定位瓶颈

与基准测试相反,分析是旨在用于分析你应用程序内部特性的。分析器允许白盒测试,帮助你通过捕获程序每个部分的执行时间和资源消耗来识别瓶颈。通过在运行时检查你的应用程序,分析器为你提供了关于你的代码行为的详细信息,包括以下内容:

CPU 周期在哪里消耗

如何使用内存,以及对象在哪里实例化、释放(或者如果你有内存泄漏,则不会释放!)

IO 操作执行的位置

哪些线程正在运行、阻塞或空闲

大多数分析器在编译时或运行时对观察到的代码进行仪器化,以注入计数器和分析组件。这种仪器化在运行时产生成本,降低了系统吞吐量和延迟。因此,分析器不应用于评估生产环境中系统的预期吞吐量和延迟(提醒一下,这是一个基准测试用例)。

通常,您在决定进行任何性能驱动型改进之前,应该始终对应用程序进行性能分析。您应该确保您计划改进的代码部分实际上是一个瓶颈。

将基准测试和分析配对

分析器和基准测试有不同的目的,它们帮助我们回答不同的问题。提高性能的典型工作流程应该利用这两种技术,并利用它们的优势来优化代码改进过程。在实践中,这个工作流程看起来如下:

对当前版本的代码运行基准测试,以建立性能基线。

使用分析器分析内部行为并定位瓶颈。

改进造成瓶颈的部分。

对新代码运行与步骤 1 相同的基准测试。

将新基准测试的结果与基线基准测试结果进行比较,以确定您所做更改的有效性。

请记住,运行所有基准测试和分析会话在相同的环境中非常重要。查阅您的资源清单,以确保您的环境在测试中保持一致。任何资源的变化都会使您的测试结果无效。就像科学实验一样,您必须小心地一次只改变实验的一部分。

注意

基准测试和分析在您的开发过程中扮演什么角色?您在决定改进代码的下一部分之前,是否总是对应用程序进行性能分析?您的“完成”定义是否包括基准测试?您能否在尽可能接近生产环境的环境中基准测试和分析应用程序?

案例研究

在本书中,我们将提供代码示例来阐述所涵盖的主题。为了使之前描述的技术在您的专业生活中尽可能有用,我们将我们的示例与一家虚构的金融交易公司相关联,该公司名为 MV Trading。公司名称来源于作者名字首字母的组合。巧合的是,这些首字母也形成了 Unix 文件移动命令,象征着公司正在不断发展!自一年前成立以来,MV Trading 已为少量客户提供成功的股票交易策略。在过去十二个月中,软件基础设施迅速建立,以支持业务的各个方面。MV Trading 开发了支持在各个股票交易所进行实时交易(即买卖)的软件,并且还建立了一个历史交易执行分析,以创建表现更好的交易算法。如果您没有金融领域的知识,请不要担心。在每个示例中,我们也会定义该领域的关键部分。

工具

我们建议您提前安装所有必要的工具,这样您就可以在没有设置时间的情况下完成这些示例。安装说明简短,因为每个所需工具的配套网站上都有详细的安装指南。以下软件是所有即将到来的章节所需的:

在编写时使用 Oracle JDK 8+,版本为 v1.8 u66

sbt v0.13+,编写时使用 v0.13.11,可在www.scala-sbt.org/找到,该版本在编写时使用

小贴士

有关下载代码包的详细步骤,请参阅本书的序言。请查看它。本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Scala-High-Performance-Programming。我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!

摘要

在本章中,我们专注于了解如何讨论性能。我们建立了一个词汇表来讨论性能,确定了用百分位数总结性能的最佳方式,并培养了对性能的直觉。我们介绍了我们的案例研究,然后安装了所需的工具来运行本书提供的代码示例和源代码。在下一章中,我们将探讨可用的工具来衡量 JVM 性能和分析我们的 Scala 应用程序的性能。

第二章. 在 JVM 上衡量性能

在上一章中,我们介绍了与性能相关的重要概念。虽然非常有价值,但到目前为止,我们的旅程多少有些学术性,你可能已经迫不及待地想要运用你新获得的知识。幸运的是,第二章正是这样做的!我们将仔细研究一个真实场景,并深入代码以分析应用程序并测量其性能特征。本章的实战部分专注于 MV Trading 最成功的产品之一:订单簿。这是为了在保持低延迟的同时提供高吞吐量而开发的软件。在本章中,我们将涵盖以下主题:

对应用程序的延迟和吞吐量进行基准测试

使用 Flight Recorder 分析系统

使用 JMH 进行代码微基准测试

漫步金融领域

本月标志着MV Trading(MVT)成立一周年的纪念日。在过去的一年里,公司通过利用新颖的交易策略,为客户带来了丰厚的回报。这些策略在能够在新价格信息接收后的毫秒内完成交易时最为有效。为了支持低延迟的交易,MVT 工程团队直接集成到股票交易所。交易所集成涉及数据中心工作,将交易系统与交易所协同定位,以及开发工作以构建交易系统。

交易系统的一个关键组件,称为订单簿,持有交易所的状态。交易所的目标是跟踪有多少买家和卖家对某只股票有活跃的兴趣,以及每一方愿意以什么价格进行交易。作为交易者,例如 MVT,提交买卖股票的订单时,交易所会确定何时发生交易,并通知买家和卖家关于交易的信息。交易所和 MVT 所管理的状态很有趣,因为订单并不总是在达到交易所时执行。相反,订单可以保持开放或挂起状态,直到交易日的长度(大约六小时)。这个订单簿的第一个版本允许交易者放置称为限价订单的订单。限价订单包括对最低可接受价格的约束。对于购买,限价代表交易者愿意支付的最高价格,对于销售,这表示交易者愿意为股票接受的最低价格。订单簿支持的另一个操作是取消未完成的限价订单,这将从簿中移除其存在。为了帮助总结可能的订单状态,以下表格列出了支持交易所动作的可能结果:

交换动作

结果

提交了价格低于最佳出价或要价的限价订单。

订单停留在订单簿上,这意味着订单将保持待处理状态,直到来自对立方的订单生成交易或提交的订单被取消。

提交了价格高于或等于最佳出价或要价的限价订单。

订单成交。成交是行业术语,表示订单因为其价格与订单簿对立方的订单相匹配而触发了交易。一笔交易包括两次执行,每次一边。

提交了取消挂单的请求。

挂单从订单簿中移除。

提交了取消已执行或不存在订单的请求。

取消请求被拒绝。

假设你作为新聘用的 MVT 员工,刚刚加入负责维护和改进订单簿的工程团队。今天是你的第一天,你计划用大部分上午时间平静地浏览代码,熟悉应用程序。

在检出源代码仓库后,你从领域模型开始:

case class Price(value: BigDecimal)

case class OrderId(value: Long)

sealed trait LimitOrder {

def id: OrderId

def price: Price

}

case class BuyLimitOrder(id: OrderId, price: Price)

extends LimitOrder

case class SellLimitOrder(id: OrderId, price: Price)

extends LimitOrder

case class Execution(orderId: OrderId, price: Price)

上一段代码中的类和特质定义代表了业务概念。我们特别注意到了两种订单(BuyLimitOrder和SellLimitOrder),它们通过唯一的 ID 和假设为美元的价格来识别。

注意

你可能会想知道为什么我们决定为Price和OrderId创建不同的类定义,尽管它们仅仅作为唯一属性的包装(分别是一个用于价格的BigDecimal和一个用于唯一 ID 的Long)。或者,我们也可以直接依赖原始类型。

BigDecimal可以代表很多不同的事物,包括价格,但也可以是税率或地球上的纬度。使用一个名为Price的特定类型,给BigDecimal赋予上下文意义,并确保编译器帮助我们捕捉可能的错误。我们相信,始终定义明确的类型来表示业务关注点是良好的实践。这项技术是被称为领域驱动设计的最佳实践之一,我们在整本书中经常应用这些原则。要了解更多关于这种软件开发方法的信息,我们推荐由 Eric Evans 所著的优秀书籍《领域驱动设计:软件核心的复杂性处理》(www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215)。

OrderBook模块利用领域模型来定义可用的命令以及由订单产生的结果事件:

object OrderBook {

// all the commands that can be handled by the OrderBook module

object Commands {

sealed trait Command

case class AddLimitOrder(o: LimitOrder) extends Command

case class CancelOrder(id: OrderId) extends Command

}

// events are the results of processing a command

object Events {

sealed trait Event

case class OrderExecuted(buy: Execution, sell: Execution)

extends Event

case object LimitOrderAdded extends Event

case object OrderCancelRejected extends Event

case object OrderCanceled extends Event

}

// the entry point of the module - the current book and

// the command to process are passed as parameters,

// the new state of the book and the event describing the

// result of processing the command are returned

def handle(book: OrderBook, command: Command): (OrderBook, Event) = // omitted for brevity

}

假设你正准备详细查看handle函数的实现,这时你在即时通讯客户端收到技术负责人 Alice 的消息:“会议室所有人,生产环境中出现了问题!”

注意

拥有金融领域专业知识的读者可能会意识到,所提出的行动反映了实际金融交易所功能的一个子集。一个明显的例子是订单中缺少数量。在我们的例子中,我们假设每个订单代表购买相等数量的股票(例如,100 股)。有经验的读者知道,订单量进一步复杂化了订单簿状态管理,例如,通过引入部分执行的概念。我们故意简化了领域,以在处理现实问题的同时,最大限度地减少对领域新手的理解障碍。

意外的波动摧毁了利润

Alice 和首席交易员 Dave 通过总结生产问题开始了会议。你在会议中从问题中汲取了很多洞见。你了解到,目前市场波动性很高,价格的快速波动放大了产生盈利交易的机会。不幸的是,对于 MVT 来说,在最近几周,高波动性带来了前所未有的订单量。交易员们正在向市场大量提交限价订单和取消订单,以应对价格变化的快速行动。MVT 的订单簿通过负载测试认证,可以处理每秒最多 15,000 个订单(OPS),99^(th)百分位延迟为 10 毫秒(ms)。当前市场条件产生了持续的水平为 45,000 OPS,这正在摧毁尾部订单簿的延迟。在生产中,部署的订单簿版本现在产生 99^(th)百分位延迟高达 80 毫秒,最大延迟达到 200 毫秒。在交易业务中,反应慢可能会迅速将盈利交易变成巨大的损失。这正是 MVT 正在发生的事情。通常,在波动时期,MVT 能够产生健康的回报,但最近几周,损失惊人。我们的目标是应用我们在第一章《性能之路》中学到的技术,来克服性能问题。

MVT 的交易员正在寻找快速的性能提升,以利用当前的市场环境。交易员们相信市场波动将持续另一周才会平息。一旦波动消失,赚钱的机会也随之消失。因此,已经强调给工程团队,将 99^(th)百分位延迟从 40 毫秒逐步降低,应该能够停止交易策略的损失并实际上产生小额利润。一旦波动平息,更深入和广泛的表现改进将受到欢迎。目前,时间正在流逝,我们需要通过逐步提高性能来找到停止损失的方法。

重复问题

这并不是你预期的第一天,但前方有一个多么激动人心的挑战!我们通过重现问题来开始对性能问题的调查。正如我们在上一章中提到的,正确基准测试一个应用程序以建立基线总是至关重要的。基线用于评估我们可能尝试实施的任何改进的有效性。我们创建了两个简单的基准测试来重现生产中观察到的负载,并测量系统的吞吐量和延迟。

Note

但是等等,我还没有完成对OrderBook实际实现的研究!你说得对!你仍然不知道该模块的实现。然而,生产环境已经出现问题,我们需要迅速采取行动!更严重的是,这是我们强调基准测试的一个重要特性的方式,我们在第一章《性能之路》中提到了这个特性。基准测试将应用程序视为一个黑盒。你有机会研究模块接口,这足以编写好的基准测试。

Throughput benchmark

我们的第一个基准测试测量我们应用程序的吞吐量。操作团队为我们提供了从生产环境中记录的历史数据。这些数据包含了几十万条实际命令,这些命令由订单簿处理。我们使用这个样本,并在测试环境中重新播放这些消息,以获得对系统行为的准确了解。

Note

从第一章《性能之路》中回顾,运行所有基准测试在完全相同的环境下是很重要的,这样才能进行比较。为了确保测试的一致性,我们创建了一个命令生成器,能够输出一组静态的命令。我们鼓励您对其进行审查。

下面是我们吞吐量基准测试的代码,您可以在章节子项目中找到:

object ThroughputBenchmark {

def main(args: Array[String]): Unit = {

val commandSample = DataCodec.read(new File(args(0)))

val commandCount = args(1).toInt

jvmWarmUp(commandSample)

val commands = generateCount(commandSample, commandCount)

val start = System.currentTimeMillis()

commands.foldLeft(OrderBook.empty)(OrderBook.handle(_, _)._1)

val end = System.currentTimeMillis()

val delayInSeconds = (end - start) / 1000.0

println {

s"""

|Processed ${commands.size} commands

|in $delayInSeconds seconds

|Throughput: ${commands.size / delayInSeconds} operations/sec"""

.stripMargin

}

}

}

代码相当直接,让我们来了解一下。首先,我们读取输入参数,第一个是包含我们历史数据的文件路径,第二个是我们想要运行的命令数量。请注意,在我们的实现中,如果我们请求的命令数量超过了静态文件中的数量,程序将只会循环遍历提供的命令。然后,我们通过执行多达 100,000 条命令而不记录任何吞吐量信息来预热 JVM。预热的目的给 JVM 一个机会来执行代码并找到可以被即时编译器(JIT)优化的潜在热点。

Note

JIT 编译器是在应用程序启动后运行的编译器。它将字节码(即javac编译器第一次编译的结果)即时编译成优化的形式,通常是操作系统的原生指令。JIT 能够根据运行时使用情况优化代码,这是传统编译器无法做到的,因为它在代码可以执行之前运行。

代码的下一部分是我们实际记录吞吐量的地方。我们保存了开始时间戳,执行了针对最初为空的订单簿的所有命令,并记录了结束时间戳。通过将命令计数除以执行所有命令的耗时,我们可以轻松计算出每秒的操作吞吐量。由于我们的吞吐量是以秒为单位的,因此对于我们的基准测试需求,毫秒级的精度是足够的。最后,我们打印出有趣的结果。我们可以通过以下命令以 250,000 个命令为参数运行此基准测试:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.ThroughputBenchmark src/main/resources/historical_data 250000'

在一系列命令计数范围内运行基准测试,得到以下结果:

命令计数

处理时间(秒)

吞吐量(每秒操作数)

25 万

2.2

112,309

50 万

6.1

81,886

75 万

12.83

58,456

100 万

22.56

44,328

我们可以看到,当命令计数增加时,我们的吞吐量下降。一个可能的解释是,当收到更多订单时,订单簿的大小增加,因此效率降低。在此阶段,我们能够评估我们应用程序的吞吐量。在下一节中,我们将专注于测量程序的延迟。

备注

我们的基准测试以及本章后面将要编写的基准测试都是简单的。它在一个 JVM 中运行测试和订单簿。一个更现实的例子将涉及一个与客户端交换 FIX 消息(FIX 是金融界最广泛使用的协议)以放置或取消订单的维护 TCP 连接的服务器订单簿。我们的基准测试将模拟这些客户端之一,以模拟我们订单簿的生产负载。为了简单起见,并允许我们关注更有趣的主题,我们决定将这个问题放在一边。

延迟基准测试

回想一下第一章《性能之路》,延迟是指操作发生所需的时间,而操作的定义取决于你的领域。在我们的情况下,我们将操作定义为从收到命令到生成新的订单簿和相应事件的时间处理。

首次延迟基准测试

以下列表展示了我们延迟基准测试的第一个版本:

object FirstLatencyBenchmark {

def main(args: Array[String]): Unit = {

val commandSample = DataCodec.read(new File(args(0)))

val (commandsPerSecond, iterations) = (args(1).toInt, args(2).toInt)

val totalCommandCount = commandsPerSecond * iterations

jvmWarmUp(commandSample)

@tailrec

def sendCommands(

xs: List[(List[Command], Int)],

ob: OrderBook,

testStart: Long,

histogram: HdrHistogramReservoir): (OrderBook, HdrHistogramReservoir)

=

xs match {

case head :: tail =>

val (batch, offsetInSeconds) = head

val shouldStart = testStart + (1000 * offsetInSeconds)

while (shouldStart > System.currentTimeMillis()) {

// keep the thread busy while waiting for the next batch to be

sent

}

val updatedBook = batch.foldLeft(ob) {

case (accBook, c) =>

val operationStart = System.currentTimeMillis()

val newBook = OrderBook.handle(accBook, c)._1

val operationEnd = System.currentTimeMillis()

// record latency

histogram.update(operationEnd - operationStart)

newBook

}

sendCommands(tail, updatedBook, testStart, histogram)

case Nil => (ob, histogram)

}

val (_, histogram) = sendCommands(

// Organizes commands per 1 second batches

generateCount(commandSample, totalCommandCount)

.grouped(commandsPerSecond).zipWithIndex

.toList,

OrderBook.empty,

System.currentTimeMillis(),

new HdrHistogramReservoir())

printSnapshot(histogram.getSnapshot)

}

}

这段代码的开始部分与我们在吞吐量基准测试中使用的类似。我们使用 HdrHistogram 来记录每个操作的延迟。尾递归方法sendCommands是大多数有趣事情发生的地方(我们将在后面的章节中更详细地探讨尾递归)。我们的命令按大小和commandsPerSecond的批次分组,这意味着我们将每秒发送一个批次。我们在发送命令之前记录当前时间(operationStart)和收到响应之后(operationEnd)。这些时间戳用于更新直方图。

注意

HdrHistogram 是直方图的高效实现。它专门设计用于在延迟和性能敏感的应用中。它在空间和时间上保持固定的成本。它不涉及内存分配操作,其内存占用是恒定的。要了解更多关于 HdrHistogram 的信息,请访问hdrhistogram.org/。

最后,在所有批次都处理完毕后,我们会对直方图的状态进行快照并打印有趣的指标。让我们运行一下这个测试:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.FirstLatencyBenchmark src/main/resources/historical_data 45000 10'

... // removed for brevity

[info] Processed 450000 commands

[info] 99p latency: 1.0 ms

[info] 99.9p latency: 1.0 ms

[info] Maximum latency: 24 ms

我们以每秒 45,000 次操作的速度对我们的系统进行了 10 秒的测试,并观察到 99.9 百分位数的延迟为 1 毫秒。这些是出色的结果!但它们也是完全错误的。在我们匆忙编写延迟基准测试时,我们忽略了一个经常被忽视的问题:协同遗漏问题。

协同遗漏问题

我们的基准测试是错误的,因为我们测量处理命令所需的时间,而没有考虑到命令等待处理的时间。如果之前的命令处理时间超过了预期,这就会成为一个问题。以我们的先前的例子来说:我们每秒运行 45,000 个命令,即每毫秒 45 个命令。如果处理前 45 个命令需要超过 1 毫秒的时间怎么办?接下来的 45 个命令必须等待才能被选中并处理。然而,在我们的当前基准测试中,我们忽略了这种等待时间。让我们以一个通过 HTTP 提供网页的 Web 应用程序为例。一个典型的基准测试可能会通过测量请求处理和响应准备发送之间的时间来记录请求延迟。然而,这不会考虑到 Web 服务器读取请求并实际发送响应所需的时间。一个更好的基准测试将测量客户端发送请求和实际收到响应之间的延迟。要了解更多关于协同遗漏问题的信息,请参考包含直接链接到文章和演示文稿的讨论线程groups.google.com/forum/#!msg/mechanical-sympathy/icNZJejUHfE/BfDekfBEs_sJ。

为了解决这个问题,我们需要记录 operationStart,而不是当我们开始处理一批命令时,而是在这批命令应该被处理时,无论系统是否延迟。

第二个延迟基准

在我们的第二次尝试中,我们确保在考虑命令应该发送的时间时启动时钟,而不是它准备好被处理的时间。

基准测试代码除了记录延迟外保持不变,现在使用 shouldStart 而不是 operationStart:

histogram.update(operationEnd - shouldStart)

此更改后,这是新的基准输出:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.SecondLatencyBenchmark src/main/resources/historical_data 45000 10'

... // removed for brevity

[info] Processed 450000 commands

[info] 99p latency: 743.0 ms

[info] 99.9p latency: 855.0 ms

[info] Maximum latency: 899 ms

与我们的第一次基准测试相比,结果非常不同。实际上,这段新代码也存在一个缺陷。它假设在同一秒内发送的所有请求都应该在这一秒的开始时被处理。虽然技术上可行,但更有可能的是这些命令将在第二秒的不同时间发送(一些在第一毫秒,一些在第二毫秒,依此类推)。我们当前的基准测试可能大大高估了我们的延迟,因为大多数命令的计时器启动得太早。

最终的延迟基准

我们将尝试修复最新问题,并最终制定一个可靠的基准。在这个阶段,我们正在尝试解决每秒命令分布的问题。解决这个问题的最佳方法就是使用真实的生产数据。如果我们用于基准测试的记录命令附有时间戳(即,它们被生产系统接收的时刻),我们就可以复制在生产中观察到的命令分布。

不幸的是,我们当前的订单簿应用程序在记录数据时没有记录时间戳。我们可以选择不同的路线。一个选项是随机在第二秒发送命令。另一个选项是假设命令的均匀分布(即,每个毫秒发送相同数量的命令)。

我们选择修改基准测试,假设后者。为了实现这个目标,我们修改了事件的生成。由于我们的新策略是在时间上分布命令而不是批量处理命令,对于单个瞬间,新的命令列表返回类型从 List[(List[Command], Int)] 变为 List[(Command, Int)]。生成命令列表的逻辑也相应地改变,如下所示:

generateCount(sampleCommands, totalCommandCount)

.grouped(cps.value)

.toList.zipWithIndex

.flatMap {

case (secondBatch, sBatchIndex) =>

val batchOffsetInMs = sBatchIndex * 1000

val commandIntervalInMs = 1000.0 / cps.value

secondBatch.zipWithIndex.map {

case (command, commandIndex) =>

val commandOffsetInMs =

Math.floor(commandIntervalInMs * commandIndex).toInt

(command, batchOffsetInMs + commandOffsetInMs)

}

}

我们创建命令集的过程稍微复杂一些。我们现在为每个命令计算一个偏移量,考虑到每个命令之间应该经过的毫秒数。使用这个基准测试,我们的最终结果如下:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.FinalLatencyBenchmark src/main/resources/historical_data 45000 10'

[info] Processed 450000 commands

[info] 99p latency: 92.0 ms

[info] 99.9p latency: 137.0 ms

[info] Maximum latency: 145 ms

我们最终为我们的系统建立了一个良好的延迟基准,确实,我们的结果接近当前在生产中观察到的结果。

注意

希望这次练习让你反思了自己的生产系统以及你可能想要基准测试的操作类型。本节的主要收获是正确记录操作延迟并考虑协调遗漏问题的重要性。你认为衡量你系统延迟的最佳方法是什么?如果你已经有了基准测试,它们是否考虑了协调遗漏效应?

定位瓶颈

现在我们能够一致地通过我们的基准测试在生产环境中重现不良性能,因此我们有信心可以准确测量我们做出的任何更改的影响。基准测试将订单簿视为一个黑盒,这意味着你无法洞察到订单簿的哪些区域导致了我们的性能问题。如果你之前熟悉这段代码,你可以利用你的直觉作为启发式方法,对需要更深入关注的子组件做出有根据的猜测。由于今天是你在 MVT 的第一天,你没有先前的直觉可以依赖。而不是猜测,我们将对订单簿进行剖析,以深入了解我们黑盒的各个方面。

JDK 捆绑了一个名为 Flight Recorder 的优秀剖析器。Flight Recorder 在非生产环境中免费使用。有关商业使用的更多信息,请参阅 Oracle 的许可协议,docs.oracle.com/javacomponents/jmc-5-5/jfr-runtime-guide/about.htm。拥有一个优秀的剖析器是 Scala 成为生产质量函数式编程实用选择的原因之一。Flight Recorder 通过使用内部 JVM 钩子来记录 JVM 在运行时发出的事件。Flight Recorder 捕获的事件包括内存分配、线程状态变化、IO 活动和 CPU 活动。要了解更多关于 Flight Recorder 内部的信息,请参阅 Oracle 的 Flight Recorder 文档:docs.oracle.com/javacomponents/jmc-5-5/jfr-runtime-guide/about.htm#sthref7。与无法访问 JVM 内部结构的第三方剖析器相比,Flight Recorder 能够访问 JVM 安全点之外的数据。JVM 安全点是一个所有线程都暂停执行的时间点。安全点是协调全局 JVM 活动(包括停止世界的垃圾收集)所必需的。要了解更多关于 JVM 安全点的信息,请查看 Alexey Ragozin 在blog.ragozin.info/2012/10/safepoints-in-hotspot-jvm.html上发表的这篇优秀的博客文章。如果一个剖析器只能检查安全点,那么它很可能是缺少有用的数据点,因为只出现了一个部分画面。

通过设置飞行记录器试验,让我们首先查看订单簿。为了加快周期时间,我们在执行ThroughputBenchmark回放历史数据的运行时,通过sbt设置分析器。我们使用以下 JVM 参数设置飞行记录器:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:+UnlockCommercialFeatures", "-XX:+FlightRecorder", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints", "-XX:FlightRecorderOptions=defaultrecording=true,dumponexit=true,dumponexitpath=/tmp/order-book.jfr")'

最大 JVM 堆大小设置为与我们的基准测试运行匹配,然后是四个 JVM 参数。-XX:+UnlockCommercialFeatures和-XX:+FlightRecorder参数是必需的,以便为飞行记录器发出 JVM 事件。飞行记录器文档引用-XX:+UnlockDiagnosticVMOptions和-XX:+DebugNonSafepoints以改善采样质量。这些选项指示编译器生成元数据,使飞行记录器能够捕获不在安全点的样本。最后一个参数配置飞行记录器在程序启动时开始记录,并在程序退出时将配置文件结果输出到提供的路径。在我们的情况下,这意味着配置文件将在基准测试开始时开始,并在基准测试结束时终止。或者,也可以通过以下配置来配置飞行记录器,以延迟其启动时间并记录固定时间:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:+UnlockCommercialFeatures", "-XX:+FlightRecorder", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints", "-XX:StartFlightRecording=delay=10s,duration=60s,name=Recording,filename=/tmp/order-book.jfr")'

前面的选项配置飞行记录器在五秒后(delay选项)启动并记录一分钟(duration选项)。结果存储在/tmp/order-book.jfr。

我们现在可以生成配置文件结果了。接下来,我们运行配置为回放 2,000,000 个请求的基准测试。回放更多请求,分析器就有更多机会捕获 JVM 事件。在其他条件相同的情况下,应优先选择较长的配置文件而不是较短的配置文件。以下输出显示了基准测试调用及其结果:

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:+UnlockCommercialFeatures", "-XX:+FlightRecorder", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints", "-XX:FlightRecorderOptions=defaultrecording=true,dumponexit=true,dumponexitpath=/tmp/order-book.jfr")' 'runMain highperfscala.benchmarks.ThroughputBenchmark src/main/resources/historical_data 2000000'

[info]

[info] Processed 2000000 commands

[info] in 93.276 seconds

[info] Throughput: 21441.742784853555 commands/sec

要查看配置文件结果,我们使用Java Mission Control(JMC),这是一个免费捆绑的 GUI,支持许多功能,包括检查飞行记录器结果和运行飞行记录器配置文件会话。JMC 位于 Java 可执行文件的同一目录中,这意味着只需键入以下内容即可通过路径访问它:

jmc

一旦 GUI 加载,通过导航到文件 | 打开来打开分析器结果。浏览到配置文件结果并点击确定以加载它们。在我们查看结果的同时,我们将构建一个清单,列出在审查分析器结果时需要考虑的问题。这些问题旨在让您批判性地分析结果。这些问题确保实验结果真正解决了导致配置文件的假设。在本章结束时,我们将这些问题以单个清单的形式呈现,以便以后更容易回顾。

我是否使用预期的资源集进行了测试?

如果测试环境被设置为使用不正确的资源,则结果无效。因此,首先检查环境设置是有意义的。幸运的是,飞行记录器为您捕获了大部分这些信息。

常规和系统选项卡组是本清单项需要关注的区域。在常规中,点击JVM 信息以识别关键 JVM 事实。在本节中,确认以下内容:

重点关注区域

重点关注该区域的原因

JVM 启动时间

这是一个快速检查点,确认测试是在你认为的时间执行的。凭借一些配置文件结果,确保你正在审查正确的结果变得微不足道。随着你收集更多信息并调查更多假设,这个简单的检查确保你不会混淆结果。

JVM 版本

JVM 版本的差异会产生不同的结果。确保你使用的是与生产环境相同的 JVM 版本。

JVM 命令行参数和 JVM 标志

通过命令行参数调整 JVM 是很常见的。通常,参数化会在运行之间发生变化,这使得后来难以回忆起哪个运行对应于哪组参数。审查这些信息为审查结果提供了有用的背景。

Java 应用程序参数

与前面的担忧类似,目标是确保你确信你理解了测试的输入。

为了补充确认 JVM 配置,请在内存选项卡组下查看GC 配置选项卡。此选项卡详细说明了垃圾收集配置,它反映了用户提供的配置和 JVM 默认值的组合。正如你所意识到的,垃圾收集配置的微小变化可能会产生显著的运行时性能变化。鉴于应用程序性能对垃圾收集配置的敏感性,你应该反思此选项卡中的每个参数。在审查时需要考虑的问题如下:

如果我配置了此参数,配置的值是否生效?

如果我没有配置此参数,调整此值可能会产生什么影响?这个问题经常帮助你为未来的配置文件运行创建假设。

接下来,我们关注系统选项卡组。概览选项卡列举了非 JVM 资源,以便清楚地了解创建此配置文件所使用的资源。继续从常规选项卡组的主题出发,总体目标如下:

确认记录的信息是否与您预期的资源相匹配(例如,可用的 RAM 数量是否与您认为存在的数量相匹配?)

寻找测试资源和生产环境之间的意外差异(例如,我的本地机器使用内核版本 v3.18.x,而生产环境使用较旧的次要版本,v3.17.x)

如果您通过环境变量配置系统,应该检查环境变量选项卡。与概览选项卡一样,您需要确保您的测试资源已按预期配置和提供。需要重复的是,测试资源中的任何意外差异都会使测试结果无效。

分析期间系统环境是否干净?

一旦您确信使用了适当的资源,下一步就是确认只有被分析的程序使用了资源。这是在审查测试结果之前的一个重要诊断步骤,因为它确保了被分析的程序在测试中确实被隔离。幸运的是,飞行记录器收集了有用的信息来回答这个问题。在系统选项卡组中,进程选项卡捕获了分析期间运行的所有进程。在查看此列表时,请考虑以下问题:

当我扫描进程列表时,我是否看到任何不应该运行的进程?

当我通过命令行列进行筛选并输入java时,我是否看到预期的 JVM 应用程序运行集?

当我扫描进程列表时,我是否看到任何重复的进程?

接下来,在常规选项卡组下检查记录选项卡。飞行记录器提供了创建并发记录的能力。配置文件结果将包含并发记录的并集。如果只有几个运行中的一个出现了意外的多个记录,那么您可能无法在记录之间进行苹果对苹果的结果比较。

下一个需要关注的是在分析期间系统 CPU 的使用情况。在常规选项卡组中,选择概览选项卡。此视图显示 CPU 使用面板,它提供了在整个记录过程中检查机器 CPU 使用情况的能力。在这里,您正在寻找 JVM 和机器总 CPU 使用之间的意外差异。以下截图描述了一个值得调查的差异场景:

CPU 使用面板突出显示的意外非 JVM CPU 使用情况

在前面的截图中,JVM + 应用(用户)和JVM + 应用(内核)的组合表示正在测试的 JVM 的 CPU 使用情况,而机器总表示机器(即,正在测试的 JVM 和所有其他进程)的 CPU 使用情况。在这次记录的大部分时间里,正在测试的 JVM 的 CPU 使用情况代表了机器 CPU 使用情况的大部分。如果你的应用程序应该是唯一使用系统资源的进程,那么JVM + 应用(用户)和机器总之间的小差异就代表了期望的状态。记录期间中间附近的变化表明另一个进程或多个进程正在使用 CPU 资源。这些峰值表明了可能对分析结果产生负面影响的不正常行为。值得考虑其他进程正在使用系统资源,以及你的测试结果是否仍然有效。

这是一个介绍范围导航器的好机会,它是每个标签顶部的小型水平小部件,包含红色标记。范围导航器是一个时间线,它以红色标记显示当前标签中随时间发生的事件。默认情况下,整个时间线被选中,并且分析持续时间显示在时间线的中心上方。你可以选择时间线的一部分来放大感兴趣的区域。例如,你可能希望放大机器 CPU 使用率激增时的 CPU 使用情况。当选择时间线的一部分且数据只针对特定时间点(例如,记录开始时)可用,或者数据不表示时间序列(例如,JVM 版本)时,则数据被隐藏或替换为 N/A。

在内存标签组下的概览标签页中的内存使用面板中进行最终检查,以检查已使用的机器物理内存。在此检查期间,检查你是否正在尝试评估已使用的机器物理内存量是否是一个合理的值。如果剩余的机器物理内存很少,而保留的堆内存只占总机器物理内存的一小部分,你应该暂停下来考虑其他进程正在使用内存。以下截图展示了这种情况:

使用内存使用面板捕获的大多数可用内存的非 JVM 进程

在前面的示例中,预留的堆空间为 2 GB,占可用 16 GB 系统内存的 1/8,而系统内存使用了 13 GB。这意味着除了被分析 JVM 之外,还有 11 GB 的系统内存被其他进程消耗。除非你预期测试环境中会运行其他进程,否则这种内存使用差异需要进一步调查。例如,如果你的应用程序使用了堆外内存,这种差异可能会使你的测试结果无效,因为你的应用程序可能无法按需分配内存,或者可能导致系统交换过度。

JVM 的内部资源是否按预期运行?

我们从最广泛的准则开始,通过验证资源是否正确配置和分配来构建我们的分析清单。我们继续缩小清单范围,通过关注 JVM 配置来确保测试结果来自有效的配置。现在,我们通过检查 JVM 内部来继续验证配置文件是否未受到损害。

几乎所有生产级应用程序都涉及多线程,以更好地利用多个 CPU 核心或分离 I/O 密集型工作与 CPU 中心型工作。线程选项卡组帮助你熟悉应用程序内部的劳动分工,并提供了一些深入调查的建议。以下表格概述了线程选项卡组中的重点区域,并突出了当你对被分析的应用程序不熟悉或需要比较多个配置文件结果时需要考虑的问题:

关注区域

新接触应用程序

熟悉应用程序

线程计数面板在概述选项卡中

总共有多少个线程?这与你预期的数量是否不同?例如,如果应用程序是 CPU 密集型,线程数量是核心数量的十倍,这可能是一个警告信号,表明应用程序的调优不佳。

线程计数在配置文件之间是否有定性变化?例如,线程计数加倍或减半可能表明配置错误。

热点线程面板在热点线程选项卡中

样本计数分布是否均匀,还是有几个线程主导样本计数?最热的线程可能是需要深入调查的线程,也是你应该最熟悉的代码区域。

样本计数分布的线程样本计数是否有显著变化?如果有,这些变化是否合理?

顶级阻塞锁、顶级阻塞线程和顶级阻塞线程在竞争标签中

熟悉锁的存在位置以及哪些线程最常阻塞。这些信息在考虑影响关键路径性能的因素时可能很有用。

与之前的配置文件结果相比,线程阻塞的频率或分布是否有所增加?结果中是否出现了新的锁?

延迟堆栈跟踪在延迟标签中

熟悉不同事件类型的最大延迟,以便更好地理解哪些操作对应用程序影响最大。对应用程序中更延迟的部分进行深入思考。

最大延迟是定性增加、减少还是与之前的配置文件结果相似?当存在多个顶级堆栈跟踪的事件类型时,考虑那些对关键路径性能影响最大的。

在彻底检查线程标签组之后,你应该开始形成一个关于此应用程序如何运行以及哪些区域可能对进一步研究最有兴趣的心理图像。我们现在转向一个与 JVM 线程紧密相关的话题:I/O。

I/O标签组提供了有关配置文件应用程序文件和套接字访问的有价值信息。就像对线程标签组的审查一样,本节可能提供有关应用程序存在意外或不希望的行为的线索。在深入到这个标签组之前,请停下来考虑何时或什么原因导致磁盘读取和写入,以及网络读取和写入。当你审查概述标签时,你是否看到了你的想法与分析结果之间的差异?如果是这样,你应该确定这种差异的原因,以及它是否使你的测试结果无效。

一个意外的 I/O 行为示例可能是对标准输出的过度写入。这可能在调试语句被遗留时意外发生。想象一下,如果这种副作用发生在应用程序的关键路径上。这将负面地影响配置文件结果并使测试无效。在 Flight Recorder 中,标准输出的写入通过一个空白的写入路径被捕获。以下截图显示了一个简单的一次性应用程序的文件写入结果,该应用程序以高频率反复写入标准输出:

通过 I/O 标签组识别标准输出中的意外写入

这个例子为了效果夸张了,这就是为什么随着时间的推移会有连续的写操作块。通过检查文件写入选项卡,我们还可以看到花费在写入标准输出上的时间、写入的数据量以及发生的写入次数。在约 15 秒的执行时间内,发生了惊人的 40,000 次写入!文件写入堆栈跟踪提供了宝贵的情报,使我们能够回溯以确定哪些应用程序部分负责这些写入。飞行记录器还允许您按线程查看写入操作。在生产应用程序中,如果有专门的写入线程,您可以快速隔离不希望的 I/O 访问。

监控 I/O 读取和写入是讨论如何配置飞行记录器记录参数的好机会。-XX:+FlightRecorderOptions接受一个名为settings的参数,默认情况下,它指向$JAVA_HOME/jre/lib/jfr/default.jfc。您可以选择提供自己的配置文件或修改默认文件。在这个配置文件中,您可以调整飞行记录器记录的事件以及捕获某些事件的频率。例如,默认情况下,java/file_write事件有一个 20 毫秒的阈值。这是一个合理的默认值,但您可能希望将其调低,如果您专注于分析文件写入。降低阈值意味着捕获更多的事件样本。请仔细调整,因为更多并不总是更好。较低的阈值意味着更高的开销和更多需要筛选的信息。

最后一个要调查的领域是在代码选项卡组下的异常选项卡。即使在分析期间您密切监控应用程序日志,您可能也不会持久化日志以进行历史分析。幸运的是,飞行记录器会捕获异常和错误以供审查。扫描异常和错误时,请注意总共发生了多少异常和错误,以及哪些异常发生得最频繁。使用范围导航器缩小时间范围,以更好地了解异常和错误是否集中在应用程序启动时、分析后期或均匀发生。异常和错误发生的时间通常可以提供有关根本原因是配置错误还是意外运行时行为的洞察。始终如一,如果您注意到异常或错误的数量令人担忧,请在深入了解它们发生的原因之前,考虑使分析结果无效。

CPU 瓶颈在哪里?

在这个阶段,我们已经完成了所有必要的检查,以最大限度地提高分析结果有效并值得进一步调查的可能性。现在,我们开始可能是分析过程中最有意思的部分:识别 CPU 瓶颈。这是一个令人愉快的过程,因为分析器让您深入了解应用程序的黑盒。这是一个通过将分析结果与您的假设和应用程序工作方式的心理模型进行比较,以客观测试您的假设的机会。一旦您确定了瓶颈,您将感到一种释然,因为您现在知道在哪里定位您的下一组更改以改进应用程序性能。

让我们从概述标签页开始,这是代码标签组的一部分。这个视图有助于您从底层开始了解代码中哪些区域最昂贵。以下图显示了订单簿样本运行的概述标签页:

总结昂贵包和类的代码概述标签页

在这个视图中,初始目标是了解应用程序中 CPU 时间的分布。热点包面板迅速使我们清楚,订单簿严重依赖于scala.collection包中的代码。热点类面板显示,订单簿花费了相当多的时间,大约 55%的时间,执行某种类型的迭代操作。在这个屏幕截图中,我们还看到,只有部分配置文件持续时间被范围导航器选中。查看配置文件期间的不同子集通常很有帮助,以确定热点是否随时间保持不变。在这个例子中,配置结果的前部分被排除,因为这些时间包括发送到订单簿的请求的准备工作。选择这个子集使我们能够专注于订单簿操作,而不受测试设置的干扰。

重要的是要注意,百分比列表示在显示的包和类中执行的应用程序时间量。这意味着这个视图,连同热点方法标签页,是自下而上的视图,而不是自上而下的视图。在自上而下的视图中,百分比列表示在堆栈跟踪的一部分中花费的总时间量。这个视图在调用树标签页中捕获。这种区别是关键的,因为这两个视图帮助我们回答不同的问题。以下表格从两个角度探讨了几个主题,以更好地理解何时每个视图最有帮助:

主题

自上而下的视图(调用树)

自下而上的视图(概述/热点方法)

视角

这是从宏观图到微观图

这是从微观图到宏观图

确定热点

这些是代码库中委托给最昂贵函数调用的区域

这些是最昂贵的函数

每个视图最佳回答的问题

订单簿取消操作的成本是否高于添加一个静态限价订单?

关键路径上 CPU 时间花在哪里?

|

从Double价格表示切换到BigDecimal表示是否创建了任何热点?

相比于应用的其他部分,哪些树操作成本最高?

|

作为订单簿的首次分析者,你现在从概览标签页了解到订单簿大量使用了 Scala 集合,但你还没有感受到哪些订单簿操作导致了性能下降。为了深入了解不同订单簿操作的成本,你通过调查调用树标签页采取自上而下的视角:

使用调用树调查订单簿瓶颈

深入分析调用树,可以清楚地看出取消操作是瓶颈。绝大多数 CPU 时间都花在取消订单上,而不是添加订单上。你叫来公司最敏锐的交易员戴夫,与他分享这一发现。当提到取消操作成本高昂时,戴夫的眼睛亮了起来。作为一名交易领域的专家,他深知在波动时期,取消操作的频率相较于平静的市场时期显著增加。戴夫解释说,交易员在波动市场中经常取消订单,以便快速调整摇摆的价格。取消操作导致更多的取消,因为交易员正在学习有价值的定价信息。结束对话时,他告诉你取消你正在做的所有其他事情(没有开玩笑!),并专注于提高订单取消的性能。

通过确定可能是 MVT 交易损失来源的瓶颈,你从对话中感到更加安心。为了更好地了解情况,你进一步深入到调用树中,揭示取消订单中哪些函数是昂贵的。这是从宏观视角转向微观视角的过程。最后,你查看调用树标签页,如下所示:

通过调用树了解哪些取消订单函数最慢

调用树显示,取消订单涉及对RedBlackTree find()函数和exists()函数的调用。当你进一步查看调用时,你也会注意到百分比列变得更小。这是因为从自上而下的视角来看,百分比列代表了一个特定函数及其所有下级函数所花费的总 CPU 时间。根据结果,84.48%的 CPU 时间被用于executingOrderBook$$anonfun$handleCancelOrder$1.apply()以及调用树中更深层的函数。从这个视角来看,我们还可以看到,在 84.48%的 CPU 时间中,53.24%的时间被用于withinAbstractIterator.exists()以及更深层的函数调用。这看起来是最大的瓶颈,其次是Queue.iterator()的调用,占用了 31.24%的 CPU 时间。

反思这些信息,你好奇地想从最昂贵的函数开始,换句话说,从底部开始,逐步通过回溯来识别受影响的订单簿操作。为了满足你的好奇心,你调查了热方法标签页,并看到了以下视图:

从微观视角到宏观视角,通过热方法

通过数量级,你发现与调用树调查中相同的两个罪魁祸首是热方法。这是一个令人欣慰的发现,因为它增强了信心,认为对取消订单实现的更改可能会带来质的飞跃。由于你没有花时间研究源代码,关于实现的神秘之处仍然很多。退一步考虑情况,你思考在抽象中建模的操作。取消订单涉及找到可能具有任何价格的现有订单,然后一旦找到,修改订单簿的状态以删除订单。你的直觉表明,这些操作中的一些可能是线性的,或者最多是对数级的。当你开始考虑其他可能的更快实现时,Dave 打断了你。

你听到一个急促的声音说:“你修复订单簿了吗?我们现在需要部署它!”当然,你不知道如何回应,想到第一天就部署代码让你有些不安。你与 Dave 分享你的发现,希望你的发现能满足他对进步的渴望,并为你争取更多思考的时间。不幸的是,Dave 对订单簿性能之谜仍未解决并不高兴,“我们每天都在因为这个原因而损失金钱!”你承认你理解形势的严重性,并且你正在尽可能快地行动。毕竟,这是你的第一天!Dave 叹了口气,承认他有点苛刻,他的沮丧导致他反应过度。随着对话的结束,Dave 提到他对你快速掌握情况的感激之情,并就他如何无法理解他新买的智能手机,尽管加载了额外的内存,仍然运行缓慢进行了闲聊。“现在似乎没有什么能快速运行了!”他大声说道。他提到的内存让你有了顿悟。

你记得你还没有审查内存使用结果。你希望通过调整垃圾收集器来提高性能,而不需要修改代码,可能会有一些容易的胜利。在做出任何更改之前,你查看内存标签组以了解内存分配模式。

内存分配模式是什么?

内存标签组是我们分析中需要深入的最后区域。尽管我们没有花时间查看订单簿源代码,但代码标签组展示了不同操作顺序的相对成本。研究热点方法可以了解订单簿各个区域使用的对象类型。查看内存分配模式,我们希望识别年轻和老年代垃圾收集趋势以及哪些对象分配最多和最少。

默认的飞行记录器配置设置不跟踪对象分配。为了更全面地查看内存消耗,应启用以下配置设置:

Allocation-profiling-enabled

Heap-statistics-enabled

gc-enabled-all

为java/object_alloc_in_new_TLAB和java/object_alloc_outside_TLAB事件启用 Allocation-profiling-enabled

一旦生成包含所有先前参数的配置文件,你将在垃圾收集标签页的堆面板中首次了解应用程序的内存分配模式:

通过垃圾收集标签可视化内存分配模式

此视图显示的形状通常被称为锯齿形模式。由于 JVM 不断释放年轻代内存,频繁的垃圾回收在数据中创建了一个类似牙齿的模式。垃圾回收调优是一个广泛的话题,超出了本书的范围。我们鼓励您通过阅读这篇名为“理解 Java 垃圾回收”的精彩博客文章来深入了解这一领域(www.cubrid.org/blog/dev-platform/understanding-java-garbage-collection/)。

如下截图所示,飞行记录器在GC 时间标签页中也提供了每个垃圾回收类别的摘要指标:

按收集事件类型总结垃圾回收

在检查堆使用可视化以及按收集类型拆分的垃圾回收时,以下是一些值得考虑的问题:

问题

需要考虑的思考

平均而言,内存使用量是保持恒定、向下倾斜还是向上倾斜?

内存使用量向上倾斜可能表明存在内存泄漏。在这种情况下,堆将增长,直到旧生代填满,导致旧生代收集。如果存在内存泄漏,旧生代收集不会清除很多内存,最终可能导致内存不足错误。在分析此类型问题时,获取尽可能长的分析结果,以确保尽可能全面地查看情况。

异常暂停时间是否与其他主要事件相关?

根据垃圾回收拆分,最大收集时间是平均收集时间的十倍。在堆面板中查找比平均收集时间质上更大的收集暂停。您是否在异常值中看到了某种模式?考虑您的应用程序与外部系统的交互以及它部署到的机器。是否存在解释极端暂停发生的原因?比较不同配置文件中的异常值,以确定该模式是否仅针对单个配置文件或似乎具有系统性。

收集的频率是多少?典型的收集持续多久?

在其他条件相同的情况下,较低的收集次数更可取,因为它表明垃圾生成速度较慢。话虽如此,较低的收集次数可能是由于堆大小增加的结果,这可能导致平均收集时间增加。重要的是要记住,检查收集次数和延迟应该持谨慎态度。因此,总垃圾收集时间指标是有洞察力的。总收集时间反映了收集频率和持续时间的影响。此外,它不会像平均收集持续时间那样遭受损失。

重要用例中对象的生存周期是多久?

在研究垃圾收集性能的分解时,了解您的应用程序中不同用例的内存分配方式是很重要的。理解这种关系可能有助于您找出为什么会出现某些分配模式。在波动较大的市场中,我们预计订单簿中有许多短生存期的对象,因为交易员经常取消订单。在波动较小的市场中,我们可能预计订单簿上休息的订单的平均年龄更高,这意味着有更多长生存期的对象。

研究这些内存分配的视角提供了内存分配活动的总结。通过调查分配标签页,可以以多种不同的方式查看应用程序中哪些部分正在施加内存压力。飞行记录器提供了三种分配视图:按类、按线程和按配置文件:

通过按类分配关联高压列表分配

类和配置文件分配在先前的屏幕截图中显示。请注意,由于订单簿是单线程的,因此在此情况下跳过了按线程分配。

使用自上而下的分配配置文件视图确认内存分配压力

当您审查这些分配视图时,应考虑以下问题。在阅读这些问题时,反思您将如何回答它们,以更好地理解如何提高订单簿的性能:

问题

需要考虑的思考

当检查按类分配时,具有重收集压力的类与在关键路径上引用的类之间是否存在正相关?

如果你确定创建最多垃圾收集压力的类也经常在关键路径上分配,那么你有充分的理由相信,如果你优化关键路径,将带来算法速度和垃圾收集的双重好处。订单簿结果表明,列表分配是最严重的违规者。回溯显示,分配几乎完全来自处理取消订单,这是我们已知是瓶颈的地方。

当检查按线程分配时,垃圾收集压力的分布是什么样的?

注意哪些线程负责生成最多的垃圾收集压力,可以帮助你确定代码中需要集中注意力的区域。通过减轻最严重的违规者的垃圾收集压力,将反映在总的暂停时间上。

当深入到分配配置文件堆栈跟踪时,已知的 CPU 时间瓶颈是否与高垃圾收集压力相关?

订单簿显示,大约 99%的垃圾是在处理取消订单时生成的。这证实了处理取消订单是计算密集型的,并且由于高对象分配率,进一步减慢了系统。建立这种相关性提供了强有力的证据,表明对这部分代码的代码更改将带来定性的性能改进。

尝试挽救局面

知道 Dave 很快会再次询问你关于提高订单簿性能的改进,你花了几分钟时间反思你的发现。很明显,处理取消订单既是 CPU 时间也是内存分配的瓶颈。在更多时间思考问题的同时,你自信可以改变订单簿实现来解决这个问题或可能两个问题。不幸的是,时间是你目前没有的东西。从内存分配中有一个有趣的观察:在波动市场中,大多数垃圾通常都是短暂的。有两个低成本的测试选项浮现在脑海中:

JVM 内存调整选项

假设

从默认的老一代收集器、并行老收集器切换到并发标记清除(CMS)。

CMS 收集器旨在保持应用程序的响应性。切换到 CMS 收集器可能不会提高订单簿吞吐量,但在市场波动剧烈时,它可能提供更一致的响应延迟。

将新大小从默认值(大约最大堆大小的三分之一)增加到最大堆大小的四分之三。

订单簿有 1GB 的堆来存储状态,目前只使用大约 380MB 来存储年轻代对象。你希望利用频繁取消导致频繁短暂对象出现的直觉。增加新代大小是一种赌注,即将不会有超过 250MB 的持久对象,并且增加年轻代堆大小可以提高订单簿吞吐量,因为收集的频率更低。

下表总结了每个实验的结果:

设置

命令

99^(th) 百分位(毫秒)

原始

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G")' 'runMain highperfscala.benchmarks.FinalLatencyBenchmark src/main/resources/historical_data 45000 10'

92

CMS 收集器

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:+UseConcMarkSweepGC")' 'runMainhighperfscala.benchmarks.FinalLatencyBenchmark src/main/resources/historical_data 45000 10'

118

750M 新大小

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:NewSize=750M")' 'runMain highperfscala.benchmarks.FinalLatencyBenchmark src/main/resources/historical_data 45000 10'

74

CMS 收集器和 750M 新大小

sbt 'project chapter2' 'set javaOptions := Seq("-Xmx1G", "-XX:NewSize=750M", "-XX:+UseConcMarkSweepGC")' 'runMain highperfscala.benchmarks.FinalLatencyBenchmark src/main/resources/historical_data 45000 10'

148

看起来提高订单簿性能会比预期更复杂。至少有一种选择,即增加新大小,似乎能带来更好的整体延迟。我们建议你再次阅读本章,并使用这些新的选项集对应用程序进行基准测试和性能分析。观察这些 JVM 选项引入的新行为,并尝试理解由此产生的延迟增加或减少。

一点注意事项

我们想花点时间强调一下关于我们在上一节中解释的分析结果的一些背景信息。我们通过一个展示了多个现实世界问题的例子进行了分析。我们提出了一种实用方法来处理性能问题,这种方法是作者们在日常工作中应用的。需要注意的是,订单簿的复杂度可能远低于你日常工作中遇到的大多数应用。我们故意选择了一个足够复杂以展示如何处理性能问题的例子,同时也足够简单,无需数小时代码审查就能理解。在实践中,你可能需要多次重复分析,每次测试一个新的假设,以便在性能问题上取得进展。应用我们介绍的结构化方法将确保你在分析结果之前验证它们,并且它还将确保你有充分的证据来确定需要做出改变的地方。

分析清单

我们在本章中逐项检查了配置清单。为了便于参考,我们在此列出整个清单如下:

我是否测试了预期的资源集?

在分析过程中,系统环境是否干净?

JVM 内部资源是否按预期运行?

CPU 瓶颈在哪里?

内存分配模式是什么?

使用微基准测试迈出大步

在接下来的章节中,我们将分享来自函数式范式和 Scala 语言的技巧,这些技巧使你能够编写更高效的软件。然而,你不应该盲目接受我们的建议。衡量性能是确定更改是否提高性能的客观方式。微基准测试是一个术语,用来描述对较大应用程序的一个小部分进行测试的基准。由于微基准测试设计上测试的是一小段代码,当进行细微更改时,运行微基准测试通常比基准测试整个应用程序更容易。

不幸的是,准确观察细微更改的性能是困难的,尤其是在 JVM 上。考虑以下这些与订单簿相关的更改示例,这些更改值得进行微基准测试。

用一个更有效地处理取消操作的数据结构替换持有静态限价订单的数据结构

将股票价格从双精度表示规范化为整数表示,以进行具有较低开销的订单匹配

确定重新排序一组分支语句以反映你认为最频繁访问的顺序的性能提升

你会如何衡量每次更改前后的性能?你可以尝试编写小的基准程序,这些程序类似于ThroughputBenchmark。由于 JVM 的智能,这种方法可能提供不可靠的结果。JVM 应用了多种启发式方法来进行运行时优化。在生产环境中,这些更改是受欢迎的,因为性能的改进总是受欢迎的。然而,在微基准测试中,这些更改并不受欢迎,因为它们会降低对微基准测试仅隔离细微更改的信心。JVM 能够做出的更改示例包括以下内容:

死代码消除

即时编译优化(请参阅我们之前关于 JIT 编译器的侧边栏)

常量折叠(一种优化,用于避免在每次调用具有常量参数和依赖于这些参数的返回值的函数时进行评估)

我们鼓励你通过阅读 Oracle 的《Java HotSpot 性能引擎架构》(www.oracle.com/technetwork/java/whitepaper-135217.html)来了解更多关于 JVM 优化的信息。鉴于这是一个隔离小代码更改的挑战,我们如何编写一个合适的微基准测试?幸运的是,OpenJDK 团队认识到了这些相同的挑战,并引入了一个名为 JMH 的库,即 Java 微基准测试工具(openjdk.java.net/projects/code-tools/jmh/)。JMH 是专门为克服我们提到的限制而设计的,以便隔离你更改的性能影响。使用 JMH 的过程与其他测试库类似。类似于 JUnit,JMH 定义了一组注解来控制测试设置和执行。尽管测试可以通过多种方式运行,但我们专注于通过 sbt 插件 sbt-jmh(github.com/ktoso/sbt-jmh)执行测试,以便于使用。让我们通过创建、运行和分析使用订单簿的微基准测试的过程来一探究竟。在未来的章节中,我们将利用我们的 JMH 知识来客观地衡量指定更改的性能影响。

备注

你最近是否对应用程序进行了可能从微基准测试中受益的更改?如果你没有对更改进行微基准测试,你认为微基准测试是否可能引导你找到替代解决方案?

对订单簿进行微基准测试

通过调整 JVM 内存设置来提高性能取得进展后,你开始关注更好地理解取消性能。根据分析器结果中存在 scala.collection.immutable.Queue 的信息,你假设可能存在对 FIFO 队列的线性时间遍历来支持顺序取消。测试这一假设的一种方法是为不同场景设计一个微基准测试来衡量取消性能。你头脑风暴了以下场景:

取消一个不存在的订单

取消价格水平队列中的第一个订单

取消价格水平队列中的最后一个订单

在现实世界中,当取消请求到达之前,一个处于休息状态的订单被取消,这种情况就会发生。这是一个有趣的场景,因为你不确定是否存在提前终止逻辑来使这个操作更便宜,或者取消一个不存在的订单是否需要检查整个订单簿。剩下的两个场景关注的是证券交易所提供的填充保证。当以相同价格放置多个订单时,它们保证按照先到先得的原则进行填充。你推测在配置文件结果中看到的 FIFO 队列正在保留该价格水平上休息订单的时间顺序。你期望取消队列中的第一个订单比取消队列中的最后一个订单快一个线性因子。

在阅读了hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/上的优秀 JMH 示例和一些深入思考之后,你能够组合以下测试来捕捉你感兴趣的情景。代码是完整的,并且随后是一个遍历,如下所示:

@BenchmarkMode(Array(Throughput))

@OutputTimeUnit(TimeUnit.SECONDS)

class CancelBenchmarks {

@Benchmark

def cancelLastOrderInLine(b: BookWithLargeQueue): (OrderBook, Event) = OrderBook.handle(b.book, b.cancelLast)

@Benchmark

def cancelFirstOrderInLine(b: BookWithLargeQueue): (OrderBook, Event) = OrderBook.handle(b.book, b.cancelFirst)

@Benchmark

def cancelNonexistentOrder(b: BookWithLargeQueue): (OrderBook, Event) = OrderBook.handle(b.book, b.cancelNonexistent)

}

object CancelBenchmarks {

@State(Scope.Benchmark)

class BookWithLargeQueue {

private val p = Price(BigDecimal(1.00))

private val firstId: Int = 1

private val defaultCancelLast = CancelOrder(OrderId(-1))

@Param(Array("1", "100", "1000"))

var enqueuedOrderCount: Int = 0

var book: OrderBook = OrderBook.empty

@Setup(Level.Trial)

def setup(): Unit = {

if (enqueuedOrderCount < 0)

sys.error(s"Invalid enqueued order count = $enqueuedOrderCount")

assert(book == OrderBook.empty)

assert(cancelLast == defaultCancelLast)

cancelLast = CancelOrder(OrderId(enqueuedOrderCount))

book = {

(firstId to enqueuedOrderCount).foldLeft(OrderBook.empty) {

case (ob, i) =>

OrderBook.handle(ob, AddLimitOrder(BuyLimitOrder(OrderId(i),

p)))._1

}

}

assert(cancelLast != defaultCancelLast)

if (enqueuedOrderCount > 0)

assert(book.bids.head._2.size == enqueuedOrderCount,

s"Book built incorrectly! Expected book to contain " +

s"$enqueuedOrderCount bids for $p, but actual book is $book")

}

var cancelLast: CancelOrder = defaultCancelLast

val cancelFirst: CancelOrder = CancelOrder(OrderId(firstId))

val cancelNonexistent: CancelOrder = CancelOrder(OrderId(-1))

}

}

我们不会重复 JMH 文档,而是将重点放在感兴趣的特定部分,并期望你也调查 JMH 样本以获取更多背景信息。调查CancelBenchmarks类,你看到使用注解来定义基准测试和控制基准测试输出。存在几种基准模式。我们使用吞吐量模式来衡量基准测试在固定时间段内完成的次数。每个取消基准测试的实现仅通过取消订单的 ID 不同。让我们将重点转向CancelBenchmarks对象,它提供了设置每个基准测试所需的必要框架。

CancelBenchmarks对象定义了BookWithLargeQueue状态,这是我们观察到每个基准测试的参数。定义测试所需的州是参数化基准测试的第一步。对于这一组测试,我们通过创建一个只有$1.00 价格水平的订单簿来简化测试设置。我们专注于清除$1.00 价格水平上入队的订单数量,以帮助我们识别我们认为在线性时间内运行的运行时行为。使用param注解提供了一组用于清除入队订单计数的默认值。我们使用setup注解来指示 JMH 在调用每个三个基准测试之前准备订单簿的状态。对于每个入队订单计数值,JMH 调用setup方法来创建一个在$1.00 级别具有所需数量休息订单的订单簿。

接下来,我们从 sbt 运行基准测试。JMH 提供了一些命令行标志来控制测试配置,这些标志可以通过以下命令在sbt中查看:

sbt 'project chapter2' 'jmh:run -help'

所有配置为注解的参数都可以通过提供相关的命令行标志来覆盖。以下是一个CancelBenchmarks的示例调用:

sbt 'project chapter2' 'jmh:run CancelBenchmarks -wi 3 -w 5s -i 30 -r 10s -jvmArgs "-Xmx1G -Xms1G" -gc true -foe true -p enqueuedOrderCount=1,10,50,100'

在这次 JMH 调用中,我们配置了三个预热迭代,每个迭代运行 5 秒。预热迭代不计入输出吞吐量结果。我们配置了 30 个记录迭代,每个迭代持续 10 秒以计算吞吐量。我们为这次测试提供了 1 GB 的堆大小,并在未捕获异常时退出基准测试以防御代码回归。最后,我们参数化了我们希望扫描的入队订单数量,表明我们希望对 1、10、50 和 100 个订单的入队订单数量运行三个预热迭代和 30 个记录迭代。

在书中只有一个订单的情况下,我们假设所有操作的成本大致相等。因为我们认为取消操作运行在线性时间,所以我们的预期是,当入队订单数量为 50 时,每个基准测试应该比当数量为 10 时慢大约五倍。我们将测试限制在 100 个入队订单,因为在与 Dave 讨论时,我们了解到在他的经验中,他从未分析过超过 85 个订单的级别。将测试限制在 100 个订单确保我们了解的性能特征在一个我们预计不会在生产中看到但可能发生的级别。

注意

想象一下,你正在为系统中性能最敏感的使用案例编写一个微基准测试。为了全面了解系统在这个使用案例中的性能,哪些变量是重要的需要扫描的?你将如何确定基准测试的基础案例、步长值和最大值来参数化你的测试?考虑与领域专家交谈或使用生产数据来指导决策。

执行测试后,我们看到了以下总结结果:

基准测试

入队订单数量

吞吐量(每秒操作数)

误差(每秒操作数)

误差占吞吐量的百分比

取消第一个订单

1

6,688,878.23

±351,518.041

±5.26

取消第一个订单

10

2,202,233.77

±103,557.824

±4.70

取消第一个订单

50

555,592.56

±18,632.547

±3.35

取消第一个订单

100

305,615.75

±14,345.296

±4.69

取消最后一个订单

1

7,365,825.52

±284,773.895

±3.87

取消最后一个订单

10

1,691,196.48

±54,903.319

±3.25

取消最后一个订单

50

509,339.60

±15,582.846

±3.06

取消最后一个订单

100

242,049.87

±8,967.785

±3.70

取消不存在的订单

1

13,285,699.96

±374,134.340

±2.82

取消不存在的订单

10

3,048,323.44

±140,983.947

±4.62

取消不存在的订单

50

772,034.39

±16,535.652

±2.14

取消不存在的订单

100

404,647.90

±3,889.509

±0.96

从这个结果集中,我们可以回答许多有趣的问题,这些问题有助于我们描述订单簿的性能。以下是一些您可以通过检查结果来回答的问题:

单个入队订单的基本情况是否导致三个基准测试在质量上具有相似的性能?

随着入队订单数量的增加,每个基准测试是否表现出线性吞吐量下降?

随着入队订单数量的增加,基准测试之间的相对性能是否发生变化(例如,当入队订单数量为 100 而不是 10 时,取消第一个和最后一个订单)?

在评估不存在的订单时,是否似乎存在提前终止逻辑?

除了针对订单簿的特定问题之外,对于任何基准测试结果,还必须问自己以下问题:

结果是否通过了一个合理性测试?

测试可能出了什么问题(即产生无效结果),我们是否已经采取措施防止这些缺点发生?

测量或测试设置中的错误会破坏结果的完整性。这些问题旨在让您批判性地分析微基准测试结果。合理性测试要求您为执行的测试建立心理模型。使用订单簿,我们需要考虑每秒可能的取消操作数量。回答这个问题的方法之一是取一个吞吐量结果,例如,取消最后一个订单,有 50 个入队订单,并计算每个操作的毫秒数。这很有用,因为我们已经从早期的基准测试中获得了每个操作的成本的感知;每秒 509,339.595 个取消转换为大约 0.002 毫秒的操作。这个结果可能出人意料地低,但请注意,这些结果没有考虑到协调的遗漏,因为没有特定的吞吐量速率(即测试试图尽可能每秒发送尽可能多的取消)。成本可能低于预期的另一个原因是,订单簿中只有一个价格水平。通常,书籍在买卖双方包含许多价格水平。这可能会引导我们设计基准测试,以扫描价格水平的数量,从而更好地了解管理多个价格水平的成本。

第二个问题迫使我们批判性地分析测试设置和测试方法。例如,我们如何知道设置函数产生了预期的订单簿?一种防御方法是添加断言来强制执行预期的约束。另一个需要验证的问题是基准测试中的每个取消调用都产生预期的事件。为单个试验的基准测试添加断言可能解决这个问题。然而,在基准测试代码中留下断言可能会影响性能,并且如果有的话,应该谨慎使用。为了额外的安全性,为正在测试的场景编写单元测试可能是有意义的,以确保期望的行为发生,并确保单元测试和性能测试代码是共享的。

在解释 JMH 结果时,考虑计算错误的显著性很重要。JMH 通过从基准测试的迭代中构建置信区间来计算错误。置信区间假设结果遵循正态分布,而错误表示计算出的 99.9%置信区间的范围。这表明,在其他条件相同的情况下,运行更多的基准测试迭代可以提高你对结果的信心。结果表中的最后一列说明了结果的变异性。变异性越低,你越应该相信结果。结果的高变异性表明存在测量错误或存在阻碍你测量真实性能特征的因素。这通常是一个警告信号,表明你需要重新审视你的测试方法,并且你应该对结果持怀疑态度。

注意

对于我们的示例,我们运行了 30 次迭代以记录吞吐量信息。你认为运行较少迭代会有什么影响?或者,考虑运行较少迭代但增加持续时间的效果。例如,10 次迭代,每次持续 30 秒。构建一个假设,然后运行 JMH 以查看结果。对不同的基准参数敏感性有意识,这是建立如何处理未来基准测试直觉的另一种方法。

由于我们的 JMH 配置没有考虑协调的省略,而是向订单簿发送大量取消请求,因此我们应该关注相对结果,而不是绝对吞吐量值。在结果之后提出的相关订单簿问题集中在相对差异上,这些差异应该独立于测试环境(例如,可用核心或 RAM)可见。关注相对问题是有价值的,因为答案应该对变化更加稳健。如果未来的代码更改导致显著的相对变化,例如,导致指数而不是线性取消性能下降,那么你可以更有信心地认为这种下降是由于代码更改而不是环境变化。

在本节中,我们了解了如何设置、执行和解释一个 JMH 微基准测试。在这个过程中,我们探讨了没有 JMH 的微基准测试的不足之处,以及在基准测试结果分析过程中需要注意的问题。我们对 JMH 的能力只是触及了皮毛。我们将在未来的章节中继续介绍 JMH。

摘要

恭喜,在本章中你帮助提升了 MVT 订单簿的性能,这将直接转化为公司利润的增加和损失的减少!在这个过程中,你深入了解了如何在 JVM 上进行基准测试和性能分析,以及需要避免的不足之处。你还完成了一个 JMH 微基准测试入门教程,这将使你能够在未来的章节中客观地评估性能改进。在下一章中,我们将探讨如何使用 Scala 语言特性来编写函数式软件,并使用本章学到的技能来评估它们对性能的影响。

第三章.释放 Scala 性能

在本章中,我们将探讨 Scala 特定的构造和语言特性,并检查它们如何帮助或损害性能。凭借我们新获得的性能测量知识,我们将分析如何更好地使用 Scala 编程语言提供的丰富语言特性。对于每个特性,我们将介绍它,展示它如何编译成字节码,然后确定在使用此特性时的注意事项和其他考虑因素。

在本章中,我们将展示 Scala 源代码和由 Scala 编译器生成的字节码。检查这些工件对于丰富你对 Scala 如何与 JVM 交互的理解是必要的,这样你就可以对你的软件的运行时性能有一个直观的认识。我们将在编译命令后通过调用javapJava 反汇编器来检查字节码,如下所示:

javap -c

减号c开关打印反汇编的代码。另一个有用的选项是-private,它打印私有定义的方法的字节码。有关javap的更多信息,请参阅手册页。我们将涵盖的示例不需要深入了解 JVM 字节码知识,但如果你希望了解更多关于字节码操作的信息,请参阅 Oracle 的 JVM 规范docs.oracle.com/javase/specs/jvms/se7/html/jvms-3.html#jvms-3.4。

定期,我们还将通过运行以下命令来检查移除 Scala 特定功能的 Scala 源代码版本:

scalac -print

这是一种有用的方式,可以了解 Scala 编译器如何将便捷的语法转换为 JVM 可以执行的构造。在本章中,我们将探讨以下主题:

值类和标记类型

专业化

元组

模式匹配

尾递归

Option数据类型

Option的替代方案

值类

在第二章《在 JVM 上测量性能》中,我们介绍了订单簿应用程序的领域模型。这个领域模型包括两个类,Price和OrderId。我们指出,我们为Price和OrderId创建了领域类,以提供对包装的BigDecimal和Long的上下文意义。虽然这为我们提供了可读的代码和编译时安全性,但这种做法也增加了我们的应用程序创建的实例数量。分配内存和生成类实例通过增加收集频率和可能引入额外的长生命期对象,为垃圾收集器增加了更多的工作。垃圾收集器将不得不更加努力地收集它们,这个过程可能会严重影响我们的延迟。

幸运的是,从 Scala 2.10 开始,AnyVal抽象类可供开发者为解决此问题定义自己的值类。AnyVal类在 Scala 文档中定义(www.scala-lang.org/api/current/#scala.AnyVal)为,“所有值类型的根类,描述了在底层宿主系统中未实现为对象的值。”AnyVal类可用于定义值类,该类会接受编译器的特殊处理。值类在编译时优化,以避免分配实例,而是使用包装类型。

字节码表示

例如,为了提高我们的订单簿的性能,我们可以将Price和OrderId定义为值类:

case class Price(value: BigDecimal) extends AnyVal

case class OrderId(value: Long) extends AnyVal

为了说明值类的特殊处理,我们定义了一个假方法,该方法接受一个Price值类和一个OrderId值类作为参数:

def printInfo(p: Price, oId: OrderId): Unit =

println(s"Price: ${p.value}, ID: ${oId.value}")

从这个定义中,编译器生成以下方法签名:

public void printInfo(scala.math.BigDecimal, long);

我们看到生成的签名接受一个BigDecimal对象和一个long对象,尽管 Scala 代码允许我们利用模型中定义的类型。这意味着我们不能在调用printInfo时使用BigDecimal或Long的实例,因为编译器会抛出一个错误。

注意

一个有趣的现象是,printInfo的第二个参数不是编译为Long(一个对象),而是long(一个原始类型,注意小写的'l)。Long和其他与原始类型匹配的对象,如Int、Float或Short`,会被编译器特别处理,在运行时以它们的原始类型表示。

值类也可以定义方法。让我们丰富我们的Price类,如下所示:

case class Price(value: BigDecimal) extends AnyVal {

def lowerThan(p: Price): Boolean = this.value < p.value

}

// Example usage

val p1 = Price(BigDecimal(1.23))

val p2 = Price(BigDecimal(2.03))

p1.lowerThan(p2) // returns true

我们的新方法使我们能够比较两个Price实例。在编译时,为Price创建了一个伴随对象。这个伴随对象定义了一个lowerThan方法,该方法接受两个BigDecimal对象作为参数。实际上,当我们对一个Price实例调用lowerThan时,代码被编译器从实例方法调用转换为在伴随对象中定义的静态方法调用:

public final boolean lowerThan$extension(scala.math.BigDecimal, scala.math.BigDecimal);

Code:

0: aload_1

1: aload_2

2: invokevirtual #56 // Method scala/math/BigDecimal.$less:(Lscala/math/BigDecimal;)Z

5: ireturn

如果我们将前面的 Scala 代码写成伪代码,它看起来可能如下所示:

val p1 = BigDecimal(1.23)

val p2 = BigDecimal(2.03)

Price.lowerThan(p1, p2) // returns true

性能考虑

值类是我们开发者工具箱中的一个很好的补充。它们帮助我们减少实例数量,并为垃圾收集器节省一些工作,同时允许我们依赖有意义的类型,这些类型反映了我们的业务抽象。然而,扩展 AnyVal 需要满足一系列条件,这些条件类必须满足。例如,值类可能只有一个主构造函数,该构造函数接受一个公共 val 作为单个参数。此外,此参数不能是值类。我们看到了值类可以通过 def 定义方法。值类内部不允许使用 val 或 var。嵌套类或对象定义也是不可能的。另一个限制条件阻止值类扩展任何不是通用特质的类型,即扩展 Any 的特质,只有 defs 作为成员,并且不执行初始化。如果这些条件中的任何一个没有得到满足,编译器将生成错误。除了前面列出的限制条件之外,还有一些特殊情况,其中值类必须由 JVM 实例化。这些情况包括执行模式匹配或运行时类型测试,或将值类赋值给数组。后者的一个例子如下所示:

def newPriceArray(count: Int): Array[Price] = {

val a = new ArrayPrice

for(i <- 0 until count){

a(i) = Price(BigDecimal(Random.nextInt()))

}

a

}

生成的字节码如下:

public highperfscala.anyval.ValueClasses$$anonfun$newPriceArray$1(highperfscala.anyval.ValueClasses$Price[]);

Code:

0: aload_0

1: aload_1

2: putfield #29 // Field a$1:[Lhighperfscala/anyval/ValueClasses$Price;

5: aload_0

6: invokespecial #80 // Method scala/runtime/AbstractFunction1$mcVI$sp."":()V

9: return

public void apply$mcVI$sp(int);

Code:

0: aload_0

1: getfield #29 // Field a$1:[Lhighperfscala/anyval/ValueClasses$Price;

4: iload_1

5: new #31 // class highperfscala/anyval/ValueClasses$Price

// omitted for brevity

21: invokevirtual #55 // Method scala/math/BigDecimal$.apply:(I)Lscala/math/BigDecimal;

24: invokespecial #59 // Method highperfscala/anyval/ValueClasses$Price."":(Lscala/math/BigDecimal;)V

27: aastore

28: return

注意 mcVI$sp 是如何从 newPriceArray 中调用的,并在第 5 条指令处创建了一个新的 ValueClasses$Price 实例。

由于将单个字段案例类转换为值类与扩展 AnyVal 特质一样简单,我们建议您尽可能使用 AnyVal。开销相当低,并且它在垃圾收集性能方面带来了高收益。要了解更多关于值类、它们的限制和使用案例,您可以在docs.scala-lang.org/overviews/core/value-classes.html找到详细的描述。

标签类型 - 值类的替代方案

值类是一个易于使用的工具,并且它们可以在性能方面带来显著的提升。然而,它们附带一系列限制条件,这可能会在某些情况下使它们无法使用。我们将通过查看 Scalaz 库实现的标签类型功能来结束本节,该功能提供了一个有趣的替代方案(github.com/scalaz/scalaz)。

注意

Scalaz 对标签类型的实现受到了另一个 Scala 库的启发,该库名为 shapeless。shapeless 库提供了编写类型安全、泛型代码的工具,且具有最少的样板代码。虽然我们不会深入探讨 shapeless,但我们鼓励您了解更多关于这个项目的信息,请访问github.com/milessabin/shapeless。

标记类型是强制编译时类型检查而不产生实例实例化成本的另一种方式。它们依赖于在Scalaz库中定义的Tagged结构类型和@@类型别名,如下所示:

type Tagged[U] = { type Tag = U }

type @@[T, U] = T with Tagged[U]

让我们重写我们代码的一部分,以利用Price对象进行标记类型:

object TaggedTypes {

sealed trait PriceTag

type Price = BigDecimal @@ PriceTag

object Price {

def newPrice(p: BigDecimal): Price =

TagBigDecimal, PriceTag

def lowerThan(a: Price, b: Price): Boolean =

Tag.unwrap(a) < Tag.unwrap(b)

}

}

让我们简要地浏览一下代码片段。我们将定义一个PriceTag密封特质,我们将用它来标记我们的实例,创建并定义一个Price类型别名,它是一个标记为PriceTag的BigDecimal对象。Price对象定义了有用的方法,包括用于标记给定的BigDecimal对象并返回一个Price对象(即标记的BigDecimal对象)的newPrice工厂函数。我们还将实现一个与lowerThan方法等效的函数。这个函数接受两个Price对象(即两个标记的BigDecimal对象),提取两个BigDecimal对象标签的内容,并比较它们。

使用我们新的Price类型,我们重写了之前查看过的相同newPriceArray方法(为了简洁,代码被省略,但你可以参考附带的源代码),并打印以下生成的字节码:

public void apply$mcVI$sp(int);

Code:

0: aload_0

1: getfield #29 // Field a$1:[Ljava/lang/Object;

4: iload_1

5: getstatic #35 // Field highperfscala/anyval/TaggedTypes$Price$.MODULE$:Lhighperfscala/anyval/TaggedTypes$Price$;

8: getstatic #40 // Field scala/package$.MODULE$:Lscala/package$;

11: invokevirtual #44 // Method scala/package$.BigDecimal:()Lscala/math/BigDecimal$;

14: getstatic #49 // Field scala/util/Random$.MODULE$:Lscala/util/Random$;

17: invokevirtual #53 // Method scala/util/Random$.nextInt:()I

20: invokevirtual #58 // Method scala/math/BigDecimal$.apply:(I)Lscala/math/BigDecimal;

23: invokevirtual #62 // Method highperfscala/anyval/TaggedTypes$Price$.newPrice:(Lscala/math/BigDecimal;)Ljava/lang/Object;

26: aastore

27: return

在这个版本中,我们不再看到Price的实例化,尽管我们将其分配给一个数组。标记的Price实现涉及到运行时转换,但我们预计这个转换的成本将低于之前值类Price策略中观察到的实例分配(以及垃圾收集)。我们将在本章后面再次查看标记类型,并使用它们来替换标准库中的一个知名工具:Option。

专业化

要理解专业化的重要性,首先掌握对象装箱的概念是至关重要的。JVM 定义了原始类型(boolean、byte、char、float、int、long、short和double),这些类型是在栈上分配而不是在堆上分配的。当引入泛型类型时,例如scala.collection.immutable.List,JVM 引用的是对象等价物,而不是原始类型。在这个例子中,一个整数列表的实例化将是堆分配的对象,而不是整数原语。将原始类型转换为对象等价物的过程称为装箱,而相反的过程称为拆箱。装箱对于性能敏感的编程来说是一个相关的问题,因为装箱涉及到堆分配。在执行数值计算的性能敏感代码中,装箱和拆箱的成本可能会造成显著的性能下降。以下是一个示例,用于说明装箱的开销:

List.fill(10000)(2).map(_* 2)

通过fill创建列表会产生 10,000 个整数对象的堆分配。在map中进行乘法操作需要 10,000 次拆箱以执行乘法,然后需要 10,000 次装箱将乘法结果添加到新列表中。从这个简单的例子中,你可以想象到由于装箱或拆箱操作,临界区算术将如何被减慢。

如 Oracle 在docs.oracle.com/javase/tutorial/java/data/autoboxing.html教程中所示,Java 和 Scala 中的装箱是透明的。这意味着,如果没有仔细的剖析或字节码分析,很难确定你在哪里支付了对象装箱的成本。为了改善这个问题,Scala 提供了一个名为特殊化的功能。专业化指的是在编译时生成泛型特质的或类的重复版本,这些版本直接引用原始类型而不是相关的对象包装器。在运行时,编译器生成的泛型类(或通常称为类的专业化版本)被实例化。这个过程消除了装箱原始类型的运行时成本,这意味着你可以在保持手写专业化实现性能的同时定义泛型抽象。

字节码表示

让我们通过一个具体的例子来更好地理解专业化过程是如何工作的。考虑一个关于购买股票数量的简单、通用的表示,如下所示:

case class ShareCountT

对于这个例子,让我们假设预期的用法是在ShareCount的整数或 long 表示之间进行交换。根据这个定义,基于 long 的ShareCount实例化将产生装箱的成本,如下所示:

def newShareCount(l: Long): ShareCount[Long] = ShareCount(l)

这个定义转换成以下字节码:

public highperfscala.specialization.Specialization$ShareCount newShareCount(long);

Code:

0: new #21 // class orderbook/Specialization$ShareCount

3: dup

4: lload_1

5: invokestatic #27 // Method scala/runtime/BoxesRunTime.boxToLong:(J)Ljava/lang/Long;

8: invokespecial #30 // Method orderbook/Specialization$ShareCount."":(Ljava/lang/Object;)V

11: areturn

在前面的字节码中,在指令5处很明显,在实例化ShareCount实例之前,原始的 long 值被装箱了。通过引入@specialized注解,我们能够通过让编译器提供一个与原始 long 值一起工作的ShareCount实现来消除装箱。你可以通过提供一组类型来指定你希望专业化的类型。如Specializables特质(www.scala-lang.org/api/current/index.html#scala.Specializable)定义的,你可以为所有 JVM 原始类型、Unit和AnyRef进行专业化。对于我们的例子,让我们将ShareCount专业化为整数和 long,如下所示:

case class ShareCount@specialized(Long, Int) T

根据这个定义,字节码现在变成以下形式:

public highperfscala.specialization.Specialization$ShareCount newShareCount(long);

Code:

0: new #21 // class highperfscala.specialization/Specialization$ShareCount$mcJ$sp

3: dup

4: lload_1

5: invokespecial #24 // Method highperfscala.specialization/Specialization$ShareCount$mcJ$sp."":(J)V

8: areturn

装箱消失了,奇怪地被不同的类名ShareCount $mcJ$sp所取代。这是因为我们正在调用为长值特殊化的编译器生成的ShareCount版本。通过检查javap的输出,我们看到编译器生成的特殊化类是ShareCount的子类:

public class highperfscala.specialization.Specialization$ShareCount$mcI$sp extends highperfscala.specialization.Specialization$ShareCount

在我们转向性能考虑部分时,请记住这个特殊化实现细节。使用继承在更复杂的使用案例中会迫使做出权衡。

性能考虑

初看起来,特殊化似乎是 JVM 装箱问题的简单万能药。然而,在使用特殊化时需要考虑几个注意事项。大量使用特殊化会导致编译时间显著增加和代码大小的增加。考虑对Function3进行特殊化,它接受三个参数作为输入并产生一个结果。对所有类型(即Byte、Short、Int、Long、Char、Float、Double、Boolean、Unit和AnyRef)进行四个参数的特殊化会产生 10⁴ 或 10,000 种可能的排列组合。因此,标准库在应用特殊化时非常谨慎。在你的使用案例中,仔细考虑你希望特殊化的类型。如果我们只为Int和Long对Function3进行特殊化,生成的类数量将减少到 2⁴ 或 16。涉及继承的特殊化需要特别注意,因为在扩展泛型类时很容易丢失特殊化。考虑以下示例:

class ParentFoo@specialized T

class ChildFooT extends ParentFooT

def newChildFoo(i: Int): ChildFoo[Int] = new ChildFooInt

在这个场景中,你可能会期望ChildFoo是用原始整数定义的。然而,由于ChildFoo没有用@specialized注解标记其类型,因此没有创建任何特殊化的类。以下是证明这一点的字节码:

public highperfscala.specialization.Inheritance$ChildFoo newChildFoo(int);

Code:

0: new #16 // class highperfscala/specialization/Inheritance$ChildFoo

3: dup

4: iload_1

5: invokestatic #22 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

8: invokespecial #25 // Method highperfscala/specialization/Inheritance$ChildFoo."":(Ljava/lang/Object;)V

11: areturn

下一个逻辑步骤是将@specialized注解添加到ChildFoo的定义中。在这个过程中,我们会遇到编译器警告关于特殊化的使用,如下所示:

class ParentFoo must be a trait. Specialized version of class ChildFoo will inherit generic highperfscala.specialization.Inheritance.ParentFoo[Boolean]

class ChildFoo@specialized T extends ParentFooT

编译器指出,你创建了一个菱形继承问题,其中特殊化的ChildFoo版本同时扩展了ChildFoo及其关联的特殊化版本ParentFoo。这个问题可以通过以下方式使用特质来建模:

trait ParentBar[@specialized T] {

def t(): T

}

class ChildBar@specialized T extends ParentBar[T]

def newChildBar(i: Int): ChildBar[Int] = new ChildBar(i)

这个定义使用特殊化的ChildBar版本编译,正如我们最初所希望的,如下所示:

public highperfscala.specialization.Inheritance$ChildBar newChildBar(int);

Code:

0: new #32 // class highperfscala/specialization/Inheritance$ChildBar$mcI$sp

3: dup

4: iload_1

5: invokespecial #35 // Method highperfscala/specialization/Inheritance$ChildBar$mcI$sp."":(I)V

8: areturn

类似且同样容易出错的场景是当在特殊化类型周围定义泛型方法时。考虑以下定义:

class FooT

object Foo {

def createT: Foo[T] = new Foo(t)

}

def boxed: Foo[Int] = Foo.create(1)

在这里,create的定义与继承示例中的子类类似。从create方法实例化的包含原始值的Foo实例将被装箱。以下字节码演示了boxed如何导致堆分配:

public highperfscala.specialization.MethodReturnTypes$Foo boxed();

Code:

0: getstatic #19 // Field highperfscala/specialization/MethodReturnTypes$Foo$.MODULE$:Lhighperfscala/specialization/MethodReturnTypes$Foo$;

3: iconst_1

4: invokestatic #25 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

7: invokevirtual #29 // Method highperfscala/specialization/MethodReturnTypes$Foo$.create:(Ljava/lang/Object;)Lhighperfscala/specialization/MethodReturnTypes$Foo;

10: areturn

解决方案是在调用点应用@specialized注解,如下所示:

def createSpecialized@specialized T: Foo[T] = new Foo(t)

最后一个有趣的场景是当使用多个类型进行特殊化,其中一个类型扩展 AnyRef 或是值类时。为了说明这个场景,考虑以下示例:

case class ShareCount(value: Int) extends AnyVal

case class ExecutionCount(value: Int)

class Container2@specialized X, @specialized Y

def shareCount = new Container2(ShareCount(1), 1)

def executionCount = new Container2(ExecutionCount(1), 1)

def ints = new Container2(1, 1)

在这个例子中,您期望使用哪些方法将 Container2 的第二个参数装箱?为了简洁,我们省略了字节码,但您可以轻松地自行检查。结果发现,shareCount 和 executionCount 会将整数装箱。编译器不会为 Container2 生成一个接受原始整数和扩展 AnyVal(例如,ExecutionCount)的值的专用版本。shareCount 方法也由于编译器从源代码中移除值类类型信息的顺序而导致装箱。在这两种情况下,解决方案是定义一个特定于一组类型的案例类(例如,ShareCount 和 Int)。移除泛型允许编译器选择原始类型。

从这些例子中可以得出的结论是,为了在整个应用程序中避免装箱,特殊化需要额外的关注。由于编译器无法推断出您意外忘记应用 @specialized 注解的场景,它无法发出警告。这使您必须对性能分析和检查字节码保持警惕,以检测特殊化意外丢失的场景。

注意

为了克服特殊化带来的某些缺点,正在积极开发一个名为 miniboxing 的编译器插件,可以在 scala-miniboxing.org/ 上找到。这个编译器插件采用了一种不同的策略,涉及将所有原始类型编码为一个长值,并携带元数据以回忆原始类型。例如,boolean 可以使用一个位来表示真或假,在 long 中表示。这种方法下,性能在定性上与特殊化相似,但为大量排列产生了数量级的更少类。此外,miniboxing 能够更稳健地处理继承场景,并在装箱将要发生时发出警告。虽然特殊化和 miniboxing 的实现不同,但最终用户的使用方式相当相似。像特殊化一样,您必须添加适当的注解来激活 miniboxing 插件。要了解更多关于插件的信息,您可以查看 miniboxing 项目网站上的教程。

确保特殊化生成无堆分配代码的额外关注是值得的,因为在性能敏感的代码中,它带来了性能上的优势。为了强调特殊化的价值,考虑以下微基准测试,该测试通过将份额计数与执行价格相乘来计算贸易的成本。为了简单起见,直接使用原始类型而不是值类。当然,在生产代码中这种情况永远不会发生:

@BenchmarkMode(Array(Throughput))

@OutputTimeUnit(TimeUnit.SECONDS)

@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)

@Measurement(iterations = 30, time = 10, timeUnit = TimeUnit.SECONDS)

@Fork(value = 1, warmups = 1, jvmArgs = Array("-Xms1G", "-Xmx1G"))

class SpecializationBenchmark {

@Benchmark

def specialized(): Double =

specializedExecution.shareCount.toDouble * specializedExecution.price

@Benchmark

def boxed(): Double =

boxedExecution.shareCount.toDouble * boxedExecution.price

}

object SpecializationBenchmark {

class SpecializedExecution@specialized(Int) T1, @specialized(Double) T2

class BoxingExecutionT1, T2

val specializedExecution: SpecializedExecution[Int, Double] =

new SpecializedExecution(10l, 2d)

val boxedExecution: BoxingExecution[Long, Double] = new BoxingExecution(10l, 2d)

}

在这个基准测试中,定义了通用执行类的两个版本。SpecializedExecution 由于专业化而无需装箱即可计算总成本,而 BoxingExecution 需要对象装箱和拆箱来执行算术。微基准测试使用以下参数化调用:

sbt 'project chapter3' 'jmh:run SpecializationBenchmark -foe true'

注意

我们通过在代码中类级别放置的注解来配置这个 JMH 基准测试。这与我们在 第二章 中看到的不同,在 JVM 上测量性能,在那里我们使用了命令行参数。注解的优势在于为你的基准测试设置适当的默认值,并简化命令行调用。仍然可以通过命令行参数覆盖注解中的值。我们使用 -foe 命令行参数来启用错误时的失败,因为没有注解可以控制这种行为。在这本书的其余部分,我们将使用注解参数化 JMH,并在代码示例中省略注解,因为我们总是使用相同的值。

结果总结如下表:

基准测试

吞吐量(每秒操作数)

错误作为吞吐量的百分比

boxed

251,534,293.11

±2.23

specialized

302,371,879.84

±0.87

这个微基准测试表明专业化实现提供了大约 17% 的更高吞吐量。通过在代码的关键部分消除装箱,可以通过谨慎地使用专业化获得一个数量级的性能提升。对于性能敏感的算术,这个基准测试为确保专业化正确应用所需的额外努力提供了依据。

元组

Scala 中的第一类元组支持简化了需要将多个值组合在一起的使用场景。使用元组,你可以使用简洁的语法优雅地返回多个值,而无需定义案例类。以下部分展示了编译器如何转换 Scala 元组。

字节码表示

让我们看看 JVM 如何处理创建元组,以更好地理解 JVM 如何支持元组。为了培养我们的直觉,考虑创建一个长度为二的元组,如下所示:

def tuple2: (Int, Double) = (1, 2.0)

此方法的对应字节码如下:

public scala.Tuple2 tuple2();

Code:

0: new #36 // class scala/Tuple2$mcID$sp

3: dup

4: iconst_1

5: ldc2_w #37 // double 2.0d

8: invokespecial #41 // Method scala/Tuple2$mcID$sp."":(ID)V

11: areturn

这个字节码显示编译器将括号元组定义语法反编译为名为 Tuple2 的类的分配。为每个支持的元组长度(例如,Tuple5 支持五个成员)定义了一个元组类,直到 Tuple22。字节码还显示在第 4 和 5 条指令中使用了 Int 和 Double 的原始版本来分配这个 tuple 实例。

性能考虑

在前面的示例中,Tuple2 由于对两个泛型类型的特殊化处理,避免了原始数据类型的装箱。由于 Scala 的表达式元组语法,将多个值组合在一起通常很方便。然而,这会导致过度的内存分配,因为大于两个参数的元组没有进行特殊化。以下是一个说明这一问题的示例:

def tuple3: (Int, Double, Int) = (1, 2.0, 3)

这个定义与我们所审查的第一个元组定义类似,但现在有一个三个参数的参数数量。这个定义产生了以下字节码:

public scala.Tuple3 tuple3();

Code:

0: new #45 // class scala/Tuple3

3: dup

4: iconst_1

5: invokestatic #24 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

8: ldc2_w #37 // double 2.0d

11: invokestatic #49 // Method scala/runtime/BoxesRunTime.boxToDouble:(D)Ljava/lang/Double;

14: iconst_3

15: invokestatic #24 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

18: invokespecial #52 // Method scala/Tuple3."":(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V

21: areturn

在这个字节码中,由于整数和双精度浮点数的装箱存在,可以清楚地看到没有特殊化。如果你正在处理应用程序中性能敏感的区域,并且发现存在三个或更多参数的元组,你应该考虑定义一个案例类以避免装箱开销。你的案例类的定义将不包含任何泛型。这使得 JVM 能够使用原始数据类型而不是在堆上为原始元组成员分配对象。

即使使用 Tuple2,仍然可能你正在承担装箱的成本。考虑以下代码片段:

case class Bar(value: Int) extends AnyVal

def tuple2Boxed: (Int, Bar) = (1, Bar(2))

根据我们对 Tuple2 和值类字节码表示的了解,我们预计这个方法的字节码应该是两个栈分配的整数。不幸的是,在这种情况下,生成的字节码如下:

public scala.Tuple2 tuple2Boxed();

Code:

0: new #18 // class scala/Tuple2

3: dup

4: iconst_1

5: invokestatic #24 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

8: new #26 // class highperfscala.patternmatch/PatternMatching$Bar

11: dup

12: iconst_2

13: invokespecial #29 // Method highperfscala.patternmatch/PatternMatching$Bar."":(I)V

16: invokespecial #32 // Method scala/Tuple2."":(Ljava/lang/Object;Ljava/lang/Object;)V

19: areturn

在前面的字节码中,我们看到整数被装箱,并且实例化了 Bar。这个例子与我们在 Container2 中调查的最终特殊化示例类似。回顾那个例子,应该很明显,Container2 是 Tuple2 的一个紧密的类似物。与之前一样,由于编译器实现特殊化的方式,编译器无法避免在这个场景中的装箱。如果你面临性能敏感的代码,解决方案仍然是定义一个案例类。以下是一个证明定义案例类可以消除不希望的值类实例化和原始数据装箱的例子:

case class IntBar(i: Int, b: Bar)

def intBar: IntBar = IntBar(1, Bar(2))

这个定义产生了以下字节码:

public highperfscala.patternmatch.PatternMatching$IntBar intBar();

Code:

0: new #18 // class highperfscala.patternmatch/PatternMatching$IntBar

3: dup

4: iconst_1

5: iconst_2

6: invokespecial #21 // Method highperfscala.patternmatch/PatternMatching$IntBar."":(II)V

9: areturn

注意,IntBar 不是一个值类,因为它有两个参数。与元组定义不同,这里既没有装箱也没有对 Bar 值类的引用。在这种情况下,定义一个案例类对于性能敏感的代码来说是一个性能上的胜利。

模式匹配

对于 Scala 新手程序员来说,模式匹配通常是语言特性中最容易理解的之一,但它也开启了以新的方式思考编写软件的方法。这个强大的机制允许你使用优雅的语法在编译时安全地对不同类型进行匹配。鉴于这种技术对于函数式编程范式中的 Scala 编写至关重要,考虑其运行时开销是很重要的。

字节码表示

让我们考虑一个涉及使用表示订单可能方向的代数数据类型进行订单处理的示例:

sealed trait Side

case object Buy extends Side

case object Sell extends Side

def handleOrder(s: Side): Boolean = s match {

case Buy => true

case Sell => false

}

注意

术语代数数据类型(ADT)是更正式地指代一个密封特性和其情况的方式。例如,Side、Buy和Sell形成一个 ADT。就我们的目的而言,一个 ADT 定义了一个封闭的情况集。对于Side,封装的情况是Buy和Sell。密封修饰符提供了封闭集语义,因为它禁止在单独的源文件中扩展Side。ADT 隐含的封闭集语义是允许编译器推断模式匹配语句是否完备的原因。如果您想研究 ADT 的另一个示例,请查看第二章中定义的订单簿命令,测量 JVM 上的性能。

如以下字节码所示,模式匹配被转换为一系列 if 语句:

public boolean handleOrder(highperfscala.patternmatch.PatternMatching$Side);

Code:

0: aload_1

1: astore_2

2: getstatic #148 // Field highperfscala.patternmatch/PatternMatching$Buy$.MODULE$:Lhighperfscala.patternmatch/PatternMatching$Buy$;

5: aload_2

6: invokevirtual #152 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z

9: ifeq 17

12: iconst_1

13: istore_3

14: goto 29

17: getstatic #157 // Field highperfscala.patternmatch/PatternMatching$Sell$.MODULE$:Lhighperfscala.patternmatch/PatternMatching$Sell$;

20: aload_2

21: invokevirtual #152 // Method java/lang/Object.equals:(Ljava/lang/Object;)Z

24: ifeq 31

27: iconst_0

28: istore_3

29: iload_3

30: ireturn

31: new #159 // class scala/MatchError

34: dup

35: aload_2

36: invokespecial #160 // Method scala/MatchError."":(Ljava/lang/Object;)V

39: athrow

检查字节码可以展示 Scala 编译器如何将模式匹配表达式转换为一系列高效的 if 语句,其中包含ifeq指令,位于9和24索引处。这是一个说明 Scala 如何提供表达性和优雅的一等语言特性,同时保留高效字节码等价的示例。

性能考虑

对包含状态(例如,一个 case 类)的值进行模式匹配会带来额外的运行时成本,这在查看 Scala 源代码时并不立即明显。考虑以下对先前示例的扩展,它引入了状态:

sealed trait Order

case class BuyOrder(price: Double) extends Order

case class SellOrder(price: Double) extends Order

def handleOrder(o: Order): Boolean = o match {

case BuyOrder(price) if price > 2.0 => true

case BuyOrder(_) => false

case SellOrder(_) => false

}

在这里,示例更加复杂,因为必须为所有三个情况识别实例类型,并在第一种情况中增加了对BuyOrder价格的谓词复杂性。在下面,我们将查看移除了所有 Scala 特定特征的scalac输出片段:

case10(){

if (x1.$isInstanceOf[highperfscala.patternmatch.PatternMatching$BuyOrder]())

{

rc8 = true;

x2 = (x1.$asInstanceOf[highperfscala.patternmatch.PatternMatching$BuyOrder](): highperfscala.patternmatch.PatternMatching$BuyOrder);

{

val price: Double = x2.price();

if (price.>(2.0))

matchEnd9(true)

else

case11()

}

}

else

case11()

};

case11(){

if (rc8)

matchEnd9(false)

else

case12()

};

这种转换说明了关于 Scala 编译器的几个有趣点。识别Order的类型利用了java.lang.Object中的isInstanceOf,它映射到instanceOf字节码指令。通过asInstanceOf进行类型转换将Order强制转换为BuyOrder价格或SellOrder。第一个启示是,携带状态的模式匹配类型会带来类型检查和转换的运行时成本。

另一个洞察是,Scala 编译器能够通过创建一个名为rc8的布尔变量来优化第二个模式匹配的实例检查,以确定是否发现了BuyOrder。这种巧妙的优化简单易写,但它去除了模式匹配的优雅和简单性。这是编译器能够从表达性、高级代码中产生高效字节码的另一个示例。

从前面的例子中,现在可以清楚地看出模式匹配被编译成 if 语句。对于关键路径代码的一个性能考虑是模式匹配语句的顺序。如果你的代码有五个模式匹配语句,并且第五个模式是最频繁访问的,那么你的代码正在为始终评估其他四个分支付出代价。让我们设计一个 JMH 微基准测试来估计模式匹配的线性访问成本。每个基准定义了十个使用不同值(例如,值类、整数字面量、case 类等)的模式匹配。对于每个基准,匹配的索引被遍历以显示访问第一个、第五个和第十个模式匹配语句的成本。以下是基准定义:

class PatternMatchingBenchmarks {

@Benchmark

def matchIntLiterals(i: PatternMatchState): Int = i.matchIndex match {

case 1 => 1

case 2 => 2

case 3 => 3

case 4 => 4

case 5 => 5

case 6 => 6

case 7 => 7

case 8 => 8

case 9 => 9

case 10 => 10

}

@Benchmark

def matchIntVariables(ii: PatternMatchState): Int = ii.matchIndex match {

case `a` => 1

case `b` => 2

case `c` => 3

case `d` => 4

case `e` => 5

case `f` => 6

case `g` => 7

case `h` => 8

case `i` => 9

case `j` => 10

}

@Benchmark

def matchAnyVal(i: PatternMatchState): Int = CheapFoo(i.matchIndex) match {

case CheapFoo(1) => 1

case CheapFoo(2) => 2

case CheapFoo(3) => 3

case CheapFoo(4) => 4

case CheapFoo(5) => 5

case CheapFoo(6) => 6

case CheapFoo(7) => 7

case CheapFoo(8) => 8

case CheapFoo(9) => 9

case CheapFoo(10) => 10

}

@Benchmark

def matchCaseClass(i: PatternMatchState): Int =

ExpensiveFoo(i.matchIndex) match {

case ExpensiveFoo(1) => 1

case ExpensiveFoo(2) => 2

case ExpensiveFoo(3) => 3

case ExpensiveFoo(4) => 4

case ExpensiveFoo(5) => 5

case ExpensiveFoo(6) => 6

case ExpensiveFoo(7) => 7

case ExpensiveFoo(8) => 8

case ExpensiveFoo(9) => 9

case ExpensiveFoo(10) => 10

}

}

object PatternMatchingBenchmarks {

case class CheapFoo(value: Int) extends AnyVal

case class ExpensiveFoo(value: Int)

private val (a, b, c, d, e, f, g, h, i, j) = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

@State(Scope.Benchmark)

class PatternMatchState {

@Param(Array("1", "5", "10"))

var matchIndex: Int = 0

}

}

通过运行 30 次试验,每次持续 10 秒,并包含三个预热试验,每个试验持续 5 秒来评估性能。以下是基准调用:

sbt 'project chapter3' 'jmh:run PatternMatchingBenchmarks -foe true'

结果总结在下表中:

基准测试

匹配的索引

吞吐量(每秒操作数)

吞吐量误差(吞吐量的百分比)

吞吐量变化(基准运行的百分比)

matchAnyVal

1

350,568,900.12

±3.02

0

matchAnyVal

5

291,126,287.45

±2.63

-17

matchAnyVal

10

238,326,567.59

±2.95

-32

matchCaseClass

1

356,567,498.69

±3.66

0

matchCaseClass

5

287,597,483.22

±3.50

-19

matchCaseClass

10

234,989,504.60

±2.60

-34

matchIntLiterals

1

304,242,630.15

±2.95

0

matchIntLiterals

5

314,588,776.07

±3.70

3

matchIntLiterals

10

285,227,574.79

±4.33

-6

matchIntVariables

1

332,377,617.36

±3.28

0

matchIntVariables

5

263,835,356.53

±6.53

-21

matchIntVariables

10

170,460,049.63

±4.20

-49

最后的列取每个基准测试在匹配第一个索引时的第一次尝试作为基准情况。对于匹配第五和第十个索引的尝试,显示相对性能下降。在所有情况下,除了匹配字面整数的第五个索引外,随着匹配更深层的索引,吞吐量几乎线性下降。唯一违反这一模式的是匹配字面整数的尝试。在这个尝试中,当访问第五个索引时,性能相对于第一个索引有所提高。通过检查字节码,我们发现这种情况产生了一个跳转表而不是一系列 if 语句。以下是生成的字节码片段:

6: tableswitch { // 1 to 10

1: 113

2: 109

3: 105

4: 101

5: 97

6: 92

7: 87

8: 82

9: 77

10: 72

default: 60

}

这个字节码片段展示了 JVM 如何使用 tableswitch 指令将整数字面量的模式匹配转换为跳转表。这是一个常数时间操作,而不是 if 语句的线性遍历。鉴于观察到的错误是几个百分点,并且三次试验中的观察差异大致也是几个百分点,我们可以推断线性访问成本不适用于这种情况。相反,在 N^(th) 索引处匹配字面整数由于生成的跳转表而具有常数访问成本。相比之下,在第十个索引处匹配整数变量证明要贵近一倍。从这个实验中可以清楚地看出,对于任何生成一系列 if 语句的模式匹配,访问 N^(th) 模式匹配语句的成本是线性的。如果你在性能敏感的代码中至少匹配了三个案例,请考虑审查代码以确定语句顺序是否与访问频率相匹配。

注意

你有只包含两个模式的模式匹配的例子吗?在只涉及两个直接匹配值的模式匹配语句的场景中,编译器能够生成一个高效的跳转表。当匹配原始字面量(例如,字符串字面量或整数字面量)时,编译器能够为更大的模式匹配生成跳转表。类似于 @tailrec 注解,Scala 定义了一个 @switch 注解,以便你告知编译器你期望这个模式匹配语句被编译成跳转表。如果编译器无法生成跳转表,而是生成一系列 if 语句,那么将发出警告。类似于 @tailrec 注解,编译器将应用跳转表启发式方法,无论你是否提供了 @switch 注解。在实践中,我们很少使用这个注解,因为它适用性有限,但了解其存在是有价值的。以下是一个编译成跳转表的注解模式匹配的例子:

def processShareCount(sc: ShareCount): Boolean =

(sc: @switch) match {

case ShareCount(1) => true

case _ => false

}

尾递归

当一个函数调用自身时,我们称其为递归。递归是一个强大的工具,它通常用于函数式编程。它允许你将复杂问题分解成更小的子问题,使它们更容易推理和解决。递归也与不可变性概念很好地结合。递归函数为我们提供了一种管理状态变化的好方法,而不需要使用可变结构或可重新分配的变量。在本节中,我们关注在 JVM 上使用递归的不同缺点,尤其是在 Scala 中。

让我们看看一个递归方法的简单例子。以下片段显示了一个 sum 方法,用于计算整数列表的总和:

def sum(l: List[Int]): Int = l match {

case Nil => 0

case x :: xs => x + sum(xs)

}

前面的代码片段中展示的sum方法执行的是所谓的头递归。sum(xs)递归调用不是函数中的最后一条指令。这个方法需要递归调用的结果来计算自己的结果。考虑以下调用:

sum(List(1,2,3,4,5))

它可以表示为:

1 + (sum(List(2,3,4,5)))

1 + (2 + (sum(List(3,4,5))))

1 + (2 + (3 + (sum(List(4,5)))))

1 + (2 + (3 + (4 + (sum(List(5))))))

1 + (2 + (3 + (4 + (5))))

1 + (2 + (3 + (9)))

1 + (2 + (12))

1 + (14)

15

注意每次我们执行递归调用时,我们的函数都会挂起,等待计算的正确部分完成以便返回。由于调用函数需要在收到递归调用的结果后完成自己的计算,因此每个调用都会在栈上添加一个新的条目。栈的大小是有限的,没有任何东西可以阻止我们用一个非常长的列表调用sum。如果列表足够长,对sum的调用将导致StackOverflowError:

$ sbt 'project chapter3' console

scala> highperfscala.tailrec.TailRecursion.sum((1 to 1000000).toList)

java.lang.StackOverflowError

at scala.collection.immutable.Nil$.equals(List.scala:424)

at highperfscala.tailrec.TailRecursion$.sum(TailRecursion.scala:12)

at highperfscala.tailrec.TailRecursion$.sum(TailRecursion.scala:13)

at highperfscala.tailrec.TailRecursion$.sum(TailRecursion.scala:13)

at highperfscala.tailrec.TailRecursion$.sum(TailRecursion.scala:13)

...omitted for brevity

栈跟踪显示了所有递归调用堆积在栈上,等待从后续步骤的结果。这证明了在完成递归调用之前,对sum的所有调用都无法完成。在最后一个调用能够执行之前,我们的栈空间已经耗尽。

为了避免这个问题,我们需要重构我们的方法使其成为尾递归。如果一个递归调用是最后一条执行的指令,那么这个递归方法被称为尾递归。尾递归方法可以被优化,将一系列递归调用转换为类似于while循环的东西。这意味着只有第一个调用被添加到栈上:

def tailrecSum(l: List[Int]): Int = {

def loop(list: List[Int], acc: Int): Int = list match {

case Nil => acc

case x :: xs => loop(xs, acc + x)

}

loop(l, 0)

}

这个sum的新版本是尾递归的。注意我们创建了一个内部loop方法,它接受要加和的列表以及一个累加器来计算当前结果的状态。loop方法是因为递归loop(xs, acc+x)调用是最后一条指令,所以是尾递归的。通过在迭代过程中计算累加器,我们避免了递归调用的堆栈。初始累加器值如下所示:

scala> highperfscala.tailrec.TailRecursion.tailrecSum((1 to 1000000).toList)

res0: Int = 1784293664

注意

我们提到递归是函数式编程的一个重要方面。然而,在实践中,你很少需要自己编写递归方法,尤其是在处理List等集合时。标准 API 已经提供了优化的方法,应该优先使用。例如,计算整数列表的总和可以写成如下:

list.foldLeft(0)((acc, x) => acc + x) 或者当利用 Scala 糖语法时,我们可以使用以下代码:

list.foldLeft(0)(+) 内部实现的foldLeft函数使用了一个while循环,不会引发StackOverflowError异常。

实际上,List有一个sum方法,这使得计算整数列表的总和变得更加容易。sum方法是用foldLeft实现的,与前面的代码类似。

字节码表示

实际上,JVM 不支持尾递归优化。为了让这可行,Scala 编译器在编译时优化尾递归方法,并将它们转换为while循环。让我们比较每个实现生成的字节码。

我们原始的、头递归的sum方法编译成了以下字节码:

public int sum(scala.collection.immutable.List);

Code:

0: aload_1

// omitted for brevity

52: invokevirtual #41 // Method sum:(Lscala/collection/immutable/List;)I

55: iadd

56: istore_3

57: iload_3

58: ireturn

// omitted for brevity

而尾递归的loop方法产生了以下结果:

private int loop(scala.collection.immutable.List, int);

Code:

0: aload_1

// omitted for brevity

60: goto 0

// omitted for brevity

注意sum方法如何在52索引处使用invokevirtual指令调用自身,并且仍然需要执行一些与返回值相关的指令。相反,loop方法在60索引处使用goto指令跳回到其块的开始,从而避免了多次递归调用自身。

性能考虑

编译器只能优化简单的尾递归情况。具体来说,只有那些递归调用是最后一条指令的自调用函数。有许多边缘情况可以描述为尾递归,但它们对于编译器来说过于复杂,无法优化。为了避免无意中编写一个不可优化的递归方法,你应该始终使用@tailrec来注释你的尾递归方法。@tailrec注释是一种告诉编译器的方式:“我相信你将能够优化这个递归方法;然而,如果你不能,请在编译时给我一个错误。”需要记住的一点是,@tailrec并不是要求编译器优化方法,如果可能,它仍然会这样做。这个注释是为了让开发者确保编译器可以优化递归。

注意

到目前为止,你应该意识到所有while循环都可以在不损失性能的情况下替换为尾递归方法。如果你在 Scala 中使用过while循环结构,你可以思考如何用尾递归实现来替换它们。尾递归消除了对可变变量的使用。

这里是带有@tailrec注释的相同tailrecSum方法:

def tailrecSum(l: List[Int]): Int = {

@tailrec

def loop(list: List[Int], acc: Int): Int = list match {

case Nil => acc

case x :: xs => loop(xs, acc + x)

}

loop(l, 0)

}

如果我们尝试注释我们的第一个、头递归的实现,我们会在编译时看到以下错误:

[error] chapter3/src/main/scala/highperfscala/tailrec/TailRecursion.scala:12: could not optimize @tailrec annotated method sum: it contains a recursive call not in tail position

[error] def sum(l: List[Int]): Int = l match {

[error] ^

[error] one error found

[error] (chapter3/compile:compileIncremental) Compilation failed

我们建议始终使用@tailrec来确保你的方法可以被编译器优化。因为编译器只能优化尾递归的简单情况,所以在编译时确保你没有无意中编写一个可能引起StackOverflowError异常的非可优化函数是很重要的。我们现在来看几个编译器无法优化递归方法的案例:

def sum2(l: List[Int]): Int = {

def loop(list: List[Int], acc: Int): Int = list match {

case Nil => acc

case x :: xs => info(xs, acc + x)

}

def info(list: List[Int], acc: Int): Int = {

println(s"${list.size} elements to examine. sum so far: $acc")

loop(list, acc)

}

loop(l, 0)

}

sum2中的loop方法无法优化,因为递归涉及到两个不同的方法相互调用。如果我们用info的实际实现替换调用,那么优化将是可能的,如下所示:

def tailrecSum2(l: List[Int]): Int = {

@tailrec

def loop(list: List[Int], acc: Int): Int = list match {

case Nil => acc

case x :: xs =>

println(s"${list.size} elements to examine. sum so far: $acc")

loop(list, acc)

}

loop(l, 0)

}

有一种类似的使用案例涉及到编译器无法考虑按名传递的参数:

def sumFromReader(br: BufferedReader): Int = {

def read(acc: Int, reader: BufferedReader): Int = {

Option(reader.readLine().toInt)

.fold(acc)(i => read(acc + i, reader))

}

read(0, br)

}

read方法无法被编译器优化,因为它无法使用Option.fold的定义来理解递归调用实际上是尾位置的。如果我们用其确切实现替换对 fold 的调用,我们可以按如下方式标注该方法:

def tailrecSumFromReader(br: BufferedReader): Int = {

@tailrec

def read(acc: Int, reader: BufferedReader): Int = {

val opt = Option(reader.readLine().toInt)

if (opt.isEmpty) acc else read(acc + opt.get, reader)

}

read(0, br)

}

编译器也会拒绝优化非 final 的公共方法。这是为了防止子类用非尾递归版本覆盖方法的风险。从超类发出的递归调用可能通过子类的实现,并破坏尾递归:

class Printer(msg: String) {

def printMessageNTimes(n: Int): Unit = {

if(n > 0){

println(msg)

printMessageNTimes(n - 1)

}

}

}

尝试将printMessageNTimes方法标记为尾递归会导致以下错误:

[error] chapter3/src/main/scala/highperfscala/tailrec/TailRecursion.scala:74: could not optimize @tailrec annotated method printMessageNTimes: it is neither private nor final so can be overridden

[error] def printMessageNTimes(n: Int): Unit = {

[error] ^

[error] one error found

[error] (chapter3/compile:compileIncremental) Compilation failed

递归方法无法优化的另一个案例是当递归调用是 try/catch 块的一部分时:

def tryCatchBlock(l: List[Int]): Int = {

def loop(list: List[Int], acc: Int): Int = list match {

case Nil => acc

case x :: xs =>

try {

loop(xs, acc + x)

} catch {

case e: IOException =>

println(s"Recursion got interrupted by exception")

acc

}

}

loop(l, 0)

}

与先前的示例相反,在这个例子中,编译器不应受到责备。递归调用不在尾位置。由于它被 try/catch 包围,该方法需要准备好接收潜在的异常并执行更多计算来处理它。作为证明,我们可以查看生成的字节码并观察到最后几条指令与 try/catch 相关:

private final int loop$4(scala.collection.immutable.List, int);

Code:

0: aload_1

// omitted for brevity

61: new #43 // class scala/MatchError

64: dup

65: aload_3

66: invokespecial #46 // Method scala/MatchError."":(Ljava/lang/Object;)V

69: athrow

// omitted for brevity

114: ireturn

Exception table:

from to target type

48 61 70 Class java/io/IOException

我们希望这些少数示例已经说服你,编写非尾递归方法是容易犯的一个错误。你最好的防御方法是始终使用@tailrec注解来验证你的直觉,即你的方法可以被优化。

Option数据类型

Option数据类型在 Scala 标准库中被广泛使用。像模式匹配一样,它是 Scala 初学者早期经常采用的语言特性。Option数据类型提供了一种优雅的方式来转换和处理不需要的值,从而消除了 Java 代码中常见的 null 检查。我们假设你理解并欣赏Option为在函数式范式编写 Scala 带来的价值,因此我们不会进一步重申其好处。相反,我们专注于分析其字节码表示,以获得性能洞察。

字节码表示

检查 Scala 源代码,我们看到Option被实现为一个抽象类,其中包含可能的输出结果Some和None,它们扩展了Option以编码这种关系。以下代码片段中显示了移除实现后的类定义,以方便查看:

sealed abstract class Option[+A] extends Product with Serializable

final case class Some+A extends Option[A]

case object None extends Option[Nothing]

研究定义后,我们可以推断出关于字节码表示的几个要点。关注Some,我们注意到它没有扩展AnyVal。由于Option是通过继承实现的,因此Some不能是一个值类,这是我们在值类部分提到的限制。这种限制意味着每个作为Some实例包装的值都有一个分配。此外,我们观察到Some没有被专门化。从我们对专门化的考察中,我们意识到作为Some实例包装的原始数据将被装箱。以下是一个简单示例,用于说明这两个问题:

def optionalInt(i: Int): Option[Int] = Some(i)

在这个简单的例子中,一个整数被编码为一个Some实例,用作Option数据类型。以下字节码被生成:

public scala.Option optionalInt(int);

Code:

0: new #16 // class scala/Some

3: dup

4: iload_1

5: invokestatic #22 // Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;

8: invokespecial #25 // Method scala/Some."":(Ljava/lang/Object;)V

11: areturn

如我们所预期,有一个对象分配来创建一个Some实例,然后是将提供的整数装箱以构建Some实例。

None实例是一个从字节码角度更容易理解的简单案例。因为None被定义为 Scala 对象,所以创建None实例没有实例化成本。这很有意义,因为None代表没有状态需要维护的场景。

注意

你是否曾经考虑过单个值None如何代表所有类型的无值?答案在于理解Nothing类型。Nothing类型扩展了所有其他类型,这使得None可以成为任何A类型的子类型。要了解更多关于 Scala 类型层次结构的见解,请查看这个有用的 Scala 语言教程:docs.scala-lang.org/tutorials/tour/unified-types.html。

性能考虑

在任何非性能敏感的环境中,默认使用Option来表示不需要的值是合理的。在性能敏感的代码区域,选择变得更加具有挑战性且不那么明确。特别是在性能敏感的代码中,你必须首先优化正确性,然后才是性能。我们建议始终以最地道的方式实现你正在建模的问题的第一个版本,也就是说,使用Option。从Some的字节码表示中获得的意识,下一步的逻辑是进行性能分析,以确定是否Option的使用是瓶颈。特别是,你关注的是内存分配模式和垃圾回收成本。根据我们的经验,代码中通常存在其他开销来源,这些开销比Option的使用更昂贵。例如,不高效的算法实现、构建不良的领域模型或系统资源使用不当。如果你的情况下已经消除了其他效率低下的来源,并且确信Option是导致你性能问题的原因,那么你需要采取进一步的步骤。

向提高性能的增量步骤可能包括移除使用Option高阶函数。在关键路径上,通过用内联等价物替换高阶函数可以实现显著的成本节约。考虑以下简单的例子,它将Option数据类型转换为String数据类型:

Option(10).fold("no value")(i => s"value is $i")

在关键路径上,以下变更可能会带来实质性的改进:

val o = Option(10)

if (o.isDefined) s"value is ${o.get} else "no value"

将fold操作替换为 if 语句可以节省创建匿名函数的成本。需要重复强调的是,这种类型的更改只有在经过广泛的性能分析并发现Option使用是瓶颈之后才应考虑。虽然这种代码更改可能会提高你的性能,但由于使用了o.get,它既冗长又不安全。当这种技术被谨慎使用时,你可能会在关键路径代码中保留对Option数据类型的使用。

如果用内联和不安全的等效函数替换高阶Option函数使用未能充分提高性能,那么你需要考虑更激进的措施。此时,性能分析应该揭示Option内存分配是瓶颈,阻止你达到性能目标。面对这种场景,你有两种选择(这并非巧合!)进行探索,这两种选择都涉及实施时间的高成本。

一种进行的方式是承认,对于关键路径,Option是不合适的,必须从类型签名中移除,并用 null 检查替换。这是最有效的方法,但它带来了显著的维护成本,因为你和所有其他在关键路径上工作的团队成员都必须意识到这个建模决策。如果你选择这种方式进行,为关键路径定义清晰的边界,将 null 检查限制在代码的最小可能区域。在下一节中,我们将探讨第二种方法,该方法涉及构建一个新的数据类型,该类型利用了我们在本章中获得的知识。

案例研究 – 更高性能的选项

如果你还没有准备好丢失由Option数据类型编码的信息,那么你可能希望探索更符合垃圾回收友好的Option的替代实现。在本节中,我们介绍了一种替代方法,它也提供了类型安全,同时避免了Some实例的装箱和实例化。我们利用标记类型和特化,并禁止Some作为有效值,从而得出以下实现:

sealed trait Opt

object OptOps {

def some@specialized A: A @@ Opt = Tag(x)

def nullCheckingSome@specialized A: A @@ Opt =

if (x == null) sys.error("Null values disallowed") else Tag(x)

def none[A]: A @@ Opt = Tag(null.asInstanceOf[A])

def isSomeA: Boolean = o != null

def isEmptyA: Boolean = !isSome(o)

def unsafeGetA: A =

if (isSome(o)) o.asInstanceOf[A] else sys.error("Cannot get None")

def foldA, B(ifEmpty: => B)(f: A => B): B =

if (o == null) ifEmpty else f(o.asInstanceOf[A])

}

此实现定义了用于构建可选类型的工厂方法(即some、nullCheckingSome和none)。与 Scala 的Option相比,此实现使用标记类型向值添加类型信息,而不是创建一个新值来编码可选性。none的实现利用了 Scala 中将null视为值而不是语言关键字的事实。记住,除非性能要求需要这种极端措施,否则我们不会默认采用这些更神秘的方法。每个工厂方法返回的标记类型保留了类型安全,并且需要显式解包才能访问底层类型。

注意

如果你想了解更多关于 Scala 对null值表示的信息,我们鼓励你查看这两个 StackOverflow 帖子:stackoverflow.com/questions/8285916/why-doesnt-null-asinstanceofint-throw-a-nullpointerexception 和 stackoverflow.com/questions/10749010/if-an-int-cant-be-null-what-does-null-asinstanceofint-mean。在这两个帖子中,多位响应者提供了优秀的回答,这将帮助你深化理解。

OptOps中剩余的方法定义了你在 Scala 的Option实现中会发现的方法。由于没有通过工厂方法分配新实例,我们看到这些方法是静态的,而不是实例方法。我们有可能定义一个隐式类,它将提供模拟实例方法调用的语法,但我们避免这样做,因为我们假设极端的性能敏感性。从语义上讲,在OptOps中定义的操作与 Scala 的Option类似。我们不是匹配表示无值的值(即None),而是再次利用将null作为值的引用能力。

使用此实现,运行时开销包括实例检查和对scalaz.Tag的调用。我们失去了模式匹配的能力,而必须要么折叠,或者在极端情况下使用isSome和unsafeGet。为了更好地理解运行时差异,我们使用 Scala 的Option和前面的标记类型实现进行了微基准测试。微基准测试让你对语法的变化有所体会。我们鼓励你运行javap来反汇编字节码,以证明这个实现避免了装箱和对象创建:

class OptionCreationBenchmarks {

@Benchmark

def scalaSome(): Option[ShareCount] = Some(ShareCount(1))

@Benchmark

def scalaNone(): Option[ShareCount] = None

@Benchmark

def optSome(): ShareCount @@ Opt = OptOps.some(ShareCount(1))

@Benchmark

def optSomeWithNullChecking(): ShareCount @@ Opt =

OptOps.nullCheckingSome(ShareCount(1))

@Benchmark

def optNone(): ShareCount @@ Opt = OptOps.none

@Benchmark

def optNoneReuse(): ShareCount @@ Opt = noShares

}

object OptionCreationBenchmarks {

case class ShareCount(value: Long) extends AnyVal

val noShares: ShareCount @@ Opt = OptOps.none

}

我们使用以下熟悉的参数运行测试:

sbt 'project chapter3' 'jmh:run OptionCreationBenchmarks -foe true'

结果总结在下表中:

基准

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

optNone

351,536,523.84

±0.75

optNoneReuse

344,201,145.90

±0.23

optSome

232,684,849.83

±0.37

optSomeWithNullChecking

233,432,224.39

±0.28

scalaNone

345,826,731.05

±0.35

scalaSome

133,583,718.28

±0.24

在这里最令人印象深刻的结果可能是,当使用Some的标记类型实现而不是 Scala 提供的实现时,吞吐量大约提高了 57%。这很可能是由于减少了内存分配压力。我们发现None创建的吞吐量在质量上相似。我们还观察到,在标记Some选项的构造中添加空检查似乎没有成本。如果你信任你的团队能够避免传递空值,那么这个检查就是多余的。我们还创建了一系列基准测试来评估折叠性能,以了解使用这种替代Option实现的相对成本。以下是简单折叠基准测试的源代码:

class OptionFoldingBenchmarks {

@Benchmark

def scalaOption(): ShareCount =

scalaSome.fold(ShareCount(0))(c => ShareCount(c.value * 2))

@Benchmark

def optOption(): ShareCount =

OptOps.fold(optSome)(ShareCount(0))(c => ShareCount(c.value * 2))

}

object OptionFoldingBenchmarks {

case class ShareCount(value: Long) extends AnyVal

val scalaSome: Option[ShareCount] = Some(ShareCount(7))

val optSome: ShareCount @@ Opt = OptOps.some(ShareCount(7))

}

这个基准测试使用了之前相同的参数集:

jmh:run OptionFoldingBenchmarks -foe true

以下是测试结果的总结:

基准测试

吞吐量(每秒操作数)

误差作为吞吐量的百分比

optOption

346,208,759.51

±1.07

scalaOption

306,325,098.74

±0.41

在这个基准测试中,我们希望证明在使用替代的标记类型启发的实现而不是 Scala Option时,没有显著的吞吐量下降。性能的显著下降将危及我们在创建基准测试中找到的性能提升。幸运的是,这个基准测试表明折叠吞吐量实际上比 Scala Option折叠实现提高了大约 13%。

注意

看到基准测试的结果证实了你的假设,这让人感到欣慰。然而,同样重要的是要理解为什么会产生有利的结果,并且能够解释这一点。如果没有理解这些结果是如何产生的,你很可能无法重现这些结果。你将如何解释标记类型启发的实现相对于 Scala Option实现的折叠吞吐量改进?考虑我们讨论的实现和内存分配差异。

基准测试表明,受标记类型启发的Option实现相对于 Scala 的Option实现带来了定性的性能提升。如果你面临性能问题,并且分析显示 Scala 的Option是瓶颈,那么探索这种替代实现可能是有意义的。虽然性能有所提升,但要注意存在权衡。在使用替代实现时,你将失去模式匹配的能力。这看起来是一个微不足道的代价,因为你能够使用折叠操作。更高的代价是集成标准库和第三方库。如果你的关键路径代码与 Scala 标准库或第三方库有大量交互,你将被迫重写大量代码以使用替代的Option实现。在这种情况下,如果你处于时间压力之下,重新考虑是否用null建模域的部分部分是有意义的。如果你的关键路径代码避免与 Scala 标准库或第三方库有重大交互,那么使用替代的Option实现可能是一个更容易的决定。

我们的案例研究受到了 Alexandre Bertails 在他的博客文章bertails.org/2015/02/15/abstract-algebraic-data-type/中探索的一种新颖方法的启发。他通过定义一种他称之为抽象代数数据类型的方法来解决我们处理过的相同性能问题。这两种方法都依赖于使用类型约束来建模Option而不进行实例分配。通过抽象Option代数数据类型及其操作,他能够设计出一种无分配和无装箱的实现。我们鼓励你探索这种方法,因为它又是如何实现安全性的同时仍然提供优秀性能的一个很好的例子。

摘要

在本章中,我们深入探讨了常用 Scala 语言特征的字节码表示和性能考虑。在我们的案例研究中,你亲眼看到了如何结合关于 Scala 语言特征的几个领域的知识,以及出色的 Scalaz 库,以产生更适合高性能需求的Option实现。

在我们所有的示例中,一个一致的宗旨是在考虑性能权衡的同时,促进类型安全和正确性。作为函数式程序员,我们重视编译时正确性和引用透明性。即使在标记类型Option实现中使用null,我们也保留了正确性,因为null值是一个内部实现细节。当你反思我们所讨论的策略时,考虑每个策略是如何在保持引用透明(即无副作用)的代码的同时,仍然帮助你达到性能目标的。

在这个阶段,你应该对 Scala 优雅的语言特性带来的权衡更加自信。通过我们的分析,你学习了如何将简洁的 Scala 语法转换为 JVM 字节码。这是一项宝贵的技能,可以帮助调试性能问题。随着你通过研究更多示例来提高自己的意识,你将培养出更强的直觉,了解潜在问题的所在。随着时间的推移,你可以回顾这一章节,以复习常见的补救策略,平衡优雅与安全与性能之间的权衡。在下一章中,我们将通过深入研究集合,继续提升利用 Scala 编写高效、函数式代码的能力。

第四章。探索集合 API

在本章中,我们回到 MVT,以应对跨越多个 MVT 团队的多重挑战。市场数据团队需要改进关键路径订单簿性能以处理增加的取消请求量。数据科学团队希望有更好的临时数据分析工具来研究交易策略。每个人都面临一个昨天就必须解决的问题。这就是创业生活!

我们利用函数式范式、现有知识和 Scala 集合 API 的优势来解决这些挑战。Scala 语言及其集合 API 的力量允许你以前未曾想到的方式处理问题。随着我们解决这些挑战并遇到新的 Scala 集合使用,我们将详细说明集合实现和需要考虑的权衡。在本章中,我们将考虑以下集合:

List

TreeMap

Queue

Set

Vector

Array

高吞吐量系统 - 提高订单簿性能

在第一章《通往绩效之路》中,你在紧张的情境下遇到了 MVT 的首席交易员 Dave。金融市场经历了一段极端波动的时期,暴露了订单簿设计的弱点。在与 Dave 交谈后,你了解到在波动市场中,订单量主要由取消订单组成,因为交易员正在对快速变化的市场条件做出反应。通过订单簿基准测试和配置文件分析,你证实了在高成交量下,取消性能导致高订单簿响应延迟的怀疑。

尽管导致交易损失的波动市场已经过去,Dave 认识到未来波动对 MVT 回报的风险。Dave 希望投入工程力量,使订单簿在频繁取消时更具性能。通过与数据科学团队合作,Dave 分析了三个月的订单簿历史活动,并发现了有趣的市场特征。他与你分享,在分析的三个月中,按每个交易日计算,取消订单平均占订单簿命令的 70%。分析还显示,在波动最大的市场日,取消活动占订单簿活动的约 85%。以他的双关语而闻名,Dave 总结道:“现在,你知道了我所知道的一切。就像订单簿一样,我们依赖你来执行!”

理解历史权衡 - 列实现

为了提高订单簿的性能而感到兴奋,你的第一步是熟悉订单簿的实现。当你打开订单簿仓库时,你 ping 了 Gary,一位有先前的订单簿开发经验的工程师。由于 Gary 了解订单簿开发的历史,他告诉你检查ListOrderBook。“这是我们第一次尝试建模订单簿。我认为你可以通过看到它的第一个版本来学习我们的设计,”他补充说,“一旦你理解了实现,检查QueueOrderBook。那是订单簿的下一个版本。我们在波动波期间对这个实现的老版本进行了分析。如果你有任何问题,请告诉我!”在感谢他之后,你深入到仓库中寻找ListOrderBook。

ListOrderBook类定义以下状态来管理购买(出价)和销售(报价):

case class ListOrderBook(

bids: TreeMap[Price, List[BuyLimitOrder]],

offers: TreeMap[Price, List[SellLimitOrder]]) {

def bestBid: Option[BuyLimitOrder] =

??? // hidden for brevity

def bestOffer: Option[SellLimitOrder] =

??? // hidden for brevity

}

为了刷新我们的记忆,以下是Price、BuyLimitOrder和SellLimitOrder的定义:

sealed trait LimitOrder {

def id: OrderId

def price: Price

}

case class BuyLimitOrder(id: OrderId, price: Price) extends LimitOrder

case class SellLimitOrder(id: OrderId, price: Price) extends LimitOrder

case class Price(value: BigDecimal)

LimitOrder是一种代数数据类型(ADT),它表示两种可能的订单方向。Price类是BigDecimal的强类型包装。回忆一下值类提供的性能提升,你修改了Price的定义,如下所示:

case class Price(value: BigDecimal) extends AnyVal

ListOrderBook类使用两种 Scala 集合类型来维护其状态:List和TreeMap。让我们更深入地了解这些数据结构,以了解它们所呈现的权衡。

列表

Scala 将List实现为一个不可变的单链表。List是相同类型元素的有序集合。List是一个密封的抽象类,有两个实现:Nil,表示空列表,以及::(通常称为 cons),用于表示一个元素和尾部。为了使事情更加具体,让我们看看一些伪代码,它接近实际的实现:

sealed trait List[+A]

case object Nil extends List[Nothing]

case class ::A extends List[A]

可以使用以下记法构造包含三个整数的List:

val list = ::(1, ::(2, ::(3, Nil)))

注意

注意List特质定义中的加号。加号(+)表示List在其类型参数A上是一致的。一致性允许你使用泛型类型表达多态约束。为了使这一点更加具体,请考虑以下定义:

sealed trait Base

case class Impl(value: Int) extends Base

在这里,Base和Impl之间存在一种关系。Impl类是Base的子类型。当与List一起使用时,一致性允许我们表达List[Impl]是List[Base]的子类型。用示例表达,一致性是以下代码片段能够编译的原因:

val bases: List[Base] = ListImpl)

一致性属于更广泛的话题——变异性。如果你想要了解更多关于 Scala 中变异性的信息,请参考 Andreas Schroeder 在blog.codecentric.de/en/2015/03/scala-type-system-parameterized-types-variances-part-1/上的这篇优秀的博客文章。

与 Scala 中的大多数其他集合不同,List支持对其内容的模式匹配。这是一种编写表达性代码的强大方式,可以处理多个场景,同时保持编译时安全性,即处理所有可能的案例。考虑以下片段:

List(1,2,3,4) match {

case 1 :: x :: rest => println(s"second element: $x, rest: $rest")

}

在这个简单的模式匹配中,我们能够表达几个关注点。在这里,1是1,x是2,而rest是List(3,4)。当编译时,这个片段会引发编译器警告,因为 Scala 编译器推断出存在可能的未匹配的List模式(例如,空的List)。编译器提供的警告最小化了你忘记处理有效输入的机会。

List针对预添加操作进行了优化。将 0 添加到上一个列表就像这样做一样简单:

val list = ::(1, ::(2, ::(3, Nil)))

val listWithZero = ::(0, list)

这是一个常数时间操作,并且由于List实现了数据共享,所以几乎没有内存开销。换句话说,新的列表listWithZero不是list的深拷贝,而是重新使用所有已分配的元素,并只分配一个新元素,即包含0的单元格:

与预添加操作相比,追加操作(即在列表末尾添加一个元素)在计算上很昂贵,因为必须复制整个List:

由于List的追加性能较差,你可能想知道是否安全地使用map转换。map转换是通过将函数应用于List中的连续元素来发生的,这可以通过将转换后的值追加到新的List中在逻辑上表示。为了避免这种性能陷阱,List.map覆盖了由TraversableOnce特质提供的默认实现,使用预添加操作来应用转换。这提供了改进的List.map性能,同时保留了相同的 API。覆盖默认行为以提供专用实现是 Scala 集合模式中的常见做法。常数时间头部操作使List非常适合涉及后进先出(LIFO)操作的算法。对于随机访问和先进先出(FIFO)行为,你应该有选择地使用List。

在下一节中,我们将研究TreeMap。TreeMap类是SortedMap特质的实现,用于维护出价和报价。

TreeMap

TreeMap类是一个根据提供的排序策略对键进行排序的映射。其类定义的以下片段清楚地说明了排序要求:

class TreeMap[A, +B] private (tree: RB.Tree[A, B])(implicit val ordering: Ordering[A])

Ordering类是一个类型类,它定义了A类型元素的天然排序契约。

注意

如果类型类对你来说是一个新概念,我们鼓励你阅读 Daniel Westheide 关于此主题的出色博客文章,链接为danielwestheide.com/blog/2013/02/06/the-neophytes-guide-to-scala-part-12-type-classes.html。

在ListOrderBook中,我们看到Price是关键。查看Price的伴随对象,我们看到排序是通过委托给底层BigDecimal类型的排序定义来实现的:

object Price {

implicit val ordering: Ordering[Price] = new Ordering[Price] {

def compare(x: Price, y: Price): Int =

Ordering.BigDecimal.compare(x.value, y.value)

}

}

ListOrderBook中引用的TreeMap类,就像List一样,是不可变的。不可变性提供了强大的推理保证。我们可以确信没有副作用,因为从映射中添加或删除值的效应总是反映为一个新的映射。

TreeMap类实现是一种特殊的二叉搜索树,即红黑树。这种树实现提供了查找、添加和删除的对数时间操作。你可能惊讶地看到TreeMap代替了HashMap。如 Scala 集合性能概述文档所述(docs.scala-lang.org/overviews/collections/performance-characteristics.html),HashMap提供常数时间的查找、添加和删除,这比TreeMap更快。然而,TreeMap在执行有序遍历时提供了更好的性能。例如,在TreeMap中,可以在对数时间内找到映射中的最大键,而在HashMap中则需要线性时间。这种差异表明订单簿实现需要高效的有序Price遍历。

添加限价订单

回到ListOrderBook的实现,我们看到以下部分方法定义反映了订单簿的核心:

def handle(

currentTime: () => EventInstant,

ob: ListOrderBook,

c: Command): (ListOrderBook, Event) = c match {

case AddLimitOrder(_, o) => ??? // hidden for brevity

case CancelOrder(_, id) => ??? // hidden for brevity

}

注意

可能会让人觉得奇怪,一个函数被作为参数提供以检索当前时间。实现相同效果的一个可能更简单的方法是调用System.currentTimeMillis()。这种方法的不利之处在于访问系统时钟是一个副作用,这意味着函数不再是引用透明的。通过提供一个函数来检索当前时间,我们能够控制这种副作用的发生,并产生可重复的测试用例。

给定一个Command、一个订单簿实例以及获取事件时间戳当前时间的方法,将产生一个Event和一个新状态。为了刷新我们的记忆,以下是订单簿可以处理的命令:

sealed trait Command

case class AddLimitOrder(i: CommandInstant, o: LimitOrder) extends Command

case class CancelOrder(i: CommandInstant, id: OrderId) extends Command

以下是通过处理命令可能创建的事件:

sealed trait Event

case class OrderExecuted(i: EventInstant, buy: Execution,

sell: Execution) extends Event

case class LimitOrderAdded(i: EventInstant) extends Event

case class OrderCancelRejected(i: EventInstant,

id: OrderId) extends Event

case class OrderCanceled(i: EventInstant,

id: OrderId) extends Event

让我们专注于支持AddLimitOrder命令,以更好地理解历史设计选择算法的特性。当添加限价订单时,可能出现两种结果之一:

进入的订单价格跨越订单簿,导致OrderExecuted

即将到来的订单基于产生LimitOrderAdded的书籍

判断订单是否跨越订单簿需要查看对立方的最佳价格。回到LimitOrderBook的定义,并完全实现bestBid和bestOffer,我们看到以下内容:

case class ListOrderBook(

bids: TreeMap[Price, List[BuyLimitOrder]],

offers: TreeMap[Price, List[SellLimitOrder]]) {

def bestBid: Option[BuyLimitOrder] =

bids.lastOption.flatMap(_._2.headOption)

def bestOffer: Option[SellLimitOrder] =

offers.headOption.flatMap(_._2.headOption)

}

实现显示我们正在利用TreeMap的对数有序搜索属性。最佳买入价是具有最高价格的键,它是树中的最后一个值,因为排序是升序的。最佳卖出价是具有最低价格的键,它是树中的第一个值。

专注于买入限价订单的添加以及最佳出价,以下比较发生以确定即将到来的买入订单是穿过订单簿还是停留在订单簿上:

orderBook.bestOffer.exists(buyOrder.price.value >= _.price.value)

match {

case true => ??? // cross the book

case false => ??? // rest on the book

}

让我们首先假设即将到来的买入订单的价格低于最佳出价,这意味着订单被添加到订单簿中(即停留在订单簿上)。我们试图回答的问题是,“订单应该添加到订单簿的哪个位置?”订单簿执行对数搜索以找到与订单价格相关的价格水平。根据ListOrderBook的定义,你知道映射中的每个值(价格水平)都表示为一个订单的List。回忆与首席交易员 Dave 的讨论,你记得在同一价格水平内的订单是按照时间优先级执行的。首先添加到价格水平的订单是首先被执行的。从概念上讲,价格水平是一个先进先出(FIFO)队列。这意味着向价格水平添加订单是一个线性时间操作,因为订单被追加到末尾。下面的摘要是确认你的假设:

val orders = orderBook.bids.getOrElse(buyOrder.price, Nil)

orderBook.copy(bids = orderBook.bids + (buyOrder.price -> orders.:+(buyOrder))) ->

LimitOrderAdded(currentTime())

摘要显示,向订单簿中添加一个休息限价订单涉及到对BuyLimitOrder的List进行线性时间追加操作。在你的脑海中,你开始怀疑 MVT 如何能够利用这个订单簿进行有利可图的交易。在做出这样的严厉判断之前,你考虑了如何处理订单簿的交叉。

假设即将到来的买入订单的价格大于或等于最佳出价,那么买入订单将穿过订单簿,导致执行。时间优先级规定,首先收到的卖出订单将与即将到来的买入订单执行,这相当于取价格水平中的第一个卖出订单。在生成执行时,你意识到使用List来模拟价格水平提供了常数时间性能。以下摘要是如何修改买入执行中的价格水平的:

case (priceLevel, (sell :: Nil)) => (orderBook.copy(offers = orderBook.offers - sell.price),

OrderExecuted(currentTime(), Execution(buy.id, sell.price),

Execution(sell.id, sell.price)))

case (_, (sell :: remainingSells)) => (orderBook.copy(offers = orderBook.offers + (sell.price -> remainingSells)),

OrderExecuted(currentTime(),

Execution(buy.id, sell.price), Execution(sell.id, sell.price)))

ListOrderBook利用List模式匹配来处理两种可能的交叉场景:

执行的卖出订单是该价格水平中唯一可用的订单

额外的卖出订单仍然停留在价格水平

在前一种情况下,通过从 offers TreeMap 中移除键来从订单簿中删除价格层级。在后一种情况下,剩余的订单形成新的价格层级。显然,订单簿是针对执行操作而不是添加挂起订单进行优化的。你想知道为什么订单簿实现中存在这种偏见。你自问,“也许,执行操作比挂起订单更普遍?”你不确定,并在心里记下要和 Dave 聊聊。

注意

休息一下,考虑一下你设计的系统中存在的偏见。你是否优化了与使用量或延迟约束成比例的操作?回顾过去,你的设计选择是否使你朝着最重要的操作的最佳性能迈进?当然,事后诸葛亮很容易指出次优的设计选择。通过反思你如何做出这些选择,你可能能够更好地避免未来系统中类似的缺陷。

取消订单

ListOrderBook 也支持使用 CancelOrder 命令通过 ID 删除现有订单。取消请求对 ListOrderBook 构成了算法挑战。因为只提供了订单 ID,ListOrderBook 无法高效地确定订单位于哪一侧(即买入或卖出)。为了确定侧边,需要遍历买入和卖出的价格层级以找到订单 ID。这是一个与每侧价格层级数量和每个价格层级的长度成比例的操作。最坏的情况是提交一个在订单簿中不存在的订单 ID。整个订单簿必须被遍历以识别提供的订单 ID 是否缺失。恶意交易者可以通过提交一系列不存在的订单 ID 来减缓 MVT 订单簿的操作。你记下笔记,打算和 Dave 谈谈恶意交易活动以及 MVT 可以如何防御这些活动。

假设取消请求中引用的订单存在于订单簿中,并且其价格层级已被发现,从订单簿中移除已取消订单的操作也是昂贵的。取消是一个线性时间操作,需要遍历订单的链表并移除匹配订单 ID 的节点。以下代码片段实现了在 ListOrderBook 中取消卖出订单:

orderBook.offers.find { case (price, priceLevel) => priceLevel.exists(_.id == idToCancel) }

.fold(ListOrderBook, Event), idToCancel)) {

case (price, priceLevel) =>

val updatedPriceLevel = priceLevel.filter(_.id != idToCancel)

orderBook.copy(offers = updatedPriceLevel.nonEmpty match {

case true => orderBook.offers + (price -> updatedPriceLevel)

case false => orderBook.offers - price

}) -> OrderCanceled(currentTime(), idToCancel)

研究这个代码片段,取消性能是最低效的订单簿操作,这并不让你感到惊讶。取消订单需要在每个价格层级上执行两次线性时间遍历。首先,exists 遍历价格层级订单列表以确定要取消的 ID 是否存在于价格层级中。一旦找到包含该 ID 的价格层级,就会通过 filter 执行第二次遍历来更新订单簿的状态。

ListOrderBook中的取消实现是 Scala 表达式集合 API 双刃剑的例证。由于表达能力强,取消逻辑简单易懂且易于维护。然而,其表达性也使得它容易隐藏从价格层级中删除订单的运行时间是2 * N,其中N是价格层级中的订单数量。这个简单的例子清楚地表明,在性能敏感的环境中,从代码中退一步考虑所使用数据结构的运行时开销是很重要的。

当前订单簿 – 队列实现

你不会对ListOrderBook过于苛刻,因为你从先前的软件开发经验中知道,可能存在导致这种实现的特殊情况。你将注意力转向当前的订单簿实现,它在QueueOrderBook中。查看源代码后,你惊讶地发现实现看起来与ListOrderBook相似,只是价格层级的数据结构不同:

case class QueueOrderBook(

bids: TreeMap[Price, Queue[BuyLimitOrder]],

offers: TreeMap[Price, Queue[SellLimitOrder]])

这两个实现之间的唯一区别是将List替换为scala.collection.immutable.Queue来表示价格层级。从建模的角度来看,使用 FIFO 队列是有意义的。因为时间优先级决定了执行顺序,所以 FIFO 队列是存储挂起订单的自然选择。你开始怀疑是否将List替换为Queue仅仅是为了建模目的。你心中的问题是,“用Queue替换List是如何提高订单簿性能的?”理解这个变化需要更深入地挖掘 Scala 的Queue实现。

队列

这个Queue定义的代码片段揭示了一个有趣的见解:

class Queue[+A] protected(protected val in: List[A], protected val out: List[A])

在没有深入阅读Queue实现之前,我们看到它使用两个Lists来管理状态。鉴于在ListOrderBook中使用List来模拟 FIFO 队列,使用List来构建不可变的 FIFO 队列数据结构并不令人惊讶。让我们看看入队和出队操作,以了解进出如何影响Queue性能。以下代码片段显示了入队实现的示例:

def enqueueB >: A = new Queue(elem :: in, out)

当元素被添加到in中时,入队操作是常数时间操作。回想一下,类似的ListOrderBook操作是添加一个挂起订单,其运行时间性能是线性的。这是QueueOrderBook的一个明显的性能优势。接下来,我们考虑出队实现:

def dequeue: (A, Queue[A]) = out match {

case Nil if !in.isEmpty => val rev = in.reverse ; (rev.head, new Queue(Nil, rev.tail))

case x :: xs => (x, new Queue(in, xs))

case _ => throw new NoSuchElementException("dequeue on empty queue")

}

注意

如实现所示,当使用空的Queue调用dequeue时,会抛出异常。这个异常是调用dequeue时的一个意外结果,在函数式编程范式中感觉不合适。因此,Queue还提供了dequeueOption,它返回一个Option。这使得处理空的Queue变得明确且更容易推理。我们建议在无法保证dequeue始终在非空Queue上调用的情况下使用dequeueOption。

dequeue操作比enqueue操作更复杂,因为它是由于in和out之间的交互。为了理解Queue状态是如何通过dequeue操作管理的,请查看以下表格。该表格通过一系列的enqueue和dequeue操作,列出了每个步骤的in和out状态。在查看表格时,考虑哪些dequeue模式与调用的语句相匹配:

Operation

In

Out

enqueue(1)

List(1)

Nil

enqueue(2)

List(1, 2)

Nil

enqueue(3)

List(1, 2, 3)

Nil

dequeue

Nil

List(2, 3)

dequeue

Nil

List(3)

enqueue(4)

List(4)

List(3)

dequeue

List(4)

Nil

dequeue

Nil

Nil

由于enqueue和dequeue调用是交织在一起的,in和out都保留了状态。在显示的最后序列中,队列返回到其初始状态(即,in和out都为空)。从这个实现的关键洞察是,通过推迟从in和out的转移,Queue将dequeue的成本摊销为常数时间。从in和out到每个元素的转移是一个线性时间的reverse操作,以维护先进先出排序。将这种昂贵的操作的成本推迟到out为空,是一种惰性评估的形式。这是一个说明如何使用惰性评估来提高运行时性能的示例。

现在你已经了解了Queue的实现方式,你可以推理出QueueOrderBook带来的性能提升。下表列出了每个场景修改价格级别的运行时性能:

Scenario

ListOrderBook

QueueOrderBook

Add resting limit order

Linear

Constant

Generate execution

Constant

Amortized constant

Cancel order

Linear

Linear

该表格说明了如何通过理解 Scala 集合 API 的运行时特性,通过对你实现的小幅改动,可以带来实际的性能提升。回想一下,当QueueOrderBook被引入时,它的实现与ListOrderBook相同,该模块的改变是将List操作替换为类似的Queue操作。这是之前展示的性能提升的一个相对简单的改动。

你很高兴看到QueueOrderBook在处理限价订单时的性能提升,但你仍然在思考如何提高取消订单的性能。QueueOrderBook保持相同的取消性能让你感到不安。特别是,由于最近市场波动暴露了订单簿取消性能的弱点,导致 MVT 交易无利可图。延迟评估是处理限价订单的一个大性能提升。这个原则是否也可以应用于取消请求?

通过延迟评估提高取消性能

Queue使用额外的状态,即第二个List,提供高性能的enqueue和dequeue操作,以延迟和批量处理昂贵的操作。这个原则可以应用于订单簿。在取消订单时,有两个昂贵的操作:

识别包含待取消订单的价格水平

遍历Queue或List以移除已取消的订单

专注于第二个操作,激发的问题是,“订单簿如何将线性遍历的成本延迟到修改内部状态?”为了回答这个问题,考虑你实现的优势通常是有帮助的。无论是哪种订单簿实现,我们都知道有出色的执行性能。利用这一洞察力的一种策略是延迟取消直到订单执行。方法是使用额外的状态来维持取消意图,而不从订单簿状态中移除订单,直到这样做是高效的。这种方法可能看起来如下:

case class LazyCancelOrderBook(

pendingCancelIds: Set[OrderId],

bids: TreeMap[Price, Queue[BuyLimitOrder]],

offers: TreeMap[Price, Queue[SellLimitOrder]])

LazyCancelOrderBook类通过添加一个scala.collection.immutable.Set形式的额外状态来管理尚未反映到bids和offers状态中的已取消请求的 ID。在深入研究pendingCancelIds的使用之前,让我们调查 Scala 中Set的实现。

Set

Scala 的Set实现既不是 ADT(如List),也不是具体实现(如TreeMap)。相反,它是一个特质,如下是其定义的片段所示:

trait Set[A]

标准库将其定义为特质的原因是为了支持根据元素数量特定的实现。Set伴生对象定义了从零到四的大小五种实现。每个实现包含固定数量的元素,如Set3所示,如下:

class Set3[A] private[collection] (elem1: A, elem2: A, elem3: A)

当元素数量较少时,使用手工编写的Set实现运行时性能更快。使用这种技术,添加和删除操作指向下一个或上一个手工编写的实现。例如,考虑Set3中的+和-操作:

def + (elem: A): Set[A] =

if (contains(elem)) this

else new Set4(elem1, elem2, elem3, elem)

def - (elem: A): Set[A] =

if (elem == elem1) new Set2(elem2, elem3)

else if (elem == elem2) new Set2(elem1, elem3)

else if (elem == elem3) new Set2(elem1, elem2)

else this

在Set4之后,标准库使用了一个名为HashSet的实现。这在你向Set4添加元素时是可见的:

def + (elem: A): Set[A] =

if (contains(elem)) this

else new HashSet[A] + (elem1, elem2, elem3, elem4, elem)

HashSet 与 TreeMap 类似,因为它背后有一个高效的数据结构来管理内部状态。对于 HashSet,其底层数据结构是一个哈希 trie。哈希 trie 提供了平均常数时间的性能,用于添加、删除和包含操作,如 Scala 集合性能概述(docs.scala-lang.org/overviews/collections/performance-characteristics.html)中所述。如果你想要深入了解哈希 trie 的工作原理,Scala 哈希 trie 概述(docs.scala-lang.org/overviews/collections/concrete-immutable-collection-classes.html#hash-tries)是一个很好的起点。

回到 LazyCancelOrderBook,我们现在知道使用 pendingCancelIds 的常见集合操作是在平均常数时间内完成的。只要我们专注于添加、删除和包含操作,这表明随着集合大小的增加,开销将最小。我们可以使用 pendingCancelIds 来表示从订单簿中移除订单的意图,而不必承担执行删除的成本。这简化了取消订单的处理,只需将取消订单添加到 pendingCancelIds 中的常数时间操作即可:

def handleCancelOrder(

currentTime: () => EventInstant,

ob: LazyCancelOrderBook,

id: OrderId): (LazyCancelOrderBook, Event) =

ob.copy(pendingCancelIds = ob.pendingCancelIds + id) ->

OrderCanceled(currentTime(), id)

handleCancelOrder 的实现变得非常简单,因为从订单簿中删除订单的工作被推迟了。虽然这是一个性能提升,但这种实现存在一个严重的缺陷。这种实现不再能够识别订单簿中不存在的订单 ID,这会导致 OrderCancelRejected。为了满足这一要求,可以维护一个包含正在订单簿上活跃的订单 ID 的额外 Set。现在,LazyCancelOrderBook 的状态如下:

case class LazyCancelOrderBook(

activeIds: Set[OrderId],

pendingCancelIds: Set[OrderId],

bids: TreeMap[Price, Queue[BuyLimitOrder]],

offers: TreeMap[Price, Queue[SellLimitOrder]])

根据这个定义,我们可以重写 handleCancelOrder 以处理不存在的订单 ID:

def handleCancelOrder(

currentTime: () => EventInstant,

ob: LazyCancelOrderBook,

id: OrderId): (LazyCancelOrderBook, Event) =

ob.activeIds.contains(id) match {

case true => ob.copy(activeIds = ob.activeIds - id,

pendingCancelIds = ob.pendingCancelIds + id) ->

OrderCanceled(currentTime(), id)

case false => ob -> OrderCancelRejected(currentTime(), id)

}

当订单 ID 存在于订单簿中时,这个实现涉及三个平均常数时间的操作。首先,有一个操作用于确定订单 ID 是否存在于订单簿中。然后,提供的订单 ID 从活动 ID 集合中移除,并添加到待取消集合中。以前,这种情况需要两个线性运行时操作。处理不存在订单 ID 的退化场景现在缩小到单个平均常数时间的操作。

在庆祝性能提升之前,请记住我们仍然需要从订单簿中移除已取消的订单。为了减少取消的成本,我们在订单簿中添加了两个可能很大的集合,这增加了内存占用和垃圾回收的压力。此外,需要进行基准测试以证明理论上的性能提升可以转化为实际世界的性能提升。

要完成LazyCancelOrderBook的实现,我们需要在处理限价订单时考虑activeIds,在生成执行时考虑pendingCancelIds。如您所回忆的那样,处理限价订单涉及两种场景:

添加挂单限价订单

交叉订单簿以生成执行

这里是一个部分实现的代码片段,为我们处理这两种场景的BuyLimitOrder做准备:

orderBook.bestOffer.exists(_.price.value <= buy.price.value) match {

case true => ??? // crossing order

case false => ??? // resting order

为了支持挂单买入,提供的买入订单必须入队,并且,买入订单 ID 必须添加到activeOrderIds集合中:

def restLimitOrder: (LazyCancelOrderBook, Event) = {

val orders = orderBook.bids.getOrElse(buy.price, Queue.empty)

orderBook.copy(bids = orderBook.bids + (buy.price -> orders.enqueue(buy)),

activeIds = orderBook.activeIds + buy.id) -> LimitOrderAdded(currentTime())

}

orderBook.bestOffer.exists(_.price.value <= buy.price.value) match {

case true => ??? // crossing order

case false => restLimitOrder

添加挂单限价订单的逻辑在前面代码中展示,并提取为一个名为restLimitOrder的方法。这个逻辑类似于ListOrderBook的类似场景,增加了摊销常数时间的活跃订单 ID 添加操作。这个更改很简单,并且几乎没有增加处理时间的开销。最后,我们考虑更复杂的订单交叉场景。这个场景类似于Queue.dequeue,因为这种实现承担了延迟操作的成本。首先需要解决的困境是确定哪些订单可以执行,哪些订单必须被移除,因为它们已被取消。findActiveOrder提供了这个功能,并且假设orderBook在作用域内,如下所示:

@tailrec

def findActiveOrder(

q: Queue[SellLimitOrder],

idsToRemove: Set[OrderId]): (Option[SellLimitOrder], Option[Queue[SellLimitOrder]], Set[OrderId]) =

q.dequeueOption match {

case Some((o, qq)) => orderBook.pendingCancelIds.contains(o.id) match {

case true =>

findActiveOrder(qq, idsToRemove + o.id)

case false =>

(Some(o), if (qq.nonEmpty) Some(qq) else None, idsToRemove + o.id)

}

case None => (None, None, idsToRemove)

}

findActiveOrder递归检查售价水平,直到找到可执行的订单或价格水平为空。除了可选地解决可以执行的卖出订单外,该方法还返回剩余的价格水平。这些订单 ID 已被取消,必须从pendingCancelIds中移除。在这里,我们看到在处理取消请求时,大部分取消工作被延迟。现在,当执行重复发生且中间没有取消时,执行被摊销为常数时间操作。最坏的情况是线性运行时间,与价格水平中取消订单的数量成比例。让我们看看findActiveOrder是如何用来更新订单簿状态的:

orderBook.offers.headOption.fold(restLimitOrder) {

case (price, offers) => findActiveOrder(offers, Set.empty) match {

case (Some(o), Some(qq), rms) => (orderBook.copy(

offers = orderBook.offers + (o.price -> qq), activeIds = orderBook.activeIds -- rms),

OrderExecuted(currentTime(),

Execution(buy.id, o.price), Execution(o.id, o.price)))

case (Some(o), None, rms) => (orderBook.copy(

offers = orderBook.offers - o.price, activeIds = orderBook.activeIds -- rms),

OrderExecuted(currentTime(),

Execution(buy.id, o.price), Execution(o.id, o.price)))

case (None, _, rms) =>

val bs = orderBook.bids.getOrElse(buy.price, Queue.empty).enqueue(buy)

(orderBook.copy(bids = orderBook.bids + (buy.price -> bs),

offers = orderBook.offers - price,

activeIds = orderBook.activeIds -- rms + buy.id),

LimitOrderAdded(currentTime()))

}

}

由于需要从pendingCancelIds中移除已取消的订单以及移除已移除的订单 ID,订单交叉实现现在可能比ListOrderBook或QueueOrderBook更复杂。在所有三个模式匹配语句中,作为最终元组的成员返回的订单 ID 集合被从pendingCancelIds中移除,以指示订单现在已从订单簿中移除。前两个模式匹配语句处理了在价格水平中找到一个有剩余订单的一个或多个订单的活跃订单与找到一个价格水平中剩余订单为零的活跃订单之间的区别。在后一种情况下,价格水平从订单簿中移除。第三个模式匹配语句考虑了找不到活跃订单的情况。如果一个活跃订单因为所有订单都处于挂起取消状态而找不到,那么,根据定义,整个价格水平都被搜索过,因此现在是空的。

基准测试 LazyCancelOrderBook

作为一名严谨的性能工程师,你意识到尽管你的代码编译成功且测试通过,但你的工作还远未完成。你开始思考如何基准测试LazyCancelOrderBook以确定你的更改是否真正提高了实际性能。你的第一个想法是单独测试取消操作以确认这一操作确实得到了优化。为此,你重新设计了在第二章中引入的CancelBenchmarks,即《在 JVM 上测量性能》,使其与QueueOrderBook和LazyCancelOrderBook一起工作。这个基准测试通过取消第一订单、最后订单和不存在订单,对不同价格水平大小进行扫描。我们省略了源代码,因为它与之前的实现相同,而是考虑了结果。这些结果是通过运行以下内容产生的:

sbt 'project chapter4' 'jmh:run CancelBenchmarks -foe true'

基准测试为我们提供了以下结果:

基准测试

入队订单数量

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

eagerCancelFirstOrderInLine

1

6,912,696.09

± 0.44

lazyCancelFirstOrderInLine

1

25,676,031.5

± 0.22

eagerCancelFirstOrderInLine

10

2,332,046.09

± 0.96

lazyCancelFirstOrderInLine

10

12,656,750.43

± 0.31

eagerCancelFirstOrderInLine

1

5,641,784.63

± 0.49

lazyCancelFirstOrderInLine

1

25,619,665.34

± 0.48

eagerCancelFirstOrderInLine

10

1,788,885.62

± 0.39

lazyCancelFirstOrderInLine

10

13,269,215.32

± 0.30

eagerCancelFirstOrderInLine

1

9,351,630.96

± 0.19

lazyCancelFirstOrderInLine

1

31,742,147.67

± 0.65

eagerCancelFirstOrderInLine

10

6,897,164.11

± 0.25

lazyCancelFirstOrderInLine

10

24,102,925.78

± 0.24

这个测试表明,在取消第一个订单、最后一个订单以及不存在订单时,LazyCancelOrderBook在订单队列大小为一和十的情况下,始终优于QueueOrderBook。这正是预期的,因为LazyCancelOrderBook将最昂贵的操作推迟到订单执行时。我们看到性能保持恒定,不受即将取消的订单位置的影响,这进一步证明了移除工作被推迟。同样,正如预期的那样,我们看到取消一个不存在的订单会导致性能提升,因为不再需要线性遍历来确认订单不存在。然而,我们注意到当入队订单数量从一增加到十时,LazyCancelOrderBook的性能下降。我们可以假设近 50%的吞吐量减少是由于管理活跃和挂起的取消订单 ID 状态的开销。

这个结果是一个有希望的迹象,表明你的更改确实提高了现实世界的性能。由于新的实现通过了初始的基准测试,你开始考虑如何代表性地模拟执行和取消的组合。你决定专注于创建一个微基准,该基准结合了执行和取消,以在更接近生产的场景中测试LazyCancelOrderBook。你回想起与 Dave 最近的一次午餐谈话,他提到每笔执行大约有两笔取消是很常见的。带着这个想法,你创建了一个交替交易和取消的基准。对于两个订单簿实现,你想要测试以下场景中的性能:

每次取消对应两笔交易

每次取消对应一笔交易

每笔交易对应两次取消

这三个场景将有助于通过关注类似生产的订单簿活动来揭示LazyCancelOrderBook的不足。基准要求为每个订单簿初始化一组待取消或执行的休息订单。以下代码片段展示了如何在 JMH 测试中初始化订单簿:

@State(Scope.Benchmark)

class InterleavedOrderState {

var lazyBook: LazyCancelOrderBook = LazyCancelOrderBook.empty

var eagerBook: QueueOrderBook = QueueOrderBook.empty

@Setup

def setup(): Unit = {

lazyBook = (1 to maxOrderCount).foldLeft(LazyCancelOrderBook.empty) {

case (b, i) => LazyCancelOrderBook.handle(

() => EventInstant.now(), b, AddLimitOrder(

CommandInstant.now(), BuyLimitOrder(OrderId(i), bidPrice)))._1

}

eagerBook = (1 to maxOrderCount).foldLeft(QueueOrderBook.empty) {

case (b, i) => QueueOrderBook.handle(

() => EventInstant.now(), b, AddLimitOrder(

CommandInstant.now(), BuyLimitOrder(OrderId(i), bidPrice)))._1

}

}

}

在每次试验之前,两个订单簿都将填充maxOrderCount(定义为 30)的休息出价。由于有三个场景要测试和两个订单簿,因此为此测试定义了六个基准。每个三组场景在每个订单簿实现中都是相同的。为了避免重复,以下代码片段显示了为LazyCancelOrderBook实现的三个基准:

@Benchmark

def lazyOneToOneCT(state: InterleavedOrderState): LazyCancelOrderBook = {

val b1 = LazyCancelOrderBook.handle(() => EventInstant.now(),

state.lazyBook, firstCancel)._1

LazyCancelOrderBook.handle(() => EventInstant.now(),

b1, firstCrossSell)._1

}

@Benchmark

def lazyTwoToOneCT(state: InterleavedOrderState): LazyCancelOrderBook = {

val b1 = LazyCancelOrderBook.handle(() => EventInstant.now(),

state.lazyBook, firstCancel)._1

val b2 = LazyCancelOrderBook.handle(() => EventInstant.now(),

b1, secondCancel)._1

LazyCancelOrderBook.handle(() => EventInstant.now(),

b2, firstCrossSell)._1

}

@Benchmark

def lazyOneToTwoCT(state: InterleavedOrderState): LazyCancelOrderBook = {

val b1 = LazyCancelOrderBook.handle(() => EventInstant.now(),

state.lazyBook, firstCancel)._1

val b2 = LazyCancelOrderBook.handle(() => EventInstant.now(),

b1, firstCrossSell)._1

LazyCancelOrderBook.handle(() => EventInstant.now(),

b2, secondCrossSell)._1

}

这些基准遵循先表示取消频率("C")然后表示交易频率("T")的惯例。例如,最终的基准实现了每两笔交易对应一次取消的场景。命令被定义为作用域之外的值,以避免在基准调用期间生成垃圾。基准调用看起来如下所示:

sbt 'project chapter4' 'jmh:run InterleavedOrderBenchmarks -foe true'

此调用产生了以下结果:

基准测试

吞吐量(每秒操作数)

错误率作为吞吐量的百分比

eagerOneToTwoCT

797,339.08

± 2.63

lazyOneToTwoCT

1,123,157.94

± 1.26

eagerOneToOneCT

854,635.26

± 2.48

lazyOneToOneCT

1,469,338.46

± 1.85

eagerTwoToOneCT

497,368.11

± 0.72

lazyTwoToOneCT

1,208,671.60

± 1.69

在所有方面,LazyCancelOrderBook 都优于 QueueOrderBook。惰性和积极性能之间的相对差异显示出一种有趣的关系。以下表格捕捉了相对性能差异:

基准测试

LazyCancelOrderBook 百分比性能提升

OneToTwoCT

141.00%

OneToOneCT

172.00%

TwoToOneCT

243.00%

通过研究前表,我们观察到当每笔交易有两个取消操作时,LazyCancelOrderBook 显示出最大的性能提升。这一结果证明了推迟处理取消请求成本的好处。我们看到的下一个趋势是,随着交易频率的增加和取消频率的减少,QueueOrderBook 相对于 LazyCancelOrderBook 的性能有所提升。这一结果是有道理的,因为 LazyCancelOrderBook 在进行交易时会产生额外的成本。除了搜索已取消订单外,LazyCancelOrderBook 还必须更新 activeIds。QueueOrderBook 避免了这些成本,但我们看到取消处理的高昂成本仍然继续影响 QueueOrderBook 的性能。总结这些结果,我们更有信心认为 LazyCancelOrderBook 可以作为 QueueOrderBook 的替代品。在涉及大量取消操作的场景中,它似乎是一个明显的赢家,而在其他场景中,它似乎与 QueueOrderBook 保持一致。

经验教训

在本节中,我们利用 Scala 集合,并结合谨慎使用惰性评估,来提高 MVT 基础设施中一个关键组件的性能。通过研究几个订单簿实现,你亲自学习了如何选择合适的数据结构可以提升性能,而选择不优的数据结构则可能导致性能下降。这项练习还让你了解了 Scala 如何实现其一些集合,你现在可以利用这些集合来解决性能问题。

LazyCancelOrderBook 展示了在性能敏感的环境中延迟评估的价值。面对性能挑战时,问问自己以下问题,看看是否有可能推迟工作(CPU 工作量,而不是你的实际工作量!)!以下表格列出了每个问题以及如何通过订单簿回答:

问题

订单簿示例应用

我如何将任务分解成更小的离散块?

取消操作被分解为识别发送给请求者的事件,并从订单簿状态中删除已取消的订单。

为什么我现在要执行所有这些步骤?

最初,订单移除是急切发生的,因为这是最逻辑的方式来建模这个过程。

我能否更改任何约束,以便我可以以不同的方式建模问题?

理想情况下,我们希望移除要求拒绝不存在订单的约束。不幸的是,这超出了我们的控制范围。

我系统中哪些操作是最高效的?

执行订单和在订单簿上挂单是最高效的操作。我们利用快速执行时间从订单簿中移除已取消的订单。

就像任何方法一样,延迟评估并不是万能的。勤奋的基准测试和性能分析是必要的,以验证变化带来的好处。可以说,LazyCancelOrderBook的实现比QueueOrderBook更复杂,这将增加维护系统的成本。除了使实现更加复杂之外,由于订单执行的变量成本,现在推理运行时性能也更加困难。对于我们所测试的场景,LazyCancelOrderBook与QueueOrderBook保持一致,甚至在某些情况下表现更好。然而,我们只测试了众多可能场景中的一小部分,并且我们只在一个订单簿的单个价格水平上进行了测试。在现实世界中,需要额外的基准测试和性能分析,以建立足够的信心,确信这种新的实现提供了更好的性能。

历史数据分析

你在订单簿方面做了出色的工作,并且我们希望,在这个过程中你已经学到了宝贵的技能!现在是时候探索 MVT 活动的新方面了。一群专家交易员和数据科学家一直在研究历史市场数据,以设计高效的交易策略。到目前为止,公司还没有奢侈地将技术资源分配给这个团队。因此,这个团队一直在使用笨拙、不可靠且表现不佳的工具来分析市场数据并构建复杂的交易策略。有了高效的订单簿,首要任务是专注于改进公司实施的策略。你的新朋友 Dave 明确要求你加入团队,帮助他们现代化他们的基础设施。

滞后时间序列回报

团队主要使用的工具是一个简单的程序,用于从历史交易执行数据中计算滞后时间序列回报。到目前为止,这个工具令人大失所望。不仅它返回的结果大多无效,而且它运行缓慢且脆弱。在深入代码之前,Dave 给你简要介绍了涉及的业务规则。回报时间序列是从中间价时间序列派生出来的。中间价是基于每个交易的买入价和卖出价在每个分钟计算的。以下表格可以作为简单的例子:

执行时间

买入价

卖出价

中间价

01/29/16 07:45

2.3

2.5

2.55

01/29/16 07:46

2.1

2.4

2.25

01/29/16 07:47

2.9

3.4

3.15

01/29/16 07:48

3.2

3.4

3.3

01/29/16 07:49

3.1

3.3

3.2

计算中点的公式是 (买价 + 卖价) / 2。例如,01/29/16 07:47 的中点是 (2.9 + 3.4) / 2,即 3.15。

备注

在现实世界中,中点会根据交易量进行加权,时间序列会使用更细粒度的时间单位,例如秒或甚至毫秒。为了使示例简单,我们通过假设所有执行的交易量为 1 来忽略体积维度。我们还专注于每分钟计算一个数据点,而不是使用秒或甚至毫秒的更细粒度的时间序列。

一系列中点用于计算一系列返回值。对于一定分钟的汇总值,定义了一系列返回值。为了计算时间 t[3] 的三分钟返回值,公式是:(t[3] 时的中点 - t[0] 时的中点) / t[0] 时的中点。我们还乘以 100 以使用百分比。如果我们使用之前的中点系列来计算三分钟返回值系列,我们得到以下表格:

时间

中点

3 分钟回报

01/29/16 07:45

2.55

N/A

01/29/16 07:46

2.25

N/A

01/29/16 07:47

3.15

N/A

01/29/16 07:48

3.3

22.73

01/29/16 07:49

3.2

29.69

注意,前三个中点没有对应的三个分钟回报,因为没有足够旧的中点可以使用。

现在,你已经熟悉了该领域,可以查看现有的代码。从以下模型开始:

case class TimestampMinutes(value: Int) extends AnyVal {

def next: TimestampMinutes = TimestampMinutes(value + 1)

}

case class AskPrice(value: Int) extends AnyVal

case class BidPrice(value: Int) extends AnyVal

case class Execution(time: TimestampMinutes, ask: AskPrice, bid: BidPrice)

case class Midpoint(time: TimestampMinutes, value: Double)

object Midpoint {

def fromAskAndBid(time: TimestampMinutes,askPrice: AskPrice,

bidPrice: BidPrice): Midpoint =

Midpoint(time, (bidPrice.value + askPrice.value) / 2D)

}

case class MinuteRollUp(value: Int) extends AnyVal

case class Return(value: Double) extends AnyVal

object Return {

def fromMidpoint(start: Midpoint, end: Midpoint): Return =

Return((end.value - start.value) / start.value * 100)

}

似乎一切都很直接。请注意,价格、中点和回报被表示为 Int 和 Double。我们假设我们的系统能够将价格规范化为整数而不是小数。这简化了我们的代码,并提高了程序的性能,因为我们使用原始的 Double 而不是例如 BigDecimal 实例。"TimestampMinutes" 与更常用的纪元时间戳类似,但只精确到分钟(见 en.wikipedia.org/wiki/Unix_time)。

在研究模型后,我们查看 computeReturnsWithList 方法的现有实现:

def computeReturnsWithList(

rollUp: MinuteRollUp,

data: List[Midpoint]): List[Return] = {

for { i <- (rollUp.value until data.size).toList} yield Return.fromMidpoint(data(i - rollUp.value), data(i))

}

此方法假设接收到的输入中点列表已经按执行时间排序。它随机访问列表的各个索引以读取计算每个返回值所需的中点。为了使用三分钟的汇总值计算第二个返回值(返回列表中的索引 1),我们访问输入列表中的索引 4 和 1 的元素。以下图表提供了计算返回值的视觉参考:

你已经被警告这种方法很慢,但它也是不正确的。Dave 已经多次验证它返回了错误的结果。在解决性能问题之前,你必须处理正确性问题。优化一个不正确的方法不会是你时间的良好利用,因此也不会是公司金钱的良好利用!迅速地,你意识到这种方法对输入数据的信任过多。为了使这个算法工作,输入的中点列表必须做到以下几点:

这必须按执行时间正确排序,从最早的执行到最新的执行

这必须每分钟不超过一个中点

这不能包含任何没有中点的分钟,也就是说,它没有缺失的数据点

你把这个问题提给 Dave,以便更好地理解中点序列是如何生成的。他解释说,它是从由订单簿记录的顺序日志中加载的。可以肯定的是,列表是按执行时间排序的。他还向你保证,考虑到订单簿处理的大量交易量,不可能有一个没有单次执行的分钟。然而,他也承认,对于同一执行时间,很可能计算了多个中点。看起来你已经找到了导致无效返回的问题。修复它不应该太复杂,你认为现在是时候反思性能问题。

在上一节中,我们花费时间研究了单链表的结构。你知道它优化了涉及列表头和尾的操作。相反,通过索引随机访问元素是一个昂贵的操作,需要线性时间。为了提高中点执行性能,我们转向具有改进随机访问性能的数据结构:Vector。

向量

为了提高我们系统的性能,我们应该重新考虑存储Midpoint值的数据库结构。一个好的选择是将List替换为标准库提供的另一个 Scala 集合Vector。Vector是一个高效的集合,它提供了有效的常数时间随机访问。随机访问操作的成本取决于各种假设,例如,Vector的最大长度。Vector被实现为一个有序树数据结构,称为 trie。在 trie 中,键是存储在Vector中的值的索引(要了解更多关于 trie 及其用例的信息,请参阅en.wikipedia.org/wiki/Trie)。由于Vector实现了Seq特质,就像List一样,修改现有方法很简单:

def computeReturnsWithVector(

rollUp: MinuteRollUp,

data: Vector[Midpoint]): Vector[Return] = {

for {

i <- (rollUp.value until data.size).toVector

} yield Return.fromMidpoint(data(i - rollUp.value), data(i))

}

只需更改集合的类型就足以切换到更高效的实现。为了确保我们确实提高了性能,我们设计了一个简单的基准测试,该测试旨在使用几小时的历史交易执行情况,并测量每个实现的吞吐量。结果如下:

基准测试

返回汇总所需时间(分钟)

吞吐量(每秒操作数)

错误率(吞吐量百分比)

computeReturnsWithList

10

534.12

± 1.69

computeReturnsWithVector

10

49,016.77

± 0.98

computeReturnsWithList

60

621.28

± 0.64

computeReturnsWithVector

60

51,666.50

± 1.64

computeReturnsWithList

120

657.44

± 1.07

computeReturnsWithVector

120

43,297.88

± 0.99

不仅 Vector 提供了显著更好的性能,它无论汇总的大小如何都能提供相同的吞吐量。作为一个一般规则,最好将 Vector 作为不可变索引序列的默认实现。Vector 不仅为元素随机访问提供有效常数时间复杂度,还为头部和尾部操作以及向现有 Vector 中追加和预追加元素提供有效常数时间复杂度。

Vector 的实现是一个 32 阶的树结构。每个节点实现为一个大小为 32 的数组,它可以存储最多 32 个指向子节点的引用或最多 32 个值。这种 32 叉树结构解释了为什么Vector的复杂度是“有效常数”而不是“常数”。实现的真正复杂度是 log(32, N),其中 N 是向量的大小。这被认为足够接近实际常数时间。这个集合是一个存储非常大的序列的好选择,因为内存是在 32 个元素的块中分配的。这些块不是为树的所有级别预先分配的,而是按需分配。

直到 Scala 2.10,与 List 相比,Vector 的一个缺点是缺乏模式匹配支持。现在这个问题已经解决了,你可以像匹配 List 实例一样匹配 Vector 实例。考虑以下一个方法匹配 Vector 以访问和返回其第三个元素或如果它包含少于三个元素则返回 None 的简短示例:

def returnThirdElementA: Option[A] = v match {

case _ +: _ +: x +: _ => Some(x)

case _ => None

}

在 REPL 中调用此方法可以演示模式匹配的应用,如下所示:

scala> returnThirdElement(Vector(1,2,3,4,5))

res1: Option[Int] = Some(3)

数据清理

返回算法现在非常快。也就是说,返回错误结果的速度非常快!记住我们仍然需要处理一些边缘情况并清理输入数据。我们的算法只有在每分钟恰好有一个中点时才有效,Dave 通知我们我们可能会看到同一分钟计算出的多个中点。

为了处理这个问题,我们创建了一个专门的 MidpointSeries 模块,并确保正确创建了一个 MidpointSeries 实例,它包装了一系列 Midpoint 实例,且没有重复:

class MidpointSeries private(val points: Vector[Midpoint]) extends AnyVal

object MidpointSeries {

private def removeDuplicates(v: Vector[Midpoint]): Vector[Midpoint] = {

@tailrec

def loop(

current: Midpoint,

rest: Vector[Midpoint],

result: Vector[Midpoint]): Vector[Midpoint] = {

val sameTime = current +: rest.takeWhile(_.time == current.time)

val average = sameTime.map(_.value).sum / sameTime.size

val newResult = result :+ Midpoint(current.time, average)

rest.drop(sameTime.size - 1) match {

case h +: r => loop(h, r, newResult)

case _ => newResult

}

}

v match {

case h +: rest => loop(h, rest, Vector.empty)

case _ => Vector.empty

}

}

def fromExecution(executions: Vector[Execution]): MidpointSeries = {

new MidpointSeries(removeDuplicates(

executions.map(Midpoint.fromExecution)))

}

我们的removeDuplicates方法使用尾递归方法(参考第三章 Unleashing Scala Performance)。这种方法将所有具有相同执行时间的中间点分组,计算这些数据点的平均值,并使用这些平均值构建一个新的序列。我们的模块提供了一个fromExecution工厂方法,用于从Execution的Vector构建MidpointSeries的实例。这个工厂方法调用removeDuplicates来清理数据。

为了改进我们的模块,我们将之前的computeReturns方法添加到MidpointSeries类中。这样,一旦构建,MidpointSeries的一个实例就可以用来计算任何回报序列:

class MidpointSeries private(val points: Vector[Midpoint]) extends AnyVal {

def returns(rollUp: MinuteRollUp): Vector[Return] = {

for {

i <- (rollUp.value until points.size).toVector

} yield Return.fromMidpoint(points(i - rollUp.value), points(i))

}

}

这是我们之前写的相同代码,但这次我们确信points不包含重复项。请注意,构造函数被标记为private,因此创建MidpointSeries实例的唯一方法是使用我们的工厂方法。这保证了不可能使用“脏”的Vector创建MidpointSeries的实例。你发布了这个新版本的程序,祝 Dave 和他的团队好运,然后离开去享受应得的午餐休息时间。

当你回来时,你惊讶地发现数据科学家之一 Vanessa 正坐在你的办公桌前。“返回序列代码仍然不起作用”,她说。团队对于终于得到了一个可以工作的算法感到非常兴奋,以至于他们决定放弃午餐来玩这个。不幸的是,他们发现了一些与结果的不一致性。你试图收集尽可能多的数据,花了一个小时查看 Vanessa 提到的无效结果。你注意到它们都涉及两个特定符号的交易执行:FOO 和 BAR。这些符号的交易记录数量出奇地少,而且交易执行之间通常会有几分钟的间隔。你向 Dave 询问这些符号。他解释说,这些是交易量较小的股票,对于它们来说,看到较低的成交量并不罕见。现在问题对你来说已经很明确了。这些符号记录的中点序列没有满足你的算法的一个先决条件:每分钟至少有一个执行。你忍住没有提醒 Dave 他之前向你保证这种情况不可能发生,并开始着手解决问题。交易员总是对的!

你并不自信能够重新设计算法使其更加健壮,同时保持当前的吞吐量。更好的选择是找到一种方法来清理数据以生成缺失的数据点。你向 Vanessa 寻求建议。她解释说,基于周围现有的点对缺失数据点进行线性外推不会干扰交易算法。你编写了一个简短的方法来使用前一个和后一个点(以下代码片段中的a和b)在特定时间外推中点:

private def extrapolate(a: Midpoint,b: Midpoint, time: TimestampMinutes): Midpoint = {

val price = a.value +

((time.value - a.time.value) / (b.time.value - a.time.value)) *

(b.value - a.value)

Midpoint(time, price)

}

使用这个方法,我们可以编写一个清理方法,遵循之前提到的removeDuplicates函数的模式来预处理数据:

private def addMissingDataPoints(

v: Vector[Midpoint]): Vector[Midpoint] = {

@tailrec

def loop(

previous: Midpoint,

rest: Vector[Midpoint],

result: Vector[Midpoint]): Vector[Midpoint] = rest match {

case current +: mPoints if previous.time.value == current.time.value - 1 =>

// Nothing to extrapolate, the data points are consecutive

loop(current, mPoints, result :+ previous)

case current +: mPoints if previous.time.value < current.time.value - 1 =>

//Need to generate a data point

val newPoint = extrapolate(previous, current, previous.time.next)

loop(newPoint, rest, result :+ previous)

case _ => result :+ previous

}

v match {

case h +: rest => loop(h, rest, Vector.empty)

case _ => Vector.empty

}

}

我们内部尾递归方法处理了两个点已经连续的情况,以及一个点缺失的情况。在后一种情况下,我们使用我们的extrapolate方法创建一个新的点,并将其插入到结果Vector中。请注意,我们使用这个新点来外推连续缺失的点。我们更新我们的工厂方法,在删除可能的重复项后执行此附加清理操作:

def fromExecution(executions: Vector[Execution]): MidpointSeries = {

new MidpointSeries(

addMissingDataPoints(

removeDuplicates(

executions.map(Midpoint.fromExecution))))

}

现在我们有保证,我们的输入数据是干净的,并且可以用于我们的回报序列算法。

处理多个回报序列

团队对你的改进印象深刻,以及你快速修复现有代码的能力。他们提到了一个他们一直想做的项目,但不知道如何着手。几周前,Vanessa 设计了一个机器学习算法,用于评估多个股票的贸易策略,基于它们的回报序列。这个算法要求所有涉及的回报序列包含相同数量的数据点。你之前的变化已经处理了这个要求。然而,另一个条件是回报值必须归一化或缩放。特征是机器学习术语,指单个可测量的属性。在我们的例子中,每个回报数据点都是一个特征。特征缩放用于标准化可能值的范围,以确保广泛的值范围不会扭曲学习算法。Vanessa 解释说,缩放特征将帮助她的算法提供更好的结果。我们的程序将处理一组回报序列,计算缩放向量,并计算一组新的归一化回报序列。

Array

对于这个系统,我们考虑从Vector切换到Array。Array是一个可变的、索引的值集合。它提供了对随机访问的有效常数复杂度,而Vector则在这个操作中实现了有效常数时间。然而,与Vector相反,Array作为单个连续的内存块分配一次。此外,它不允许追加和预置操作。Scala 的Array是用 Java 的Array实现的,这是内存优化的。Scala 的Array比原生的 JavaArray更易于使用。当使用Array时,大多数在其他 Scala 集合上可用的方法都可用。隐式转换用于通过ArrayOps和WrappedArray增强Array。ArrayOps是Array的一个简单包装,用于暂时丰富Array,使其具有索引序列中找到的所有操作。在ArrayOps上调用的方法将产生一个Array。相反,从Array到WrappedArray的转换是永久的。在WrappedArray上调用转换器方法将产生另一个WrappedArray。我们可以在标准库文档中看到这一点,如下所示:

val arr = Array(1, 2, 3)

val arrReversed = arr.reverse // arrReversed is an Array[Int]

val seqReversed: Seq[Int] = arr.reverse

// seqReversed is a WrappedArray

决定为我们新的模块使用Array后,我们开始编写代码以缩放每个回报序列的特征:

class ReturnSeriesFrame(val series: Array[Array[Return]]) {

val scalingVector: Array[Double] = {

val v = new ArrayDouble

for (i <- series.indices) {

v(i) = series(i).max.value

}

v

}

}

为一组序列计算一个缩放向量。向量的第一个值用于缩放第一个序列,第二个值用于第二个序列,依此类推。缩放值是序列中的最大值。我们现在可以编写代码来使用缩放向量并计算框架的归一化版本:

object ReturnSeriesFrame {

def scaleWithMap(frame: ReturnSeriesFrame): ReturnSeriesFrame = {

new ReturnSeriesFrame(

frame.series.zip(frame.scalingVector).map {

case (series, scaling) => series.map(point => Return(point.value / scaling))

})

}

}

我们将每个序列与其缩放值压缩在一起,并创建一个新的缩放回报序列。我们可以比较使用Array呈现的代码版本与另一个几乎相同的、使用Vector实现的版本(为了简洁,此代码在此省略,但可以在附于书籍的源代码中找到):

基准测试

序列大小

每秒操作吞吐量

吞吐量误差百分比

normalizeWithVector

60

101,116.50

± 0.85

normalizeWithArray

60

176,260.52

± 0.68

normalizeWithVector

1,440

4,077.74

± 0.71

normalizeWithArray

1,440

7,865.85

± 1.39

normalizeWithVector

28,800

282.90

± 1.06

normalizeWithArray

28,800

270.36

± 1.85

这些结果表明,对于较短的序列,Array的性能优于Vector。随着序列大小的增加,它们的性能相当。我们甚至可以看到,对于包含 20 天数据(28,800 分钟)的序列,吞吐量是相同的。对于更长的序列,Vector的局部性和其内存分配模型减轻了与Array的差异。

我们的实现是典型的:它使用高阶函数和不可变结构。然而,使用转换函数,如zip和map,会创建新的Array实例。另一种选择是利用Array的可变性质来限制程序产生的垃圾数量。

使用 Spire 的 cfor 宏循环

Scala 支持两种循环结构:for循环和while循环。尽管后者具有良好的性能特性,但在函数式编程中通常避免使用。它需要使用可变状态和var来跟踪循环条件。在本节中,我们将向您展示一种利用while循环性能的技术,以防止可变引用泄漏到应用程序代码中。

Spire 是一个为 Scala 编写的数值库,允许开发者编写高效的数值代码。Spire 利用模式,如类型类、宏和特化(记得第三章中的特化,释放 Scala 性能)。您可以在github.com/non/spire了解更多关于 Spire 的信息。

Spire 提供的宏之一是cfor。其语法灵感来源于 Java 中更传统的 for 循环。在以下特征缩放实现的示例中,我们使用cfor宏遍历我们的序列并归一化值:

def scaleWithSpire(frame: ReturnSeriesFrame): ReturnSeriesFrame = {

import spire.syntax.cfor._

val result = new Array[Array[Return]](frame.series.length)

cfor(0)(_ < frame.series.length, _ + 1) { i =>

val s = frame.series(i)

val scaled = new ArrayReturn

cfor(0)(_ < s.length, _ + 1) { j =>

val point = s(j)

scaled(j) = Return(point.value / frame.scalingVector(i))

}

result(i) = scaled

}

new ReturnSeriesFrame(result)

}

此示例表明cfor宏可以嵌套。该宏本质上是一种语法糖,编译为 Scala 的while循环。我们可以检查以下生成的字节码来证明这一点:

public highperfscala.dataanalysis.ArrayBasedReturnSeriesFrame scaleWithSpire(highperfscala.dataanalysis.ArrayBasedReturnSeriesFrame);

Code:

0: aload_1

1: invokevirtual #121 // Method highperfscala/dataanalysis/ArrayBasedReturnSeriesFrame.series:()[[Lhighperfscala/dataanalysis/Return;

4: arraylength

5: anewarray #170 // class "[Lhighperfscala/dataanalysis/Return;"

8: astore_2

9: iconst_0

10: istore_3

11: iload_3

[... omitted for brevity]

39: iload 6

[... omitted for brevity]

82: istore 6

84: goto 39

[... omitted for brevity]

95: istore_3

96: goto 11

99: new #16 // class highperfscala/dataanalysis/ArrayBasedReturnSeriesFrame

102: dup

103: aload_2

104: invokespecial #19 // Method highperfscala/dataanalysis/ArrayBasedReturnSeriesFrame."":([[Lhighperfscala/dataanalysis/Return;)V

107: areturn

我们注意到两个goto语句,指令96和84,分别用于分别回到外循环和内循环的开始(分别从指令11和39开始)。我们可以运行这个新实现的基准测试来确认性能提升:

基准测试

序列大小

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

normalizeWithArray

60

176,260.52

± 0.68

normalizeWithCfor

60

256,303.49

± 1.33

normalizeWithArray

1,440

7,865.85

± 1.39

normalizeWithCfor

1,440

11,446.47

± 0.89

normalizeWithArray

28,800

270.36

± 1.85

normalizeWithCfor

28,800

463.56

± 1.51

该宏编译为 while 循环,能够提供更好的性能。使用cfor构造,我们能够在避免引入多个变量的情况下保持性能。尽管这种方法牺牲了不可变性,但可变性的范围有限,并且比使用命令式的while或for循环的等效实现更不易出错。

摘要

在本章中,我们探索并实验了各种集合实现。我们讨论了每种数据结构的底层表示、复杂性和用例。我们还引入了第三方库 Spire,以提高我们程序的性能。一些实现偏离了典型的函数式编程实践,但我们能够将可变状态的使用限制在内部模块中,同时仍然公开函数式公共 API。我们预计你渴望了解更多,但在下一章中,我们将变得懒惰!与本章专注于急切集合相比,我们将注意力转向下一章的懒集合。

第五章。懒惰集合和事件溯源

在上一章中,我们探索了许多可以立即进行贪婪评估的 Scala 集合。Scala 标准库提供了两个操作懒惰的集合:视图和流。为了激发对这些集合的探索,我们将解决 MVT 围绕为客户端生成性能报告的性能困境。在本章中,我们将涵盖以下主题:

视图

使用两个真实世界应用的流处理

事件溯源

马尔可夫链生成

提高客户报告生成速度

想要了解更多关于 MVT 客户的信息,你决定参加每周的客户状态会议。当你环顾四周时,你看到自己是这里唯一的工程师,其他人都是销售团队的人。MVT 客户管理团队的负责人约翰尼列出了新签约的客户名单。每次他读出一个名字,就会响起一声响亮的铃声。这对你来说似乎是一种奇怪的习俗,但每当铃声响起,销售团队都会兴奋地欢呼。

在新客户名单公布结束,耳边铃声停止后,销售团队中的一名成员问约翰尼:“性能报告何时能生成得更快?客户每天都在打电话给我,抱怨他们无法在交易日看到自己的持仓和盈亏情况。我们没有这种透明度,这很尴尬,我们可能会因此失去业务。”你意识到这个问题报告是一个可以通过 MVT 向客户公开的私有网络门户下载的 PDF 文件。除非客户足够熟练,能够使用 MVT 的性能 API 设置自己的报告,否则客户将依赖于门户来检查最近的交易表现。

意识到这是一个更好地理解问题的机会,你问道:“嗨,我是工程团队的一员。我想今天来了解一下我们的客户。你能分享更多关于报告性能问题的情况吗?我想帮助解决这个问题。”通过与销售团队的交谈,你了解到 PDF 报告是向实时流式 Web 应用迈出的第一步。PDF 报告允许 MVT 快速向客户提供交易表现洞察。每次客户点击“查看表现”时,都会生成一份报告,通过显示客户在过去一小时、一天和七天内是否实现了盈利或亏损来总结表现趋势。尤其是在市场波动时,你了解到客户更有可能生成报告。销售团队认为这加剧了问题,因为当每个人都试图查看最近的交易表现时,报告生成速度会变得更慢。在一些最糟糕的情况下,性能报告需要大约十几分钟才能生成,这对期望近乎实时结果的客户来说是完全不能接受的。

深入报告代码

急于深入探究问题,你找到了负责处理报告数据的存储库。你探索领域模型以了解此范围内表示的关注点:

case class Ticker(value: String) extends AnyVal

case class Price(value: BigDecimal) extends AnyVal

case class OrderId(value: Long) extends AnyVal

case class CreatedTimestamp(value: Instant) extends AnyVal

case class ClientId(value: Long) extends AnyVal

sealed trait Order {

def created: CreatedTimestamp

def id: OrderId

def ticker: Ticker

def price: Price

def clientId: ClientId

}

case class BuyOrder(created: CreatedTimestamp, id: OrderId, ticker: Ticker, price: Price, clientId: ClientId) extends Order

case class SellOrder(created: CreatedTimestamp, id: OrderId, ticker: Ticker, price: Price,clientId: ClientId) extends Order

case class Execution(created: CreatedTimestamp, id: OrderId, price: Price)

在报告的上下文中,将订单与执行关联起来对于构建性能趋势报告非常重要,因为这种关联使得 MVT 能够识别出从交易中实现的利润或损失。ClientId是一个你在处理订单簿或执行数据分析时未曾接触过的概念。客户 ID 用于识别 MVT 客户的账户。由于交易是代表客户执行的,因此客户 ID 使我们能够将已执行的订单与客户账户关联起来。

在扫描代码库时,你发现了性能趋势报告在转换为 PDF 格式之前的表示:

sealed trait LastHourPnL

case object LastHourPositive extends LastHourPnL

case object LastHourNegative extends LastHourPnL

sealed trait LastDayPnL

case object LastDayPositive extends LastDayPnL

case object LastDayNegative extends LastDayPnL

sealed trait LastSevenDayPnL

case object LastSevenDayPositive extends LastSevenDayPnL

case object LastSevenDayNegative extends LastSevenDayPnL

case class TradingPerformanceTrend(

ticker: Ticker,

lastHour: LastHourPnL,

lastDay: LastDayPnL,

lastSevenDay: LastSevenDayPnL)

利润和损失(PnL)趋势由每个支持的时间段对应的不同的 ADT 表示:最后一个小时、最后一天和最后七天。对于每个股票代码,这三个时间段都包含在TradingPerformanceTrend中。在多个股票代码中,你可以推断出客户可以确定 MVT 是否在一段时间内产生利润或损失。检查负责计算TradingPerformanceTrend的trend方法的签名,你可以证实你的想法:

def trend(

now: () => Instant,

findOrders: (Interval, Ticker) => List[Order],

findExecutions: (Interval, Ticker) => List[Execution],

request: GenerateTradingPerformanceTrend):

List[TradingPerformanceTrend]

case class GenerateTradingPerformanceTrend(

tickers: List[Ticker], clientId: ClientId)

计算性能趋势需要一种确定当前时间的方法,以便确定需要回溯多远来计算每个时间段的趋势。findOrders和findExecutions参数是查询特定股票代码在特定时间间隔内创建的订单和执行的函数。最后一个参数包含客户的 ID 和要报告的股票代码。每个时间段的趋势是通过一个名为periodPnL的通用内部方法计算的,其形式如下:

def periodPnL(

duration: Duration): Map[Ticker, PeriodPnL] = {

val currentTime = now()

val interval = new Interval(currentTime.minus(duration), currentTime)

(for {

ticker <- request.tickers

orders = findOrders(interval, ticker)

executions = findExecutions(interval, ticker)

idToExecPrice = executions.groupBy(_.id).mapValues(es =>

Price.average(es.map(_.price)))

signedExecutionPrices = for {

o <- orders

if o.clientId == request.clientId

price <- idToExecPrice.get(o.id).map(p => o match {

case _: BuyOrder => Price(p.value * -1)

case _: SellOrder => p

}).toList

} yield price

trend = signedExecutionPrices.foldLeft(PnL.zero) {

case (pnl, p) => PnL(pnl.value + p.value)

} match {

case p if p.value >= PnL.zero.value => PeriodPositive

case _ => PeriodNegative

}

} yield ticker -> trend).toMap

}

periodPnL方法是一个包含多个逻辑步骤的复杂方法。对于每个客户提供的股票代码,检索提供的时间段内的相关订单和执行。为了关联订单与执行,使用groupBy构建一个OrderId到Execution的映射。为了简化后续的计算,计算每个已执行订单的平均执行价格,将单个订单的多个执行减少到一个值。

在构建了idToExecPrice查找表之后,下一步的逻辑步骤是过滤掉其他客户的订单。一旦只剩下客户的订单,idToExecution用于识别已执行的订单。最后两个步骤通过编制客户的绝对回报(即利润和损失)来计算性能趋势。这些步骤涉及领域模型的两个添加,如下所示:

case class PnL(value: BigDecimal) extends AnyVal

object PnL {

val zero: PnL = PnL(BigDecimal(0))

}

sealed trait PeriodPnL

case object PeriodPositive extends PeriodPnL

case object PeriodNegative extends PeriodPnL

PnL值是一个用于表示客户美元回报的价值类。PeriodPnL类似于之前引入的 ADT,可以应用于任何时间段的数据。这使得PeriodPnL可以重复用于最后一个小时、最后一天和最后七天的趋势计算。

当交易代表买入时,执行价格会被取反,因为交易代表的是现金交换股票。当交易代表卖出时,执行价格保持正值,因为交易代表的是用股票交换现金。在计算每个股票的性能趋势后,Ticker和PeriodPnL元组的List被转换为Map。

理解这个实现的过程,你可以开始想象生成这个 PDF 为什么耗时。没有缓存结果的迹象,这意味着每次客户端发起请求时,趋势报告都会重新计算。随着请求报告的客户数量增加,计算报告时的等待时间也会增加。重新架构报告基础设施以缓存报告是一个过于庞大的短期改变。相反,你尝试识别可以改进报告生成性能的增量变化。

使用视图加快报告生成时间

当我们在订单簿上工作时,我们了解到List会急切地评估结果。这个特性意味着在periodPnL中,对orders执行的脱糖 for-comprehension filter和map操作会产生新的列表。也就是说,每个转换都会产生一个新的集合。对于订单数量大的客户来说,迭代订单集三次可能会在 CPU 时间上造成成本,并且由于重复创建List而产生垃圾收集成本。为了缓解这个问题,Scala 提供了一种方法,可以在下游计算需要元素时才延迟转换元素。

从概念上讲,这是通过在急切评估的集合之上添加一个视图来完成的,该视图允许使用延迟评估语义定义转换。可以通过调用view从任何 Scala 集合构建集合的懒加载视图。例如,以下片段从一个整数List创建了一个视图:

val listView: SeqView[Int, List[Int]] = List(1, 2, 3).view

从这个片段中,我们了解到 Scala 使用一个不同的SeqView类型来表示集合的视图,该类型由两种类型参数化:集合元素和集合类型。看到视图的使用使得更容易理解它与急切评估集合的运行时差异。考虑以下片段,它在List及其List视图上执行相同的操作:

println("List evaluation:")

val evens = List(0, 1, 2, 3, 4, 5).map(i => {

println(s"Adding one to $i")

i + 1

}).filter(i => {

println(s"Filtering $i")

i % 2 == 0

})

println("--- Printing first two even elements ---")

println(evens.take(2))

println("View evaluation:")

val evensView = List(0, 1, 2, 3, 4, 5).view.map(i => {

println(s"Adding one to $i")

i + 1

}).filter(i => {

println(s"Filtering $i")

i % 2 == 0

})

println("--- Printing first two even elements ---")

println(evensView.take(2).toList)

这个片段执行简单的算术运算,然后过滤以找到偶数元素。为了加深我们的理解,这个片段通过添加println副作用打破了函数式范式。列表评估的输出符合预期:

List evaluation:

Adding one to 0

Adding one to 1

Adding one to 2

Adding one to 3

Adding one to 4

Adding one to 5

Filtering 1

Filtering 2

Filtering 3

Filtering 4

Filtering 5

Filtering 6

--- Printing first two even elements ---

List(2, 4)

在急切评估中,每个转换在移动到下一个转换之前都会应用于每个元素。现在,考虑以下来自视图评估的输出:

View evaluation:

--- Printing first two even elements ---

Adding one to 0

Filtering 1

Adding one to 1

Filtering 2

Adding one to 2

Filtering 3

Adding one to 3

Filtering 4

List(2, 4)

如我们之前讨论的,使用惰性评估时,只有在需要元素时才会应用转换。在这个例子中,这意味着加法和过滤操作不会在调用toList之前发生。在“视图评估”之后没有输出,这表明没有发生任何转换。有趣的是,我们还看到只有六个元素中的前四个被评估。当一个视图应用转换时,它将对每个元素应用所有转换,而不是对每个元素应用每个转换。通过一步应用所有转换,视图能够返回前两个元素,而无需评估整个集合。在这里,我们看到了由于惰性评估而使用视图的潜在性能提升。在将视图的概念应用到性能趋势报告之前,让我们更深入地了解一下视图的实现。

构建自定义视图

视图能够通过返回一个组合了先前转换状态和下一个转换的数据结构来延迟评估。Scala 视图的实现确实复杂,因为它提供了大量的功能,同时仍然支持所有 Scala 集合。为了构建对视图实现的理解,让我们构建我们自己的仅适用于List且仅支持map操作的惰性评估视图。首先,我们定义我们的PseudoView视图实现所支持的运算:

sealed trait PseudoView[A] {

def mapB: PseudoView[B]

def toList: List[A]

}

PseudoView被定义为一种特质,它支持从A到B的转换的惰性应用,同时也支持评估所有转换以返回一个List。接下来,我们定义两种视图类型,以支持零转换已应用时的初始情况,以及支持将转换应用到先前转换过的视图。签名在以下代码片段中显示:

final class InitialViewA extends PseudoView[A]

final class ComposedViewA, B extends PseudoView[B]

在这两种情况下,原始的List必须保留以支持最终应用转换。在InitialView的基本情况下,没有转换,这就是为什么没有额外的状态。ComposedView通过携带先前fa转换的状态来支持链式计算。

实现InitialView是一个简单的委托给ComposedView:

final class InitialViewA extends PseudoView[A] {

def mapB: PseudoView[B] = new ComposedViewA, B

def toList: List[A] = xs

}

List实现展示了如何使用函数组合将转换链在一起:

final class ComposedViewA, B extends PseudoView[B] {

def mapC: PseudoView[C] = new ComposedView(xs, f.compose(fa))

def toList: List[B] = xs.map(fa)

}

让我们构建一个PseudoView伴生对象,它提供视图构建,如下所示:

object PseudoView {

def viewA, B: PseudoView[A] = new InitialView(xs)

}

我们现在可以通过一个简单的程序来练习PseudoView,以证明它延迟了评估:

println("PseudoView evaluation:")

val listPseudoView = PseudoView.view(List(0, 1, 2)).map(i => {

println(s"Adding one to $i")

i + 1

}).map(i => {

println(s"Multiplying $i")

i * 2

})

println("--- Converting PseudoView to List ---")

println(listPseudoView.toList)

运行这个程序,我们看到输出与 Scala 视图实现的用法相当:

PseudoView evaluation:

--- Converting PseudoView to List ---

Adding one to 0

Multiplying 1

Adding one to 1

Multiplying 2

Adding one to 2

Multiplying 3

List(2, 4, 6)

PseudoView有助于建立对 Scala 如何实现视图的直觉。从这里,你可以开始考虑如何支持其他操作。例如,如何实现filter?filter很有趣,因为它限制了原始集合。如定义,PseudoView不适合支持filter操作,这是 Scala 视图处理复杂性的一个例子。Scala 视图通过定义一个名为Transformed的特质来应对这一挑战。Transformed特质是所有视图操作的基础特质。部分定义如下:

trait Transformed[+B] extends GenTraversableView[B, Coll] {

def foreachU: Unit

lazy val underlying = self.underlying

}

underlying懒值是原始包装集合的访问方式。这与PseudoView将List状态传递到ComposedView的方式类似。Transformed定义了一个副作用foreach操作来以懒加载方式支持集合操作。使用foreach允许实现此特质的实例修改底层集合。这就是filter是如何实现的:

trait Filtered extends Transformed[A] {

protected[this] val pred: A => Boolean

def foreachU {

for (x <- self)

if (pred(x)) f(x)

}

}

在视图 API 中,Transformed用于维护必要操作的状态,而外部 API 支持与SeqView交互。遵循在 Scala 集合中常见的另一种模式,SeqView通过混合其他特质继承了一组操作。SeqView间接混合了TraversableViewLike,这提供了对Transformed操作的访问。

应用视图来提高报告生成性能

通过我们新开发的视图直觉,我们可能(无意中!)以不同的方式看待性能趋势报告的构建。Scala 对视图的实现使得从急切评估的集合切换到懒加载版本变得非常简单。如果你还记得,一旦构建了订单 ID 到平均执行价格查找表,就会对在特定时间段和股票代码下检索到的订单应用一系列转换。通过将orders转换为视图,就有机会避免不必要的转换并提高性能趋势报告的速度。

虽然转换为视图很简单,但确定在哪些条件下懒加载评估优于急切评估则不那么简单。作为一名优秀的性能工程师,你希望对你的提议进行基准测试,但你没有访问历史订单和执行数据来构建基准。相反,你编写了一个微基准测试来模拟你正在建模的问题。你试图回答的问题是:“对于什么大小的集合和多少操作,使用视图而不是List是有意义的?”构建视图是有成本的,因为它涉及到保留关于延迟转换的信息,这意味着它不总是性能最佳解决方案。你提出了以下场景来帮助你回答问题:

@Benchmark

def singleTransformList(state: ViewState): List[Int] =

state.numbers.map(_ * 2)

@Benchmark

def singleTransformView(state: ViewState): Vector[Int] =

state.numbers.view.map(_ * 2).toVector

@Benchmark

def twoTransformsList(state: ViewState): List[Int] =

state.numbers.map(_ * 2).filter(_ % 3 == 0)

@Benchmark

def twoTransformsView(state: ViewState): Vector[Int] =

state.numbers.view.map(_ * 2).filter(_ % 3 == 0).toVector

@Benchmark

def threeTransformsList(state: ViewState): List[Int] =

state.numbers.map(_ * 2).map(_ + 7).filter(_ % 3 == 0)

@Benchmark

def threeTransformsView(state: ViewState): Vector[Int] =

state.numbers.view.map(_ * 2).map(_ + 7).filter(_ % 3 == 0).toVector

对于每种集合类型,一个List和一个Vector上的视图,你定义了三个测试,以练习不断增加的转换数量。Vector被用来代替List,因为视图上的toList没有针对List进行优化。正如我们之前看到的,List操作被编写为利用常数时间和预加性能。toList执行线性时间追加操作,这给人一种视图提供较低性能的错觉。切换到Vector提供了有效的常数时间追加操作。此基准的状态如下所示:

@State(Scope.Benchmark)

class ViewState {

@Param(Array("10", "1000", "1000000"))

var collectionSize: Int = 0

var numbers: List[Int] = Nil

@Setup

def setup(): Unit = {

numbers = (for (i <- 1 to collectionSize) yield i).toList

}

}

ViewState通过遍历不同的集合大小来帮助识别视图性能对集合大小的敏感性。基准通过以下方式调用:

sbt 'project chapter5' 'jmh:run ViewBenchmarks -foe true'

此调用产生以下结果:

基准测试

集合大小

吞吐量(每秒操作数)

吞吐量误差百分比

singleTransformList

10

15,171,067.61

± 2.46

singleTransformView

10

3,175,242.06

± 1.37

singleTransformList

1,000

133,818.44

± 1.58

singleTransformView

1,000

52,688.80

± 1.11

singleTransformList

1,000,000

30.40

± 2.72

singleTransformView

1,000,000

86.54

± 1.17

twoTransformsList

10

5,008,830.88

± 1.12

twoTransformsView

10

4,564,726.04

± 1.05

twoTransformsList

1,000

44,252.83

± 1.08

twoTransformsView

1,000

80,674.76

± 1.12

twoTransformsList

1,000,000

22.85

± 3.78

twoTransformsView

1,000,000

77.59

± 1.46

threeTransformsList

10

3,360,399.58

± 1.11

threeTransformsView

10

3,438,977.91

± 1.27

threeTransformsList

1,000

36,226.87

± 1.65

threeTransformsView

1,000

58,981.24

± 1.80

threeTransformsList

1,000,000

10.33

± 3.58

threeTransformsView

1,000,000

49.01

± 1.36

这些结果为我们提供了对使用视图产生更好性能的案例的有趣见解。对于小集合,例如我们基准中的 10 个元素,List的表现更好,无论操作量如何,尽管这个差距在 1,000,000 个元素时缩小。当我们转换一个大集合时,例如我们基准中的 1,000,000 个元素,随着转换数量的增加,视图变得更加高效,差异也在增加。例如,对于 1,000,000 个元素和两次转换,视图提供的吞吐量大约是List的三倍。在中等大小集合的情况下,例如本例中的 1,000 个元素,这并不那么明显。在执行单个转换时,急切的List表现更好,而在应用多个转换时,视图提供的吞吐量更高。

随着数据量和转换次数的增加,视图提供更好的性能的可能性也更大。在这里,你可以看到避免中间集合的实质性好处。考虑性能的第二个方面是转换的性质。从早期终止中受益的转换(例如,find),从懒加载中受益很大。这个基准测试说明,了解你的数据大小和打算执行的转换非常重要。

视图注意事项

视图提供了一种简单的方法,通过最小侵入性更改系统来提高性能。易用性是视图吸引力的部分,可能会诱使你比平时更频繁地使用它们。正如我们上一节中的基准测试所显示的,使用视图存在非微不足道的开销,这意味着默认使用视图是一个次优选择。从纯粹的性能角度来看,使用视图时还有其他需要谨慎的理由。

SeqView 扩展了 Seq

由于视图反映了集合 API,识别何时应用懒加载的转换可能是一个挑战。因此,我们建议为视图的使用设定明确的边界。在处理客户端报告时,我们将视图的使用限制在一个内部函数中,并使用 List 急加载集合类型作为返回类型。最小化系统执行懒加载的区域可以减少构建运行时执行心理模型时的认知负荷。

在相关方面,我们认为谨慎地处理视图如何转换为急加载的集合类型非常重要。我们通过调用 toList 来展示转换,这使得意图变得明确。SeqView 还提供了一个 force 方法来强制评估。作为一般规则,我们避免使用 force,因为它通常返回 scala.collection.immutable.Seq。SeqView 保留集合类型作为其第二个泛型参数,这使得在有足够证据的情况下,force 可以返回原始集合类型。然而,某些操作,如 map,会导致视图失去原始集合类型的证据。当这种情况发生时,force 返回更通用的 Seq 集合类型。Seq 是一个特质,它是集合库中所有序列的超类型,包括视图和我们将要讨论的另一个懒加载数据结构,名为 scala.collection.immutable.Stream。这种继承方案允许以下三个语句编译:

val list: Seq[Int] = List(1, 2, 3)

val view: Seq[Int] = list.view

val stream: Seq[Int] = list.toStream

我们认为这是不可取的,因为 Seq 数据类型隐藏了关于底层实现的临界信息。它使用相同的类型表示懒加载和急加载的集合。考虑以下代码片段示例,以了解为什么这是不可取的:

def shouldGenerateOrder(xs: Seq[Execution]): Boolean =

xs.size >= 3

在这个人为的例子中,想象一下shouldGenerateOrder方法被一个Vector对象调用,但后来Vector被SeqView替换。使用Vector时,识别集合长度是一个常数时间操作。使用SeqView时,你无法确定操作的运行时间,只能说它肯定比Vector.size更昂贵。由于难以推理运行时行为,因此应避免使用Seq,以及因此导致的force的使用,因为这可能导致意外的副作用。

在典型的软件系统中,责任区域被划分为离散的模块。使用性能趋势报告的例子,你可以想象一个独立的模块,它包含将List[TradingPerformanceTrend]转换为 PDF 报告的转换。你可能想将视图暴露给其他模块以扩展延迟转换的好处。如果基准测试证明进行此类更改是合理的,那么我们鼓励你选择这些选项之一。在这种情况下,我们首选的选择是使用Stream,它是List的延迟评估版本。我们将在本章后面探讨Stream。如果无法使用Stream,请在使用SeqView数据类型时保持严格,以清楚地界定集合是延迟评估的。

视图不是记忆化器

使用视图时,还需要考虑的一点是要意识到何时重复应用转换。例如,考虑以下人为的例子,它关注一个视图作为多个计算基础的用例:

> val xs = List(1,2,3,4,5).view.map(x => { println(s"multiply $x"); x * 2 })

xs: scala.collection.SeqView[Int,Seq[_]] = SeqViewM(...)

> val evens = xs.filter(_ % 2 == 0).toList

multiply 1

multiply 2

multiply 3

multiply 4

multiply 5

evens: List[Int] = List(2, 4, 6, 8, 10)

> val odds = xs.filter(_ % 2 != 0).toList

multiply 1

multiply 2

multiply 3

multiply 4

multiply 5

odds: List[Int] = List()

在这个例子中,xs是一个整数列表的视图。一个map转换被延迟应用于将这些整数乘以 2。然后,视图被用来创建两个List实例,一个包含偶数元素,另一个包含奇数元素。我们观察到转换被应用于视图两次,每次我们将视图转换为列表时。这表明转换是延迟应用的,但计算的结果没有被缓存。这是视图的一个需要注意的特性,因为多次应用昂贵的转换可能会导致显著的性能下降。这也是为什么在应用于视图的转换中应避免副作用的原因。如果由于某种原因,引用透明性没有得到保持,由于视图的使用导致的副作用和多次评估的组合可能会导致难以维护的软件。

这个例子很简单,视图的误用很容易被发现。然而,即使是标准库提供的方法,在与视图一起使用时也可能导致不希望的结果。考虑以下片段:

> val (evens, odds) = List(1,2,3,4,5).view.map(x => { println(s"multiply $x"); x * 2 }).partition(_ % 2 == 0)

evens: scala.collection.SeqView[Int,Seq[_]] = SeqViewMF(...)

odds: scala.collection.SeqView[Int,Seq[_]] = SeqViewMF(...)

> println(evens.toList, odds.toList)

multiply 1

multiply 2

multiply 3

multiply 4

multiply 5

multiply 1

multiply 2

multiply 3

multiply 4

multiply 5

(List(2, 4, 6, 8, 10),List())

这个例子实现了与上一个示例相同的结果,但我们依赖于内置的 partition 方法将原始列表分割成两个独立的集合,每个集合都操作原始视图。再次,我们看到 map 转换被两次应用于原始视图。这是由于 TraversableViewLike 中 partition 的底层实现。主要的启示是视图和懒加载可以帮助提高性能,但它们应该谨慎使用。在 REPL 中实验并尝试你的算法是一个好主意,以确认你正确地使用了视图。

在我们的关于报告交易性能趋势的运行示例中,我们看到了一个容易忽视的懒加载示例,当在 Map 上操作时。回想一下,有一个使用以下代码构建的查找表:

executions.groupBy(_.id).mapValues(es =>

Price.average(es.map(_.price)))

mapValues 的返回类型是 Map[A, B],这并不暗示任何评估策略上的差异。让我们在 REPL 中运行一个简单的例子:

> val m = Map("a" -> 1, "b" -> 2)

m: scala.collection.immutable.Map[String,Int] = Map(a -> 1, b -> 2)

> val m_prime = m.mapValues{ v => println(s"Mapping $v"); v * 2}

Mapping 1

Mapping 2

m_prime: scala.collection.immutable.Map[String,Int] = Map(a -> 2, b -> 4)

> m_prime.get("a")

Mapping 1

res0: Option[Int] = Some(2)

> m_prime.get("a")

Mapping 1

res1: Option[Int] = Some(2)

注意每次我们在 m_prime 上调用 get 来检索一个值时,我们都可以观察到转换的应用,即使使用相同的键。mapValues 是对映射中每个值进行懒加载转换,类似于在映射的键上操作的视图。涉及到的类型并不提供任何见解,除非你检查 Map 的实现或仔细阅读与 mapValues 相关的文档,否则你可能会错过这个重要的细节。在处理 mapValues 时,考虑视图的注意事项。

报告生成中的压缩

在调查 TradingPerformanceTrend 的实现时,我们深入研究了视图,并发现了它们如何提高性能。现在我们回到 trend 的实现,以完成 List[TradingPerformanceTrend] 的生成。以下片段显示了 trend,其中 periodPnL 的实现被隐藏,因为我们已经彻底审查了它:

def trend(

now: () => Instant,

findOrders: (Duration, Ticker) => List[Order],

findExecutions: (Duration, Ticker) => List[Execution],

request: GenerateTradingPerformanceTrend): List[TradingPerformanceTrend] = {

def periodPnL(

start: Instant => Instant): Map[Ticker, PeriodPnL] = { ... }

val tickerToLastHour = periodPnL(now =>

now.minus(Period.hours(1).getMillis)).mapValues {

case PeriodPositive => LastHourPositive

case PeriodNegative => LastHourNegative

}

val tickerToLastDay = periodPnL(now =>

now.minus(Period.days(1).getMillis)).mapValues {

case PeriodPositive => LastDayPositive

case PeriodNegative => LastDayNegative

}

val tickerToLastSevenDays = periodPnL(now =>

now.minus(Period.days(7).getMillis)).mapValues {

case PeriodPositive => LastSevenDayPositive

case PeriodNegative => LastSevenDayNegative

}

tickerToLastHour.zip(tickerToLastDay).zip(tickerToLastSevenDays).map({

case (((t, lastHour), (_, lastDay)), (_, lastSevenDays)) =>

TradingPerformanceTrend(t, lastHour, lastDay, lastSevenDays)

}).toList

}

此方法专注于将某个时间段的 PnL 转换到相应时间段的性能趋势。涉及两个 zip 调用的最终表达式使从具有 Ticker 键和相应时间段 PnL 趋势值的三个映射转换到 List[TradingPerformanceTrend] 变得优雅。zip 遍历两个集合,为每个集合的每个索引生成一个元组。以下是一个简单的片段,用于说明 zip 的用法:

println(List(1, 3, 5, 7).zip(List(2, 4, 6)))

这会产生以下结果:

List((1,2), (3,4), (5,6))

结果是相应的索引被“压缩”在一起。例如,在索引一,第一个列表的值是三,第二个列表的值是四,得到元组 (3, 4)。第一个列表有四个元素,而第二个列表只有三个元素;这在结果集合中被默默地省略了。这种行为有很好的文档记录,但一开始可能会让人感到意外。在我们的报告用例中,我们确信每个键(即每个Ticker)都出现在所有三个映射中。在这个用例中,我们确信所有三个映射的长度相等。

然而,在我们的zip使用中存在一个微妙的错误。zip使用集合的迭代器来遍历元素,这意味着zip的使用对排序敏感。这三个映射中的每一个都是通过调用toMap构建的,这间接地委托给scala.collection.immutable.HashMap的Map实现。与Set类似,Scala 为小集合大小提供了几个手写的Map实现(例如,Map2),在构建HashMap之前。到现在,你可能已经意识到缺陷,HashMap不保证排序。

为了修复这个错误并保留zip的使用,我们可以利用我们之前发现的SortedMap,它是基于TreeMap并具有排序键的特质。用SortedMap替换Map,并对Ticker定义适当的Ordering,我们现在有一个无错误的、优雅的解决方案来生成交易性能趋势报告。通过审慎地使用视图,我们发现了一种通过最小侵入性更改实现迭代性能改进的方法。这将给销售团队带来一些值得庆祝的事情!这也给我们提供了更多时间来考虑其他生成报告的方法。

重新思考报告架构

在部署了一个包含您视图更改的性能报告的新版本网站门户之后,您开始思考还能做些什么来提高报告生成性能。您突然想到,对于特定的时间间隔,报告是不可变的。对于特定小时的计算 PnL 趋势一旦计算出来就永远不会改变。尽管报告是不可变的,但它每次客户端请求报告时都会被无谓地重新计算。根据这种思考方式,您想知道当新的执行数据可用时,每小时生成一份新报告有多困难。在创建时,订单和执行事件可以即时转换为客户端性能趋势报告所需的输入。有了预先生成的报告,网站门户的性能问题应该完全消失,因为报告生成的责任不再属于网站门户。

这种新的报告生成策略引导我们探索一个新的设计范式,称为事件源。事件源描述了一种设计系统的架构方法,它依赖于处理随时间推移的事件,而不是依赖于当前状态模型来回答不同的问题。我们工作的报告系统为了识别执行订单的子集而进行了大量工作,因为当前状态而不是事件被存储。想象一下,如果我们不是与数据,如Order和Execution,而是与代表系统随时间发生的事情的事件一起工作。一个相关的报告事件可以是OrderExecuted事件,可以按以下方式建模:

case class OrderExecuted(created: CreatedTimestamp, orderId: OrderId, price: Price)

此事件描述了发生的事情,而不是表示当前状态的快照。为了扩展这个例子,想象一下如果Order还包括一个可选的Price来表示执行价格:

sealed trait Order {

def created: CreatedTimestamp

def id: OrderId

def ticker: Ticker

def price: Price

def clientId: ClientId

def executionPrice: Option[Price]

}

如果将此数据模型映射到关系数据库,executionPrice将是一个可空数据库值,在执行发生时被覆盖。当领域模型仅反映当前状态时,不可变性就会丢失。作为一个函数式程序员,这个声明应该让你感到担忧,因为你理解不可变性提供的推理能力。仅存储数据的当前状态也可能导致过大的对象,难以编程。例如,你将如何表示一个Order被取消?按照当前的方法,最快捷的方法是添加一个名为isCanceled的布尔标志。随着时间的推移,随着你系统需求的变得更加复杂,Order对象将增长,你将跟踪更多关于当前状态的特征。这意味着从数据库中加载一系列Order对象到内存中将会变得更加难以控制,因为内存需求不断增长。如果你有丰富的对象关系映射(ORM)经验,你很可能已经经历过这种困境。

为了避免Order膨胀,你可能尝试分解订单的概念以支持多个用例。例如,如果你只对已执行的订单感兴趣,模型可能会将executionPrice数据类型从Option[Price]改为Price,你可能也不再需要取消的布尔标志,因为根据定义,已执行的订单不可能被取消。

识别出曾经认为是一个单一概念的多重定义或表示,是解决我们所经历的不足的重要步骤。扩展这一方法,我们回到了事件源的话题。我们可以回放一系列事件来构建OrderExecuted。让我们稍微修改订单簿发出的以下事件:

sealed trait OrderBookEvent

case class BuyOrderSubmitted(created: CreatedTimestamp,

id: OrderId, ticker: Ticker, price: Price, clientId: ClientId)

extends OrderBookEvent

case class SellOrderSubmitted(created: CreatedTimestamp,

id: OrderId, ticker: Ticker, price: Price clientId: ClientId)

extends OrderBookEvent

case class OrderCanceled(created: CreatedTimestamp, id: OrderId)

extends OrderBookEvent

case class OrderExecuted(created: CreatedTimestamp,

id: OrderId, price: Price) extends OrderBookEvent

如果所有OrderBookEvents都被持久化(例如,存储到磁盘),那么就可以编写一个程序来读取所有事件,并通过将BuyOrderSubmitted和SellOrderSubmitted事件与OrderExecuted事件相关联来构建一组ExecutedOrders。我们注意到这种方法的一个优点是,随着时间的推移,我们能够提出关于系统发生的新问题,然后通过读取事件轻松回答它们。相比之下,如果一个基于当前状态的模型在最初设计时没有包括执行,那么就 impossible to retroactively answer the question, "Which orders executed last week?"(无法事后回答“上周哪些订单被执行了?”)。

我们的新想法令人兴奋,并且有可能带来巨大的改进。然而,它也带来了一系列挑战。与上一节的主要区别在于,我们新的用例不是从数据存储中加载Order和Execution集合到内存中。相反,我们计划处理由订单簿生成的OrderBookEvent。从概念上讲,这种方法仍然涉及处理数据序列。然而,在先前的方法中,整个数据集在开始任何转换之前就已经存在。即时处理事件需要设计处理尚未生成的数据的软件。显然,无论是急切集合还是视图都不是我们新系统的理想工具。幸运的是,标准的 Scala 库为我们提供了正确的抽象:Stream。让我们更仔细地看看这种新的集合类型,以更好地理解Stream如何帮助我们实现客户端性能报告架构的事件源方法。

Stream 概述

Stream 可以看作是列表和视图的混合体。像视图一样,它是延迟评估的,并且只有在访问或收集其元素时才应用转换。像List一样,Stream的元素只被评估一次。Stream有时被描述为未实现的List,这意味着它本质上是一个尚未完全评估或实现的List。

List可以用 cons(::)运算符构建,Stream也可以用其自己的运算符类似地构建:

> val days = "Monday" :: "Tuesday" :: "Wednesday" :: Nil

days: List[String] = List(Monday, Tuesday, Wednesday)

> val months = "January" #:: "February" #:: "March" #:: Stream.empty

months: scala.collection.immutable.Stream[String] = Stream(January, ?)

创建Stream的语法与创建List的语法相似。一个区别是返回值。List是立即评估的,而Stream不是。只有第一个元素("January")被计算;其余的值仍然是未知的(用?字符表示)。

让我们观察当我们访问流的一部分时会发生什么:

scala> println(months.take(2).toList)

List(January, February)

scala> months

res0: scala.collection.immutable.Stream[String] = Stream(January, February, ?)

我们通过将其转换为List(见下文侧边栏)来强制评估Stream的前两个元素。前两个月的数据被打印出来。然后我们显示months的值,发现第二个元素("February")现在已经被计算。

注意

在前面的示例中,toList 是强制评估 Stream 的调用。take(2) 是一个惰性应用的转换器,它也返回一个未评估的 Stream:

scala> months.take(2)

res0: scala.collection.immutable.Stream[String] = Stream(January, ?)

为了突出 Stream 的评估特性,我们来看另一个创建 Stream 的示例:

def powerOf2: Stream[Int] = {

def next(n: Int): Stream[Int] = {

println(s"Adding $n")

n #:: next(2 * n)

}

1 #:: next(1)

}

这段简短的代码定义了一个函数,它创建一个包含 2 的幂的 Stream。它是一个无限 Stream,以第一个值 1 初始化,尾部定义为另一个 Stream。我们添加了一个 println 语句,以便我们可以研究元素的评估:

scala> val s = powerOf2

s: Stream[Int] = Stream(1, ?)

scala> s.take(8).toList

Adding 1

Adding 2

Adding 4

Adding 8

Adding 16

Adding 32

Adding 64

res0: List[Int] = List(1, 1, 2, 4, 8, 16, 32, 64)

scala> s.take(10).toList

Adding 128

Adding 256

res1: List[Int] = List(1, 1, 2, 4, 8, 16, 32, 64, 128, 256)

注意,前八个元素只有在我们将它们转换为 List 的第一次转换时才会被评估。在第二次调用中,只有第 9 和第 10 个元素被计算;前八个已经实现,并且是 Stream 的一部分。

注意

根据前面的示例,你可能想知道 Stream 是否是一个不可变的数据结构。它的完全限定名是 scala.collection.immutable.Stream,所以这应该给你一个很好的提示。确实,访问 Stream 和实现其一些元素会导致 Stream 的修改。然而,这个数据结构仍然被认为是不可变的。它包含的值一旦分配就不会改变;甚至在评估之前,这些值就存在,并在 Stream 中有定义。

前面的示例展示了 Stream 的一个有趣特性:可以创建一个几乎无限的 Stream。由 powerOf2 创建的 Stream 是无界的,并且由于我们的 next 方法,总是可以创建一个额外的元素。另一种有用的技术是递归流的创建。递归 Stream 在其定义中引用自身。让我们调整我们之前的示例。我们不会返回完整的 2 的幂序列,而是允许调用者设置一个起始值:

def powerOf2(n: Int): Stream[Int] = math.pow(2, n).toInt #:: powerOf2(n+1)

使用 math.pow 来计算 2^n。注意,我们计算第一个值,并将其余的 Stream 定义为 powerOf2(n+1),即下一个 2 的幂:

scala> powerOf2(3).take(10).toList

res0: List[Int] = List(8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096)

Stream 的伴随对象提供了几个工厂方法来实例化一个 Stream。让我们看看其中的一些:

Stream.apply: 这允许我们为有限序列的值创建一个 Stream:

scala> Stream(1,2,3,4)

res0: scala.collection.immutable.Stream[Int] = Stream(1, ?)

scala> Stream(List(1,2,3,4):_*)

res1: scala.collection.immutable.Stream[Int] = Stream(1, ?)

Stream.fillA(a: => A): 这会产生一个包含元素 a,重复 n 次的 Stream:

scala> Stream.fill(4)(10)

res0: scala.collection.immutable.Stream[Int] = Stream(10, ?)

scala> res0.toList

res1: List[Int] = List(10, 10, 10, 10)

Stream.from(start: Int): 这创建了一个以 start 开始的递增整数序列:

scala> Stream.from(4)

res0: scala.collection.immutable.Stream[Int] = Stream(4, ?)

scala> res0.take(3).toList

res1: List[Int] = List(4, 5, 6)

我们邀请您查看伴随对象上可用的其他方法。注意,Stream 也可以直接从 List 构建,如下所示:

scala> List(1,2,3,4,5).toStream

res0: scala.collection.immutable.Stream[Int] = Stream(1, ?)

之前的代码可能具有误导性。将 List 转换为 Stream 并不会节省在内存中评估整个 List 的代价。同样,如果我们要在调用 toStream 之前对 List 应用转换(如 map 或 filter),我们将在整个 List 上执行这些计算。

就像 List 一样,你可以在 Stream 上进行模式匹配,如下所示:

scala> val s = Stream(1,2,3,4)

s: scala.collection.immutable.Stream[Int] = Stream(1, ?)

scala> s match {

| case _ #:: _ #:: i #:: _ => i

| }

res0: Int = 3

此模式匹配从s流中提取第三个元素。在流上执行模式匹配会强制实现评估匹配表达式所需的元素。在前面的例子中,前三个项目被计算。

注意

要在空流上进行模式匹配,你可以使用Stream.Empty对象。它是一个单例实例,用于表示空的Stream。它的工作方式与List中的Nil类似。请注意,Stream对象包含一个返回此单例的empty方法;然而,模式匹配需要一个稳定的标识符,并且不能使用方法调用作为有效的case。

事件转换

回到报告系统,我们如何应用事件源原则并利用Stream来改变报告的生成方式?为了计算客户的TradingPerformanceTrend,我们需要计算三个时间段的 PnL 趋势值:每小时、每天和每七天。我们可以编写一个具有以下签名的函数,这使我们更接近于识别每个趋势的 PnL:

def processPnl(e: OrderBookEvent, s: TradeState): (TradeState, Option[PnlEvent])

processPnl的签名接受一个OrderBookEvent和以TradeState形式的状态,以生成一个新的TradeState和可选的PnlEvent。让我们首先检查PnlEvent,以了解此方法的结果,然后再检查TradeState:

sealed trait PnlEvent

case class PnlIncreased(created: EventInstant, clientId: ClientId,

ticker: Ticker, profit: Pnl) extends PnlEvent

case class PnlDecreased(created: EventInstant, clientId: ClientId,

ticker: Ticker, loss: Pnl)extends PnlEvent

case class Pnl(value: BigDecimal) extends AnyVal {

def isProfit: Boolean = value.signum >= 0

}

object Pnl {

def fromExecution(buy: Price, sell: Price): Pnl =

Pnl(sell.value - buy.value)

val zero: Pnl = Pnl(BigDecimal(0))

}

我们可以看到PnlEvent模型了一个 ADT,它表达了客户的 PnL 何时增加或减少。使用过去时来命名事件(例如,增加了)清楚地表明这是一个事实或已完成某事的记录。我们尚未查看TradeState的定义或processPnl的实现,但我们可以通过研究发出的事件来推断行为。我们显示TradeState的定义,这是将提交的订单与执行相关联所需的,如下所示:

case class PendingOrder(ticker: Ticker, price: Price,

clientId: ClientId)

case class TradeState(

pendingBuys: Map[OrderId, PendingOrder],

pendingSells: Map[OrderId, PendingOrder]) {

def cancelOrder(id: OrderId): TradeState = copy(

pendingBuys = pendingBuys - id, pendingSells = pendingSells - id)

def addPendingBuy(o: PendingOrder, id: OrderId): TradeState =

copy(pendingBuys = pendingBuys + (id -> o))

def addPendingSell(o: PendingOrder, id: OrderId): TradeState =

copy(pendingSells = pendingSells + (id -> o))

}

object TradeState {

val empty: TradeState = TradeState(Map.empty, Map.empty)

}

接下来,我们检查processPnl的实现,以查看PnlEvents是如何创建的,如下所示:

def processPnl(

s: TradeState,

e: OrderBookEvent): (TradeState, Option[PnlEvent]) = e match {

case BuyOrderSubmitted(_, id, t, p, cId) =>

s.addPendingBuy(PendingOrder(t, p, cId), id) -> None

case SellOrderSubmitted(_, id, t, p, cId) =>

s.addPendingSell(PendingOrder(t, p, cId), id) -> None

case OrderCanceled(_, id) => s.cancelOrder(id) -> None

case OrderExecuted(ts, id, price) =>

val (p, o) = (s.pendingBuys.get(id), s.pendingSells.get(id)) match {

case (Some(order), None) =>

Pnl.fromBidExecution(order.price, price) -> order

case (None, Some(order)) =>

Pnl.fromOfferExecution(price, order.price) -> order

case error => sys.error(

s"Unsupported retrieval of ID = $id returned: $error")

}

s.cancelOrder(id) -> Some(

if (p.isProfit) PnlIncreased(ts, o.clientId, o.ticker, p)

else PnlDecreased(ts, o.clientId, o.ticker, p))

}

此实现表明PnlEvent被模式匹配以确定事件类型,并相应地处理。当提交订单时,TradeState被更新以反映存在一个新挂起的订单,该订单将被取消或执行。当订单被取消时,挂起的订单从TradeState中移除。当发生执行时,挂起的订单被移除,并且,在计算交易 PnL 后,还会发出一个PnlEvent。交易 PnL 将执行价格与挂起订单的原始价格进行比较。

PnlEvent 提供了足够的信息来计算 TradingPerformanceTrend 所需的所有三个时间段(小时、天和七天)的盈亏趋势。从 OrderBookEvent 到 PnlEvent 的转换是无副作用的,创建新事件而不是替换当前状态,导致不可变模型。鉴于这些特性,processPnl 很容易进行单元测试,并使意图明确。通过使意图明确,可以与技术利益相关者之外的人沟通系统的工作方式。

使用 PnlEvent 作为遵循类似 (State, InputEvent) => (State, Option[OutputEvent]) 签名的方法的输入,我们现在可以计算小时的盈亏趋势,如下所示:

def processHourlyPnl(e: PnlEvent, s: HourlyState): (HourlyState, Option[HourlyPnlTrendCalculated])

此签名表明,通过在 HourlyState 中维护状态,可以发出 HourlyPnlTrendCalculated 事件。发出的事件定义如下:

case class HourlyPnlTrendCalculated(

start: HourInstant,

clientId: ClientId,

ticker: Ticker,

pnl: LastHourPnL)

对于特定的小时、客户端 ID 和股票代码,HourlyPnlTrendCalculated 记录了上一小时的盈亏(PnL)是正数还是负数。HourInstant 类是一个值类,它有一个伴随对象方法,可以将一个瞬间转换为小时的开始:

case class HourInstant(value: Instant) extends AnyVal {

def isSameHour(h: HourInstant): Boolean =

h.value.toDateTime.getHourOfDay == value.toDateTime.getHourOfDay

}

object HourInstant {

def create(i: EventInstant): HourInstant =

HourInstant(i.value.toDateTime.withMillisOfSecond(0)

.withSecondOfMinute(0).withMinuteOfHour(0).toInstant)

}

让我们看看 HourlyState 的定义,以便更好地理解产生 HourlyPnlTrendCalculated 所需的状态:

case class HourlyState(

keyToHourlyPnl: Map[(ClientId, Ticker), (HourInstant, Pnl)])

object HourlyState {

val empty: HourlyState = HourlyState(Map.empty)

}

对于 ClientId 和 Ticker,当前小时的盈亏存储在 HourlyState 中。累积盈亏允许 processHourlyPnl 在小时结束时确定盈亏趋势。我们现在检查 processHourlyPnl 的实现,以了解 PnlEvent 如何转换为 HourlyPnlTrendCalculated:

def processHourlyPnl(

s: HourlyState,

e: PnlEvent): (HourlyState, Option[HourlyPnlTrendCalculated]) = {

def processChange(

ts: EventInstant,

clientId: ClientId,

ticker: Ticker,

pnl: Pnl): (HourlyState, Option[HourlyPnlTrendCalculated]) = {

val (start, p) = s.keyToHourlyPnl.get((clientId, ticker)).fold(

(HourInstant.create(ts), Pnl.zero))(identity)

start.isSameHour(HourInstant.create(ts)) match {

case true => (s.copy(keyToHourlyPnl = s.keyToHourlyPnl +

((clientId, ticker) ->(start, p + pnl))), None)

case false => (s.copy(keyToHourlyPnl =

s.keyToHourlyPnl + ((clientId, ticker) ->

(HourInstant.create(ts), Pnl.zero + pnl))),

Some(HourlyPnlTrendCalculated(start, clientId, ticker,

p.isProfit match {

case true => LastHourPositive

case false => LastHourNegative

})))

}

}

e match {

case PnlIncreased(ts, clientId, ticker, pnl) => processChange(

ts, clientId, ticker, pnl)

case PnlDecreased(ts, clientId, ticker, pnl) => processChange(

ts, clientId, ticker, pnl)

}

}

处理盈亏的增加和减少遵循相同的流程。内部方法 processChange 处理相同的处理步骤。processChange 通过比较首次添加到状态时的 HourInstant 值和事件提供的时间戳的小时来决定是否发出 HourlyPnlTrendCalculated。当比较显示小时已更改时,则表示已计算了小时的盈亏趋势,因为小时已完成。当小时未更改时,提供的盈亏将添加到状态中的盈亏以继续累积小时的盈亏。

注意

这种方法的明显缺点是,当客户端或股票代码没有任何已执行订单时,将无法确定小时是否完成。为了简化,我们没有将时间视为一等事件。然而,你可以想象如何将时间的流逝建模为一个事件,它是 processHourlyPnl 的第二个输入。例如,该事件可能是以下内容:

case class HourElapsed(hour: HourInstant)

要使用此事件,我们可以将processHourlyPnl的签名更改为接收一个Either[HourElapsed, PnlEvent]类型的参数。在计时器上安排HourElapsed使我们能够修改processHourlyPnl的实现,以便在小时结束时立即发出HourlyPnlTrendCalculated,而不是在下一个小时内发生交易时发出。这个简单的例子展示了当你从事件源的角度考虑系统时,如何将时间作为域的显式部分进行建模。

想象编写类似的方法来发出每日和七日 PnL 趋势事件,然后编写一个等待所有三个 PnL 趋势事件以产生TradingPerformanceTrendGenerated事件的函数。最后一步是编写一个产生副作用的方法,将TradingPerformanceTrend持久化,以便它可以被网络门户读取。到此为止,我们有一系列执行事件转换的方法,但它们还没有紧密地连接在一起。接下来,我们将探讨如何创建一个转换事件的管道。

注意

注意,在本案例研究中,我们实际上并没有计算 PnL。进行真实的 PnL 计算将涉及更复杂的算法,并迫使我们引入更多的领域概念。我们选择了更简单的方法,使用一个更接近敞口报告的报告。这使我们能够专注于我们想要展示的代码和编程实践。

构建事件源管道

我们使用“pipeline”一词来指代一组经过安排的转换,这些转换可能需要多个步骤才能产生期望的最终结果。这个术语让人联想到一组管道,它们跨越多个方向,途中还有转弯。我们的目标是编写一个程序,该程序接收PnlEvents特性并将HourlyPnlTrendCalculated事件打印到标准输出。在一个真正的生产环境中,你可以想象用写入持久数据存储来替换打印到标准输出。在两种情况下,我们都在构建一个执行一系列引用透明转换并最终产生副作用的管道。

管道必须累积每个转换的中间状态,因为处理新事件。在函数式编程范式中,累积通常与foldLeft操作相关联。让我们看看一个玩具示例,该示例将整数列表相加,以更好地理解累积:

val sum = Stream(1, 2, 3, 4, 5).foldLeft(0) { case (acc, i) => acc + i }

println(sum) // prints 15

在这里,我们看到foldLeft被应用于通过提供一个初始总和为零并 currying 一个函数来添加当前元素到累积总和来计算整数列表的总和。acc值是“累积器”的常用缩写。在这个例子中,累积器和列表元素具有相同的数据类型,整数。这只是一个巧合,并不是foldLeft操作的要求。这意味着累积器可以与集合元素具有不同的类型。

我们可以使用 foldLeft 作为事件源管道的基础,以支持在累积中间状态的同时处理 OrderBookEvents 列表。从两个处理方法的实现中,我们看到了维护 TradeState 和 HourlyState 的需求。我们定义 PipelineState 来封装所需的状态,如下所示:

case class PipelineState(tradeState: TradeState, hourlyState: HourlyState)

object PipelineState {

val empty: PipelineState = PipelineState(TradeState.empty, HourlyState.empty)

}

PipelineState 在折叠 OrderBookEvent 时作为累加器使用,使我们能够存储两种转换方法的中间状态。现在,我们准备定义管道的签名:

def pipeline(initial: PipelineState, f: HourlyPnlTrendCalculated => Unit, xs: List[OrderBookEvent]): PipelineState

pipeline 接受初始状态,一个在生成 HourlyPnlTrendCalculated 事件时被调用的副作用函数,以及一组 OrderBookEvents 作为数据源。管道的返回值是事件处理后的管道状态。让我们看看我们如何利用 foldLeft 来实现 pipeline:

def pipeline(

initial: PipelineState,

f: HourlyPnlTrendCalculated => Unit,

xs: Stream[OrderBookEvent]): PipelineState = xs.foldLeft(initial) {

case (PipelineState(ts, hs), e) =>

val (tss, pnlEvent) = processPnl(ts, e)

PipelineState(tss,

pnlEvent.map(processHourlyPnl(hs, _)).fold(hs) {

case (hss, Some(hourlyEvent)) =>

f(hourlyEvent)

hss

case (hss, None) => hss

})

}

pipeline 的实现基于使用提供的 PipelineState 作为累加起始点的提供事件折叠。提供给 foldLeft 的柯里化函数是转换连接的地方。将两个转换方法和副作用事件处理器拼接在一起需要处理几个不同的场景。让我们逐一分析每种可能的案例,以更好地理解管道的工作原理。processPnl 被调用以生成新的 TradeState 并可选地产生一个 PnlEvent。如果没有生成 PnlEvent,则不调用 processHourlyPnl 并返回之前的 HourlyState。

如果生成了一个 PnlEvent,则 processHourlyPnl 被评估以确定是否创建了 HourlyPnlTrendCalculated。当 HourlyPnlTrendCalculated 被生成时,则调用副作用 HourlyPnlTrendCalculated 事件处理器并返回新的 HourlyState。如果没有生成 HourlyPnlTrendCalculated,则返回现有的 HourlyState。

我们构建一个简单的示例来证明管道按预期工作,如下所示:

val now = EventInstant(HourInstant.create(EventInstant(

new Instant())).value)

val Foo = Ticker("FOO")

pipeline(PipelineState.empty, println, Stream(

BuyOrderSubmitted(now, OrderId(1), Foo, Price(21.07), ClientId(1)),

OrderExecuted(EventInstant(now.value.plus(Duration.standardMinutes(30))),

OrderId(1), Price(21.00)),

BuyOrderSubmitted(EventInstant(now.value.plus(

Duration.standardMinutes(35))),

OrderId(2), Foo, Price(24.02), ClientId(1)),

OrderExecuted(EventInstant(now.value.plus(Duration.standardHours(1))),

OrderId(2), Price(24.02))))

在小时的开始,提交了一个针对股票 FOO 的买入订单。在小时内,以低于买入价的价格执行了买入订单,这表明交易是盈利的。正如我们所知,当前实现依赖于下一小时的执行来生成 HourlyPnlTrendCalculated。为了创建此事件,在第二小时的开始提交了第二个买入订单。运行此代码片段会产生一个写入标准输出的单个 HourlyPnlTrendCalculated 事件:

HourlyPnlTrendCalculated(HourInstant(2016-02-15T20:00:00.000Z),ClientId(1),Ticker(FOO),LastHourPositive)

虽然将转换连接起来有些复杂,但我们设法仅使用 Scala 标准库和我们对 Scala 集合的现有知识构建了一个简单的事件源管道。这个例子展示了foldLeft在构建事件源管道方面的强大功能。使用此实现,我们可以编写一个功能齐全的程序,能够将预先生成的性能报告写入可以被网络门户读取的持久数据存储。这种新的设计允许我们将报告生成的负担从网络门户的责任中移除,从而使网络门户能够提供响应式的用户体验。这种新方法的好处之一是它将面向领域的语言置于设计的中心。我们所有的事件都使用业务术语,并专注于建模领域概念,这使得开发者和利益相关者之间的沟通更加容易。

注意

你可能想知道一个具有Stream的一些特征但我们尚未提到的数据结构:Iterator。正如其名所示,Iterator提供了遍历数据序列的设施。其简化的定义可以归结为以下内容:

trait Iterator[A] {

def next: A

def hasNext: Boolean

}

与Stream类似,Iterator能够避免将整个数据集加载到内存中,这使得程序可以以恒定的内存使用量编写。与Stream不同,Iterator是可变的,并且仅用于对集合进行单次迭代(它扩展了TraversableOnce特质)。需要注意的是,根据标准库文档,在调用其方法后不应再使用迭代器。例如,对Iterator调用size会返回序列的大小,但它也会消耗整个序列,使Iterator实例变得无用。此规则的唯一例外是next和hasNext。这些属性导致软件难以推理,这与我们作为函数式程序员所追求的目标相反。因此,我们省略了对Iterator的深入讨论。

我们鼓励您通过阅读Event Store 数据库的文档来进一步探索事件溯源。Event Store 是一个围绕事件溯源概念开发的数据库。Event Store 是由事件溯源领域的知名作家 Greg Young 创建的。在丰富您对事件溯源的理解的同时,思考一下您认为何时应用事件溯源技术是合适的。对于具有简单行为的 CRUD 应用程序,事件溯源可能不是值得投入时间的。当您建模更复杂的行为或考虑涉及严格性能和扩展要求的场景时,事件溯源的时间投入可能变得合理。例如,就像我们在性能趋势报告中看到的那样,从事件溯源范式暴露的性能挑战揭示了一种完全不同的设计方法。

当您继续探索流处理的世界时,您会发现您希望构建比我们的事件溯源管道示例更复杂的转换。为了继续深入研究流处理的话题,我们建议研究两个相关的库:akka streams和functional streams(以前称为scalaz-stream)。这些库提供了使用不同于Stream的不同抽象构建更复杂转换管道的工具。结合学习 Event Store,您将更深入地了解事件溯源如何与流处理相结合。

流式马尔可夫链

在上一节的末尾的简单程序中,我们展示了我们可以将操作事件的转换管道连接起来。作为一个有良好意图的工程师,您希望开发自动测试来证明管道按预期工作。一种方法是将历史生产数据样本添加到存储库中构建测试。这通常是一个不错的选择,但您担心样本不足以代表广泛的场景。另一种选择是编写一个可以创建类似生产数据的生成器。这种方法需要更多的前期努力,但它提供了一种更动态的方式来测试管道。

与 Dave 关于马尔可夫链的最近午餐时间对话激发了对使用生成数据进行事件溯源管道测试的想法。Dave 描述了马尔可夫链是一个仅依赖于当前状态来确定下一个状态的统计状态转换模型。Dave 将股票市场的状态表示为马尔可夫链,使他能够根据他是否认为股票市场处于上升趋势、下降趋势或稳定状态来构建交易策略。在阅读了马尔可夫链的维基百科页面后,您设想编写一个基于马尔可夫链的事件生成器。

我们的目标是能够生成无限数量的遵循生产模式样式的OrderBookEvent。例如,根据以往的经验,取消订单通常比执行订单多,尤其是在波动较大的市场中。事件生成器应该能够表示事件发生的不同概率。由于马尔可夫链只依赖于其当前状态来识别其下一个状态,因此Stream是一个自然的选择,因为我们只需要检查当前元素来确定下一个元素。对于我们对马尔可夫链的表示,我们需要确定从当前状态转换到任何其他可能状态的几率。以下表格展示了一组可能的概率集:

当前状态

购买几率

销售几率

执行几率

取消几率

BuyOrderSubmitted

10%

15%

40%

40%

SellOrderSubmitted

25%

10%

35%

25%

OrderCanceled

60%

50%

40%

10%

OrderExecuted

30%

30%

55%

30%

这个表格定义了在当前OrderBookEvent的情况下接收OrderBookEvent的可能性。例如,给定一个销售订单已提交,下一个出现第二个销售订单的概率为 10%,下一个发生执行的概率为 35%。我们可以根据我们希望在管道中模拟的市场条件开发状态转换概率。

我们可以使用以下领域来模拟转换:

sealed trait Step

case object GenerateBuy extends Step

case object GenerateSell extends Step

case object GenerateCancel extends Step

case object GenerateExecution extends Step

case class Weight(value: Int) extends AnyVal

case class GeneratedWeight(value: Int) extends AnyVal

case class StepTransitionWeights(

buy: Weight,

sell: Weight,

cancel: Weight,

execution: Weight)

在这个领域内,Step是一个 ADT,它模拟了可能的状态。对于给定的Step,我们将关联StepTransitionWeights来定义基于提供的权重转换到不同状态的概率。GeneratedWeight是一个值类,它定义了当前Step生成的权重。我们将使用GeneratedWeight来驱动从Step到下一个Step的转换。

我们下一步要做的是利用我们的领域,根据我们定义的概率生成事件。为了使用Step,我们定义了所需的马尔可夫链状态的表示,如下所示:

case class State(

pendingOrders: Set[OrderId],

step: Step)

马尔可夫链需要了解当前状态,这由step表示。此外,我们对马尔可夫链进行了一些调整,通过维护在pendingOrders中提交的既未取消也未执行的订单集合。这个额外的状态有两个原因。首先,生成取消和执行事件需要链接到一个已知的订单 ID。其次,我们通过要求在创建取消或执行之前至少存在一个挂起的订单来限制我们对马尔可夫链的表示。如果没有挂起的订单,转换到生成OrderCanceled或OrderExecuted的状态是不合法的。

使用State,我们可以编写一个具有以下签名的方法来管理转换:

def nextState(

weight: StepTransitionWeights => GeneratedWeight,

stepToWeights: Map[Step, StepTransitionWeights],

s: State): (State, OrderBookEvent)

给定从当前StepTransitionWeights生成权重的方法、Step到StepTransitionWeights的映射以及当前State,我们能够生成一个新的State和一个OrderBookEvent。为了简洁,我们省略了nextState的实现,因为我们想最专注地关注流处理。从签名中,我们有足够的洞察力来应用该方法,但我们鼓励你检查存储库以填补你理解中的任何空白。

nextState方法是我们在马尔可夫链表示中状态转换的驱动程序。现在我们可以使用便利的Stream方法iterate根据转换概率生成无限Stream的OrderBookEvent。根据 Scala 文档,iterate通过重复应用函数到起始值来产生无限流。让我们看看我们如何使用iterate:

val stepToWeights = MapStep, StepTransitionWeights, Weight(25), Weight(40), Weight(40)),

GenerateSell -> StepTransitionWeights(

Weight(25), Weight(10), Weight(40), Weight(25)),

GenerateCancel -> StepTransitionWeights(

Weight(60), Weight(50), Weight(40), Weight(10)),

GenerateExecution -> StepTransitionWeights(

Weight(30), Weight(30), Weight(60), Weight(25)))

val next = State.nextState(

t => GeneratedWeight(Random.nextInt(t.weightSum.value) + 1),

stepToWeights, _: State)

println("State\tEvent")

Stream.iterate(State.initialBuy) { case (s, e) => next(s) }

.take(5)

.foreach { case (s, e) => println(s"$s\t$e") }

这个片段创建了一个马尔可夫链,通过提供Step到StepTransitionWeights的映射作为调用State.nextState的基础来生成各种OrderBookEvent。State.nextState是部分应用的,当前状态未被应用。next函数具有State => (State, OrderBookEvent)签名。在必要的框架到位后,使用Stream.iterate通过调用next生成无限序列的多个OrderBookEvent。类似于foldLeft,我们提供一个初始值以开始initialBuy迭代,定义如下:

val initialBuy: (State, OrderBookEvent) = {

val e = randomBuySubmitted()

State(Set(e.id), GenerateBuy) -> e

}

运行此片段会产生类似于以下内容的输出:

State = State(Set(OrderId(1612147067584751204)),GenerateBuy)

Event = BuyOrderSubmitted(EventInstant(2016-02-22T23:52:40.662Z),OrderId(1612147067584751204),Ticker(FOO),Price(32),ClientId(28))

State = State(Set(OrderId(1612147067584751204), OrderId(7606120383704417020)),GenerateBuy)

Event = BuyOrderSubmitted(EventInstant(2016-02-22T23:52:40.722Z),OrderId(7606120383704417020),Ticker(XYZ),Price(18),ClientId(54))

State = State(Set(OrderId(1612147067584751204), OrderId(7606120383704417020), OrderId(5522110701609898973)),GenerateBuy)

Event = BuyOrderSubmitted(EventInstant(2016-02-22T23:52:40.723Z),OrderId(5522110701609898973),Ticker(XYZ),Price(62),ClientId(28))

State = State(Set(OrderId(7606120383704417020), OrderId(5522110701609898973)),GenerateExecution)

Event = OrderExecuted(EventInstant(2016-02-22T23:52:40.725Z),OrderId(1612147067584751204),Price(21))

State = State(Set(OrderId(7606120383704417020), OrderId(5522110701609898973), OrderId(5898687547952369568)),GenerateSell)

Event = SellOrderSubmitted(EventInstant(2016-02-22T23:52:40.725Z),OrderId(5898687547952369568),Ticker(BAR),Price(76),ClientId(45))

当然,每次调用都取决于为GeneratedWeight创建的随机值,它用于概率性地选择下一个转换。这个片段提供了一个基础来编写更大规模的测试,用于报告基础设施。通过这个例子,我们看到马尔可夫链的一个有趣应用,它支持从各种市场条件生成代表性事件,而无需访问大量生产数据。我们现在能够编写测试来确认报告基础设施是否正确计算了不同市场条件下的 PnL 趋势。

流注意事项

尽管它们很好,但使用Stream时应谨慎。在本节中,我们提到了Stream的一些主要注意事项以及如何避免它们。

流是记忆化器

虽然views不会缓存计算结果,因此每次访问时都会重新计算并实现每个元素,但Stream会保存其元素的最后形式。一个元素只会在第一次访问时实现。虽然这是一个避免多次计算相同结果的优秀特性,但它也可能导致大量内存消耗,以至于你的程序最终可能耗尽内存。

为了避免Stream记忆化,一个好的做法是避免将Stream存储在val中。使用val会创建对Stream头部的永久引用,确保每个被实例化的元素都会被缓存。如果将Stream定义为def,它可以在不再需要时立即被垃圾回收。

当调用定义在Stream上的某些方法时,可能会发生记忆化。例如,drop或dropWhile将评估并记忆化所有要删除的中间元素。这些元素在Stream实例(Stream有自己的头部引用)上定义方法时被记忆化。我们可以实现自己的drop函数来避免在内存中缓存中间元素:

@tailrec

def dropA: Stream[A] = count match {

case 0 => s

case n if n > 0 => drop(s.tail, count - 1)

case n if n < 0 => throw new Exception("cannot drop negative count")

}

我们通过匹配count的值来判断是否可以返回给定的Stream,或者需要在尾部进行递归调用。我们的方法是尾递归的。这确保了我们不会保留Stream头部的引用,因为尾递归函数每次循环时都会回收其引用。我们的s引用将仅指向Stream的剩余部分,而不是头部。

另一个有问题的方法示例是max。调用max将记忆化Stream的所有元素以确定哪个是最大的。让我们实现一个安全的max版本,如下所示:

def max(s: => Stream[Int]): Option[Int] = {

@tailrec

def loop(ss: Stream[Int], current: Option[Int]): Option[Int] = ss match {

case Stream.Empty => current

case h #:: rest if current.exists(_ >= h) => loop(rest, current)

case h #:: rest => loop(rest, Some(h))

}

loop(s, None)

}

这次,我们使用了一个内部尾递归函数来能够提供一个友好的 API。我们使用Option[Int]来表示当前的最大值,以处理方法在空Stream上被调用的情况。请注意,max接受s作为按名参数。这很重要,因为否则我们会在调用内部尾递归loop方法之前保留对Stream头部的引用。另一个可能的实现如下:

def max(s: => Stream[Int]): Option[Int] = {

@tailrec

def loop(ss: Stream[Int], current: Int): Int = ss match {

case Stream.Empty => current

case h #:: hs if h > current => loop(hs, h)

case h #:: hs if h <= current => loop(hs, current)

}

s match {

case Stream.Empty => None

case h #:: rest => Some(loop(rest, h))

}

}

这种实现可以说是更简单。我们在max函数中检查Stream是否为空;这允许我们立即返回(使用None),或者调用loop并传递一个有效的默认值(Stream中的第一个元素)。loop不再需要处理Option[Int]。然而,这个例子并没有达到避免记忆化的目标。模式匹配将导致rest保留对原始Stream整个尾部的引用,这将阻止中间元素的垃圾回收。一个好的做法是在消耗性、尾递归方法内部仅对Stream进行模式匹配。

Stream可以是无限的

在我们的概述中,我们看到了定义一个无限Stream的可能性。然而,当你与无限Stream一起工作时,你需要小心。某些方法可能会导致整个Stream的评估,从而导致OutOfMemoryError。其中一些很明显,例如toList,它将尝试将整个Stream存储到一个List中,从而导致所有元素的实现。其他的一些则更为微妙。例如,Stream有一个size方法,它与List上定义的方法类似。在无限Stream上调用size会导致程序耗尽内存。同样,max和sum将尝试实现整个序列并使你的系统崩溃。这种行为尤其危险,因为Stream扩展了Seq,这是序列的基本特质。考虑以下代码:

def range(s: Seq[Int]): Int = s.max - s.min

这个简短的方法接受一个Seq[Int]作为单个参数,并返回其范围,即最大和最小元素之间的差值。由于Stream扩展了Seq,以下调用是有效的:

val s: Stream[Int] = ???

range(s)

编译器会愉快且迅速地为这个片段生成字节码。然而,s可以被定义为一个无限的Stream:

val s: Stream[Int] = powerOf2(0)

range(s)

java.lang.OutOfMemoryError: GC overhead limit exceeded

at .powerOf2(:10)

at $anonfun$powerOf2$1.apply(:10)

at $anonfun$powerOf2$1.apply(:10)

at scala.collection.immutable.Stream$Cons.tail(Stream.scala:1233)

at scala.collection.immutable.Stream$Cons.tail(Stream.scala:1223)

at scala.collection.immutable.Stream.reduceLeft(Stream.scala:627)

at scala.collection.TraversableOnce$class.max(TraversableOnce.scala:229)

at scala.collection.AbstractTraversable.max(Traversable.scala:104)

at .range(:10)

... 23 elided

由于max和min的实现,range的调用永远不会返回。这个例子说明了我们在本章前面提到的一个良好实践。

摘要

在本章中,我们探讨了 Scala 标准库提供的两个懒加载集合:视图和流。我们探讨了它们的特性和实现细节,以及在使用这些抽象时需要注意的限制。利用你新获得的知识,你解决了一个影响 MVT 客户端试图查看其性能趋势的关键性能问题。

在Stream部分,我们有机会将流处理的概念与事件源结合。我们简要探讨了事件源范式,并介绍了一个简单的基于事件的转换管道,以改进报告系统的架构并定义一个更强的领域模型。最后,我们构建了一个马尔可夫链事件生成器来练习我们生成报告的新方法。

通过探索急切和懒加载集合,你现在对 Scala 标准库提供的集合有了坚实的实际知识。在下一章中,我们将继续通过函数式范式探索 Scala 概念,深入探讨并发性。

第六章。Scala 中的并发

在本章中,我们将从集合转向一个不同的主题:并发。能够利用硬件提供的所有 CPU 资源对于编写高性能软件至关重要。不幸的是,编写并发代码并不容易,因为很容易编写出不安全的程序。如果你来自 Java,你可能仍然会梦魇般地涉及synchronized块和锁!java.util.concurrent包提供了许多工具,使编写并发代码变得简单。然而,设计稳定可靠并发应用仍然是一个艰巨的挑战。在本章中,我们将探讨 Scala 标准库提供的工具,以利用并发。在简要介绍主要抽象Future之后,我们将研究其行为和应避免的使用陷阱。我们将通过探索由 Scalaz 库提供的名为Task的Future的替代方案来结束本章。在本章中,我们将探讨以下主题:

并发与并行

未来使用考虑

阻塞调用和回调

Scalaz Task

并行化回测策略

数据科学家们正在使用你为他们构建的数据分析工具进行研究交易策略。然而,他们遇到了瓶颈,因为回测策略变得越来越昂贵。随着他们构建了更复杂的策略,这些策略需要更多的历史数据,并采用更多有状态的算法,回测所需时间更长。又一次,你被召唤到 MVT,利用 Scala 和函数式范式来提供高性能软件。

数据科学家们逐步构建了一个回测工具,该工具允许团队通过回放历史数据来确定策略的表现。这是通过提供一个预设的策略来运行,一个用于测试的股票代码,以及回放历史数据的时间间隔来实现的。回测器加载市场数据并将策略应用于生成交易决策。一旦回测器完成历史数据的回放,它将总结并显示策略性能结果。回测器在将策略投入实际交易生产之前,被大量依赖以确定所提交易策略的有效性。

要开始熟悉回测器,你可以查看以下代码:

sealed trait Strategy

case class PnL(value: BigDecimal) extends AnyVal

case class BacktestPerformanceSummary(pnl: PnL)

case class Ticker(value: String) extends AnyVal

def backtest(

strategy: Strategy,

ticker: Ticker,

testInterval: Interval): BacktestPerformanceSummary = ???

在先前的数据分析仓库快照中,你可以看到驱动回测的主要方法。给定一个Strategy、Ticker和Interval,它可以生成BacktestPerformanceSummary。扫描仓库,你发现一个名为CrazyIdeas.scala的文件,显示 Dave 是唯一的提交作者。在这里,你可以看到回测器的示例调用:

def lastMonths(months: Int): Interval =

new Interval(new DateTime().minusMonths(months), new DateTime())

backtest(Dave1, Ticker("AAPL"), lastMonths(3))

backtest(Dave1, Ticker("GOOG"), lastMonths(3))

backtest(Dave2, Ticker("AAPL"), lastMonths(3))

backtest(Dave2, Ticker("GOOG"), lastMonths(3))

使用回测工具可以给你一个可能的性能改进的线索。看起来当 Dave 有一个新想法时,他想要评估它在多个符号上的性能,并将其与其他策略进行比较。在其当前形式中,回测是顺序执行的。提高回测工具执行速度的一种方法是将所有回测运行的执行并行化。如果每个回测工具的调用都是并行化的,并且有额外的硬件资源,那么回测多个策略和符号将更快完成。为了理解如何并行化回测工具,我们首先需要深入研究异步编程的主题,然后看看 Scala 如何支持并发。

注意

在深入代码之前,我们需要丰富我们的词汇来讨论异步编程的特性。并发性和并行性经常被互换使用,但这两个术语之间存在一个重要的区别。并发涉及两个(或更多)任务,它们在重叠的时间段内启动和执行。两个任务都在进行中(即,它们正在运行),但在任何给定的时间点,可能只有一个任务正在执行实际工作。这是你在单核机器上编写并发代码的情况。一次只有一个任务可以进步,但多个任务可以并发进行。

并行性只有在两个任务真正同时运行时才存在。在双核机器上,你可以同时执行两个任务。从这个定义中,我们可以看出并行性取决于可用的硬件。这意味着并发性可以添加到程序中,但并行性超出了软件的控制范围。

为了更好地说明这些概念,可以考虑粉刷房间的例子。如果只有一个粉刷工,粉刷工可以在墙上刷第一层涂料,然后移动到下一面墙,再回到第一面墙刷第二层,然后完成第二面墙。粉刷工同时粉刷两面墙,但任何给定时间只能在一面墙上花费时间。如果有两个粉刷工在作业,他们可以各自专注于一面墙,并平行地粉刷它们。

探索 Future

Scala 中用于驱动并发编程的主要构造是 Future。位于 scala.concurrent 包中,Future 可以被视为一个可能尚未存在的值的容器。让我们通过一个简单的例子来说明其用法:

scala> import scala.concurrent.Future

import scala.concurrent.Future

scala> import scala.concurrent.ExecutionContext

import scala.concurrent.ExecutionContext

scala> val context: ExecutionContext = scala.concurrent.ExecutionContext.global

context: scala.concurrent.ExecutionContext = scala.concurrent.impl.ExecutionContextImpl@3fce8fd9

scala> def example(){

println("Starting the example")

Future{

println("Starting the Future")

Thread.sleep(1000) // simulate computation

println("Done with the computation")

}(context)

println("Ending example")

}

上述例子展示了一个简短的方法,创建一个 Future 值模拟了昂贵的计算,并打印了几行以便我们更容易理解应用程序的流程。当运行 example 时,我们看到以下输出:

scala> example()

Starting the example

Ending example

Starting the future

// a pause

Done with the computation

我们可以看到,Future 在 example 方法结束之后执行。这是因为当创建 Future 时,它开始并发地执行其计算。你可能想知道,“在创建 Future 时使用的 context 对象是什么类型的 ExecutionContext?” 我们将在稍后深入探讨 ExecutionContext,但现在,我们将其视为负责 Future 执行的对象。我们导入 scala.concurrent.ExecutionContext.global,这是一个由标准库创建的默认对象,以便能够执行 Future。

Future 对象是一个有状态的对象。当计算正在进行时,它可能尚未完成;一旦计算完成,它就完成了。此外,一个完成的 Future 可以是计算成功时的成功状态,或者如果计算过程中抛出了异常,则可以是失败状态。

Future API 提供了组合 Future 实例和操作它们包含的结果的组合器:

scala> import scala.concurrent.ExecutionContext.Implicits.global

import scala.concurrent.ExecutionContext.Implicits.global

scala> import scala.concurrent.Future

import scala.concurrent.Future

scala> Future(1).map(_ + 1).filter(_ % 2 == 0).foreach(println)

2

这个 Scala 控制台代码片段显示了构造一个包装常量整数值的 Future 数据类型。我们看到,包含在 Future 数据类型中的整数是通过类似于我们预期在 Option 和集合数据类型上找到的函数转换的。这些转换在先前的 Future 完成后应用,并返回一个新的 Future。

正如承诺的那样,我们现在来探讨 ExecutionContext。ExecutionContext 可以被视为 Future 背后的机制,它提供了运行时异步性。在前面的代码片段中,创建了一个 Future 来执行简单的加法和模除运算,而没有在调用位置显式提供 ExecutionContext 实例。相反,只提供了对 global 对象的导入。代码片段能够执行,因为 global 是一个隐式值,而 map 的签名接受一个隐式的 ExecutionContext。让我们看看 map 的以下签名,以加深我们的理解:

def mapS(implicit executor: ExecutionContext): Future[S]

从 map 的签名中,我们可以看到,与 List 上的 map 转换不同,Future 需要一个柯里化的、隐式的 ExecutionContext 参数。为了理解 ExecutionContext 如何提供运行时异步性,我们首先需要了解它的操作:

trait ExecutionContext {

def execute(runnable: Runnable): Unit

def reportFailure(cause: Throwable): Unit

def prepare(): ExecutionContext = this

}

execute 是一个作用于 java.lang.Runnable 的副作用方法。对于那些熟悉 Java 并发的开发者,你很可能记得 Runnable 是一个常用的接口,允许线程和其他 java.util.concurrent 抽象并发执行代码。尽管我们还不清楚 Future 如何实现运行时异步性,但我们确实知道 Future 的执行和 Runnable 的创建之间存在联系。

我们接下来要回答的问题是,“我该如何创建一个 ExecutionContext?” 通过研究伴随对象,我们发现以下签名:

def fromExecutorService(e: ExecutorService, reporter: Throwable => Unit): ExecutionContextExecutorService

def fromExecutorService(e: ExecutorService): ExecutionContextExecutorService

def fromExecutor(e: Executor, reporter: Throwable => Unit): ExecutionContextExecutor

def fromExecutor(e: Executor): ExecutionContextExecutor

标准库提供了方便的方法,可以从 java.util.concurrent.Executor 或 java.util.concurrent.ExecutorService 创建 ExecutionContext。

注意

如果你不太熟悉 java.util.concurrent 包提供的机制,并且你希望获得比 API 文档提供的更深入的处理,我们鼓励你阅读 Brian Goetz 的《Java 并发实践》(jcip.net/)。尽管《Java 并发实践》是围绕 JDK 6 的发布编写的,但它包含了今天仍然适用的许多原则。阅读这本书将为你提供对 Scala 标准库使用的 JDK 提供的并发原语进行深入了解。

工厂方法的返回类型是 ExecutionContext 的一个更专业的版本。标准库为 ExecutionContext 定义了以下继承链:

trait ExecutionContextExecutor extends ExecutionContext with java.util.concurrent.Executor

trait ExecutionContextExecutorService extends ExecutionContextExecutor with java.util.concurrent.ExecutorService

此外,在 ExecutionContext 伴随对象中,我们可以找到我们第一个示例中使用的隐式上下文,如下所示:

def global: ExecutionContextExecutor = Implicits.global

Implicits.global 的定义文档指出,这个 ExecutionContext 由一个线程池支持,其线程数等于可用的处理器数量。我们对 ExecutionContext 的深入研究展示了简单的 Future 示例是如何运行的。我们可以展示 Future 如何将其 ExecutionContext 应用到多个线程上执行:

Future(1).map(i => {

println(Thread.currentThread().getName)

i + 1

}).filter(i => {

println(Thread.currentThread().getName)

i % 2 == 0

}).foreach(println)

我们扩展了原始片段以打印执行每个转换的线程名称。在多核机器上运行时,此片段的输出是可变的,取决于哪些线程获取了转换。以下是一个示例输出:

ForkJoinPool-1-worker-3

ForkJoinPool-1-worker-5

2

此示例表明一个 worker-3 线程执行了 map 转换,而另一个 worker-5 线程执行了 filter 转换。从我们简单的示例中,我们可以得出两个关于 Future 如何影响控制流的关键见解。首先,Future 是一种并发数据类型,使我们能够将程序的流程控制分解成多个逻辑处理线程。其次,我们的示例表明 Future 在创建时立即开始执行。这意味着转换立即在程序的另一个流程中应用。我们可以利用这些见解来提高 Dave 的疯狂想法的运行时性能。

未来与疯狂想法

我们将 Future 应用到 Dave 的回测集中以提高性能。我们相信存在改进性能的机会,因为 Dave 的笔记本电脑有四个 CPU 核心。这意味着通过向我们的程序添加并发性,我们将能够从运行时并行性中受益。我们的第一次尝试使用 for-表达式:

implicit val ec = scala.concurrent.ExecutionContext.Implicits.global

for {

firstDaveAapl <- Future(backtest(Dave1, Ticker("AAPL"), lastMonths(3)))

firstDaveGoog <- Future(backtest(Dave1, Ticker("GOOG"), lastMonths(3)))

secondDaveAapl <- Future(backtest(Dave2, Ticker("AAPL"), lastMonths(3)))

secondDaveGoog <- Future(backtest(Dave2, Ticker("GOOG"), lastMonths(3)))

} yield (firstDaveAapl, firstDaveGoog, secondDaveAapl, secondDaveGoog)

每次回测调用都通过调用 Future.apply 创建一个 Future 实例进行封装。这个伴随对象方法使用按名参数来延迟对参数的评估,在这种情况下,是调用 backtest:

def applyT(executor: ExecutionContext): Future[T]

运行CrazyIdeas.scala的新版本后,你失望地看到运行时执行并没有得到改善。你迅速地检查了 Linux 机器上的 CPU 数量,如下所示:

$ cat /proc/cpuinfo | grep processor | wc -l

8

确认笔记本电脑上有八个核心后,你疑惑为什么执行时间与原始的串行执行时间相同。这里的解决方案是考虑 for-comprehension 是如何编译的。for-comprehension 等同于以下更简单的示例:

Future(1).flatMap(f1 => Future(2).flatMap(f2 => Future(3).map(f3 => (f1, f2, f3))))

在这个 for-comprehension 的简化表示中,我们看到第二个Future是在第一个Future的flatMap转换中创建和评估的。任何应用于Future的转换(例如flatMap)都只有在提供给转换的值被计算后才会被调用。这意味着先前的示例中的Future和 for-comprehension 是顺序执行的。为了达到我们想要的并发性,我们必须修改CrazyIdeas.scala使其看起来如下:

val firstDaveAaplF = Future(backtest(Dave1, Ticker("AAPL"),

lastMonths(3)))

val firstDaveGoogF = Future(backtest(Dave1, Ticker("GOOG"),

lastMonths(3)))

val secondDaveAaplF = Future(backtest(Dave2, Ticker("AAPL"),

lastMonths(3)))

val secondDaveGoogF = Future(backtest(Dave2, Ticker("GOOG"),

lastMonths(3)))

for {

firstDaveAapl <- firstDaveAaplF

firstDaveGoog <- firstDaveGoogF

secondDaveAapl <- secondDaveAaplF

secondDaveGoog <- secondDaveGoogF

} yield (firstDaveAapl, firstDaveGoog, secondDaveAapl, secondDaveGoog)

在这个代码片段中,并发启动了四个回测,并将结果转换为一个包含四个BacktestPerformanceSummary值的Tuple4的Future。眼见为实,在向 Dave 展示他的回测运行速度更快后,他兴奋地快速迭代新的回测想法。Dave 从不错过任何开玩笑的机会,他感叹道:“使用我所有的核心让我的笔记本电脑风扇转得飞快。不确定我是否喜欢这种噪音,但确实喜欢这种性能!”

Future使用注意事项

在上一个示例中,我们通过研究如何将并发引入回测器来展示了Future API 的易用性。像任何强大的工具一样,你对Future的使用必须是有纪律的,以确保正确性和性能。本节评估了在使用Future添加并发到程序时常见的一些混淆和错误。我们将详细讨论执行副作用、阻塞执行、处理失败、选择合适的执行上下文以及性能考虑。

执行副作用

当使用Future编程时,重要的是要记住Future本质上是一个有副作用的构造。除非使用success或failure工厂方法将值提升到Future中,否则工作将被安排在另一个线程上执行(这是创建Future时使用的ExecutionContext的一部分)。更重要的是,一旦执行,Future就不能再次执行。考虑以下代码片段:

scala> import scala.concurrent.Future

import scala.concurrent.Future

scala> import scala.concurrent.ExecutionContext.Implicits.global

import scala.concurrent.ExecutionContext.Implicits.global

scala> val f = Future{ println("FOO"); 40 + 2}

FOO

f: scala.concurrent.Future[Int] = scala.concurrent.impl.Promise$DefaultPromise@5575e0df

scala> f.value

res3: Option[scala.util.Try[Int]] = Some(Success(42))

Future被计算并按预期打印FOO。然后我们可以访问Future中包裹的值。注意,在访问值时,控制台上没有打印任何内容。一旦完成,Future仅仅是一个已实现值的包装器。如果你想再次执行计算,你需要创建一个新的Future实例。

注意

注意,前面的例子使用了Future.value来提取计算的输出。这样做是为了简化。在生产代码中,很少甚至从不应该使用这种方法。它的返回类型定义为Option[Try[A]]。Option用于表示已完成的Future带有Some,以及未实现的Future带有None。此外,记住,已实现的Future可以有两种状态:成功或失败。这就是内部Try的目的。像Option.get一样,几乎从不建议使用Future.value。要从Future中提取值,请参考下面描述的附加技术。

阻塞执行

当我们将并发性添加到回测器中时,我们编写了一个返回Future[(BacktestPerformanceSummary, BacktestPerformanceSummary, BacktestPerformanceSummary, BacktestPerformanceSummary)]的 for-comprehension,这可能会让你想知道如何访问Future中包裹的值。另一种提问方式是,“给定Future[T],我如何返回T?”简短的回答是,“你不能!”使用多个Future编程需要从同步执行向异步执行转变思维方式。在异步模型编程时,目标是避免直接与T交互,因为这暗示了一个同步合约。

在实践中,有些情况下拥有Future[T] => T函数是有用的。例如,考虑回测器的片段。如果从片段中的代码通过定义一个扩展App的object来创建程序,程序将在回测完成之前终止。由于ExecutionContext全局中的线程是守护线程,JVM 在创建Future后立即终止。在这种情况下,我们需要一个同步机制来暂停执行,直到结果准备好。通过扩展Awaitable特质,Future能够提供这样的功能。Await模块公开了两个实现此目标的方法:

def readyT: awaitable.type

def resultT: T

由于Future扩展了Awaitable,Future可以作为参数传递给任一方法。ready方法会暂停程序流程,直到T可用,并返回完成的Future[T]。在实践中,ready很少使用,因为在同步调用中返回Future[T]而不是T在概念上是奇怪的。你更有可能经常使用result,它提供了所需的转换,给定Future[T]返回T。例如,CrazyIdeas.scala可以被修改成以下样子:

val summariesF = for {

firstDaveAapl <- Future(backtest(Dave1, Ticker("AAPL"), lastMonths(3)))

firstDaveGoog <- Future(backtest(Dave1, Ticker("GOOG"), lastMonths(3)))

secondDaveAapl <- Future(backtest(Dave2, Ticker("AAPL"), lastMonths(3)))

secondDaveGoog <- Future(backtest(Dave2, Ticker("GOOG"), lastMonths(3)))

} yield (firstDaveAapl, firstDaveGoog, secondDaveAapl, secondDaveGoog)

Await.result(summariesF, scala.concurrent.duration.Duration(1, java.util.concurrent.TimeUnit.SECONDS))

在这个片段中,我们看到阻塞、同步调用 Await.result 返回 Future[BacktestPerformanceSummary] 的 Tuple。这个带有超时参数的阻塞调用是为了防御 Future 在一定时间内未计算的场景。使用阻塞调用检索回测结果意味着 JVM 只会在回测完成或超时到期后退出。当超时到期而回测未完成时,result 和 ready 抛出 TimeoutException。

阻塞程序执行可能会对你的程序性能产生负面影响,因此应谨慎使用。使用 Await 伴生对象上的方法可以使阻塞调用易于识别。由于 ready 和 result 在超时时抛出异常,而不是返回不同的数据类型,你必须格外小心地处理这种情况。你应该将任何涉及异步性的同步调用(要么不提供超时,要么不处理超时)视为错误。

异步编程需要一种心态转变,编写描述值出现时做什么的程序,而不是编写在执行操作之前需要值存在的程序。你应该对任何中断待计算值转换的 Await 使用持怀疑态度。一系列转换应该通过作用于 Future[T] 而不是 T 来组合。Await 的使用应限制在程序没有其他工作可做且需要转换结果的情况下,正如我们在回测器中看到的那样。

注意

由于标准库模型使用异常而不是不同的返回类型来处理超时,因此很难强制执行始终处理超时的要求。提高安全性的一个方法是通过编写返回 Option[T] 而不是 T 的实用方法来处理超时场景:

object SafeAwait {

def resultT: Option[T] =

Try(Await.result(awaitable, atMost)) match {

case Success(t) => Some(t)

case Failure(_: TimeoutException) => None

case Failure(e) => throw e

}

}

使用这种方法,可以消除整个错误类别。当你遇到其他不安全的转换时,考虑定义返回编码预期错误的数据类型的方法,以避免意外地处理转换结果不当。你还想到了哪些不安全转换的例子?

处理失败

与 Future 一起工作需要纪律性地处理错误场景,以避免编写一组难以推理的转换。当一个异常在 Future 转换内部抛出时,它会在转换的计算线程中向上冒泡并中断下游的转换。考虑以下激励示例:

Future("not-an-integer").map(_.toInt).map(i => {

println("Multiplying")

i * 2

})

你期望在第一次map转换之后发生什么?很明显,转换将失败,因为提供的输入无法转换为整数。在这种情况下,Future被视为失败的Future,并且在此示例中,对包装的Int值执行的下游转换将不会发生。在这个简单的例子中,很明显转换无法继续。想象一下,在一个更大的代码库中,操作比单个整数更复杂的数据,并且有多个命名空间和源文件中的失败场景。在现实世界的设置中,确定异步计算失败的位置更具挑战性。

Future提供了处理失败的功能。它提供了recover和recoverWith来继续下游转换。签名如下:

def recoverU >: T(implicit executor: ExecutionContext): Future[U]

def recoverWithU >: T(implicit executor: ExecutionContext): Future[U]

这两种恢复方法的区别在于,提供给recover的偏函数返回U,而recoverWith返回Future[U]。在我们的上一个例子中,我们可以使用recover来提供一个默认值以继续转换,如下所示:

Future("not-an-integer").map(_.toInt).recover {

case _: NumberFormatException => -2

}.map(i => {

println("Multiplying")

i * 2

})

运行此代码片段会产生以下输出:

Multiplying

Multiplication result = -4

这种方法允许你在转换失败时继续转换管道,但它与Await上的方法具有相同的缺点。返回的Future[T]数据类型没有反映失败的可能性。使用恢复方法容易出错,因为无法知道是否处理了错误条件,除非阅读代码。

我们所研究的错误处理适用于处理计算过程中的失败。在一系列转换完成后,你可能会希望执行特殊的逻辑。想象一下,你正在构建一个提交交易订单到交易所的 Web 服务。如果订单已提交到交易所,则订单提交成功;否则,被视为失败的提交。由于订单提交涉及与外部系统(交易所)的通信,你使用Future来模拟这个动作。以下是处理订单提交的方法:

def submitOrder(

ec: ExecutionContext,

sendToExchange: ValidatedOrder => Future[OrderSubmitted],

updatePositions: OrderSubmitted => Future[AccountPositions],

o: RawOrder): Unit = {

implicit val iec = ec

(for {

vo <- ValidatedOrder.fromRawOrder(o).fold(

Future.failedValidatedOrder))(Future.successful)

os <- sendToExchange(vo)

ap <- updatePositions(os)

} yield (os, ap)).onComplete {

case Success((os, ap)) => // Marshal order submission info to caller

case Failure(e) => // Marshal appropriate error response to caller

}

}

ExecutionContext、提交订单的方式以及提交交易后更新客户交易位置的方式允许将客户提供的RawOrder提交到交易所。在第一个处理步骤中,RawOrder被转换为ValidatedOrder,然后提升到Future。Future.failure和Future.successful是方便地将计算值提升或包装到Future中的方式。值被提升到Future中,以便将整个步骤序列写成一个单一的 for-comprehension。

在完成所有处理步骤之后,会异步调用onComplete来处理请求处理的完成。你可以想象在这个上下文中,完成请求处理意味着创建一个响应的序列化版本并将其传输给调用者。之前,我们唯一可用的机制是在值计算后执行工作,那就是使用Await进行阻塞。onComplete是一个异步调用的回调,它注册了一个在值完成时将被调用的函数。如示例所示,onComplete支持处理成功和失败情况,这使得它成为一个通用的工具,用于处理Future转换的结果。除了onComplete之外,Future还提供了onFailure专门用于失败情况,以及onSuccess和foreach专门用于成功情况。

这些回调方法公开了一个返回Unit的方法签名。作为一个函数式程序员,你应该对这些方法持谨慎态度,因为它们具有副作用。onComplete调用应该只在计算绝对结束时发生,此时副作用不能再被延迟。在 Web 服务示例中,副作用是将响应传输给调用者。使用这些具有副作用的回调的另一个常见用例是处理横切关注点,例如应用程序度量。回到 Web 服务,这是在订单提交到交易所失败时增加错误计数器的一种方法:

(for {

vo <- ValidatedOrder.fromRawOrder(o).fold(

Future.failedValidatedOrder))(Future.successful)

os <- {

val f = sendToExchange(vo)

f.onFailure({ case e => incrementExchangeErrorCount() })

f

}

ap <- updatePositions(os)

} yield (os, ap))

在这个片段中,当通过onFailure回调提交到交易所失败时,会执行副作用。在这个独立的例子中,跟踪副作用发生的位置很简单。然而,在一个更大的系统中,确定何时以及在哪里注册了回调可能是一个挑战。此外,从Future API 文档中,我们了解到回调执行是无序的,这意味着所有回调都必须独立处理。这就是为什么你必须有纪律地考虑何时以及在哪里应用这些副作用。

错误处理的另一种方法是使用可以编码错误的数据类型。我们已经在Option作为返回数据类型时看到了这种方法的应用。Option使得计算可能失败这一点很清楚,同时使用起来也很方便,因为它的转换(例如,map)操作在包装值上。不幸的是,Option不允许我们编码错误。在这种情况下,使用 Scalaz 库中的另一个工具——析取(disjunction)是有帮助的。析取在概念上类似于Either,可以用来表示两种可能类型之一。析取与Either的不同之处在于它的操作是右偏的。让我们通过一个简单的例子来说明这个想法:

scalaz.\/.rightThrowable, Int.map(_ * 2)

\/ 是 Scalaz 用来表示析取的简写符号。在这个例子中,通过将一个整型字面量包裹起来创建了一个右析取。这个析取要么具有 Throwable 值,要么具有 Int 值,它与 Either[Throwable, Int] 类似。与 Either 不同,map 转换作用于析取的右侧。在这个例子中,map 接受一个 Int 值作为输入,因为析取的右侧是一个 Int 值。由于析取是右偏的,它很自然地适合表示失败和成功值。使用中缀表示法,通常使用 Future 来定义错误处理,形式为 Future[Throwable \/ T]。代替 Throwable,可以定义一个可能的错误类型 ADT 来使错误处理更加明确。这种方法是可取的,因为它强制处理失败情况,而不依赖于作者调用恢复方法。如果您想了解更多关于如何将析取作为错误处理工具的信息,请查看 Eugene Yokota 的优秀 Scalaz 教程 eed3si9n.com/learning-scalaz/Either.html。

通过执行器提交影响性能

由于 Future 提供了一个表达性强且易于使用的 API,在大型系统中执行计算时,通常会对它进行多次转换。回顾前一小节中提到的订单提交 web 服务,您可以想象多个应用层在 Future 上操作。一个生产就绪的 web 服务通常将多个层组合起来以服务单个请求。一个示例请求流程可能包含以下阶段:请求反序列化、授权、应用服务调用、数据库查找和/或第三方服务调用,以及将响应转换为 JSON 格式。如果在工作流程中的每个阶段都使用 Future 来建模,那么处理单个请求通常需要五个或更多的转换。

将您的软件系统分解成与前面示例类似的小责任区域是一种良好的工程实践,这有助于支持独立测试和改进可维护性。然而,当与 Future 一起工作时,这种软件设计方法会带来性能成本。正如我们通过示例使用所看到的那样,几乎所有的 Future 转换都需要提交工作到 Executor。在我们的示例工作流程中,转换的大多数阶段都很小。在这种情况下,提交工作到执行器的开销主导了计算的执行时间。如果订单提交 web 服务需要处理大量具有严格吞吐量和延迟要求的客户,那么专注于可测试性和可维护性的工程实践可能会导致性能不佳的软件。

如果你考虑前面的图示,你可以看到一个线程池正在使用四个线程来对一个Future应用转换。每个转换都会提交到池中,并且有可能会选择不同的线程来进行计算。这个图示展示了多个小的转换可能会因为Executor提交的开销而影响性能。

Executor提交的开销究竟有多大?这是编写基准测试以量化提交工作到Executor开销的动机问题。这个基准测试关注以两种方式对整数 N 次加 1。一种方法是在单个Future内执行加法操作,而第二种方法是用一个新的Future转换执行每个加法操作。后一种方法是一个代理,用于表示在更大的软件系统中使用多个Future转换的顺序提交请求处理阶段。执行整数加法是一个代理操作,因为它是一种极其低廉的计算,这意味着执行时间将由Executor提交主导。基准测试如下所示:

@Benchmark

def manyTransforms(state: TransformFutureState): Int = {

import scala.concurrent.ExecutionContext.Implicits._

val init = Future(0)

val res = (1 until state.operations).foldLeft(init)

((f, _) => f.map(_ + 1))

Await.result(res, Duration("5 minutes"))

}

@Benchmark

def oneTransform(state: TransformFutureState): Int = {

import scala.concurrent.ExecutionContext.Implicits._

val res = Future {

(1 until state.operations).foldLeft(0)((acc, _) => acc + 1)

}

Await.result(res, Duration("5 minutes"))

}

TransformFutureState允许对操作数量进行参数化。manyTransforms使用涉及提交工作到Executor的map转换来表示每个加法操作。oneTransform通过Future.apply使用单个Executor提交执行所有加法操作。在这个受控测试中,Await.result被用作阻塞机制以等待计算的完成。在具有五个转换和十个转换的具有两个核心的机器上运行此测试的结果可以在以下表格中看到:

基准测试

映射计数

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

manyTransforms

5

463,614.88

± 1.10

oneTransform

5

412,675.70

± 0.81

manyTransforms

10

118,743.55

± 2.34

oneTransform

10

316,175.79

± 1.79

当两种场景都使用五个转换时,它们会产生可比较的结果,但当我们应用十个转换时,我们可以看到明显的差异。这个基准测试清楚地表明Executor提交可能会主导性能。尽管成本可能很高,但我们的建议是,在系统建模时不要一开始就考虑这个成本。根据我们的经验,重新设计一个良好建模的系统以改进性能比扩展或重新设计一个性能良好但建模不佳的系统要容易得多。因此,我们建议在尝试构建复杂系统的初始版本时,不要过分努力地分组Executor提交。

一旦你有一个良好的设计,第一步就是进行基准测试和性能分析,以确定Executor提交是否是瓶颈。如果你发现你的Future使用方式导致了性能瓶颈,那么你应该考虑几个行动方案。

开发成本最低的选项是用Future.success或Future.failure替换不必要的昂贵的Future创建。订单提交 Web 服务利用了这些工厂方法将值提升到Future中。由于值已经计算,这些工厂方法避免了向由提供的ExecutionContext引用的任务提交任何任务。当值已经计算时,用Future.successful或Future.failure替换Future.apply的使用可以节省成本。

从开发努力的角度来看,一个更昂贵的替代方案是对你的实现进行重构,以将Future转换组合在一起,类似于manyTransforms。这种策略涉及审查每个应用程序层,以确定单个层内的转换是否可以合并。如果可能的话,我们建议你避免跨应用程序层合并转换(例如,在请求反序列化或授权与应用程序服务处理之间),因为这会削弱你的模型并增加维护成本。

如果这些选项中的任何一个都不能产生可接受的性能,那么与产品所有者讨论使用硬件解决性能问题的选项可能是有价值的。由于你的系统设计没有受损,并且反映了稳健的工程实践,因此它很可能可以进行横向扩展或集群化。根据你的系统跟踪的状态,这个选项可能不需要额外的开发工作。也许产品所有者更重视一个易于维护和扩展的系统,而不是性能。如果是这样,给你的系统增加规模可能是一个可行的前进方式。

假设你无法通过购买方式摆脱性能挑战,那么还有三种额外的可能性。一个选择是调查Future的替代方案,名为Task。这个由 Scalaz 库提供的结构允许以更少的Executor提交来执行计算。这个选项需要重大的开发工作,因为需要在整个应用程序中将Future数据类型替换为Task。我们将在本章末尾探讨Task,并研究它可能提供的性能优势。

无论是使用Task还是不使用Task,审查你的应用程序模型以批判性地质疑是否在关键路径上执行了不必要的操作都是有用的。正如我们在 MVT 的报表基础设施和引入流处理中看到的那样,有时重新思考设计可以提高性能。像Task的引入一样,重新考虑你的系统架构是一个大规模的变更。最后的手段是合并应用程序层以支持Future转换的分组。我们建议不要采取这个选项,除非所有其他建议都失败了。这个选项会导致代码库更难以推理,因为关注点不再分离。短期内,你可能会获得性能上的好处,但根据我们的经验,这些好处在长期内被维护和扩展这样一个系统的成本所抵消。

处理阻塞调用和回调

如本章第一部分所述,Future API 提供了一种优雅的方式来编写并发程序。由于在Future上阻塞被认为是一种不好的做法,因此在整个代码库中广泛使用Future并不罕见。然而,你的系统可能不仅仅由你自己的代码组成。大多数现实世界的应用程序利用现有的库和第三方软件来避免重新实现一些常见问题的现有解决方案(如数据编码和解码、通过 HTTP 进行通信、数据库驱动程序等)。不幸的是,并非所有库都使用Future API,将它们优雅地集成到你的系统中可能成为一个挑战。在本节中,我们将检查你可能会遇到的一些常见陷阱,并提及可能的解决方案。

ExecutionContext和阻塞调用

在开发回测器时,你注意到代码中的一个模块被用来从一个关系型数据库中加载一些历史买入订单。由于你开始重写应用程序以利用Future,该模块 API 是完全异步的:

def findBuyOrders(

client: ClientId,

ticker: Ticker)(implicit ec: ExecutionContext): Future[List[Order]] = ???

然而,在分析应用程序后,你注意到这部分代码的性能相当差。你尝试增加数据库连接数,首先将其翻倍,然后将其增加到三倍,但都没有成功。为了理解问题的原因,你查看所有调用该方法的位置,并注意到以下模式:

import scala.concurrent.ExecutionContext.Implicits.global

findBuyOrders(clientId, tickerFoo)

所有调用者都导入全局的ExecutionContext以隐式地由方法使用。默认线程池由ForkJoinPool支持,其大小基于机器上的可用核心数。因此,它是 CPU 密集型的,旨在处理非阻塞、CPU 密集型操作。这对于不执行阻塞调用的应用程序来说是一个不错的选择。然而,如果你的应用程序以异步方式(即在Future执行中)运行阻塞调用,依赖于默认的ExecutionContext很可能会迅速降低性能。

异步与非阻塞的比较

在继续之前,我们想要澄清本节中使用的一些术语。在并发的情况下,“非阻塞”可能是一个令人困惑的术语。当使用Future时,我们执行异步操作,这意味着我们开始一个计算过程,以便它可以与程序的流程一起进行。计算在后台执行,最终将产生一个结果。这种行为有时被称为非阻塞,意味着 API 调用立即返回。然而,阻塞和非阻塞通常指的是 I/O 操作及其执行方式,特别是执行操作的线程的使用方式。例如,将一系列字节写入本地文件可能是一个阻塞操作,因为调用write的线程将不得不等待(阻塞)直到 I/O 操作完成。当使用非阻塞构造,如java.nio包中提供的那样,可以执行不会阻塞线程的 I/O 操作。

可以实现一个具有以下行为的 API 组合:

API 特性

返回

会阻塞线程吗?

同步/阻塞

计算结束时

是的,调用线程执行操作

异步/阻塞

立即

是的,这会阻塞来自专用池的线程

异步/非阻塞

立即

不,在执行阻塞操作时线程被释放

使用专用ExecutionContext来阻塞调用

显然,我们的问题是我们在全局使用ExecutionContext来执行阻塞调用。我们正在查询关系型数据库,大多数 JDBC 驱动程序都是实现为执行阻塞调用。池化线程调用驱动程序,并在等待查询和响应通过网络传输时阻塞,这使得它们无法被其他计算使用。一个选项是创建一个专门的ExecutionContext来执行Future,包括阻塞操作。这个ExecutionContext的大小是根据预期它们在执行计算时会阻塞来设定的:

val context = ExecutionContext.fromExecutorService(

Executors.newFixedThreadPool(20)

)

findBuyOrders(clientId, tickerFoo)(context)

第一个好处是我们有更多的线程可用,这意味着我们可以并发地发起更多查询。第二个好处是我们系统中执行的其他异步计算是在一个单独的池(例如,全局上下文)中完成的,并且它们将避免饥饿,因为没有线程被阻塞。

我们编写了一个简短的基准测试来评估我们新系统的性能。在这个例子中,我们使用findBuyOrders的模拟实现来模拟查询数据库:

def findBuyOrders(

client: ClientId,

ticker: Ticker)(ec: ExecutionContext): Future[List[Order]] = Future {

Thread.sleep(100)

Order.staticList.filter(o => o.clientId == client

&& o.ticker == ticker)

}(ec)

我们将ExecutionContext作为参数传递。我们的基准测试比较了一个依赖于默认ExecutionContext的应用程序和一个使用ExecutionContext(后者专门用于阻塞操作)的应用程序的吞吐量;后者初始化了二十倍更多的线程。结果如下:

基准测试

操作计数

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

withDefaultContext

10

3.21

± 0.65

withDedicatedContext

10

9.34

± 1.00

withDefaultContext

1,000

0.04

± 2.56

withDedicatedContext

1,000

0.73

± 0.41

结果证实了我们的直觉。专用池比默认上下文大,这是为了预期线程在等待阻塞操作完成时被阻塞。由于有更多的线程可用,它能够并发地启动更多的阻塞操作,从而实现更好的吞吐量。创建一个专门的 ExecutionContext 是隔离阻塞操作并确保它们不会减慢 CPU 密集型计算的好方法。在设计你的专用线程池时,确保你理解底层资源(例如,连接、文件句柄等)的使用方式。例如,当处理关系型数据库时,我们知道一个连接一次只能用于执行一个查询。一个很好的经验法则是创建一个线程池,其线程数与你想与数据库服务器打开的连接数相同。如果连接数少于线程数,一些线程可能会等待连接而保持未使用。如果你有比线程更多的连接,相反的情况可能会发生,一些连接可能会保持未使用。

一个好的策略是依赖类型系统和编译器来确保你没有混淆不同的 ExecutionContext 实例。除非类型被区分,否则你可能会在执行阻塞操作时意外地使用 CPU 密集型上下文。你可以创建自己的 DatabaseOperationsExecutionContext 类型,它包装了一个 ExecutionContext,并在创建数据库访问模块时接受此类型。另一个想法是使用 Scalaz 提供的标记类型。参考 第三章,释放 Scala 性能,以复习标记类型。考虑以下示例:

object DatabaseAccess {

sealed trait BlockingExecutionContextTag

type BlockingExecutionContext = ExecutionContext @@ BlockingExecutionContextTag

object BlockingExecutionContext {

def fromContext(ec: ExecutionContext): BlockingExecutionContext =

TagExecutionContext, BlockingExecutionContextTag

def withSize(size: Int): BlockingExecutionContext =

fromContext(ExecutionContext.fromExecutor(Executors.newFixedThreadPool(size)))

}

}

class DatabaseAccess(ec: BlockingExecutionContext) {

// Implementation elided

}

使用标记类型为我们的 ExecutionContext 提供了额外的安全性。在连接应用程序时,在 main 方法中犯错是很常见的,并且可能会在创建模块时无意中使用错误的 ExecutionContext。

使用阻塞结构

标准库提供了一个 blocking 构造,可以用来指示在 Future 内部执行的阻塞操作。我们可以修改之前的示例,利用 blocking 而不是专门的 ExecutionContext:

import scala.concurrent.ExecutionContext.Implicits.global

def findBuyOrders(

client: ClientId,

ticket: Ticker): Future[List[Order]] = Future {

scala.concurrent.blocking{

Thread.sleep(100)

Order.staticList.filter(o => o.clientId == client && o.ticker == ticker)

}

}

注意,在先前的实现中,我们使用默认的ExecutionContext来执行Future。blocking构造用于通知ExecutionContext一个计算正在阻塞。这允许ExecutionContext调整其执行策略。例如,默认的全局ExecutionContext在执行被blocking包装的计算时,会临时增加线程池中的线程数量。线程池中会创建一个专用线程来执行阻塞计算,确保其余的线程池仍然可用于 CPU 密集型计算。

应谨慎使用blocking。blocking构造仅用于通知ExecutionContext被包装的操作正在阻塞。实现特定行为或忽略通知是ExecutionContext的责任。唯一真正考虑并实现特殊行为的实现是默认的全局ExecutionContext。

使用 Promise 翻译回调

虽然Future是scala.concurrent API 的主要构造,但另一个有用的抽象是Promise。Promise是创建和完成Future的另一种方式。Future是一个只读容器,用于存储最终将被计算的结果。Promise是一个句柄,允许你显式设置Future中包含的值。Promise始终与一个Future相关联,并且这个Future是特定于Promise的。可以使用成功结果或异常(这将使Future失败)来完成Promise的Future。

让我们通过一个简短的例子来了解Promise是如何工作的:

scala> val p = Promise[Int] // this promise will provide an Int

p: scala.concurrent.Promise[Int] = scala.concurrent.impl.Promise$DefaultPromise@d343a81

scala> p.future.value

res3: Option[scala.util.Try[Int]] = None

// The future associated to this Promise is not yet completed

scala> p.success(42)

res4: p.type = scala.concurrent.impl.Promise$DefaultPromise@d343a81

scala> p.future.value

res5: Option[scala.util.Try[Int]] = Some(Success(42))

Promise只能用于一次完成其关联的Future,无论是成功还是失败。尝试完成已经实现的Promise将抛出异常,除非你使用trySuccess、tryFailure或tryComplete。这三个方法将尝试完成与Promise链接的Future,如果Future已完成则返回true,如果之前已经完成则返回false。

在这一点上,你可能想知道在什么情况下你会真正利用Promise。特别是考虑到先前的例子,返回内部Future而不是依赖于Promise会简单一些吗?请记住,先前的代码片段旨在演示一个简单的流程,该流程说明了Promise API。然而,我们理解你的问题。在实践中,我们看到了两种常见的Promise使用场景。

从回调到基于 Future 的 API

第一个用例是将基于回调的 API 转换为基于Future的 API。想象一下,你需要与第三方产品集成,比如 MVT 最近通过购买使用许可证获得的专有数据库。这是一个非常好的产品,用于按时间戳和股票代码存储历史报价。它附带了一个客户端应用程序使用的库。不幸的是,这个库虽然完全异步和非阻塞,但却是基于回调的,如下所示:

object DatabaseClient {

def findQuote(instant: Instant, ticker: Ticker,

f: (Quote) => Unit): Unit = ???

def findAllQuotes(from: Instant, to: Instant, ticker: Ticker,

f: (List[Quote]) => Unit, h: Exception => Unit): Unit = ???

}

毫无疑问,客户端工作得很好;毕竟,MVT 为此支付了一大笔钱!然而,将其与自己的应用程序集成可能并不容易。你的程序严重依赖于Future。这就是Promise能帮到我们的地方,如下所示:

object DatabaseAdapter {

def findQuote(instant: Instant, ticker: Ticker): Future[Quote] = {

val result = Promise[Quote]

DatabaseClient.findQuote(instant, ticker, {

q: Quote =>

result.success(q)

})

result.future

}

def findAllQuotes(from: Instant, to: Instant, ticker: Ticker):

Future[List[Quote]] = {

Val result = Promise[List[Quote]]

DatabaseClient.findQuote(from, to, ticker, {

quotes: List[Quote] => result.success(quotes)

}, {

ex: Exception => result.failure(ex)

}

}

result.future

}

多亏了Promise抽象,我们能够返回一个Future。我们只需在相应的回调中使用success和failure来调用专有客户端。这种用例在生产环境中很常见,当你需要与 Java 库集成时。尽管 Java 8 对 Java 并发包进行了重大改进,但大多数 Java 库仍然依赖于回调来实现异步行为。使用Promise,你可以在程序中充分利用现有的 Java 生态系统,同时不放弃 Scala 对并发编程的支持。

将 Future 与 Promise 结合

Promise也可以用来组合Future的实例。例如,让我们给Future添加一个超时功能:

def runA: Future[A] = {

val res = Promise[A]

Future {

Thread.sleep(timeout.getMillis)

res.tryFailure(new Exception("Timed out")

}

f onComplete {

case r => res.tryCompleteWith(f)

}

res.future

}

我们的方法接受一个按名称传递的Future(即尚未开始执行执行的Future)以及要应用的超时值。在方法中,我们使用Promise作为结果的容器。我们启动一个内部的Future,在超时期间会阻塞,然后使用Exception失败Promise。我们还启动主要的Future并注册一个回调,以计算的结果完成Promise。这两个Future中的第一个终止的将有效地使用其结果完成Promise。请注意,在这个例子中,我们使用了tryFailure和tryCompleteWith。很可能这两个Future最终都会终止并尝试完成Promise。我们只对第一个完成的Future的结果感兴趣,但我们还希望避免在尝试完成已经实现的Promise时抛出Exception。

注意

之前的例子是一个超时的简单实现。它主要是一个原型,用于展示如何利用Promise来丰富Future并实现复杂的行为。一个更现实的实现可能需要使用ScheduledExecutorService。ScheduledExecutorService允许你在一定延迟后调度一个计算的执行。它允许我们调度对tryFailure的调用,而不需要通过Thread.sleep调用阻塞线程。我们选择保持这个例子简单,并没有引入新的类型,但我们鼓励你研究这种ScheduledExecutorService的实现。

在实践中,你可能会偶尔需要编写自己的自定义组合器 Future。如果你需要这样做,Promise 是你的工具箱中的一个有用的抽象。然而,Future 及其伴随对象已经提供了一系列内置的组合器和方法,你应该尽可能多地利用它们。

承担更多回测性能改进的任务

发现 Future 并采用异步思维有助于你更好地利用计算资源,更快地测试多个策略和股票代码。你通过将回测视为黑盒来提高性能。在不改变回测实现的情况下,可以轻松获得性能提升。将转换的逻辑序列识别为并发的候选策略是一个在考虑如何加快软件速度时可以应用的不错策略。

让我们将这个想法扩展到回测器内部更小的逻辑处理单元。回测针对某个股票代码在一段时间内进行策略测试。在与 Dave 交谈后,你发现 MVT 不会在夜间保持仓位。在交易日的结束时,MVT 交易系统通过确保所有股票仓位被清算来降低风险。这样做是为了防御市场关闭后价格波动的风险,公司无法通过交易来应对。由于仓位不在夜间持有,每个交易日可以独立于前一个交易日进行模拟。回到我们的异步思维模式,这个洞察意味着可以并行执行交易日模拟。

在使用 Future 进行实现之前,我们将分享一个名为 Task 的替代抽象,这是由 Scalaz 库提供的。Task 为我们提出的回测修改提供了有说服力的使用理由。我们将在你准备好接受任务的情况下介绍 Task!

介绍 Scalaz Task

Scalaz Task 提供了一种不同的方法来实现并发。尽管 Task 可以以模仿 Future 的方式使用,但这两个抽象之间存在着重要的概念差异。Task 允许对异步执行进行细粒度控制,这提供了性能优势。Task 还保持了引用透明性,这提供了更强的推理能力。引用透明性是表达式无副作用的属性。为了更好地理解这个原则,考虑以下简单的 sum 方法:

def sum(x: Int, y: Int): Int = x + y

想象一下,我们正在执行两个求和操作:

sum(sum(2, 3), 4)

由于 sum 是无副作用的,我们可以将其替换为以下结果:

sum(5, 4)

这个表达式将始终评估为 9,这满足了引用透明性。现在想象一下 sum 实现中的一个转折:

class SumService(updateDatabase: () => Unit) {

def sum(x: Int, y: Int): Int = {

updateDatabase()

x + y

}

}

现在,sum 包含一个写入数据库的副作用,这破坏了引用透明性。我们不能再用值 9 替换 sum(2, 3),因为这样数据库就不会更新了。引用透明性是函数式编程范式核心的概念,因为它提供了强大的推理保证。Haskell 维基提供了额外的评论和值得回顾的示例,请参阅 wiki.haskell.org/Referential_transparency。

让我们看看常见的 Task API 使用方法,以更好地理解 Task 的工作原理。

创建和执行 Task

Task 伴生对象提供的方法是访问 API 的主要入口点,以及创建 Task 实例的最佳方式。Task.apply 是第一个需要检查的方法。它接受一个返回 A 实例的计算(即 A 类型的按名参数)和一个隐式的 ExecutorService 来运行计算。与使用 ExecutionContext 作为线程池抽象的 Future 不同,Task 使用的是定义在 Java 标准库中的 ExecutorService:

scala> val t = Task {

| println("Starting task")

| 40 + 2

| }

t: scalaz.concurrent.Task[Int] = scalaz.concurrent.Task@300555a9

你可能首先注意到的是,尽管我们实例化了一个新的 Task,但屏幕上没有打印任何内容。这是比较 Task 和 Future 时的一个重要区别;虽然 Future 是急切评估的,但 Task 不会计算,直到你明确请求它:

scala> t.unsafePerformSync

Starting task

res0: Int = 42

上述示例调用 unsafePerformSync 实例方法来执行任务。我们可以看到 println 以及返回的结果 42。请注意,unsafePerformSync 是一个不安全的调用。如果计算抛出异常,异常将由 unsafePerformSync 重新抛出。为了避免这种副作用,调用 unsafePerformSyncAttempt 是首选的。unsafePerformSyncAttempt 实例捕获异常,并具有 Throwable \/ A 的返回类型,这允许你干净地处理失败情况。请注意,在创建任务 t 时,我们没有提供 ExecutorService。默认情况下,apply 创建一个将在 DefaultExecutorService 上运行的 Task,这是一个固定大小的线程池,其大小基于机器上可用的处理器数量,使用默认参数。DefaultExecutorService 与我们用 Future 探索的全局 ExecutionContext 类似。它是 CPU 密集型,大小基于机器上的可用核心数。我们也可以在创建时提供一个不同的 ExecutorService:

scala> val es = Executors.newFixedThreadPool(4)

es: java.util.concurrent.ExecutorService = java.util.concurrent.ThreadPoolExecutor@4c50cd8c[Running, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0]

scala> val t = Task {

println("Starting task on thread " + Thread.currentThread.getName)

40 + 2

}(es)

t: scalaz.concurrent.Task[Int] = scalaz.concurrent.Task@497db010

scala> println("Calling run from " + Thread.currentThread.getName)

Calling run from run-main-1

scala> t.unsafePerformSync

Starting task on thread pool-8-thread-2

res2: Int = 42

输出显示 Task 是在提供的 ExecutorService 上执行的,而不是在主线程上。

谈到 Task 执行,让我们进行一个小实验。我们将创建一个 Task 实例,并连续两次调用 unsafePerformSync:

scala> val t = Task {

| println("Starting task")

| 40 + 2

| }

t: scalaz.concurrent.Task[Int] = scalaz.concurrent.Task@300555a9

scala> t.unsafePerformSync

Starting task

res0: Int = 42

scala> t.unsafePerformSync

Starting task

res1: Int = 42

我们观察到每次调用unsafePerformSync后都会打印出“开始任务”。这表明每次我们调用unsafePerformSync时都会执行完整的计算。这与Future的另一个区别。当Future在计算后记住其结果时,Task每次我们调用unsafePerformSync都会执行其计算。换句话说,Task是引用透明的,因此比Future更接近函数式编程范式。

异步行为

与Future一样,异步使用Task是可能的(甚至推荐)。可以通过调用unsafePerformAsync来异步执行Task的实例。此方法接受一个类型为(Throwable \/ A) => Unit的回调,在计算结束时被调用。观察以下片段:

def createAndRunTask(): Unit = {

val t = Task {

println("Computing the answer...")

Thread.sleep(2000)

40 + 2

}

t.unsafePerformAsync {

case \/-(answer) => println("The answer is " + answer)

case -\/(ex) => println("Failed to compute the answer: " + ex)

}

println("Waiting for the answer")

}

我们创建我们的Task,并添加一个Thread.sleep来模拟昂贵的计算。我们调用unsafePerformAsync并使用一个简单的回调来打印答案(如果计算失败,则为异常)。我们调用createAndRunTask并观察以下输出:

scala> TaskExample.createAndRunTask()

Waiting for the answer

scala> Computing the answer...

The answer is 42

我们可以看到,我们的最后一个语句“等待答案”首先被打印出来。这是因为unsafePerformAsync立即返回。我们可以看到我们的计算中的语句,以及回调中打印的答案。这种方法是 Scala 的Future上定义的onComplete的粗略等价物。

Task的伴生对象还提供了一个有用的方法async。记得我们之前是如何使用Promise将基于回调的 API 转换为返回Future实例的 API 的吗?使用Task也可以实现相同的目标;也就是说,我们可以将基于回调的 API 转换为返回Task的更单调的 API,如下所示:

object CallbackAPI {

def doCoolThingsA => Unit): Unit = ???

}

def doCoolThingsToTaskA: Task[A] =

Task.async { f =>

CallbackAPI.doCoolThingsA)

}

在 REPL 中评估此方法会产生以下结果:

> val t = doCoolThingsToTask(40+2)

> t.map(res => res / 2).unsafePerformSync

res2: Int = 21

我们的doCoolThingsToTask方法使用Task.async从定义在CallbackAPI中的基于回调的 API 创建一个Task实例。Task.async甚至可以将 Scala 的Future转换为 Scalaz 的Task:

def futureToTaskA(implicit ec: ExecutionContext): Task[A] =

Task.async { f =>

future.onComplete {

case Success(res) => f(\/-(res))

case Failure(ex) => f(-\/(ex))

}

}

注意,我们必须提供一个ExecutionContext才能在Future上调用onComplete。这是由于Future的急切评估。几乎所有在Future上定义的方法都会立即将计算提交到线程池。

注意

还可以将Task转换为Future:

def taskToFutureA: Future[A] = {

val p = Promise[A]()

t.unsafePerformAsync {

case \/-(a) => p.success(a)

case -\/(ex) => p.failure(ex)

}

p.future

}

执行模型

理解Task执行模型需要理解 Scalaz 的Future执行模型,因为Task组合了一个 Scalaz 的Future并添加了错误处理。这可以从Task的定义中看出:

class Task+A

在这个定义中,Future不是 Scala 标准库版本,而是 Scalaz 提供的替代版本。Scalaz 的Future将定义转换与执行策略解耦,为我们提供了对Executor提交的精细控制。Scalaz 的Future通过将自己定义为跳跃计算来实现这一点。跳跃是一种将计算描述为一系列离散块的技术,这些块使用恒定空间运行。要深入了解跳跃如何工作,我们建议阅读 Runar Bjarnason 的论文《无栈 Scala 与 Free Monads》,可在blog.higher-order.com/assets/trampolines.pdf找到。

Task基于 Scalaz 的Future,通过 Scalaz 的\/析取来提供错误处理。Task是对计算的描述。转换添加到最终将由线程池执行的计算的描述中。为了开始评估,必须显式启动Task。这种行为很有趣,因为当Task最终执行时,我们可以将计算执行限制在单个线程上。这提高了线程重用并减少了上下文切换。

在前面的图中,我们看到对apply和map的各种调用。这些调用仅仅是在修改要执行的任务的定义。只有在调用unsafePerformAsync时,计算才在不同的线程中实现。注意,所有转换都是由同一个线程应用的。

我们可以通过比较基于转换(例如,map和flatMap)的吞吐量和应用的转换数量来在短微基准测试中锻炼Future和Task的性能。基准测试的片段如下:

@Benchmark

def mapWithFuture(state: TaskFutureState): Int = {

implicit val ec = state.context

val init = Future(0)

val res = (1 until state.operations).foldLeft(init)

((f, _) => f.map(_ + 1))

Await.result(res, Duration("5 minutes"))

}

@Benchmark

def mapWithTask(state: TaskFutureState): Int = {

val init = Task(0)(state.es)

val res = (1 until state.operations).foldLeft(init)

((t, _) => t.map(_ + 1))

res.unsafePerformSync

}

两种场景运行类似的计算。我们创建一个包含 0 的初始Future或Task实例,然后应用几个连续的map操作来将 1 添加到累加器中。另外两种场景执行了相同的计算,但使用的是flatMap。flatMap的结果显示在下表中:

基准测试

操作计数

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

flatMapWithFuture

5

41,602.33

± 0.69

flatMapWithTask

5

59,478.50

± 2.14

flatMapWithFuture

10

31,738.80

± 0.52

flatMapWithTask

10

43,811.15

± 0.47

flatMapWithFuture

100

4,390.11

± 1.91

flatMapWithTask

100

13,415.30

± 0.60

map操作的结果可以在下表中找到:

基准测试

操作计数

吞吐量(每秒操作数)

错误率(吞吐量的百分比)

mapWithFuture

5

45,710.02

± 1.30

mapWithTask

5

93,666.73

± 0.57

mapWithFuture

10

44,860.44

± 1.80

mapWithTask

10

91,932.14

± 0.88

mapWithFuture

100

19,974.24

± 0.55

mapWithTask

100

46,288.17

± 0.46

这个基准突出了由于Task的不同执行模型所带来的性能提升。即使对于少量转换,延迟评估的吞吐量也更好。

使用Task模拟交易日

借助于我们对Task的理解,我们现在拥有了添加单个回测执行并发性的必要知识。你可能还记得,我们从 Dave 那里了解到 MVT 在每个交易日结束时关闭其头寸。这个洞察力使我们能够独立地模拟每个交易日。让我们从以下模型开始,熟悉当前的实现:

case class PnL(value: BigDecimal) extends AnyVal

object PnL {

def merge(x: PnL, y: PnL): PnL = PnL(x.value + y.value)

val zero: PnL = PnL(0)

}

case class BacktestPerformanceSummary(pnl: PnL)

case class DecisionDelayMillis(value: Long) extends AnyVal

利润和损失是每个模拟交易日的输出。PnL提供了一个方便的方法来合并两个PnL实例,这可以用来汇总多个交易日的模拟PnL。一旦所有交易日都进行了模拟,就创建一个BacktestPerformanceSummary来捕获模拟的利润和损失。对于我们在回测器上的工作,我们将使用Thread.sleep来模拟计算密集型工作,而不是实际决策策略。睡眠的长度由DecisionDelayMillis参数化。

我们展示了回测器的简化版本,展示了如何使用DecisionDelayMillis来模拟一个交易日,如下所示:

def originalBacktest(

testDays: List[MonthDay],

decisionDelay: DecisionDelayMillis): BacktestPerformanceSummary =

{

val pnls = for {

d <- testDays

_ = Thread.sleep(decisionDelay.value)

} yield PnL(10)

BacktestPerformanceSummary(pnls.reduceOption(PnL.merge).getOrElse(

PnL.zero))

}

原始回测显示了如何以同步方式模拟一系列日期。为了确保可重复性,我们用固定的$10 利润和损失值代替动态值。这个回测忽略了应用股票行情和策略,以关注我们困境的核心:我们如何使用Task为回测添加并发性?

从我们的示例中,我们看到了Task通过向ExecutorService提交多个Task以及通过使用unsafePerformAsync运行Task以避免阻塞等待结果来引入并发性。作为第一步,让我们实现一个不引入并发性的回测Task版本:

def backtestWithoutConcurrency(

testDays: List[MonthDay],

decisionDelay: DecisionDelayMillis): Task[BacktestPerformanceSummary] =

{

val ts = for (d <- testDays) yield Task.delay {

Thread.sleep(decisionDelay.value)

PnL(10)

}

Task.gatherUnordered(ts).map(pnls => BacktestPerformanceSummary(

pnls.reduceOption(PnL.merge).getOrElse(PnL.zero)))

}

这个实现将返回类型更改为Task[BacktestPerformanceSummary]。由于Task没有运行,因此在这个方法中保持了引用透明性。每个交易日都使用Task.delay进行模拟。delay是Task.now的惰性变体,它延迟评估提供的值。让我们查看以下签名以确认:

def delayA: Task[A]

如果我们用Task.now代替Task.delay,则睡眠(即模拟)将在运行Task之前生效。我们还看到了另一种新功能Task.gatherUnordered的使用。gatherUnordered在你希望进行以下转换时很有用:

List[Task[A]] => Task[List[A]]

虽然这里使用了List,但这种关系适用于任何Seq。gatherUnordered提供了一种方式,可以取一个Task集合,而不是操作一个包含底层类型集合的单个Task。让我们看看以下签名,以使我们的理解更加具体:

def gatherUnorderedA: Task[List[A]]

这个签名与之前我们定义的函数非常相似,只是增加了一个可选的布尔参数。当exceptionCancels设置为true时,任何挂起的Task将不会被评估。gatherUnordered允许我们将每个交易日的盈亏结果合并在一起,并返回一个包含BacktestPerformanceSummary的单个Task。Scala 的Future伴随对象提供了一个类似的方法,名为sequence,它对一系列Future执行相同的操作。

这是一个功能性的回测实现,但它并没有将并发添加到历史交易日的模拟中。在我们的下一个迭代中,我们利用了Task API 的新部分,Task.fork。让我们看看它是如何使用的,然后我们将解释它是如何工作的:

def backtestWithAllForked(

testDays: List[MonthDay],

decisionDelay: DecisionDelayMillis): Task[BacktestPerformanceSummary] =

{

val ts = for (d <- testDays) yield Task.fork {

Thread.sleep(decisionDelay.value)

Task.now(PnL(10))

}

Task.gatherUnordered(ts).map(pnls => BacktestPerformanceSummary(

pnls.reduceOption(PnL.merge).getOrElse(PnL.zero)))

}

这个实现以与之前相同的方式收集交易日的PnL,但这次它使用Task.fork和Task.now的组合来模拟交易日。让我们看看Task.fork的签名,以了解运行时行为是如何变化的:

def forkA(implicit pool: ExecutorService = Strategy.DefaultExecutorService): Task[A]

fork接受一个Task作为按名参数和一个隐式的ExecutorService,默认为 CPU 密集型执行器。签名显示fork将提供的Task提交到pool,以便将计算分叉到不同的线程。fork是使用Task显式控制并发的明确方式。从概念上讲,fork类似于任何涉及提交到执行器的Future转换(例如,map)。由于fork是惰性评估其参数的,因此可以使用Task.now将交易日的盈亏提升到Task。在这个实现中,代表每个交易日的Task被提交到执行器。如果我们假设正在回测 30 个交易日,并且使用的计算机有两个核心,那么这个实现允许每个核心模拟 15 个交易日,而不是单个核心模拟 30 天。

正如我们在早期的基准测试中看到的,向执行器提交大量的小型计算是昂贵的。通过使用Task的fork来显式控制并发,我们可以通过优化执行器提交的频率来提高我们的性能。在我们的第三次尝试中,我们利用了已知要模拟的交易日数量来控制执行器提交。现在的实现看起来如下:

def backtestWithBatchedForking(

testDays: List[MonthDay],

decisionDelay: DecisionDelayMillis): Task[BacktestPerformanceSummary] =

{

val ts = for (d <- testDays) yield Task.delay {

Thread.sleep(decisionDelay.value)

PnL(10)

}

Task.gatherUnordered(ts.sliding(30, 30).toList.map(xs =>

Task.fork(Task.gatherUnordered(xs)))).map(pnls =>

BacktestPerformanceSummary(

pnls.flatten.reduceOption(PnL.merge).getOrElse(PnL.zero)))

}

此实现返回到使用Task.delay表示每个交易日模拟而不使用任何并发。与之前的实现相比,交易日模拟Task的列表被使用sliding分成 30 个块。每个 30 个Task的块被一个Task.fork调用来并发执行。这种方法允许我们平衡并发的收益和执行器提交的开销。

在这三个实现中,哪个性能最好?答案并不直接,因为它取决于模拟交易日的数量和模拟交易日的计算成本。为了更好地理解权衡,我们编写了一个微基准测试来测试这三个回测实现。我们展示了运行基准测试所需的状态,如下所示:

@State(Scope.Benchmark)

class BenchmarkState {

@Param(Array("1", "10"))

var decisionDelayMillis: Long = 0

@Param(Array("1", "12", "24" ))

var backtestIntervalMonths: Int = 0

var decisionDelay: DecisionDelayMillis = DecisionDelayMillis(-1)

var backtestDays: List[MonthDay] = Nil

@Setup

def setup(): Unit = {

decisionDelay = DecisionDelayMillis(decisionDelayMillis)

backtestDays = daysWithin(trailingMonths(backtestIntervalMonths))

}

}

此基准测试允许我们扫描不同的回测间隔和决策延迟组合。使用一个daysWithin方法(在代码片段中省略),将表示月份数量的计数转换为模拟交易日的列表。我们只显示一个基准测试的实现,因为其他两个是相同的,如下所示:

@Benchmark

def withBatchedForking(state: BenchmarkState): BacktestPerformanceSummary =

Backtest.backtestWithBatchedForking(state.backtestDays,

state.decisionDelay)

.unsafePerformSync

为了准确测量完成Task计算所需的时间,我们使用阻塞的unsafePerformSync方法开始计算。这是一个罕见的例子,在没有超时的情况下进行阻塞调用是可以接受的。在这个受控测试中,我们确信所有调用都将返回。对于这个测试,我们扫描月份计数,将决策延迟固定在 1 毫秒。在具有四个核心的机器上运行此基准测试产生以下结果:

Benchmark

Months

Decision delay milliseconds

Throughput (ops per second)

Error as percentage of throughput

withoutConcurrency

1

1

25.96

± 0.46

withAllForked

1

1

104.89

± 0.36

withBatchedForking

1

1

27.71

± 0.70

withoutConcurrency

12

1

1.96

± 0.41

withAllForked

12

1

7.25

± 0.22

withBatchedForking

12

1

8.60

± 0.49

withoutConcurrency

24

1

0.76

± 2.09

withAllForked

24

1

1.98

± 1.46

WithBatchedForking

24

1

4.32

± 0.88

这些结果使得在批处理的开销和收益之间的权衡更加清晰。随着月份的增加,批处理在 1 毫秒的计算延迟下是一个明显的胜利。考虑 24 个月回测且决策延迟为 1 毫秒的情况。假设每月 30 天,有 720 个交易日需要模拟。将这些交易日分成 30 个批次,就有 24 次fork调用而不是 720 次。将Task分割成批次的开销以及收集每个批次结果的成本,被更少的执行器提交的数量级所掩盖。我们对分叉的显式控制在这个场景中使得吞吐量翻倍。

随着月份的减少,创建任务批次的开销成为一个主导因素。在一个 12 个月的回测中,有 360 个交易日,产生 12 个批次。在这里,批处理相对于所有任务的分支操作提高了大约 20%的吞吐量。将 24 个月测试中的交易日数量减半,性能优势减少了超过一半。在最坏的情况下,当只有一个月需要模拟时,批处理策略未能充分利用机器上的所有核心。在这种情况下,只创建了一个批次,导致 CPU 资源利用率低下。

总结回测器

正如我们所看到的,这里有许多变量在发挥作用。考虑到计算成本、可用核心数、预期的任务执行者提交次数和批处理开销可能会很具挑战性。为了扩展我们的工作,我们可以研究一种更动态的批处理策略,该策略通过更小的回测间隔更好地利用 CPU 资源。使用这个基准,我们尝到了任务提供的额外工具的滋味,以及显式控制执行者提交如何影响吞吐量。

通过对回测器的工作,我们获得的见解也可以应用于更大规模的软件系统。我们专注于分析具有短 1 毫秒决策延迟的结果。随着执行每个任务的成本增加(例如,10 毫秒决策延迟),批处理带来的边际性能提升逐渐减少。这是因为执行者提交的成本被计算成本所掩盖。虽然 1 毫秒看起来是一小段时间,但在这一时间框架内可以完成大量计算。考虑到我们在早期努力中以及通过你的工作所进行的基准测试,你可以找到许多我们处理吞吐量高于每毫秒 1 次操作的例子。从这个思想实验中得出的结论是,大量用例符合短计算(即 1 毫秒)的定义,这意味着有大量机会通过谨慎使用fork来优化并发性。

注意

回测器是批处理的首选候选者,因为工作负载,即需要模拟的天数,在处理开始时是已知的。在流处理环境中,工作负载是未知的。例如,考虑接收实时事件的订单簿。如何在流处理环境中实现批处理?

我们希望提供的回测器能够提供一个有说明性的例子,让你对 Task 有一个感觉。Task 还提供了其他一些我们没有探索的工具。我们邀请你阅读 Scalaz 库中 Task 的文档。在由 Scalaz 贡献者 Rúnar Bjarnason 和 Paul Chiusano 撰写的《Scala 函数式编程》一书中,有一个出色的章节描述了 Scalaz Task 的简化版本实现。这是一个了解 API 设计的绝佳资源。

摘要

在本章中,我们发现了如何利用 Scala 标准库中的 Future 和 Promise 来掌握异步编程的力量。我们通过引入并发来提高运行时性能,从而提高了 MVT 回测的性能,并发现了如何使用 Promise 来扩展 Future。在这个过程中,我们还研究了 Future 的不足之处以及缓解这些不足的技术。我们还探索了 Future 的替代方案——Scalaz 的 Task,它提供了令人信服的性能优势,同时保持了引用透明性。利用本章所学,你可以充分利用多核硬件,使用 Scala 来扩展你的软件系统并提高吞吐量。在我们的最后一章,第七章,针对性能进行架构设计中,我们探索了一系列高级函数式编程技术和概念,以丰富你的函数式编程工具箱。

第七章. 为性能而架构

在探索 Scala 和各种编写高性能代码的技术方面,我们已经取得了长足的进步。在本章的最后,我们探讨了一些更开放的话题。最后的话题在很大程度上适用于 Scala 和 JVM 之外。我们深入研究了各种工具和实践,以提高应用程序的架构和设计。在本章中,我们探讨了以下主题:

无冲突复制数据类型(CRDTs)

队列的吞吐量和延迟影响

Free monad

分布式自动化交易员

多亏了我们的辛勤工作,MVT 正在蓬勃发展。销售部门正在签订合同,仿佛明天就是世界末日,销售铃声从日出响到日落。订单簿能够处理更多的订单,由于流量的增加,MVT 提供的另一产品出现了性能问题:自动化交易系统。自动化交易员从订单簿接收订单,并实时应用各种交易策略,代表客户自动下单。由于订单簿正在处理数量级的交易订单,自动化交易系统无法跟上,因此无法有效地应用其策略。最近,一些大客户由于算法决策失误和执行延迟高而损失了大量资金。工程团队需要解决这个性能问题。你的技术负责人 Alice 要求你找到解决方案,防止公司失去新获得的客户。

在上一章中,我们研究了并发,并利用了并发。我们学习了如何设计代码以利用多核硬件的强大功能。自动化交易器已经优化以运行并发代码并利用机器上的所有 CPU 资源。事实是,即使是几个核心,一台机器也只能处理这么多。为了扩展系统并跟上订单簿带来的流量,我们不得不开始实施分布式系统。

分布式架构的概览

分布式计算是一个丰富的主题,我们无法假装在一章中完全解决它。本节提供了一个简短且不完整的分布式计算描述。我们将尝试为您概述这个范式,并指出分布式系统的一些主要优势和挑战。

分布式计算背后的理念是设计一个涉及多个组件的系统,这些组件运行在不同的机器上,并通过彼此(例如,通过网络)进行通信以完成一个任务或提供一项服务。分布式系统可以涉及不同性质的组件,每个组件提供特定的服务并参与任务的实现。例如,可以将 Web 服务器部署来接收 HTTP 请求。为了处理请求,Web 服务器可能通过网络查询认证服务以验证凭证,并查询数据库服务器以存储和检索数据并完成请求。Web 服务器、认证服务和数据库共同构成了一个分布式系统。

分布式系统也可能涉及相同组件的多个实例。这些实例形成一个节点集群,并且可以用来在他们之间分配工作。这种拓扑结构允许系统通过向集群添加更多实例来扩展并支持更高的负载。例如,如果一个 Web 服务器能够每秒处理 20,000 个请求,那么可能可以通过运行三个相同的 Web 服务器集群来处理每秒 60,000 个请求(假设您的架构允许您的应用程序线性扩展)。分布式集群还有助于实现高可用性。如果一个节点崩溃,其他节点仍然可以运行并能够满足请求,同时崩溃的实例正在重新启动或恢复。因为没有单点故障,所以没有服务中断。

尽管分布式系统带来了诸多好处,但也伴随着其自身的缺点和挑战。组件之间的通信可能会出现故障和网络中断。应用程序需要实现重试机制和错误处理,并处理丢失的消息。另一个挑战是管理共享状态。例如,如果所有节点都使用单个数据库服务器来保存和检索信息,数据库必须实现某种形式的锁定机制来确保并发修改不会冲突。一旦集群节点数量足够大,数据库可能无法高效地为他们提供服务,并可能成为瓶颈。

现在您已经简要了解了分布式系统,我们将回到 MVT。团队决定将自动化交易转变为分布式应用程序,以便能够扩展平台。您被分配了设计系统的任务。是时候去白板前了。

首次尝试分布式自动化交易

您的第一个策略很简单。您计划部署几个自动化交易员的实例以形成一个节点集群。这些节点可以共享工作并处理进入订单的每个部分。集群前面的负载均衡器可以在节点之间均匀分配负载。这种新的架构有助于扩展自动化交易。然而,您面临着分布式系统的一个常见问题:节点必须共享一个公共状态才能运行。为了理解这个要求,我们探索自动化交易的一个功能。为了能够使用 MVT 的自动化交易系统,客户必须向 MVT 开设账户并为其提供足够的资金以覆盖他们的交易。这是 MVT 作为安全网执行其客户订单的一种方式,以免客户无法履行其交易的风险。为了确保自动化策略不会过度消费,自动化交易员跟踪每个客户的当前余额并在代表他们下单之前检查客户的余额。

您的计划包括部署几个自动化交易系统的实例。每个实例接收订单簿处理的部分订单,运行策略并代表客户下单。现在系统由几个并行运行的相同实例组成,每个实例都可以代表同一客户下单。为了能够执行余额验证,它们都需要知道所有客户的当前余额。客户余额成为一个需要在集群中同步的共享状态。为了解决这个问题,您设想部署一个作为独立组件的余额监控服务器,并持有每个客户余额的状态。当一个交易订单被自动化交易集群的节点接收时,该节点会查询余额监控服务器以验证客户的账户是否有足够的资金进行自动化交易。同样,当交易执行时,一个节点会指示余额监控服务器更新客户的余额。

上述图描述了您架构组件之间的各种交互。自动化交易员 1接收一个进入的交易并查询余额监控服务器以检查客户端是否有足够的资金进行交易。余额监控服务器要么授权要么拒绝订单。同时,自动化交易员 3发送一个之前由余额监控服务器批准的订单并更新客户端的余额。

注意

你可能已经发现了这个设计的缺陷。可能会遇到一个竞态条件,其中两个不同的自动化交易实例可能会验证同一客户的余额,从余额监控服务器接收授权,并行执行两笔交易并超过客户的账户限额。这类似于你在单台机器上运行的并发系统可能遇到的竞态条件。在实践中,这种风险很低,类似于 MVT 的公司可以接受这种风险。用于切断客户的限额通常设置得低于实际余额,以考虑到这种风险。设计一个处理这种情况的平台会增加系统的延迟,因为我们不得不在节点之间引入更多的剧烈同步。这是一个很好的例子,说明了商业和技术领域如何合作以优化解决方案。

在这个设计会议结束时,你走一小段路来清醒一下头脑,同时喝一瓶碳酸水。当你回到白板前,残酷的现实让你感到震惊。就像一个扁平的碳酸水瓶一样,你的想法已经消散了。你意识到,所有这些连接矩形的箭头实际上是在网络上传输的消息。目前,虽然单个自动化交易者依赖于其内部状态来执行策略和下订单,但这个新设计要求自动化交易者通过网络查询外部系统并等待答案。这个查询发生在关键路径上。这是分布式系统中的另一个常见问题:具有特定角色的组件需要相互通信以完成任务。这种通信是有成本的。它涉及到序列化、I/O 操作和网络传输。你与爱丽丝分享你的反思,她确认这是一个问题。自动化交易者必须尽可能保持内部延迟低,以便其决策具有相关性。经过简短讨论后,你们同意在关键路径上对自动化交易者执行远程调用将危害性能。你现在面临着一个任务,即实现一个由共享公共状态的组件组成但不通过关键路径相互通信的分布式系统。这就是我们可以开始讨论 CRDTs 的地方。

引入 CRDTs

CRDT 代表 无冲突复杂数据类型。CRDTs 由 Marc Shapiro 和 Nuno Preguiça 在他们的论文 设计一个交换复杂数据类型 中正式定义(参见 hal.inria.fr/inria-00177693/document)。CRDT 是一种专门设计的数据结构,旨在确保多个组件之间最终一致性,而无需同步。最终一致性是分布式系统中的一个知名概念,并不局限于 CRDTs。此模型保证最终,如果某个数据不再被修改,集群中的所有节点都将具有该数据的相同值。节点通过发送更新通知来保持其状态同步。与强一致性不同的是,在某个特定时间,一些节点可能看到稍微过时的状态,直到它们收到更新通知:

前面的图示展示了最终一致性的一个例子。集群中的所有节点都持有相同的数据块(A = 0)。节点 1 收到更新以将 A 的值设置为 1。在更新其内部状态后,它向集群的其他部分广播更新。消息在不同的时刻到达目标,这意味着直到我们达到第 4 步,A 的值根据节点而异。如果客户端在第 3 步查询节点 4 的 A 的值,他们将收到较旧的价值,因为更改尚未反映在节点 4 上。

最终一致性可能引发的问题之一是冲突的解决。想象一个简单的例子,集群中的节点共享一个整数数组的状态。以下表格描述了涉及更新该数组状态的连续事件序列:

即时

事件

状态变化

T0

集群的初始化

节点 1 和 2 持有整数数组的相同值:[1,2,3]

T1

节点 1 收到更新索引 1 的值的请求,从 2 更改为 4

节点 1 将其内部状态更新为 [1,4,3] 并向节点 2 发送更新消息

T2

节点 2 收到更新索引 1 的值的请求,从 2 更改为 5

节点 2 将其内部状态更新为 [1,5,3] 并向节点 1 发送更新消息

T3

节点 1 收到来自节点 2 的更新

节点 1 需要决定是否应该忽略或考虑更新消息

我们现在需要解决冲突。节点 1 在收到来自节点 2 的更新时是否应该更新其状态?如果节点 2 也这样做,我们将有两个节点持有不同的状态。其他节点呢?一些节点可能先收到来自节点 2 的广播,然后是来自节点 1 的,反之亦然。

存在多种策略来处理这个问题。一些协议使用时间戳或向量时钟来确定哪个更新是在时间上更晚执行的,应该具有优先权。其他协议简单地假设最后写入者获胜。这不是一个简单的问题,CRDTs 旨在完全避免冲突。实际上,CRDTs 被定义为使冲突在数学上成为不可能。要被定义为 CRDT,数据结构必须仅支持交换性更新。也就是说,无论更新操作应用的顺序如何,最终状态必须始终相同。这是无合并冲突的最终一致性的秘密。当系统使用 CRDTs 时,所有节点都可以相互发送更新消息,而不需要严格的同步。消息可以以任何顺序接收,所有局部状态最终都会收敛到相同的值。

在前面的图中,我们看到节点 3 和节点 1 接收到了两个不同的更改。它们将这个更新信息发送给所有其他节点。请注意,我们并不关心其他节点接收更新的顺序。由于更新是交换的,它们的顺序对每个节点最终计算出的最终状态没有影响。一旦所有节点都接收到了所有的更新广播,它们将保证持有相同的数据。

存在两种类型的 CRDT:

基于操作的

基于状态的

它们在这一点上是等效的,因为对于每个基于操作的 CRDT,总是可以定义一个基于状态的 CRDT,反之亦然。然而,它们的实现不同,在错误恢复和性能方面提供了不同的保证。我们定义每种类型并考虑其特性。作为一个例子,我们实现了最简单的 CRDT 版本:仅增加计数器。

基于状态的仅增加计数器

使用这个模型,当 CRDT 从客户端接收到要执行的操作时,它会相应地更新其状态,并向集群中的所有其他 CRDT 发送更新消息。这个更新消息包含 CRDT 的完整状态。当其他 CRDT 接收到这个消息时,它们将它们的状态与接收到的新的状态合并。这个合并操作必须保证最终状态始终相同。它必须是交换的、结合的和幂等的。让我们看看这个数据类型的一个可能的实现:

case class CounterUpdate(i: Int)

case class GCounterState(uid: Int, counter: Int)

class StateBasedGCounter(

uid: Int, count: Int, otherCounters: Map[Int, Int]) {

def value: Int = count + otherCounters.values.sum

def update(

change: CounterUpdate): (StateBasedGCounter, GCounterState) =

(new StateBasedGCounter(uid, count + change.i, otherCounters),

GCounterState(uid, count))

def merge(other: GCounterState): StateBasedGCounter = {

val newValue = other.counter max otherCounters.getOrElse(other.uid,0)

new StateBasedGCounter(uid, count, otherCounters.+(other.uid -> newValue) )

}

}

客户端可以使用 update 方法来增加计数器的值。这会返回一个新的基于状态的计数器,包含更新的计数,并生成一个可以发送到集群中所有其他 CRDT 的 CounterState 对象。merge 用于处理这些 CounterState 消息,并将其他计数器的新状态与本地状态合并。计数器在集群中有一个唯一的 ID。内部状态由本地状态(即 count)和集群中所有其他计数器的状态组成。我们保持这些计数器在一个映射中,并在从不同的计数器接收状态信息时在 merge 方法中更新这个映射。合并是一个简单的操作。我们比较传入的值与我们映射中的值,并保留最大的一个。这是为了确保如果我们收到两个更新消息的顺序错误,我们不会用延迟的较旧更新消息覆盖最新的状态(即最大的数字)。

基于操作的仅增加计数器

基于操作的 CRDT 与基于状态的 CRDT 类似,不同之处在于更新消息只包含刚刚执行的操作的描述。这些 CRDT 不在更新消息中发送它们的完整状态,但它们仅仅是它们刚刚执行以更新自己状态的操作的副本。这确保了集群中的所有其他 CRDT 都执行相同的操作并保持它们的状态同步。更新消息可以按不同的顺序被集群的每个节点接收。为了保证所有节点的最终状态相同,更新必须是交换的。您可以在以下示例中看到这种数据结构:

class OperationBasedCounter(count: Int) {

def value: Int = count

def update(change: CounterUpdate): (OperationBasedCounter, CounterUpdate)

=

new OperationBasedCounter(count + change.i) -> change

def merge(operation: CounterUpdate): OperationBasedCounter =

update(operation)._1

}

此实现比基于状态的示例更短。update 方法仍然返回计数器的更新实例以及应用过的 CounterUpdate 对象。对于基于操作的计数器,只需广播应用过的操作即可。这个更新通过其他实例的 merge 方法接收,以将相同的操作应用于它们自己的内部状态。请注意,update 和 merge 是等效的,merge 实际上是基于 update 实现的。在这个模型中,每个计数器不需要唯一的 ID。

基于操作的 CRDTs 使用可能更小的消息,因为它们只发送每个离散操作,而不是它们的完整内部状态。在我们的例子中,基于状态的更新包含两个整数,而基于操作的更新只有一个。较小的消息可以帮助减少带宽使用并提高系统的吞吐量。然而,它们对通信故障敏感。如果在传输过程中更新消息丢失并且没有到达节点,这个节点将无法与集群的其他部分保持同步,并且无法恢复。如果您决定使用基于操作的 CRDTs,您必须能够信任您的通信协议,并确信所有更新消息都到达了目的地并且得到了适当的处理。基于状态的 CRDTs 不受此问题的影响,因为它们总是在更新消息中发送整个状态。如果消息丢失并且没有到达节点,该节点将仅在收到下一个更新消息之前与集群不同步。通过实现节点的状态周期性广播,即使没有执行更新,也可以使此模型更加健壮。这将迫使所有节点定期发送其当前状态,并确保集群始终最终一致。

CRDTs 和自动化交易员

根据我们系统的需求,CRDTs 似乎非常适合我们的实现。每个节点可以将每个客户的当前余额状态保存在内存中作为一个计数器,在下单时更新它,并将更新消息广播到系统的其余部分。这种广播可以在关键路径之外进行,我们不必担心处理冲突,因为这是 CRDTs 的设计目的。最终,所有节点都将内存中每个余额具有相同的值,并且它们将能够本地检查交易授权。余额监控服务器可以完全移除。

要将余额状态实现为 CRDT,我们需要一个比我们之前探索的更复杂的计数器。余额不能表示为只增加的计数器,因为偶尔订单会被取消,系统必须向客户的账户进行信用。计数器必须能够处理增加和减少操作。幸运的是,这样的计数器存在。让我们看看基于状态的计数器的一个简单实现:

case class PNCounterState(incState: GCounterState, decState: GCounterState)

class StateBasedPNCounter private(

incCounter: StateBasedGCounter,

decCounter: StateBasedGCounter) {

def value = incCounter.value - decCounter.value

def update(change: CounterUpdate): (StateBasedPNCounter, PNCounterState) = {

val (newIncCounter, newDecCounter, stateUpdate) =

change match {

case CounterUpdate(c) if c >= 0 =>

val (iC, iState) = incCounter.update(change)

val dState = GCounterState(decCounter.uid, decCounter.value)

(iC, decCounter, PNCounterState(iState, dState))

case CounterUpdate(c) if c < 0 =>

val (dC, dState) = decCounter.update(change)

val iState = GCounterState(incCounter.uid, incCounter.value)

(incCounter, dC, PNCounterState(iState, dState))

}

(new StateBasedPNCounter(newIncCounter, newDecCounter), stateUpdate)

}

def merge(other: PNCounterState): StateBasedPNCounter =

new StateBasedPNCounter(

incCounter.merge(other.incState),

decCounter.merge(other.decState)

)

}

PN 计数器利用我们之前实现的只增加的计数器来提供减少功能。为了能够将计数器表示为基于状态的 CRDT,我们需要跟踪增加和减少操作的状态。这是必要的,以确保如果我们的更新消息以错误的顺序被其他节点接收,我们不会丢失信息。

小贴士

记住,只增加的计数器通过假设计数器的最高值必然是最新的来保证冲突解决。这个不变量对于 PN 计数器不成立。

这个实现展示了 CRDTs 的另一个有趣特性:简单和基本的结构可以组合起来创建更复杂和功能丰富的 CRDTs。我们是否应该继续演示基于操作的计数器的实现?结果证明,并且我们确信你之前已经注意到了,我们之前的只增计数器已经支持减操作。应用正或负的 delta 由基于操作的计数器处理。

当余额不足时

你已经完成了概念验证的实现,并打电话给 Alice 获取一些反馈。她花了几分钟研究你的新设计和你的代码。“看起来不错。别忘了同步账户黑名单。”她在说什么?“检查账户余额只是允许或阻止自动化交易的一个标准之一。客户的其他属性也需要考虑。今天,自动化交易员在后台运行信任算法,并为每位客户计算一个分数。如果分数低于某个阈值,账户将被列入黑名单直到交易日结束,并且所有自动化订单都将被拒绝。我喜欢你的设计,但你需要将这个黑名单纳入到新系统中。”面对这个新的挑战,你认为最好的解决方案是将黑名单也实现为 CRDT,前提是它适合你的当前设计。

一个新的 CRDT - 只增集合

一个 CRDT 被设计来处理我们的新用例。只增集合数据类型实现了一个只支持添加新元素而不重复的集合。我们可以将黑名单实现为只增集合。每个节点都可以运行自己的信任算法,并决定是否应该将客户端列入黑名单并拒绝当天剩余的自动化交易。在一天结束时,系统可以清除集合。以下是我们展示的一个基于状态的只增集合的可能实现:

case class AddElementA

case class GSetStateA

class StateBasedGSetA {

def contains(a: A): Boolean = value.contains(a)

def update(a: AddElement[A]): (StateBasedGSet[A], GSetState[A]) = {

val newSet = new StateBasedGSet(value + a.a)

(newSet, GSetState(newSet.value))

}

def merge(other: GSetState[A]): StateBasedGSet[A] = {

new StateBasedGSet(value ++ other.set)

}

}

我们的实现支持通过调用update方法添加元素。它返回一个带有更新集合的新StateBasedGSet实例,以及一个要广播到其他节点的GSetState实例。此更新包含计数器的整个状态,即内部集合。基于操作的实现是微不足道的,留给读者作为练习(代码库中提供了一个可能的解决方案)。类似于之前探索的增量-减量计数器,可以创建一个同时支持添加和删除元素的集合。但是有一个注意事项:由于添加和删除元素不是交换操作,必须优先考虑其中一个。在实践中,可以创建一个 2P 集合来支持添加和删除项目,但一旦删除,元素就不能再次添加。删除操作具有优先权,并保证操作是交换的,可以无冲突地处理。一个可能的实现是将两个仅增长的集合组合起来,一个用于添加元素,另一个用于删除它们。我们再次看到了简单 CRDT 的强大之处,这些 CRDT 可以组合起来创建更强大的数据类型。

免费交易策略性能改进

你盯着你的敏捷燃尽图,发现你将在明天冲刺结束时完成所有的故事点。你对本周提前交付功能感到兴奋,但你仍然想知道你是否还会与敏捷大师就估算进行另一场讨论。与其在估算上浪费精力,你不如将注意力转回到 Dave 提出的问题上。在最近的一次午餐中,Dave 谈到了公司的交易策略在基于过时信息做出交易决策时亏损。即使是几毫秒的差距也可能在极其有利可图的交易和亏损之间产生差异。他的话激起了你的兴趣,想知道你是否能提高 MVT 交易策略的性能。

MVT 的交易策略是订单簿的下游消费者。交易策略会监听最佳买入价和卖出价(BBO)的变化,以便确定何时提交买入或卖出订单。在午餐时,Dave 解释说,历史上跟踪 BBO 已被证明为给 MVT 的交易策略提供最多的信号。最佳买入价指的是最高价格的买入价,最佳卖出价指的是最低价格的卖出价。当 BBO 的任一侧因取消、执行或新的限价订单而发生变化时,则将 BBO 更新事件传输到下游交易策略。表示此事件的模型是BboUpdated,其外观如下:

case class Bid(value: BigDecimal) extends AnyVal

case class Offer(value: BigDecimal) extends AnyVal

case class Ticker(value: String) extends AnyVal

case class BboUpdated(ticker: Ticker, bid: Bid, offer: Offer)

MVT 在其自己的 JVM 中部署每个交易策略,以确保失败不会影响其他正在运行的策略。部署后,每个交易策略为其交易的股票集合维护 BBO 订阅。

在花费了大量时间处理订单簿之后,你希望找到机会将你的函数式编程知识应用于提高性能。在与 Dave 共进午餐时,你发现“更好的性能”在交易策略开发中的含义与其他系统略有不同。你问 Dave,“如果你必须在延迟提升和吞吐量提升之间选择,你会选择哪一个?”Dave 带着讽刺的口气回答,“为什么我必须选择?我想要两者都要!”之后,他继续说,“延迟!每次交易策略使用旧的 BBO 更新做出决策时,我们都会损失金钱。事实上,如果我们能的话,我宁愿丢弃旧的 BBO 更新。我们只交易高量级的股票,所以我们几乎可以保证立即看到另一个 BBO 更新。”当你开始研究代码库时,你开始思考是否可以利用 Dave 的想法来提高交易策略的性能。

基准测试交易策略

回顾你在处理订单簿时学到的经验,你的第一步是进行基准测试。你选择 MVT 的一个生产交易策略,并将你为练习订单簿而编写的基准FinalLatencyBenchmark进行适配,以便将BboUpdated事件发送到交易策略。最初,基准测试主要关注显示 99^(th)百分位延迟以及更高。正如你所知,延迟是你性能调查中最重要的因素,因此你修改了基准测试,使其也发出中位数和 75^(th)百分位延迟。这将为你提供对交易策略性能延迟的更全面视角。

查看生产指标系统,你看到的是你想要基准测试的系统的时间序列交易量图表。它显示这是一个低量级的日子,每秒大约有 4,000 个 BBO 更新事件。你挖掘历史指标以找到过去几周中最高量级的一天。市场再次波动,所以最近的高量级一天可能是衡量高吞吐率的良好代理。大约两周前,有一个交易日,每秒 BBO 更新事件量持续达到 12,000。你计划从每秒 4,000 个事件的低端开始基准测试,逐步增加到每秒 12,000 个事件,以观察性能如何变化。

测试方法是在不同的吞吐量速率下测量等量事件的时间延迟,同时确保在每个吞吐量级别上进行彻底的测试。为了实现这一目标,你将 12,000 个事件每秒的较高吞吐量乘以 30 次试验,总共 360,000 个事件。在 4,000 个事件每秒的情况下,进行 90 次基准测试相当于 360,000 个事件。在模拟生产环境的测试环境中运行基准测试,得到以下表格中显示的结果。表格将每秒事件数缩写为 EPS:

百分位

4,000 EPS

12,000 EPS

第 50 百分位(中位数)

0.0 ms

1,063.0 ms

第 75 百分位

0.0 ms

1,527.0 ms

第 99 百分位

10.0 ms

2,063.0 ms

第 99.9 百分位

22.0 ms

2,079.0 ms

第 100 百分位(最大值)

36.0 ms

2,079.0 ms

这些结果展示了性能的惊人对比。在每秒 4,000 个事件时,交易策略看起来表现良好。99%的事件在 10 毫秒内得到响应,并且我们观察到,直到 75 百分位,策略的响应延迟极小。这表明在低交易量日,这种交易策略能够快速做出信息决策,这对盈利性应该是有利的。不幸的是,在每秒 12,000 个事件时,性能是不可接受的。由于尚未查看代码,你怀疑是否可以通过在 4,000 和 12,000 事件每秒之间进行多次吞吐量扫描来发现性能的突然变化。你尝试在 4,000 和 12,000 事件每秒之间进行二分搜索,并得到以下结果:

百分位

9,000 EPS

10,000 EPS

11,000 EPS

11,500 EPS

第 50 百分位(中位数)

0.0 ms

4.0 ms

41.0 ms

487.0 ms

第 75 百分位

5.0 ms

9.0 ms

66.0 ms

715.0 ms

第 99 百分位

32.0 ms

47.0 ms

126.0 ms

871.0 ms

第 99.9 百分位

58.0 ms

58.0 ms

135.0 ms

895.0 ms

第 100 百分位(最大值)

67.0 ms

62.0 ms

138.0 ms

895.0 ms

你选择每秒 9,000 个事件作为起始点,因为它可以均匀地分成总事件数 360,000 个。在这个吞吐量级别上,策略的轮廓在定性上更接近每秒 4,000 个事件的轮廓。由于在这个级别上结果看起来合理,你将吞吐量增加到 9,000 和 12,000 事件每秒之间的中间值,以达到下一个可以均匀分成 360,000 的事件数的级别。在每秒 10,000 个事件时,我们再次观察到与每秒 4,000 个事件轮廓相似的轮廓。中位数和 75 百分位延迟的明显增加表明策略的性能开始下降。接下来,你将吞吐量增加到中间值,即每秒 11,000 个事件。由于你不能运行 32.72 次试验,你将试验次数向上取整到 33 次,总共 363,000 个事件。这些结果在各个测量的百分位上比每秒 4,000 个事件的结果差大约一个数量级。诚然,这些是弱性能结果,但这个轮廓是否与每秒 12,000 个事件的轮廓相似?

你现在有点惊慌,因为每秒 11,000 个事件大约是每秒 12,000 个事件吞吐量的 90%。然而,结果并没有显示出接近 90%的相似性。如果交易策略线性下降,你预计会看到接近于在每秒 12,000 个事件时观察到的延迟的 90%。对这个性能配置文件不满意,你尝试了另一个吞吐量,每秒 11,500 个事件。在这个吞吐量级别上,你进行了 31 次基准测试,总共 356,500 个事件。通过将吞吐量增加大约 5%,观察到的中位延迟大约是原来的 11 倍,观察到的 99^(th)百分位延迟几乎是原来的 6 倍。这些结果清楚地表明,策略的运行时性能呈指数下降。为了更好地理解结果,你迅速制作了以下条形图:

这个条形图可视化了性能的指数衰减。有趣的是,我们观察到所有测量的延迟百分位数都遵循一致的衰减模式,进一步证实了该策略已耗尽其处理请求的能力的假设。在着手提高交易策略性能之前,你思考着,“我该如何限制延迟的指数增长?”

注意

与观察到所有测量延迟百分位数持续衰减的情况不同,想象一下中位数和 75^(th)百分位数在所有配置的吞吐量级别上保持定性上的恒定。这个配置文件是否表明了与我们在处理的场景相同的性能障碍类型?花点时间考虑一下什么可能导致这种分布出现。

无界队列的危险

基准测试揭示了一个关于性能调优的普遍真理:无界队列会杀死性能。在这里,我们使用“队列”这个术语广泛地指代一个等待队列,而不是专门关注队列数据结构。例如,这个基准测试在List中排队事件,以便在特定的时间点进行传输。在生产环境中,这个队列存在于多个层级。BboUpdated事件的发送者可能在应用层排队事件,随后,网络协议(例如,TCP)可能会使用它自己的队列集来管理向消费者传输。当事件的处理速度慢于产生速度时,系统会变得不稳定,因为工作积压总是不断增加。假设有无限的内存和零响应时间保证,应用程序可以继续处理不断增长的队列项。然而,在实践中,当系统无法通过增加其消费速率以匹配或超过生产速率来自我稳定时,系统最终会失控。系统的硬件资源是有限的,随着消费者落后,它将需要越来越多的内存来应对不断增长的工作积压。如果内存需求增加到了极端,会导致垃圾回收更加频繁,这反过来又会进一步减慢消费速度。这是一个循环问题,最终会耗尽内存资源,导致系统崩溃。

通过检查交易系统代码,你会发现交易系统中存在一个用于消息处理的队列。这个应用层队列是一个LinkedBlockingQueue,它将网络 I/O 线程与应用线程分开。在基准测试中,驱动基准测试的线程直接将事件添加到队列中,模拟了生产网络线程从外部世界接收事件的行为。将应用程序的逻辑部分组合在一起,分别放入单独的线程池中,以通过并行化处理工作来提高效率,这是一种常见的做法。

注意

当我们之前使用Future和Task探索并发时,我们间接地与队列一起工作。接收Future和Task提交的ExecutorService通过将任务入队到BlockingQueue来管理其工作负载。Executors中提供的工厂方法不允许调用者提供队列。如果你探索这些工厂方法的实现,你会发现创建的BlockingQueue的类型和大小。

在网络层和应用层之间添加缓冲通常对性能有益。队列可以使应用程序能够容忍来自生产者的暂时性消费减速和消息突发。然而,正如我们在基准测试中所看到的,缓冲是一把双刃剑。LinkedBlockingQueue的默认构造函数实际上是未限定的,设置了一个等于最大支持整数值的限制。当生产率持续高于消费率时,无限期地缓冲消息,交易系统的性能会下降到不可用的状态。

应用反向压力

如果我们选择将接收事件的队列限制在一个更小的限制,会发生什么?当生产率超过消费率并且队列达到容量时,一个选项是系统阻塞,直到队列中有空位。阻塞迫使事件生产停止,这描述了应用反向压力的策略。在这个上下文中,压力指的是待处理的事件队列。压力通过不断增加的资源使用(例如,内存)表现出来。通过采用阻塞进一步生产的策略,系统将压力反馈给生产者。任何存在于应用级消费者和生产者之间的队列最终也会达到容量,迫使生产者改变其生产率以继续传输事件。

为了实现这种反向压力策略,所有队列都必须限制在一个大小,以避免过度使用资源,并且在队列满时必须阻塞生产。使用 JDK 提供的BlockingQueue接口的实现,这很容易实现。例如,以下片段使用LinkedBlockingQueue展示了这种策略:

val queue = new LinkedBlockingQueueMessage

queue.put(m)

在这个片段中,我们看到构建了一个容量限制为 1,000 条消息的LinkedBlockingQueue。根据对生产环境的了解,你感到舒适地保留最多 1,000 条消息在内存中,而不会耗尽内存资源。片段中的第二行演示了通过put方法进行阻塞操作来入队一个元素。

在应用反向压力时,队列大小的选择至关重要。为了说明原因,让我们假设我们测量了最大交易系统处理延迟为 0.5 毫秒,一旦从事件队列中消费了一条消息。在最坏的情况下,一个事件的总处理延迟等于 0.5 毫秒加上等待处理的等待时间。考虑以下场景:当新事件到达时,队列大小为 1,000,并且有 999 个事件已经排队。在最坏的情况下,新事件需要等待 499.5 毫秒,直到 999 个已经排队的其他事件被处理,再加上 0.5 毫秒来处理。配置队列大小为 1,000 产生了最大延迟 500 毫秒,这表明最大延迟与队列大小成正比。

一种更严谨的队列大小确定方法涉及考虑环境资源以及业务所能容忍的最大延迟。从与 Dave 的非正式讨论中,我们了解到,即使是几毫秒也可能决定交易策略的盈利能力。在我们有机会与他联系之前,让我们假设 10 毫秒是策略可以容忍的最大延迟,而不会造成重大的交易损失。使用这些信息,我们可以计算出确保 10 毫秒延迟限制得到遵守的队列大小。在先前的例子中,我们进行了以下最坏情况下的算术运算:

maximum total processing latency = queue size * maximum processing time

我们可以重新排列这个公式来求解队列大小,如下所示:

queue size = maximum total processing latency / maximum processing time

从这个算术中,我们代入已知值来计算队列大小,如下所示:

queue size = 10ms / 0.5ms = 20

算术表明,我们将队列大小限制为 20 个元素,以确保在最坏的情况下,一个事件可以在 10 毫秒内入队并处理。为了更深入地了解反压,我们鼓励您阅读马丁·汤普森在mechanical-sympathy.blogspot.com/2012/05/apply-back-pressure-when-overloaded.html上发表的以下博客文章。马丁是高性能软件开发方面的权威人士,这篇特定的博客文章是关于反压的宝贵学习资源。

应用负载控制策略

反压是一种策略,当消息生产者尊重以不同速率运行的消费者且不对慢速消费者进行惩罚时,这种策略效果很好。尤其是在处理第三方系统时,有时对生产者施加反压以迫使其减速可能不会得到良好的接受。在这些情况下,我们需要考虑额外的策略,以提高我们系统的容量,而无需对业务逻辑的算法进行改进。

注意

作者们在实时竞价(RTB)领域工作过,在这个领域,竞价系统参与拍卖,以竞标展示广告的机会。在这个行业中,对于无法应对配置的拍卖速率的竞价系统,容忍度很低。未能及时对高比例的拍卖做出竞价决定(无论是出价还是不出价),会导致竞价系统被罚入“小黑屋”。在“小黑屋”中,竞价系统会收到降低的拍卖速率。长时间留在“小黑屋”的竞价系统可能会被禁止参与任何拍卖,直到其性能改善。

让我们回顾一下我们在描述背压时考虑的场景,以激发我们的讨论。应用背压的前提是达到队列的容量。当队列满时,我们的第一种策略是阻止进一步的添加,直到有空间可用。我们可以调查的另一种选择是丢弃事件,因为系统已饱和。丢弃事件需要额外的领域知识来理解突然终止处理的意义。在交易系统领域,交易策略仅在收到出价或要约时才需要发送响应。当交易策略决定不进行出价或要约时,它不需要发送响应。对于交易系统领域,丢弃事件简单地意味着停止处理。在其他领域,例如实时竞价(RTB),丢弃事件意味着停止处理,并响应一条消息,表明不会在这个拍卖中放置出价。

此外,值得注意的是,每个事件都是最佳出价和要约的快照。与快照相反,想象一下,如果交易策略收到最佳出价和要约变化的离散事件,而不是BboUpdated。这与我们所探讨的基于状态与基于操作的 CRDT 操作类似。丢弃事件意味着在接收到后续事件之前只能获得部分信息。在这种情况下,与领域专家和产品所有者合作,确定在何种情况下以及多长时间内使用部分信息是可接受的,这是非常重要的。

在处理高性能系统时引入负载控制策略是思维方式的另一种转变。就像引入背压一样,这也是重新考虑在提高性能过程中所做假设的另一个机会。我们与 Dave 的午餐讨论为我们提供了一个可以应用的负载控制策略的深刻见解。Dave 表示,他认为潜在的BboUpdated事件对交易策略的盈利性弊大于利。我们可以挑战以下两个假设:

所有事件都必须被处理

正在处理的事件必须完成处理

我们可以挑战这些假设,因为 Dave 还指出 MVT 交易只涉及高成交量股票。如果丢弃 BBO 更新,Dave 有信心新的 BBO 更新一定会很快跟上来。让我们更深入地看看这些策略如何定义。

拒绝工作

拒绝工作并不是指拒绝冲刺任务,抱歉!在这个上下文中,当我们讨论工作时,这个术语指的是处理努力。在基准测试的交易系统中,当前的工作正在处理一个新的 BboUpdated 事件。尽管我们还没有深入研究代码,但我们知道从之前的基准测试工作中,有一个队列用于从网络接受 BboUpdated 事件以进行应用级处理。这个队列是进入应用程序的入口点,它代表了由于容量限制而拒绝事件的第一个应用级机会。

从我们之前的领域研究中,我们了解到拒绝请求,只需简单地将其丢弃在地板上,无需响应。交易策略只有在希望交易时才需要响应。这意味着拒绝工作的策略可以通过在队列达到容量时将请求丢弃在地板上来实现。

通过检查交易系统源代码,我们看到架构相当简单。在启动时,创建了一个 LinkedBlockingQueue 来缓冲 BboUpdated 事件,并启动了一个消费者线程从队列中消费。以下代码片段显示了此逻辑:

val queue = new LinkedBlockingQueue(MessageSentTimestamp, BboUpdated)

val eventThread = new Thread(new Runnable {

def run(): Unit = while (true) {

Option(queue.poll(5, TimeUnit.SECONDS)) match {

case Some((ts, e)) => // process event

case None => // no event found

}

}

})

eventThread.setDaemon(true)

eventThread.start()

根据我们之前的工作,我们看到工作队列的大小设置为二十个元素,以确保最大处理延迟为 10 毫秒。队列实例化后,创建并启动了消费者线程。此代码片段省略了处理逻辑,但我们观察到这个线程的唯一目的是消费可用的事件。将工作添加到队列的逻辑很简单。此代码片段假设 MessageSentTimestamp 和 BboUpdated 事件分别在 ts 和 e 的名称中具有词法作用域:

queue.put((ts, e))

我们对背压应用的探索表明,put 是一个阻塞调用。鉴于我们现在的意图是丢弃工作,put 已不再是可行的策略。相反,我们可以利用 offer。根据 API 文档,offer 返回一个 boolean 值,指示元素是否被添加到队列中。当队列满时,它返回 false。这正是我们希望强制执行的语义。我们可以相应地修改此代码片段:

queue.offer((ts, e)) match {

case true => // event enqueued

case false => // event discarded

}

注意

前一个代码片段中的模式匹配为引入应用指标以进行内省和透明度提供了良好的切入点。例如,跟踪交易系统随时间丢弃的事件数量可能是一个有趣的业务指标。这些信息也可能对数据科学团队进行离线分析有用,以确定丢弃事件和盈利性之间的有趣模式。每当遇到状态变化时,都值得考虑是否应该记录指标或是否应该发出事件。花点时间考虑一下你应用程序中的状态变化。你是否让状态变化对非技术团队成员可内省?

以每秒 12,000 事件和 30 次试验进行基准测试,总共处理了 360,000 个事件,得到以下结果:

指标

队列大小 = 20 的每秒 12,000 EPS

50^(th)(中位数)延迟

0.0 毫秒

75^(th) 延迟

0.0 毫秒

99^(th) 延迟

3.0 毫秒

99.9^(th) 延迟

11.0 毫秒

100^(th)(最大)延迟

45.0 毫秒

平均延迟

0.1 毫秒

处理事件占总事件的比例

31.49%

此表引入了两行来记录观察到的平均延迟和处理的 360,000 个事件中的百分比。这一行很重要,因为系统现在拒绝事件,这是以延迟改进为代价提高交易吞吐量的一个例子。与每秒 12,000 事件的第一次基准测试相比,延迟配置文件看起来非常好。最大延迟是我们的期望最大延迟的四倍。这表明我们的性能模型过于乐观。更高的最大延迟可以归因于不幸的垃圾回收暂停和错误估计实际处理延迟。即便如此,最大延迟比第一次基准测试观察到的最大延迟低两个数量级。我们还观察到,99.9% 的请求的延迟小于或等于 11 毫秒,这在我们声明的最大延迟目标内 10%。

虽然延迟配置文件看起来很好,但吞吐量却不能这么说。由于我们新的负载控制策略,只有大约 30% 的事件被处理。当事件被处理时,处理速度很快,但不幸的是,三分之二的事件被丢弃。从性能调整和负载控制策略中得到的另一个教训是,你可能会需要多次迭代才能正确调整策略,以在吞吐量与延迟之间取得适当的平衡。回顾基准测试的结果,你注意到平均观察到的延迟是 0.1 毫秒。作为下一步,你选择根据平均延迟校准队列大小。通过根据平均延迟进行调整,你暗示你愿意为了提高吞吐量而引入延迟。进行算术运算后,得到新的队列大小:

queue size = maximum total processing latency / maximum processing time = 10ms / 0.1ms = 100

在重新运行带有新队列大小的基准测试后,你观察到了以下结果:

指标

队列大小 = 100 的每秒 12,000 EPS

50^(th)(中位数)延迟

3.0 毫秒

75^(th) 延迟

5.0 毫秒

99^(th) 延迟

19.0 毫秒

99.9^(th) 延迟

43.0 毫秒

100^(th)(最大)延迟

163.0 毫秒

平均延迟

3.9 毫秒

处理事件占总事件的比例

92.69%

如预期的那样,与队列大小为 20 的试验相比,延迟特征有所下降。除了最大延迟外,每个百分位数都至少经历了延迟的两倍增长。这个实验的好消息是尾部延迟没有经历指数增长。吞吐量图也发生了显著变化。我们观察到吞吐量增加了两倍以上,处理了所有事件的近 93%。平均延迟是之前记录的 0.1 毫秒平均延迟的 39 倍。为了比较目的,平均数反映了中位数和 75^(th)百分位数延迟的显著增加。

作为最后的测试,出于好奇,你尝试将吞吐量率加倍,同时保持队列大小为 100 个元素。交易系统会崩溃吗,它会处理所有请求,还是会做些不同的事情?运行基准测试产生了以下结果:

指标

24,000 EPS,队列大小 = 100

50^(th) (中位数)延迟

7.0 毫秒

75^(th)延迟

8.0 毫秒

99^(th)延迟

23.0 毫秒

99.9^(th)延迟

55.0 毫秒

100^(th) (最大)延迟

72.0 毫秒

平均延迟

8.4 毫秒

处理事件占总事件的比例

44.58%

好消息是交易系统没有崩溃。它承受了接收是之前导致第二次延迟的两倍吞吐量的压力,其延迟特征与每秒 12,000 个事件的相同试验质量相似。这表明拒绝工作策略使交易系统对高量级的事件输入具有显著更高的鲁棒性。

在更高吞吐量下提高耐用性和可接受的延迟的权衡是降低吞吐量。这些实验揭示了限制队列大小的重要性,这是我们学习如何在应用背压的同时了解拒绝工作价值时学到的。在实施负载控制策略并仅调整队列大小后,我们能够产生显著不同的结果。肯定还有进一步分析和调整的空间。进一步的分析应涉及产品所有者,权衡吞吐量与延迟的权衡。重要的是要记住,尽管负载控制策略的实施依赖于对高度技术主题的了解,但其好处应以商业价值来衡量。

中断昂贵的处理

我们可以探索的第二个想法是在处理完成之前停止处理。这是一种强大的技术,可以确保处理周期不会浪费在已经过时的工作上。考虑一个从队列中取出并经过部分处理但在垃圾回收周期中断的请求。如果垃圾回收周期超过几毫秒,该事件现在已过时,可能会损害交易策略的盈利能力。更糟糕的是,队列中的后续事件现在也更可能过时。

为了解决这个不足,我们可以应用一种类似于通过在整个处理过程中施加延迟限制来拒绝工作的技术。通过携带一个表示处理开始时间的戳记,我们可以在不同的时间点评估计算的延迟。让我们考虑一个制造出的例子来说明这个想法。考虑以下处理流程,它在记录事件并更新指标后,为事件运行任意业务逻辑:

def pipeline(ts: MessageSentTimestamp, e: Event): Unit = {

val enriched = enrichEvent(e)

journalEvent(enriched)

performPreTradeBalanceChecks(enriched)

runBusinessLogic(enriched)

}

}

为了避免处理潜伏事件,我们可能编写类似于以下逻辑的代码:

def pipeline(ts: MessageSentTimestamp, e: Event): Unit = {

if (!hasEventProcessingExpiryExpired(ts)) {

val enriched = enrichEvent(e)

if (!hasEventProcessingExpiryExpired(ts)) journalEvent(enriched)

if (!hasEventProcessingExpiryExpired(ts)) performPreTradeBalanceChecks(enriched)

if (!hasEventProcessingExpiryExpired(ts)) runBusinessLogic(enriched)

}

}

}

在这个片段中,引入了一个hasEventProcessingExpiryExpired方法来分支处理,这是基于时间的。这个方法的实现被省略了,但你可以想象系统时间被查询并与已知和允许的处理持续时间(例如,5 ms)进行比较。虽然这种方法实现了我们中断潜伏事件处理的目标,但代码现在被多个关注点所杂乱。即使在这样一个简单的例子中,也变得更加难以追踪处理步骤的顺序。

这段代码的痛点在于业务逻辑与中断潜伏处理的横切关注点交织在一起。提高代码可读性的一个方法是将所完成工作的描述与如何执行这种描述分开。在函数式编程中有一个结构,称为自由单子,可以帮助我们做到这一点。让我们更深入地了解自由单子,看看我们如何使用它来提高交易策略的性能。

自由单子

在范畴论主题中,单子及其数学基础是密集的主题,值得专门探索。随着你的冲刺明天结束,你想要交付改进的交易策略性能,我们提供一个实践者的视角来展示你如何使用它们来解决现实世界的问题。为了展示将自由单子应用于我们问题的力量,我们首先展示最终结果,然后回溯以发展对自由单子如何工作的直觉。首先,让我们考虑交易策略从工作队列中选中BboUpdated事件后所需的处理步骤序列:

val enriched = enrichEvent(bboEvent)

journalEvent(enriched)

performPreTradeBalanceChecks(enriched)

val decision = strategy.makeTradingDecision(enriched)

decision.foreach(sendTradingDecision)

在交易策略做出交易决策之前,有三个步骤发生。如果交易决策是提交一个出价或报价,决策将被发送到交易所。"strategy"是TradingStrategy特质的实现,其外观如下:

trait TradingStrategy {

def makeTradingDecision(e: BboUpdated): Option[Either[Bid, Offer]]

}

接下来,让我们看看我们如何将这个处理序列转换为自由单子,并添加早期终止逻辑。

描述一个程序

为了构建我们新的交易策略管道版本,我们使用 Scalaz 提供的自由单子实现 scalaz.Free。我们使用自由单子结合领域特定语言 (DSL) 以简化构建的努力的结果如下所示:

val pipeline = for {

enriched <- StartWith(enrichEvent) within (8 millis) orElse (e =>

enrichmentFailure(e.ticker))

_ <- Step(journalEvent(enriched)) within (9 millis) orElse

tradeAuthorizationFailure

_ <- Step(performPreTradeBalanceChecks(enriched)) within (10 millis)

orElse metricRecordingFailure

decision <- MakeTradingDecision(enriched)

} yield decision

回想一下,我们第一次尝试实现短路逻辑涉及一系列的 if 语句。而不是 if 语句,基于自由单子的代码片段显示,处理管道现在可以被定义为 for-comprehension。这种方法消除了分支语句,使得理解正在发生的事情变得更加简单。在不看到 DSL 如何构建的情况下,你很可能已经推断出这个管道将做什么。例如,你很可能推断出如果 journalEvent 执行时间超过 10 毫秒,则处理将停止,并且不会调用 performPreTradeBalanceChecks 或 MakeTradingDecision。

管道构建只是故事的一半。在这个 for-comprehension 的实现背后,是自由单子。创建一个自由单子涉及两个部分:

构建程序描述

编写解释器以执行描述

for-comprehension 代表我们对程序的描述。它是对如何处理 BboUpdated 事件的描述,同时也定义了执行延迟约束。为了执行这个描述,我们必须构建一个解释器。

构建解释器

我们的解释器如下所示:

def runWithFoldInterpreter(

recordProcessingLatency: ProcessingLatencyMs => Unit,

strategy: TradingStrategy,

ts: MessageSentTimestamp,

e: BboUpdated): Unit = {

val (_, decision) = pipeline.free.foldRun(

PipelineState(ts, strategy, e)) {

case (state, StartProcessing(whenActive, whenExpired, limitMs)) =>

state -> (hasProcessingTimeExpired(state.ts, limitMs) match {

case true => whenExpired(e)

case false => whenActive(e)

})

case (state, Timed(whenActive, whenExpired, limitMs)) =>

state -> (hasProcessingTimeExpired(state.ts, limitMs) match {

case true => whenExpired()

case false => whenActive()

})

case (state, TradingDecision(runStrategy)) =>

state -> runStrategy(state.strategy)

}

decision.fold(logFailure, {

case Some(order) =>

sendTradingDecision(order)

recordProcessingLatency(ProcessingLatencyMs(

System.currentTimeMillis() - ts.value))

case None =>

recordProcessingLatency(ProcessingLatencyMs(

System.currentTimeMillis() - ts.value))

})

}

foldRun 方法是 Free 提供的一个方法,用于执行我们编写的程序描述。类似于 foldLeft 的签名,foldRun 接受一个表示初始状态的值,一个接受当前状态的 curried 函数,以及来自我们的处理管道的下一个处理步骤。下一个处理步骤由一个名为 Thunk 的 ADT 表示,具有以下成员:

sealed trait Thunk[A]

case class TimedA => A,

whenExpired: () => A,

limit: LimitMs) extends Thunk[A]

case class StartProcessingA extends Thunk[A]

case class TradingDecisionA extends Thunk[A]

Thunk 代数定义了可以转录到自由单子中的可能操作。我们之前展示的管道是通过组合 Thunk 成员的组合构建的。这个管道隐藏了 DSL 背后的构建,以消除冗余并提高可读性。以下表格将每个处理步骤映射到其关联的 Thunk:

步骤 DSL

Thunk

开始

开始处理

步骤

计时

做出交易决策

交易决策

回到柯里化的foldRun函数,我们看到解释器模式匹配以确定下一个处理步骤是哪个Thunk。这些模式匹配语句是解释器应用我们程序描述中描述的行为的方式。StartProcessing和Timed使用系统时间来确定根据提供的毫秒到期时间(LimitMs)执行哪个方法。StartProcessing和TradingDecision需要来自外部世界的状态来支持执行。对于StartProcessing,必须提供工作队列中的BboUpdated事件,而对于TradingDecision,必须提供一个Strategy以产生交易决策。

foldRun的返回值是一个累积状态的元组,该元组在片段中被丢弃,以及解释自由单子的返回值。由pipeline定义的Thunk序列的执行返回值是\/[BboProcessingFailure, Option[Either[Bid,Offer]]]。这个返回值是一个析取,以处理可能作为业务逻辑的一部分发生或因为处理到期时间已过期的失败场景。这些失败用类型为BboProcessingFailure的 ADT 表示。析取的右侧与TradingStrategy的返回类型匹配,表示完成pipeline中的所有步骤会产生一个交易决策。最后一步是对交易决策进行折叠,以记录当管道完成时的处理延迟(即返回了\/-),并条件性地向交易所发送订单。

在这个阶段,你应该已经形成的直觉是我们已经将我们希望发生的事情的描述与它如何发生的方式分离开来。自由单子允许我们通过首先创建我们程序的描述,然后其次构建一个解释器来执行由描述提供的指令来实现这一点。作为一个具体的例子,我们的pipeline程序描述并没有被如何实现早期终止的策略所拖累。相反,它只描述了处理序列中的某些步骤受到时间限制。提供给foldRun的解释器使用系统时间来强制执行这个限制。在构建了交易策略管道的运行版本之后,让我们再次进行基准测试,看看我们的变化产生了什么效果。

基准测试新的交易策略管道

使用新的交易策略管道以每秒 12,000 和 24,000 个事件运行基准测试,得到以下结果。结果列每行显示两个值。斜杠前的值是使用提供早期终止的新实现运行的结果。斜杠后的值是用于比较目的而复制的没有早期终止的运行结果:

指标

队列大小为 100 时的 12,000 EPS

队列大小为 100 时的 24,000 EPS

第 50 百分位(中位数)延迟

1.0 ms / 3.0 ms

6.0 ms / 7.0 ms

75^(th) 延迟

3.0 ms / 5.0 ms

7.0 ms / 8.0 ms

99^(th) 延迟

7.0 ms / 19.0 ms

8.0 ms / 23.0 ms

99.9^(th) 延迟

10.0 ms / 44.0 ms

16.0 ms / 55.0 ms

100^(th) (最大)延迟

197.0 ms / 163.0 ms

26.0 ms / 72.0 ms

平均延迟

2.0 ms / 3.9 ms

6.0 ms / 8.4 ms

处理事件占总事件的比例

90.43% / 92.69%

36.62% / 44.58%

从延迟角度来看,提前终止似乎是一个明显的胜利。排除最大延迟,提前终止在每个百分位上产生了更低的延迟。例如,在每秒 12,000 个事件的情况下,所有请求中有一半在一三分之一的时间内处理完成,仅用了 1 毫秒,而与处理未中断时的中位数相比。在每秒 12,000 个事件的情况下,观察到的最大延迟增加,这可能是提前终止检查后的垃圾回收暂停的指示。我们可以对我们的实现进行两项可能的改进:

在执行TradingStrategy之前,调用performPreTradeBalanceChecks之前检查处理持续时间

在交易决策创建后检查处理持续时间

在这两种情况下,如果延迟超过阈值,处理可以被中断。很容易看出,由于我们的免费单子实现提供的明确关注点分离,这两个处理步骤需要关注以减少最大延迟。考虑一下,如果管道和提前终止逻辑交织在一起,推理执行会多么具有挑战性。

从吞吐量角度来看,我们在两次试验中都看到了吞吐量的下降。吞吐量下降是由于被丢弃的潜在事件引起的。在这里,我们再次看到了吞吐量和延迟之间的权衡。我们为了更好的延迟特性牺牲了吞吐量。可以说,这是一个值得的权衡,因为更高的吞吐量包括了过时的事件,这些事件更有可能产生交易损失。

一个任务解释器

我们到目前为止的努力已经显著改善了延迟特性,而牺牲了吞吐量。如果我们能够两者兼得会怎样?一个改善了延迟特性且具有更高吞吐量的特性将是理想的,但似乎难以实现。提高吞吐量的一个策略是引入并发。也许,我们可以使交易策略执行并发,以利用具有多个核心的硬件。在深入之前,你联系了你的同事 Gary,他帮助你发现了订单簿实现的血统。你与 Gary 再次确认 MVT 策略是线程安全的。他回应了一个点赞表情符号,这给了我们并行化交易策略执行的绿灯。

在我们迄今为止对自由单子的探索中,我们已经看到了程序描述与解释器之间的关系。程序描述,用Thunk ADT 表示,对解释器是无关的。这个陈述代表了自由单子的本质,由 Adam Warski 在他的优秀的自由单子博客文章中最好地表述,见softwaremill.com/free-monads/。在自由单子中,“自由”这个术语的语义是单子可以自由地以任何方式被解释。我们将通过演示我们可以将现有的解释器转换为一个Task解释器来看到这个想法在实践中的应用。为此,我们必须将Thunk映射到Task。Scalaz 提供了一个特性来表示这种映射,称为NaturalTransformation,其类型别名为~>。以下代码片段显示了如何通过NaturalTransformation将Thunk映射到Task:

private def thunkToTask(ps: PipelineState): Thunk ~> Task =

new (Thunk ~> Task) {

def applyB: Task[B] = t match {

case StartProcessing(whenActive, whenExpired,

limitMs) => Task.suspend(

hasProcessingTimeExpired(ps.ts, limitMs) match {

case true => Task.now(whenExpired(ps.event))

case false => Task.now(whenActive(ps.event))

})

case Timed(whenActive, whenExpired, limitMs) => Task.suspend(

hasProcessingTimeExpired(ps.ts, limitMs) match {

case true => Task.now(whenExpired())

case false => Task.now(whenActive())

})

case TradingDecision(runStrategy) =>

Task.fork(Task.now(runStrategy(ps.strategy)))

}

}

该特性定义了一种实现方法,该方法接受一个Thunk并返回一个Task。与我们在foldRun中的先前的解释器一样,解释器需要相同的状态来提供BboUpdated事件、MessageSentTimestamp和TradingStrategy。我们使用模式匹配来处理每个 ADT 成员的映射。注意Task.suspend的使用,它具有以下签名:

def suspendA: Task[A]

与Task.now不同,suspend将参数的评估推迟。这是必要的,因为解释器在调用hasProcessingTimeExpired时具有检查系统时钟的副作用。使用suspend将系统时钟的调用推迟到Task运行时,而不是在Task构造时执行。

第二个有趣的实现注意事项是当转换TradingDecision时使用Task.fork。这是将并发引入交易策略管道的介绍。随着我们的转换完成,接下来的步骤是运行解释器。幸运的是,Free提供了一个类似于foldRun的方法,它接受一个名为foldMap的NaturalTransformation。以下代码片段显示了如何使用Task执行现有的Thunk管道:

pipeline.free.foldMap(thunkToTask(PipelineState(ts, strategy, event)))

.unsafePerformAsync {

case -\/(ex) => logException(ex)

case \/-(\/-(decision)) =>

decision.foreach(sendTradingDecision)

recordProcessingLatency(ProcessingLatencyMs(

System.currentTimeMillis() - ts.value))

case \/-(-\/(failure)) => logFailure(failure)

}

调用foldMap应用转换,产生一个Task。该Task通过unsafePerformAsync异步执行。让我们用我们的新实现以每秒 24,000 个事件的速度运行基准测试,并将结果与foldRun解释器进行比较:

指标

队列大小为 100 时的 24,000 EPS

第 50 百分位(中位数)延迟

0.0 ms / 6.0 ms

第 75 百分位延迟

0.0 ms / 7.0 ms

第 99 百分位延迟

4.0 ms / 8.0 ms

第 99.9 百分位延迟

13.0 ms / 16.0 ms

第 100 百分位(最大值)延迟

178.0 ms / 26.0 ms

平均延迟

0.13 ms / 6.0 ms

处理的事件占总事件的比例

96.60 % / 36.62%

在具有四个核心的计算机上运行Task解释器,在延迟和性能方面产生了实质性的差异。从吞吐量角度来看,几乎所有事件都可以被处理,与之前的 36%处理率形成对比。吞吐量的提升表明,通过使用Task.fork获得了额外的容量,这提供了运行时并行性。我们还观察到低百分位延迟的显著减少,这也可以归因于在多核机器上使用Task.fork。有趣的是,高百分位延迟仍然相当相似。正如我们之前指出的,这是因为我们仍然没有在处理管道的末尾防御潜在事件。从这个基准测试中得到的启示是,合理使用Task可以在提高延迟特性的同时,将吞吐量提高一倍。这是一个通过将交易策略视为黑盒,并且只改变系统与交易策略交互的方式所取得的令人兴奋的结果。

进一步探索免费单子

我们对免费单子的探索故意避免了深入探讨单子,而是专注于展示使用这种方法带来的实际结果。使用免费单子,我们向你展示了我们可以将程序的描述与其执行分离。这使我们能够干净地引入逻辑来中断潜在事件的处理。我们还通过编写Task解释器来向处理管道中添加并发性,而不会影响其构建。核心业务逻辑保持纯净,同时保留了优秀的运行时特性。在这里,我们看到了免费单子的显著特点。我们程序的描述是一个值,而解释器负责处理副作用。

在这个阶段,你可以看到应用这项技术的益处,但你仍然对背后的机制一无所知。对单子(monads)的全面探讨超出了我们探索的范围。通过研究与这些示例相关的源代码,以及探索其他学习资源,你将更深入地理解如何在你的系统中应用这项技术。我们建议深入阅读 Adam Warski 之前提到的博客文章,并回顾从另一个由 Ken Scrambler 构建的免费单子示例中链接的演示,该示例可在github.com/kenbot/free找到。为了更深入地理解单子,我们鼓励你阅读 Paul Chiusano 和 Rúnar Bjarnason 的《Scala 函数式编程》。

摘要

在本章中,我们关注了在更语言无关的上下文中进行的高性能系统设计。我们介绍了分布式架构,并解释了它们如何帮助扩展平台。我们提出了一些这种范式涉及到的挑战,并专注于解决集群内部共享状态的问题。我们使用 CRDTs(冲突检测与修复类型)在集群的节点之间实现高效且性能良好的同步。使用这些数据类型,我们简化了我们的架构,并通过消除对存储共享状态的独立服务的需求来避免创建瓶颈。我们还通过避免在关键路径上进行远程调用,将延迟保持在较低水平。

在本章的第二部分,我们分析了队列如何影响延迟,以及我们如何应用负载控制策略来控制延迟。通过基准测试交易策略管道,我们发现应用背压和限制队列大小对于推理最大延迟的重要性。无界队列最终会导致灾难性的生产性能。队列研究的正式名称是数学的一个分支,称为排队论。排队论,就像单子一样,是一个值得更正式处理的话题。我们专注于使用经验观察来推动改进。研究排队论将为您提供更强的理论基础和构建系统性能模型的能力。

我们将拒绝工作的策略扩展到中断耗时过长的任务。在这样做的时候,我们探索了一种新的函数式编程技术,即自由单子。自由单子允许我们保持干净的业务逻辑,描述管道做什么,而不关注管道如何实现其目标。这种关注点的分离使我们能够在不复杂化管道描述的情况下向管道添加并发性。我们讨论的原则使您能够编写高吞吐量和低延迟的系统,当系统达到容量时,这些系统能够保持稳健,同时仍然强调函数式设计。