[Rust翻译]反驳《C++比Rust更快、更安全:Yandex的基准测试》

492 阅读16分钟

本文由 简悦SimpRead 转码, 原文地址 pvs-studio.com

Spoiler: C++并不是更快或更慢--这不是重点,实际上。这篇文章继续我们的goo......

Spoiler: C++并不是更快或更慢--其实这并不是重点。这篇文章延续了我们的优良传统,打破了一些大牌俄罗斯公司对Rust语言的神话。

注。 本文最初在Habr.com上发表。经作者许可,本文被翻译并转贴于此。

本系列的前一篇文章题为"Go比Rust快:Mail.Ru(RU)的基准测试"。不久前,我试图把我的同事,一个来自另一个部门的C语言程序员引向Rust。但我失败了,因为--我引用了他的话。

2019年,我在C++ CoreHard会议上,参加了Anton @antoshkka Polukhin关于不可缺少的C++的演讲。按照他的说法,Rust是一种年轻的语言,而且它不是那么快,甚至不是那么安全。

Anton Polukhin是俄罗斯在C++标准化委员会的代表,也是几个被接受的C++标准提案的作者。他的确是一个杰出的人物,也是一切与C++有关的权威。但是他的演讲有几个关于Rust的关键事实错误。让我们看看这些错误是什么。

安东的演讲(RU)中,我们特别感兴趣的部分是13:0022:35

www.youtube.com/watch?v=fT3…

误区1.Rust的算术并不比C++的更安全

为了比较两种语言的汇编输出,Anton挑选了平方函数(link:godbolt)作为例子。

安东(13:35)。

我们得到相同的汇编输出。太好了! 我们已经得到了基线。到目前为止,C++和Rust的输出都是一样的。

的确,算术乘法在两种情况下产生相同的汇编列表--但只是到目前为止。问题是--上面的两个代码片段在语义上做了不同的事情。当然,它们都实现了一个平方函数,但是对于Rust来说,适用范围是[-2147483648, 2147483647],而对于C++来说是[-46340, 46340]。怎么会这样?神奇吗?

神奇的常数-46340和46340是最大的绝对值参数,其平方适合于 std::int32_t 类型。由于有符号整数溢出,任何高于这个数值的都会导致未定义的行为。如果你不相信我,可以问问PVS-Studio。如果你足够幸运,在一个设置了未定义行为检查的CI环境的团队中,你会得到以下信息。

runtime error:
signed integer overflow: 46341 * 46341 cannot be represented in type 'int'
runtime error:
signed integer overflow: -46341 * -46341 cannot be represented in type 'int'

在Rust中,像这样的未定义行为的算术问题简直是不可能的。

让我们看看Anton是怎么说的(13:58)。

未定义行为的出现是由于我们使用了一个有符号的值,而C++编译器假定有符号的整数值不会溢出,因为那将是未定义行为。编译器依靠这一假设进行了一系列棘手的优化。在Rust中,这种行为是有记录的,但它不会让你的生活更轻松。反正你会得到同样的汇编代码。在Rust中,这是一个有记录的行为,两个大的正数相乘会产生一个负数,这可能不是你所期望的。更重要的是,记录这种行为可以防止Rust应用它的很多优化--它们实际上在其网站的某个地方被列出。

我想了解更多关于Rust不能做的优化,特别是考虑到Rust是基于LLVM的,而LLVM正是Clang所基于的后端。因此,Rust "免费 "继承了大部分独立于语言的代码转换和优化,并与C++共享。在上面的例子中,汇编列表是相同的,实际上只是一个巧合。在C++中,棘手的优化和因签名溢出而产生的未定义行为会给调试带来很多乐趣,并激发出像这样的文章(RU)。让我们仔细看一下。

我们有一个函数,计算一个字符串的多项式哈希值,有一个整数溢出。

unsigned MAX_INT = 2147483647;

int hash_code(std::string x) {
    int h = 13;
    for (unsigned i = 0; i < 3; i++) {
        h += h * 27752 + x[i];
    }
    if (h < 0) h += MAX_INT;
    return h;
}

在某些字符串上--特别是在 "bye "上--而且只在服务器上(有趣的是,在我朋友的电脑上一切正常),该函数会返回一个负数。但是为什么呢?如果数值是负的,MAX_INT要加到它上面,从而产生一个正值。

Thomas Pornin表明,未定义行为确实是未定义的。如果你把数值27752提高到3的幂,你就会明白为什么哈希值在两个字母上计算正确,但在三个字母上却出现一些奇怪的结果。

用Rust编写的类似函数会正常工作(link:playground)。

fn hash_code(x: String) -> i32 {
    let mut h = 13i32;
    for i in 0..3 {
        h += h * 27752 + x.as_bytes()[i] as i32;
    }
    if h < 0 {
        h += i32::max_value();
    }
    return h;
}

fn main() {
    let h = hash_code("bye".to_string());
    println!("hash: {}", h);
}

由于众所周知的原因,这段代码在Debug和Release模式下的执行方式不同,如果你想统一行为,你可以使用这些函数家族。wrapping*, saturating*, overflowing*, and checked*.

正如你所看到的,有记录的行为和没有因签名溢出而产生的未定义的行为确实使生活更容易。

对一个数字进行平方运算是一个完美的例子,说明你只用三行C++语言就能把自己打得落花流水。至少你可以用一种快速和优化的方式来做。虽然未初始化的内存访问错误可以通过仔细检查代码来发现,但与算术有关的错误却突然出现在 "纯粹的 "算术代码中,你甚至不怀疑它有任何可能被破坏的地方。

误区2.Rust的唯一强项是对象寿命分析

下面的代码是作为一个例子提供的(link:godbolt)。

安东(15:15)。

Rust编译器和C++编译器都编译了这个程序,但是......bar函数什么也没做。两个编译器都发出了警告,说可能出了问题。我是怎么想的呢?当你听到有人说Rust是一种超级酷和安全的语言时,你要知道,它唯一安全的地方是对象寿命分析。你可能想不到的UB或记录的行为仍然存在。编译器仍然会编译那些明显没有意义的代码。嗯......就是它了。

我们在这里处理的是无限的递归。同样,两个编译器产生了相同的汇编输出,即C++和Rust都为 bar 函数生成了NOP。但这实际上是LLVM的一个错误。

如果你看一下无限递归代码的LLVM IR,你会看到以下情况(link:godbolt)。

ret i32 undef 正是LLVM生成的那个bug。

这个bug从2006开始就存在于LLVM中。这是一个重要的问题,因为你希望能够以这样一种方式来标记无限循环或递归,以防止LLVM将其优化为零。幸运的是,事情正在改善。LLVM 6发布时增加了内在的llvm.sideeffect,而在2019中,rustc得到了 -Z insert-sideeffect 标志,它为无限循环和递归增加了 llvm.sideeffect 。现在,无限递归被识别出来了(link:godbolt)。希望这个标志也能很快被添加到稳定版的rustc中作为默认值。

在C++中,无限递归或没有副作用的循环被认为是未定义的行为,所以LLVM的这个错误只影响到Rust和C。

现在我们已经澄清了这个问题,让我们来谈谈Anton的关键声明:"它唯一安全的地方是对象寿命分析"。这是一个错误的说法,因为Rust的安全子集使你能够在编译时消除与多线程、数据竞赛和内存拍摄有关的错误。

误区3.Rust的函数调用毫无理由地触及内存

安东(16:00)。

让我们来看看更复杂的函数。Rust对它们做了什么?我们已经修复了我们的bar函数,所以它现在调用了foo函数。你可以看到Rust生成了两条额外的指令:一条是把东西推到堆栈里,另一条是在最后从堆栈里弹出东西。在C++中没有这样的事情发生。Rust已经触碰了两次内存。这可不好。

下面是这个例子(link:godbolt)。

image.png

Rust的汇编输出很长,但我们必须找出它与C++不同的原因。在这个例子中,Anton在C++中使用 -ftrapv 标志,而在Rust中使用- C overflow-checks=on 来启用签名溢出检查。如果发生溢出,C++会跳到 ud2 指令,导致 "Illegal instruction (core dumped)",而Rust会跳到调用 core::panicking::panic 函数,其准备工作需要一半的列表。如果发生溢出, core::panicking::panic 将输出一个漂亮的解释,说明程序崩溃的原因。

$ ./signed_overflow 
thread 'main' panicked at 'attempt to multiply with overflow',
signed_overflow.rs:6:12
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

那么这些触及内存的 "额外 "指令从何而来?x86-64的调用惯例要求堆栈必须与16字节的边界对齐,而 call 指令将8字节的返回地址推入堆栈,因此破坏了对齐。为了解决这个问题,编译器会推送各种指令,如推送rax。不仅仅是Rust,C++也会这样做(link:godbolt)。

image.png

C++和Rust都生成了相同的汇编列表;为了堆栈对齐,都增加了 push rbx 。Q.E.D.

最奇怪的是,实际上是C++需要通过添加 -ftrapv 参数来进行去优化,以捕捉因有符号溢出而导致的未定义行为。早些时候我表明,即使没有 -C overflow-checks=on 标志,Rust也会做得很好,所以你可以自己检查正确工作的C++代码的成本(link:godbolt)或阅读这篇文章。此外, -ftrapv 在gcc中已经坏了自2008年起

误解4:Rust比C++慢

安东(18:10)。

Rust比C++稍慢...

在他的整个演讲中,Anton都在选择Rust的代码例子,这些代码编译成稍大的汇编代码。不仅仅是上面的例子,那些 "接触 "内存的例子,还有在17:30讨论的例子(link:godbolt),都是如此。

image.png

看起来,所有这些对汇编输出的分析都是为了证明更多的汇编代码意味着更慢的语言。

在2019年的CppCon会议上,Chandler Carruth做了一个有趣的演讲,题为"没有零成本抽象"。在17:30,你可以看到他在抱怨 std::unique_ptr 比原始指针的成本更高(link:godbolt)。为了赶上汇编输出的原始指针的成本,他必须增加 noexcept 、rvalue引用和使用 std::move ,即使只是一点点。好吧,在Rust中,上述内容不需要额外的努力就可以工作。让我们比较一下两个代码片断和它们的汇编输出。我不得不在Rust的例子中用 extern "Rust"unsafe 做一些额外的调整,以防止编译器内联调用(link:godbolt)。

image.png

以较少的努力,Rust产生了较少的汇编代码。而且你不需要通过使用 noexcept 、rvalue引用和 std::move 给编译器提供任何线索。当你比较语言时,你应该使用足够的基准。你不能随便拿一个你喜欢的例子,然后用它来证明一种语言比另一种语言慢。

在2019年12月,Rust在基准游戏中的表现超过了C++。从那时起,C++在一定程度上赶上了。但只要你继续使用合成基准,这些语言就会不断拉开差距。我想看一下充分的基准,而不是。

误区5:C → C++ - noop,C → Rust - PAIN!!!!!!!

安东(18:30)。

如果我们拿一个大型的桌面C++应用程序,并试图用Rust重写它,我们会发现我们的大型C++应用程序使用第三方库。而很多用C语言编写的第三方库都有C语言的头文件。你可以在C++中借用和使用这些头文件,如果可能的话,把它们包装成更安全的结构。但在Rust中,你必须重写所有这些头文件,或者由某些软件从原始的C头文件中生成。

在这里,Anton将两个不同的问题混为一谈:C函数的声明和它们的后续使用。

事实上,在Rust中声明C函数需要你手动声明或者让他们自动生成--因为这是两种不同的编程语言。你可以在我关于星际争霸机器人的文章中阅读更多这方面的内容,或者查看展示如何生成这些包装器的例子

幸运的是,Rust有一个叫做cargo的包管理器,它允许你一次性生成声明并与世界分享。正如你所猜测的那样,人们不仅分享原始的声明,而且还分享安全和习惯性的包装器。截至今年,即2020年,包注册表crates.io包含了大约4万个crate。

至于使用C库本身,它实际上只需要在你的配置中的一行。

# Cargo.toml
[dependencies]
flate2 = "1.0"

在考虑到版本依赖的情况下,整个编译和连接的工作将由cargo自动完成。关于flate2的例子,有趣的是,当这个板条箱只出现时,它使用了用C语言编写的C库miniz,但后来社区用Rust重写了C部分。这使得flate2更快。

误区6.不安全会关闭所有的Rust检查

安东(19:14)。

所有的Rust检查在unsafe块内都是关闭的;它在这些块内不检查任何东西,完全依赖于你写了正确的代码。

这个是将C语言库集成到Rust代码中的问题的延续。

我很抱歉这么说,但认为在 unsafe 中所有的检查都被禁用是一个典型的误解,因为Rust文档明确指出, unsafe 允许你。

  • 解除对一个原始指针的引用。
  • 调用和声明 unsafe 函数。
  • 访问或修改一个可变静态变量。
  • 实现和声明一个 unsafe 特性。
  • 访问 unions 的字段。

对禁用所有的Rust检查只字未提。如果你有寿命错误,简单地添加 unsafe 并不能帮助你的代码编译。在这个块里面,编译器会不断地检查类型,跟踪变量的生命周期,检查线程安全,等等等等。更多细节,请参见文章《你不能在Rust中 "关闭借贷检查器"》

你不应该把 unsafe 当作一种 "为所欲为 "的方式。这是给编译器的一个提示,即你要对一组特定的不变性负责,而编译器本身无法检查。以原始指针解除引用为例。你我都知道,C语言的 malloc 要么返回NULL,要么返回一个指向未初始化内存块的指针,但Rust编译器对这种语义一无所知。这就是为什么在处理由 malloc 返回的原始指针时,你必须告诉编译器:"我知道我在做什么。我已经检查过这个指针了--它不是一个空值;对于这个数据类型,内存是正确对齐的"。你要对 不安全 块中的那个指针负责。

误区7. Rust对你的C语言库没有帮助

安东(19:25)。

在过去一个月里,我在C++程序中遇到的十个bug中,有三个是由对C方法的不正确处理引起的:忘记释放内存、传错参数、传空指针而没有事先检查空值。使用C代码正是有很多问题。而Rust根本不会帮你解决这个问题。这可不好。据称Rust要安全得多,但是一旦你开始使用第三方库,你就必须像使用C++一样小心谨慎。

根据微软的统计,70%的漏洞是由于内存安全问题和其他错误类型造成的,而Rust实际上在编译时就能防止这些错误。在Rust的安全子集中,你在物理上不可能犯这些错误。

另一方面,还有一个 unsafe 子集,它允许你取消引用原始指针,调用C函数......以及做其他不安全的事情,如果误用,会破坏你的程序。嗯,这正是Rust成为系统编程语言的原因。

在这一点上,你可能会发现自己在想,在Rust中必须确保你的C函数调用安全,就像在C++中一样,并没有使Rust变得更好。但Rust的独特之处在于,它能够将安全代码与潜在的不安全代码分开,并对后者进行后续封装。如果你不能在当前级别保证正确的语义,你需要将 unsafe 委托给调用代码。

在实践中, unsafe 向上的委托就是这样做的。

// Warning:
// Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

slice::get_unchecked 是一个标准的 unsafe 函数,通过索引接收一个元素,而不检查越界错误。由于我们在函数 get_elem_by_index 中也不检查索引,而是按原样传递,所以我们的函数有潜在的错误,对它的任何访问都需要我们明确指定它为 unsafe (link:playground)。

// Warning:
// Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

fn main() {
    let elems = &[42];
    let elem = unsafe { unchecked_get_elem_by_index(elems, 0) };
    dbg!(elem);
}

如果你传递了一个超界的索引,你就会访问未初始化的内存 unsafe 块是唯一可以这样做的地方。

然而,我们仍然可以使用这个 unsafe 函数来构建一个安全版本(link:playground)。

// Warning:
// Calling this method with an out-of-bounds index is undefined behavior.
unsafe fn unchecked_get_elem_by_index(elems: &[u8], index: usize) -> u8 {
    *elems.get_unchecked(index)
}

fn get_elem_by_index(elems: &[u8], index: usize) -> Option<u8> {
    if index < elems.len() {
        let elem = unsafe { unchecked_get_elem_by_index(elems, index) };
        Some(elem)
    } else {
        None
    }
}

fn main() {
    let elems = &[42];
    let elem = get_elem_by_index(elems, 0);
    dbg!(&elem);
}

这个安全版本永远不会破坏内存,无论你传递给它什么参数。让我们把话说清楚--我根本不鼓励你在Rust中写这样的代码(用 slice::get 函数代替);我只是告诉你如何从Rust的 不安全 子集转移到安全子集仍然能够保证安全。我们可以用一个类似的C函数来代替 unchecked_get_elem_by_index

感谢跨语言LTO,C函数的调用可以是绝对自由的。

我把带有启用的编译器标志的项目上传到github。由此产生的汇编输出与用纯C语言编写的代码相同(link:godbolt),但保证与用Rust编写的代码一样安全。

误区8. Rust的安全性没有被证明

安东(20:38)。

假设我们有一种奇妙的编程语言叫X,它是一种经过数学验证的编程语言。如果我们用这种X语言编写的应用程序恰好能建立起来,这将意味着已经从数学上证明我们的应用程序中没有任何bug。听起来确实不错。但是有一个问题。我们使用C语言的库,当我们使用X语言的库时,我们所有的数学证明显然有点崩溃了。

Rust的类型系统、借用机制、所有权、生命期和并发性的正确性被证明在2018年。给定一个程序,除了某些只在语义上(而不是语法上)有良好类型的组件外,语法上是有良好类型的,基本定理告诉我们,整个程序在语义上是有良好类型的。

这意味着链接和使用一个包含 unsafes 但提供正确和安全的包装器的crate(库)不会使你的代码不安全。

作为这个模型的实际应用,其作者证明了标准库中一些基元的正确性,包括Mutex、RwLock和 thread::spawn ,它们都使用了C函数。因此,在Rust中,如果没有同步原语,你不会意外地在线程之间共享一个变量;如果你使用标准库中的Mutex,即使它们的实现依赖于C函数,变量也会被正确访问。这不是很好吗?绝对是的。

总结

不偏不倚地讨论一种编程语言相对于另一种语言的优势是很难的,特别是当你对一种语言有强烈的喜好而不喜欢另一种语言时。看到另一个 "C++杀手 "的预言家出现,在不了解C++的情况下发表强烈的言论,并预期会受到攻击,这是一件很平常的事情。

但我所期望的是来自公认的专家的加权观察,至少不包含严重的事实错误。

非常感谢Dmitry KashitsinAleksey Kladov对这篇文章的评论。


www.deepl.com 翻译