本文不是关于哪种编程语言更好,而是讨论了用于开发最快的服务器端系统软件(例如数据库引擎和HTTPS服务器)的最强大的工具集。这种类型的软件有几个特定的属性:
- 相对较大的代码库,100,000行C或C ++代码以及更多。虽然可以用汇编语言编写特定的,最“热门”的函数,但用汇编语言编写整个程序是不切实际的。
- 数据库和Web服务器是关键任务软件-我们都习惯了带有MySQL和Nginx进程的Linux系统可以工作数月和数年。有一些简单的高可用性最佳实践(https://github.com/tempesta-tech/tempesta/wiki/High-availability)可以缓解由于可能的崩溃而导致的停机时间,但这是另一个话题。同时,值得一提的是,如果真的在乎高可用性,那么应该在假设系统的任何组件随时可能崩溃的前提下构建基础架构,就像Facebook这样做一样-该公司将部署最新版本的Linux内核,一经推出就可以使用。
多年来,我们一直在开发使用C,C ++和Assembly最快的软件。既然Rust一直“专注于性能”,我们对此非常感兴趣。这虽然有些怀疑。Java编程语言的兴起,有许多报道表明JIT编译产生的代码比C ++更快。现在很难找到一种情况,当C ++比Java慢时,请参见例如基准测试。还值得一提的是,Java中的内存垃圾回收(GC)会导致较高的尾部等待时间,并且很难甚至根本无法解决该问题。由于GC,不能将Golang用于高性能编程。
C还是C ++?还是两者都?C编程语言在系统编程中占主导地位。操作系统内核是最复杂的系统软件之一的示例,不仅因为它直接与硬件打交道,而且还由于严格的性能要求。Linux和FreeBSD内核以及其他UNIX和Windows内核都是用C编写的。
FreeBSD已经支持C ++模块已有一段时间了。虽然Linux内核从不支持C ++,但是有一个用C ++编写并用作Linux内核模块的Click模块化路由器。有一些根本原因反对使用C ++进行操作系统内核开发:
- 内核空间不需要带RTTI的libstdc++。实际上,dynamic_cast不是那么经常使用,并且有很多没有RTTI编译的C ++项目。因为libstdc++使用基本的C分配,如果需要移植到内核中,必须对内核进行大量修改。
- 不能使用STL和Boost库,实际上,所有内核都已经拥有自己的库。C ++引入了文件系统,线程和网络库,这在OS内核中是毫无意义的。另一方面,现代的OS提供了高级同步原语,而这些原语在标准C ++中仍然不可用(例如,在C ++中仍然没有读写自旋锁)。
- Linux内核提供的内存分配的数量(SLAB,页面vmalloc(),kmalloc()等),因此必须使用placement new和/或只使用C函数的内存分配和释放。对齐内存对于提高性能至关重要,但是需要编写特殊程序来包装new才能对齐内存。
- 系统编程里,原始内存指针经常被强制转换为某些数据结构,强类型安全性并不那么舒适。尽管这是有争议的:虽然有些人不习惯频繁reinterpret_cast<Foo *>(ptr)而不是更短的(Foo *)ptr,但是其他人却拥有输入更多来获得更多的类型安全性。
- C ++名字处理的命名空间和函数重载,使函数很难从Assembly调用,因此需要使用extern "C"。
- 静态对象的构造函数和析构函数创建特殊的代码段.ctor以及.dtor。
- C ++异常不能跨越上下文边界,即,不能在一个线程中抛出异常而在另一个线程中捕获它。操作系统内核处理更复杂的上下文模型:内核线程,进入内核的用户空间进程,延迟和硬件中断。上下文可以以自愿或合作的方式相互抢占,因此当前上下文的异常处理可以被另一个上下文抢占。内存管理和上下文切换和异常处理代码冲突。比如说RTTI,可以在内核中实现该机制,但是标准库不能使用。
- 虽然Clang和G ++等编译器支持__restrict__扩展,但官方的C ++标准不支持它。
- 虽然不鼓励在Linux内核中使用可变长度数组(VLA),在某些场景下它们仍然很方便使用,但是在C ++中完全不可用。
因此,在内核空间中使用C ++,基本上只有模板,类继承和一些语法糖(如lambda函数)。由于系统代码很少需要复杂的抽象和继承,那么在内核空间中使用C ++仍然有意义吗?
这是最值得商榷的C ++功能之一。例如,MySQL的项目,Google编码风格(https://google.github.io/styleguide/cppguide.html#Exceptions)不建议使用异常,说明使用异常的优缺点。在这里,仅关注性能方面。
当我们不得不在很多可能的地方处理错误代码时,异常可以提高性能,例如(让函数内联并且很小)
该代码的问题是存在额外的条件跳转。现代CPU可以很好地进行分支预测,但是仍然会影响性能。在C ++中可以这样写
,因此热点代码中没有多余的条件。但是,这不是没代价的:C ++代码中的大多数函数都必须带有额外的异常表,可以捕获的异常表和适当的清除表。函数结尾不会在正常的工作流中执行,但是它们增加了代码的大小,从而导致CPU指令缓存中的额外污染。在Nico Brailovsky的博客中找到有关C ++异常处理内部的详细信息(https://monoinfinito.wordpress.com/series/exception-handling-in-c/)。
首先,并不是所有代码实际上都必须尽可能快,并且在大多数情况下,不需要自定义内存分配,也不在乎异常开销。大多数项目是在用户空间中开发的,并且受益于相对丰富的C ++标准和Boost库(尽管不如Java丰富)。
其次,C ++的杀手锏是,它是C。如果不想使用异常或RTTI,则只需关闭功能即可。大多数C程序都可以使用C ++编译器进行编译,只需进行很小的更改或完全不进行任何更改。举个例子,我们只需要这个微不足道的改变
用G ++编译器编译C程序。现代的C ++编译器提供了C兼容性扩展,例如__restrict__关键字。可以用C风格编写C ++程序中性能最关键的代码。如果不喜欢带有额外开销的STL容器(https://250bpm.com/blog:8/),则可以使用Boost.intrusive(https://www.boost.org/doc/libs/1_74_0/doc/html/intrusive.html)甚至从Linux内核或其他快速C项目移植类似的容器。例如,请参阅如何在C ++基准测试中使用来自PostgreSQL的哈希表,来自Tempesta DB的HTrie(https://github.com/tempesta-tech/tempesta/tree/master/tempesta_db)和Linux内核读/写自旋锁(全部用C编写)。
关于使用C ++编写高性能程序的最后一件事必须提到的是模板元编程。对于现代C ++标准而言,使用模板可以编写非常复杂的逻辑,这些逻辑在编译时就可以完全计算出来,而在运行时则不花任何代价。
GOTO-C的力量高级和高性能编程语言的目标是生成最高效的机器代码。每种硬件体系结构都支持跳转,这意味着可以在任何条件下跳转到任何地址。C和C ++编程语言中最接近跳转的抽象是goto操作。它不像汇编那样灵活jmp,但是C编译器提供了扩展,使操作员几乎可以完全等同于汇编跳转。但是Rust不支持goto,这使它在整个性能关键型任务中都显得笨拙。
解析器。不是配置文件解析器,它是通过一堆switch and if语句完美完成的,而是关于大型且非常快速的解析器(如HTTP解析器)的。可能会认为这是“太狭窄”或“太具体”的任务,但是回想一下解析器生成器,例如Ragel或GNU Bison-如果开发这样的解析器生成器,那么您将永远不知道将生成多大的解析器。(顺便说一下,Ragel广泛用于goto生成非常快速的解析器。)还要注意每个RDBMS中的SQL解析器。实际上,我们可以将任务的类别概括为大型和快速的有限状态机,例如,还包括正则表达式。
该HTTP解析器在Tempesta FW在其他Web服务器不是HTTP解析器要大得多(https://github.com/tempesta-tech/tempesta/blob/master/tempesta_fw/http_parser.c),因为,除了基本的HTTP解析,也做了很多的安全检查,严格安装RFC标准验证输入。此外,我们的解析器还可以处理零拷贝数据,因此也非常关心数据块。在SCALE 17x会议上的演讲中描述了解析器的技术细节,这里可以观看演讲视频或幻灯片(http://tempesta-tech.com/research/http_str.pdf)。
通常,HTTP解析器实现为输入字符和嵌套switch语句的循环,以获取允许的字符和可用状态。例如ngx_http_parse_request_line(),请参见Nginx解析器源代码。为了简洁起见,看一个简化的代码版本:
假设解析器已经完成了100状态码解析,当前数据块以字母'b'开始。不管switch语句优化(编译器使用查找表或二进制搜索进行优化),代码都存在3个问题:
- 查找状态100仍然比直接跳转代价更高。
- 当状态代码放置在state101的代码之后时100,我们必须重新输入whileandswitch语句,即再次查找下一个状态,而不是仅进一步移动一个字符并直接跳到下一个状态。
- 即使状态101是在状态100之后,编译器也可以通过以下方式重新组织代码:将状态101放置在switch语句的开头,而将状态100放置在语句的末尾。
Tempesta FW使用goto语句和标签的GCC编译器扩展特性(https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html,https://gcc.gnu.org/onlinedocs/gcc/Label-Attributes.html)通过以下代码解决了所有问题:
由于Rust不支持该goto语句,因此需要使用汇编语言通过直接跳转和最佳代码布局来实现状态机。
当汇编比C容易时现在看一个示例,该示例中的汇编语言不仅可以生成更快的代码,还可以以更有效率的方式编写程序。此示例是关于多精度整数算术。
公钥密码术和椭圆曲线尤其是对大整数起作用。Tom St Denis所著的《BigNum Math:实现加密多精度算术》一书(https://www.amazon.com/BigNum-Math-Implementing-Cryptographic-Arithmetic/dp/1597491128)提供了有关该主题以及许多算法的C实现的详细信息,但先做两个64位相加得到128位长的大整数的加法机。求和必须关心进位,C代码看起来像(参见书中的4.2.1):
代码虽小又简单,但是没有对进位做过多思考。假设x86-64是CISC体系结构,它提供了许多计算功能,其中之一是带有进位的计算,因此上面的代码只能用两条指令完成,而无需进行比较:
如果您查看任何经过优化的加密库,例如OpenSSL或Tempesta TLS,那么您会发现很多汇编代码(OpenSSL实际上使用Perl脚本生成了汇编源代码)。
再回顾下Rust乍一看,Rust具备开发非常高效的代码的精良装备:SIMD内在函数,内存对齐,内存屏障,内联汇编。Rust与C或C ++有很多比较,例如Rust与C对比(https://kornel.ski/rust-c-speed),或Yandex基准测试表明C ++的速度比Rust更快,更安全(https://www.viva64.com/en/b/0733/)。但是,如果考虑使用Rust开发基准测试领先产品,那么可能会面临一些障碍以及缺少goto 操作符的麻烦:
- 从技术上讲,Rust支持自定义内存分配器,但是存在严重的局限性。任何高性能软件都使用许多临时内存分配器。
- 就像C ++一样,Rust不提供VLA(可变长数组)。但是,C ++仍然可以使用alloca(3),Rust根本不会提供堆栈分配。因为栈分配是成本最廉价的,自定义内存分配器不是一个好选择。
- 与现代C或C ++编译器相比,likely/unlikely支持/可能性似乎弱得多。(https://doc.rust-lang.org/std/intrinsics/fn.unlikely.html)
- 在Rust中可以从原始内存读写数据结构,但是比C甚至C ++需要更多的代码。不过没什么大不了的。(https://users.rust-lang.org/t/reading-structures-in-memory-via-pointers/33886)
- Rust的 generics和macro宏比C ++提供的模板和宏弱得多。虽然,这也不是那么关键。
关于Rust系统编程的最关键的失望是它处理原始内存的能力有限,这是内存安全的另一方面。
C ++和Rust中的可靠性和安全性如果不解决Rust和C ++编程语言提供的可靠性和安全性,本文将是不完整的。希望Microsoft的Sunny Chatterjee最近在CppCon 2020上发表了这个话题(https://www.youtube.com/watch?v=_pQGRr4P16w)。Rust的主要好处是内存和并发安全性,但是现代的C ++也解决了这些主题。在本演示中,Sunny解决了Rust与C ++之间的以下6个差距:转换,switch语句,更智能的循环,更智能的复制,生存期和可变性。回顾一下差距。
- 带有编译器选项的现代C和C ++编译器可以很好地处理类型转换-Wall。
- switch语句也使用进行处理-Wall。此外,GCC还引入了 -Wimplicit-fallthrough编译器选项,该选项使“通过”明确。
- 自C ++ 11起,更聪明的循环由基于范围的for循环解决。
- 智能复制会注意到const auto &参考和细粒度的复制和移动语义。
- RAII提供了强大的生命周期,但不幸的是并非涵盖所有情况。
- const带有或不带有mutable,const引用和变量的C ++类提供了更细粒度的可变性,但是也不能涵盖所有情况。
演示文稿的结尾是“ C ++核心准则的规则涵盖了许多重大项目”,现代C和C ++编译器趋向于实现错过的检查。还值得一提的是,C / C ++世界有效地使用了地址清理器(例如,ASAN内置于LLVM和GCC编译器的现代版本中)来捕获越界内存访问。毕竟,就像在C ++中使用原始指针一样,用Rust写错误的代码,都是不安全的。
计算机语言基准性能比较在谈论性能,看一下《计算机语言基准测试》(https://benchmarksgame-team.pages.debian.net/benchmarksgame/index.html)。要比较不同语言的性能,需要以相同的方式在所有语言中实现相同的任务。这不是人们通常要做的,很难找到不同语言的真实代码示例。这些示例将桔子与桔子进行比较,而不是将桔子与苹果进行比较。虽然Benchmarks游戏是一款游戏,。Benchmarks游戏中没有汇编语言,但是相应地有Rust(用于G ++编译器的C ++)和两个用于Clang和GCC编译器的C。性能以秒为单位。
只有一个测试,第一个测试,Rust明显优于C和C ++实现。
您可能很好奇,为什么Rust中的fannkuch-redux 实现比C实现更快?我们也是。这两个程序的副本均已删减。
C程序
Rust程序
让我们启动C程序,并使用Linux perf工具收集该程序的性能概况。我们可以通过在perf report或看到perf annotate:查看程序中最热门的代码
12.76%的时间是展开循环的一部分
和cmp指令的部分while循环条件。实际上,他的循环只是反转数组中的字节。尽管C实现使用带有数组索引的朴素操作和繁重操作,而Rust实现使用高效的double迭代器:
使用SIMD进行快速阵列反转!介绍了几种提高C程序性能的方法(https://dev.to/wunk/fast-array-reversal-with-simd-j3p)。第一种是只使用一个索引i和迭代仅直到与所述阵列的经置换的部分的中间temp_Permutation[i]和temp_Permutation[high_Index - i]。这和Rust双迭代器非常接近。顺便说一下,提高两个程序性能的更高级的方法是使用PSHUFBSSSE3指令或_mm_shuffle_epi8()内部指令,而不是整个循环。由于混洗掩码的数量很少,因此可以在编译时定义所有混洗掩码,然后将它们立即加载到指令的控制掩码寄存器中。
但是,这不是实现之间的唯一区别。Rust程序利用最大输入数const MAX_N: usize = 16。由于编译器现在可以对循环和静态数组进行更好的优化,因此这种小的改进可能对性能的影响最大。该程序显式使用静态数组初始化
,而C实现在运行时无需输入数据即可进行此操作
Rust程序使用内置内存复制功能复制阵列
而C程序再次循环执行此操作
这些并不是C程序中的所有低效率,在Rust实施中已将其消除(这两个程序都基于相同的初始Ada程序)。在大多数地方,该程序的优化版本不仅会更快,而且会更短。
因此,在这种情况下,当Rust实现的速度快于C时,性能的差异不是关于更好的编译器,而是关于程序的更有效的结构,这使编译器可以更好地优化代码。
Rust作为系统编程语言?真正的高级系统编程语言必须与C兼容。比如说现实生活项目中的2个示例。
第一个是Web应用程序防火墙(WAF)。这种软件通常基于Nginx或HAproxy HTTPS服务器(它们是用C编写)构建的。为Nginx编写C ++模块很容易,但是我们需要额外的粘合代码才能在Rust中开发该模块并维护所有补丁。 Nginx的C代码。相同的开发人员可以轻松地在代码的C和C ++部分之间切换。
第二种情况下,客户希望使用MySQL用户定义函数(UDF)与操作系统进行交互来执行一些外部逻辑。我们可以用任何编程语言开发逻辑,但是有一个限制:必须在每个CPU内核上每秒执行5000个程序!即使使用posix_spawnp()Linux中执行程序的最快方法,也无法实现这一点。最终为MySQL开发了一个自定义UDF,这是一个加载到MySQL服务器进程中的共享对象。使用C ++非常简单。
将Rust用作Nginx模块的一个相反的示例是CloudFlare的Quiche,这是一种Nginx扩展,支持QUIC和HTTP / 3协议。尽管绝对可以将Rust用于此类任务,但是除了用于C / C ++绑定的FFI代码之外,这些家伙仍然不得不编写一些C代码来修补Nginx。这意味着
- 必须为C / C ++绑定编写一些额外的样板代码
- 而且仍然必须处理C / C ++和第二种语言,这使项目更加复杂。
(顺便说一下,同样适用于D编程语言,它也不能直接包含C标头。)Quiche项目中的FFI和Nginx补丁程序仅约5,000行代码,即整个代码的10%项目,这是40,000行Rust代码。如果该项目是用C或C ++开发的,那么他们也将需要Nginx补丁,但是不需要第二语言。但是在Nginx主代码库中采用代码的机会为零。这就是实际发生的情况:Nginx团队拥有大供应商的生产就绪QUIC实现,因此开发了自己的C实现。很难说“绑定”代码是可以忽略的还是开发人员在样板代码上花费了多少时间。问题是,Rust内存安全性(现代核心C ++,静态分析和地址清理器也可以实现)是否使开发如此高效,以至于额外的代码和以两种不同语言维护的代码库可以忽略不计?
结论在为Tempesta FW开发HTTP解析器时,达到了C语言的极限:如果没有在switch语句中进行查找,就无法直接跳到解析器的所需状态,也无法获得令人满意的代码布局。那时考虑将内联汇编引入解析器的代码中。零拷贝状态机已经非常复杂,我们对此想法不满意。在编译器扩展中找到计算的标签和热/冷属性真是太令人惊讶了!由于这些功能,编译器为解析器生成了最佳代码。
TIMTOWTDI表示C ++的强大功能是“有多种方法可以做到” 。是的,这是Perl的想法,但是在很多情况下,C ++允许使用高级STL或经过优化的自定义算法和数据结构,以纯C语言,在模板元编程中编写程序。现代的C ++非常复杂,需要多年的经验才能熟练使用该语言,但是它是一种专业工具,可以使专业开发人员创建最快,最可靠的软件。
不仅Rust不成熟,而且语言设计者似乎故意限制了语言。有许多不良的程序在滥用goto,因此它们只是删除了运算符:对初级用户有利,但对专业人员而言太有限了。当您在复杂的技术任务中苦苦挣扎时,语言和编译器几乎不可能给您带来惊喜。取而代之的是,当您需要做一些简单的事情时,很可能是在C或C ++时代所做的事情,会感到失望,并开始与编译器抗争。作为一个例子,likely和unlikely编译器提示在Linux内核的年龄和使用它们在用户空间C / C ++编程如此流行,它们被包含在C ++ 20标准(在程序员不得不使用编译器内部函数之前)。但是使用Rust,您会发现该API是试验性的,if仅适用于语句。