C/C++与锈蚀性能比较

2020-11-02 01:53:26

本文不是关于哪种编程语言更好,而是讨论用于开发速度最快的服务器端系统软件(如数据库引擎和HTTPS服务器)的最强大的工具集。这类软件有几个特定的特性:相对较大的代码库、100,000行C或C++代码等等。虽然可以用汇编语言编写特定的、最热门的函数,但用汇编语言编写整个程序是不切实际的。

数据库和Web服务器是任务关键型软件-我们都习惯了我们的Linux系统使用MySQL和Nginx进程可以工作数月甚至数年。有一些简单的高可用性最佳实践可以减少由于可能的崩溃而造成的停机时间,但它们是另一篇文章的主题。与此同时,值得一提的是,如果你真的-真的关心高可用性,那么你应该在构建基础设施时假设你的系统的任何组件都可能在任何时候崩溃,就像Facebook一样-公司会在最新版本的Linux内核可用时立即部署它们。

多年来,我们一直在用C、C++和汇编语言开发速度最快的软件。这并不令人惊讶,因为铁锈专注于性能,我们对它非常感兴趣。不过,我对此持怀疑态度。请记住Java编程语言的兴起:有很多报告称JIT编译生成的代码比C++快。现在很难找到一个案例,当C++比Java慢的时候,看看基准游戏。值得一提的是,Java中的内存垃圾回收(GC)会导致很高的尾部延迟,而且很难甚至不可能解决这个问题。由于GC的原因,Golang也不能考虑进行高性能编程。系统编程以C语言为主。操作系统内核是最复杂的系统软件之一,这不仅是因为它直接与硬件打交道,还因为它具有严格的性能要求。Linux和FreeBSD内核以及其他UNIX和Windows内核都是用C语言编写的。让我们从这个出色的高性能系统软件示例开始讨论。FreeBSD支持C++模块已经有一段时间了。虽然Linux内核从来不支持C++,但有用C++编写的Click模块化路由器,它可以作为Linux内核模块工作。如果您对操作系统内核开发的C++适用性感兴趣,那么您可以在C++和基本文章中找到相当好的讨论。然而,反对使用C++进行操作系统内核开发的根本原因是:内核空间中没有带有RTTI和异常的libstdc++。实际上,dynamic_cast并不经常使用,有很多C++项目没有使用RTTI编译。如果需要异常,则必须将它们移植到内核中。Libstdc++使用基本的C分配,因此必须针对内核进行重大修改。

您不能使用STL和Boost库,事实上,所有内核都已经有了自己的库。C++引入了文件系统、线程库和网络库,这些在操作系统内核中是没有意义的。另一方面,现代操作系统提供了高级同步原语,这些原语在标准C++中仍然不可用(例如,C++中仍然没有读写自旋锁)。

Linux内核提供了许多内存分配器(slale、page、vmalloc()、kmalloc()等),因此您必须使用place new和/或只使用C函数进行内存分配和释放。对齐内存对性能至关重要,但是您需要编写特殊的包装器来获得与新内存对齐的内存。

当原始内存指针频繁地强制转换到某些数据结构时,强类型安全对于系统编程来说并不是很舒服。不过,这是值得商榷的:虽然有些人对频繁使用represtrate_cast<;foo*>;(Ptr)而不是短(foo*)ptr感到不舒服,但其他人使用更多的类型和更多的类型安全是很好的。

C++名称损坏是命名空间和函数重载所必需的,它使函数很难从Assembly调用,因此您需要使用extern";C";。

您必须为静态对象构造函数和析构函数(相应的.ctor和.dtor)创建特殊的代码段。

C++异常不能跨越上下文边界,也就是说,您不能在一个线程中抛出异常,然后在另一个线程中捕获它。操作系统内核处理的上下文模型要复杂得多:有内核线程、进入内核的用户空间进程、延迟的和硬件中断。上下文可以以自愿或协作的方式彼此抢占,因此当前上下文的异常处理可以被另一个上下文抢占。还有可能与异常处理代码冲突的内存管理和上下文切换代码。就像RTTI一样,可以在内核中实现该机制,但不能使用当前的标准库。

虽然Clang和G++支持_RESTRICE_扩展,但官方C++标准不支持它。

Linux内核中不鼓励使用可变长度数组(VLA),在某些情况下它们仍然很方便,但在C++中完全不可用。

因此,对于内核空间中的C++,基本上只有模板、类继承和一些语法,比如lambda函数。既然系统代码很少需要复杂的抽象和继承,那么在内核空间中努力使用C++还有意义吗?

这是最具争议的C++特性之一,应该单独一章。例如,遵循Google编码风格的MySQL项目不使用异常。Google编码风格提供了使用异常的利弊列表。在这里,我们只关注性能方面。

当我们必须在太多的地方处理错误代码时,异常可以提高性能,例如(让函数内联并且非常小)代码的问题是有额外的条件跳转。现代CPU在分支预测方面相当不错,但它仍然会影响性能。在C++中,我们可以只编写代码,所以热路径中没有额外的条件。然而,这并不是免费的:您的C++代码中的大多数函数都必须有额外的结尾和一个这些函数可以捕获的异常表,以及一个适当的清理表。函数结尾不会在正常工作流中执行,但它们会增加代码大小,从而在CPU指令高速缓存中造成额外的污染。您可以在Nico Brailovsky的博客中找到有关C++异常处理内部的详细信息。

是的,是这样的。首先,实际上并不是整个代码都必须尽可能快,而且在大多数地方,我们不需要自定义内存分配,也不关心异常开销。大多数项目都是在用户空间开发的,受益于相对丰富的C++标准和Boost库(虽然没有Java那么丰富),尤其是新的项目。

其次,C++的杀戮特性是它是C。如果您不想使用异常或RTTI,那么您可以直接关闭这些特性。大多数C程序都可以用C++编译器编译,只需很小的修改或根本不需要修改。作为示例,我们只需要这个微不足道的更改$diff-up nbody.c nbody-new.cc@@-112,9+112,9@@static void Advance(body body[]){//round_interaction_count元素来简化以下//循环之一,并保持第二个和第三个数组在position_Deltas//中正确对齐。-静态对齐(__M128d)双position_Deltas[3][ROUNDED_INTERACTIONS_COUNT],-幅度[四舍五入交互_计数];+静态双精度+position_Deltas[3][ROUNDED_INTERACTIONS_COUNT]__属性__((对齐(16),+幅度[四舍五入交互_计数]__属性__((对齐(16);//计算每个交互的Body之间的位置_增量。For(intative_t i=0,k=0;i<;body_count-1;++i)

用G++编译器编译C程序。现代的C++编译器提供了C兼容性扩展,如__restrict__关键字。您总是可以用C风格编写性能最关键的C++程序代码。如果您不喜欢有开销的STL容器,那么您可以使用Boost.intrusive,甚至可以从Linux内核或其他FAST C项目移植一个类似的容器-在大多数情况下,这不会很痛苦。例如,请参阅如何在C++基准测试中使用PostgreSQL的哈希表、Tempesta DB的HTrie和Linux内核读/写自旋锁(都是用C编写的)。

关于用C++开发高性能程序,最后必须提到的是模板元编程。现代C++标准非常令人兴奋的是,使用模板,您可以编写非常复杂的逻辑,这些逻辑在编译时完全计算,在运行时不花费任何成本。

专业的工具必须允许您以最有效的方式使用它。高级和高性能编程语言的目标是生成最高效的机器代码。每个硬件架构都支持跳转,这意味着您可以根据任何条件跳转到任何地址。C和C++编程语言中最接近跳转的抽象是goto操作符。它不像汇编JMP那样灵活,但C编译器提供的扩展使操作符几乎完全等同于汇编跳转。不幸的是,铁锈不支持后藤,这使得它在整个班级的性能关键任务中显得很尴尬。

我们讨论解析器。不是配置文件解析器,配置文件解析器完美地完成了一堆switch和if语句,而是像HTTP解析器这样的大型且非常快速的解析器。您可能认为这太窄了,或者任务太具体了,但是回想一下解析器生成器,比如Ragel或GNU Bison-如果您开发了这样的解析器生成器,那么您永远不知道会生成多大的解析器。(顺便说一句,Ragel广泛使用GOTO来生成非常快的解析器。)。还要注意每个RDBMS中的SQL解析器。实际上,我们可以将任务类概括为大型、快速的有限状态机,例如,它还包括正则表达式。

Tempesta FW中的HTTP解析器比其他Web服务器中的HTTP解析器大得多,因为除了基本的HTTP解析器之外,它还执行许多安全检查,并根据RFC严格验证输入。此外,我们的解析器处理零拷贝数据,因此它也非常关心数据块。我们在Scale 17x会议上的演讲中描述了解析器的技术细节,您可以观看演讲视频或幻灯片。

通常,HTTP解析器实现为允许的字符和可用状态的输入字符和嵌套开关语句上的循环。例如,请参阅Nginx解析器源代码中的ngx_http_parse_request_line()。为简洁起见,让我们考虑一个简化版本的代码:While(++str_ptr){switch(Current_State){case 0:...。案例1:……。案例100:开关(*str_ptr){案例';a';:...。CURRENT_STATE=200;Break;Case';b';:...。CURRENT_STATE=101;BREAK;}BREAK;案例101:......}}。

假设解析器在状态100已经完成了对前一数据块的解析,并且当前数据块从字符开始。不管SWITCH语句优化(可以通过查找表或二进制搜索的编译器进行优化),代码有3个问题:虽然状态101的代码紧跟在状态100的代码之后,但我们必须重新输入WHILE和SWITCH语句,即再次查找下一个状态,而不是仅仅进一步移动一个字符,直接跳转到下一个状态。

即使我们总是在状态100之后到达状态101,编译器也可以以这样的方式重新组织代码,即状态101被放置在切换语句的开头,而状态100被放置在末尾的某处。

Tempesta FW使用GOTO语句和GCC编译器扩展修复了所有问题,将标签作为值和标签属性,代码如下://当我们//在当前数据块末尾退出状态机时,使用标签作为值来记住当前状态。解析器->;STATE=&;&;STATE_100;转到*解析器->;STATE;WHILE(True){STATE_0:...。STATE_1:...//编译器将状态放在更靠近代码开头//的位置。STATE_100:__ATTRIBUTE__((HOT))//我们仍然对小字符集使用小开关。开关(*str_ptr){case';a';:...++str_ptr;goto state_200;case';b';:...+str_ptr;//直接转到状态101。}//此状态由编译器放在STATE_100之后。STATE_101:__ATTRIBUTE__((COLD))...}。

由于Rust不支持GOTO语句,我们需要使用汇编语言来实现具有直接跳转和最佳代码布局的状态机。

现在让我们来看一个例子,当汇编语言不仅生成更快的代码,而且还允许以更高效的方式编写程序。这个例子是关于多精度整数算术的。

公钥密码术和椭圆曲线尤其是与大整数一起操作。Tom St Denis所著的“BigNum Math:Implementing Cryptotics Multiple Precision Algorithm”一书提供了关于该主题的大量细节以及许多算法的C实现,但现在让我们考虑一下在64位机器上将两个128位长度的大整数基本相加。大整数由两个64位长的肢体组成。为了对整数求和,我们必须关心两个肢体之间的进位,因此生成的C代码如下所示(请参阅本书中的4.2.1)://a:=a+b//x[0]是较不重要的肢体,//x[1]是最重要的肢体。Void s_mp_add(无符号长*a,无符号长*b){无符号长进位;a[0]+=b[0];进位=(a[0]<;b[0]);a[1]+=b[1]+进位;}。

代码很小很简单,但是您可能需要考虑一下进位操作的正确性。希望x86-64是CISC体系结构,即它为我们提供了很多计算特征,其中之一是进位计算,因此上面的代码只能在两条指令中完成,不需要进行比较://指向a的指针在%rdi中,指向b的指针在%rsi movq(%rdi)中,%r8movq 8(%rdi),%r9addq(%rsi),%r8//add带有进位addc 8(%rsi),%r9//使用下一个加法movq(%r8)中的进位,(%RDI)移动队列(%R9),8(%RDI)。

如果您查看任何优化良好的密码库,如OpenSSL或Tempesta TLS,就会发现大量汇编代码(OpenSSL实际上使用Perl脚本生成汇编源代码)。乍一看,Rust非常适合开发非常高效的代码:SIMD内部函数、内存对齐、内存屏障、内联汇编。有很多关于铁锈与C或C++的比较,例如,铁锈与C或C++的速度比铁锈更快、更安全:由Yandex进行基准测试。但是,如果您考虑使用Rust开发一个领先的基准测试产品,您可能会面临几个障碍,再加上没有GOTO操作符:从技术上讲,Rust支持自定义内存分配器,但有严重的限制。值得一提的是,任何高性能软件都会使用大量临时内存分配器。

就像C++一样,Rust不提供VLA。但是,如果C++仍然可以使用alloca(3),那么Rust根本不提供堆栈分配。这很遗憾,因为堆栈分配是最便宜的,而且由于前面的原因,定制内存分配器不是一个选择。

似乎/不太可能支持比现代C或C++编译器弱得多。

在Rust中读写原始内存中的数据结构是可行的,但是需要比C甚至C++更多的代码。不过没什么大不了的。

Rust的泛型和宏比C++模板和C宏提供的功能弱得多。虽然,这也不是那么关键。

Rust系统编程最让人失望的是它处理原始内存的能力有限,这是内存安全的另一面。如果不讨论Rust和C++编程语言提供的可靠性和安全性,本文将是不完整的。微软的桑尼·查特吉(Sunny Chatterjee)最近在CppCon 2020上谈到了这个话题,希望如此。Rust的主要优点是内存和并发安全性,但现代C++也解决了这些问题。在这个演示中,Sunny解决了Rust和C++之间的以下6个差距:强制转换、开关语句、更智能的循环、更智能的复制、生命周期和可变性。让我们回顾一下差距。带有-Wall编译器选项的现代C和C++编译器可以很好地处理类型转换。

Switch语句也使用-Wall进行处理。此外,GCC还引入了-WIMPLICIT-FULTHROUSING编译器选项,使&WIMPLICAL-FAULTHROUSS成为显式的。

常量自动引用和细粒度复制和移动语义负责智能复制(&A)。

带有或不带有可变成员、常量引用和变量的C++常量类提供了细粒度的可变性,BUST也不能涵盖所有情况。

演示文稿以C++核心指导方针结束,它的规则涵盖了许多大项,现代的C和C++编译器倾向于实现遗漏的检查。值得一提的是,C/C++世界有效地使用地址消毒器(例如,ASSAN内置于现代版本的llvm和GCC编译器中)来捕获越界内存访问。毕竟,您仍然可以使用Rust中的不安全代码生成错误,就像在C++中使用原始指针一样。

既然我们谈论的是性能,那么我们一定要看一下计算机语言基准游戏(Computer Language Benchmark Game)。要比较不同语言的性能,您需要以相同的方式在所有语言中实现相同的任务。这不是人们通常会做的事情,所以很难在不同的语言中找到现实生活中的代码示例,允许您将橙子与橙子进行比较,而不是将橙子与苹果进行比较。虽然基准游戏是一个游戏,比较小的具体任务的实现,它是我们拥有的最好的游戏之一。C++11与Rust的比较是C++和Rust中同等实现的又一比较。基准测试游戏中没有汇编语言,但是相应地有Rust,C++用于G++编译器,以及两个C,用于Clang和GCC编译器。在撰写本文时,实现的性能是(以秒为单位,越少越好):只有一个测试,即第一个测试,其中Rust的性能或多或少明显优于C和C++实现。

您可能会好奇为什么Rust中的fannkuch-redux实现比C实现更快?我们也是。这两个节目的副本都在裁剪之下。

//计算机语言基准游戏//杰里米·泽法斯贡献的https://salsa.debian.org/benchmarksgame-team/benchmarksgame/基于乔纳森·帕克和乔治·包豪斯的Ada程序,该程序又//基于戴夫·弗拉德博、埃克哈德·伯恩斯、海纳·马克森、喜宏伟、。//和Anh Tran以及Oleg Mazurov的Java程序。//此值控制将工作负载拆分成多少个块(只要该值小于或等于此//程序参数的阶乘),以便在//可能的情况下允许并行处理这些块。PREPRECT_NUMBER_OF_BLOCKS_TO_USE应该是某个数字,//该数字平均分为大于它的所有阶乘。它也应该是//2-8倍于。

.