【译】无缓冲 I/O 会让你的 Rust 程序变慢

在这篇文章中,我们将看看 Rust 代码中性能糟糕的常见原因,即使是经验丰富的开发人员也会遇到这种情况,以及当这种情况发生在你的程序中时该如何优化。

Rust 作为一种能够让开发人员编写快速和安全代码的语言,这种特性已经确立。每天,像Mozilla、微软、Dropbox 和亚马逊(仅举几例)这样的大型组织都会使用 Rust 为他们的客户提供一流的性能服务,同时避免了许多用 C 或 C++ 编写的程序的安全问题,这些语言在传统上更适用于高性能工作。

在 Era 软件公司,我们重视性能,我们相信通过制造高效的产品,可以帮助我们的客户从他们的数据中获得更多,同时降低他们每月的基础设施成本。性能是 Rust 成为我们的首选语言的一个主要原因。然而,仅仅用 Rust 编写代码并不能保证高性能。Rust 很好,但它不是魔术。它是一种工具,和任何工具一样,我们必须有效地使用它来获得最佳的结果。

在这篇文章中,我们将看看 Rust 代码中一个常见的低性能的原因,它甚至会阻碍资深的开发者。即,默认情况下,文件的读写是没有缓冲的。

Syscalls

程序不能直接读取或写入磁盘上的文件,而是通过系统调用(syscall)来请求操作系统协助实现。例如,在 Linux 中,内核提供了 write() 系统调用,用于将数据从一个程序传输到一个文件。我们的程序可以通过调用带有三个参数的 write() 来向文件写入数据:一个参数是文件描述符,一个是指向我们想要写入的字节的指针,以及要写入的字节数。

Linux 系统调用的一个特点是,它们的调用速度要比普通函数慢。这是因为它们的执行必须从用户模式下切换到内核模式,而这种切换是有代价的。为了确保良好的性能,我们的程序应该避免进行过多的系统调用。

I/O with and without buffering

下面是一个简单的 Rust 程序,它向一个文件写了几行字。这个程序没有使用缓冲区,这意味着三次调用 f.write() 方法会产生一次 write() 系统调用。

use std::fs;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut f = fs::File::create("/tmp/unbuffered.txt")?;
    f.write(b"foo")?;
    f.write(b"\n")?;
    f.write(b"bar\nbaz\n")?;
    return Ok(());
}

在 strace 中运行这个程序,我们可以看到确实有三个 write() 系统调用。

$ strace --trace=write ./target/release/01_unbuffered
write(3, "foo", 3)                      = 3
write(3, "\n", 1)                       = 1
write(3, "bar\nbaz\n", 8)               = 8

在这个小例子中,对性能的影响是微乎其微的,但如果在一个处理大数据文件的真实程序中,则有数百万甚至数十亿的无意义的系统调用,会导致程序变慢,且让用户失望。

幸运的是,我们可以改进我们的程序。在我们打开文件后,我们可以把它包装在一个 BufWriter 对象里面。

use std::fs;
use std::io::{self, BufWriter, Write};

fn main() -> io::Result<()> {
    let mut f = BufWriter::new(fs::File::create("x.txt")?);
    f.write(b"foo")?;
    f.write(b"\n")?;
    f.write(b"bar\nbaz\n")?;
    return Ok(());
}

现在,当我们调用 f.write() 时,我们实际上并没有执行 write() 系统调用,我们只是将字节追加到缓冲包装器内的数组中。这完全是在用户模式下发生的,所以它开销很低。只有当缓冲区满了,或者当我们关闭文件时,才会进行系统调用,将字节传送到磁盘。我们可以用 strace 来确认这个过程。

$ strace --trace=write ./target/release/02_buffered
write(3, "foo\nbar\nbaz\n", 12)         = 12

缓冲区的作用其实就是摊销。我们必须使用系统调用将数据写入磁盘,并付出调用它们的开销;然而,我们可以聪明一点,发出更少的系统调用,一次传递更多的数据。

Deserialization and buffering

当不使用缓冲时,问题很容易在 Rust 程序中显现出来。以 serde_json 为例,它是一个以简单易用的接口来读写 JSON 数据的库。它的 from_reader() 函数接受任何实现 Read trait 的对象,并将字节解码成 JSON 树。File 类型实现了 Read trait,所以我们可以非常容易地解码磁盘上的文件。下面是一个简单的程序,它的 File 故意没有被 BufReader 对象所包裹。

use std::fs;
use std::io;

fn main() -> io::Result<()> {
    let mut f = fs::File::open("sample.json")?;
    let v: serde_json::Value = serde_json::from_reader(&mut f).unwrap();
    println!("{}", v.is_object());
    return Ok(());
}

我们可以用 perf 来计算在这个程序在执行过程中进行了多少次 read()系统调用。

$ sudo perf stat -e syscalls:sys_enter_read ./target/release/04_unbuffered_json
 Performance counter stats for './target/release/04_unbuffered_json':
         2,009,119      syscalls:sys_enter_read

sample.json 文件有 2,009,108 字节。为了反序列化该文件,serde_json 对每个字节进行了一次系统调用!(额外的 11 次 read() 系统调用发生在程序开始时,用于加载 libc)我们可靠的 strace 证实了这一点。

$ strace --trace=read ./target/release/04_unbuffered_json
...
read(3, "{", 1)                         = 1
read(3, "\"", 1)                        = 1
read(3, "t", 1)                         = 1
read(3, "y", 1)                         = 1
read(3, "p", 1)                         = 1
read(3, "e", 1)                         = 1
read(3, "\"", 1)                        = 1
read(3, ":", 1)                         = 1
...

当我们通过将 file 包裹在一“个缓冲器”内来修复这个程序时,结果令人吃惊。我们将系统调用减少了近 8000 次 -- 这很有意义,因为我们每次读取 8192 个字节,而非一个 -- 这使得程序运行速度提高了 11 倍。

$ sudo perf stat -e syscalls:sys_enter_read ./target/release/05_buffered_json
 Performance counter stats for './target/release/05_buffered_json':
               257      syscalls:sys_enter_read


$ strace --trace=read ./target/release/05_buffered_json
...
read(3, "{\"type\":\"FeatureCollection\",\"crs"..., 8192) = 8192
read(3, "6200000000001}},{\"type\":\"Feature"..., 8192) = 8192
read(3, "egion\":\"AK\",\"category\":\"In-betwe"..., 8192) = 8192
read(3, "01}},{\"type\":\"Feature\",\"id\":95,\""..., 8192) = 8192
...

$ hyperfine -w 5 -m 30 \
    ./target/release/04_unbuffered_json \
    ./target/release/05_buffered_json
Benchmark #1: ./target/release/04_unbuffered_json
  Time (mean ± σ):     326.3 ms ±   8.1 ms    [User: 70.2 ms, System: 256.0 ms]
  Range (min … max):   312.2 ms … 346.8 ms    30 runs

Benchmark #2: ./target/release/05_buffered_json
  Time (mean ± σ):      28.5 ms ±   1.4 ms    [User: 22.9 ms, System: 5.6 ms]
  Range (min … max):    26.2 ms …  33.2 ms    106 runs

Summary
  './target/release/05_buffered_json' ran
   11.43 ± 0.63 times faster than './target/release/04_unbuffered_json'

我们最近在 Era 软件公司的一个产品中发现了这个问题。我们在排查一个无关的问题时,发现一个 600 兆字节的文件需要 30 多秒才能被反序列化。因为一般序列化文件只需要一秒钟,这显然是有问题的。我们进行了排查,并很快发现了我们在这篇文章中所讨论的问题 -- 我们打开了文件并反序列化了它,但没有将文件包裹在 BufReader 中。我们调整了代码,正如我们在这篇文章中所展示的那样,通过优化将反序列化的时间缩短到了一秒钟。

我想强调的是,尽管最初的代码是由一位高级开发人员编写的,并由多位了解这些东西的高级开发人员进行了 review,但这个性能错误仍然逃脱了他们的警惕,摇身一变合到了我们的主分支。正如我在介绍中所说,即使是老手也很容易错过这个问题!

如何发现这类问题

好了,我们已经知道无缓冲 I/O 的危害,但我们也知道它也容易被有经验的程序员的忽视,那么我们能做什么呢?目前,Rust 没有自动提示你这些问题的方法 -- 编译器不会发出警告,Clippy 也没有提示你 I/O 是未缓冲的 lint。

然而,我们看到 strace 工具很有用 -- 它告诉我们 read()write() 系统调用只处理了一个字节。因此,让我们看看如何使用 strace(和一些 awk 的帮助信息)来(1)排查我们是否有大量的单字节读或写,以及(2)使用 strace 的堆栈跟踪功能来看看程序中的单字节读或写发生在哪里。

我们继续使用上一节中的无缓冲程序读取一个 JSON 文件为例。为了使输出更加可读,我们将使用这个小小的 JSON 装载数据:{"id":42}。在 strace 中运行该程序! 并使用 --trace 选项只保留对 read() 的调用,并使用 awk 截取展示读取一个字节的系统调用(即以 =1 结尾的行)。strace 选项 --decode-fds=path 告诉我们描述符(3)指的是哪个文件(在本例中是 /tmp/simple.json),这对了解程序的哪个部分有问题有帮助。

$ strace --decode-fds=path \
         --trace=read \
         /tmp/04_unbuffered_json 2>&1 |
    awk '/= 1$/'
read(3</tmp/simple.json>, "{", 1)       = 1
read(3</tmp/simple.json>, "\"", 1)      = 1
read(3</tmp/simple.json>, "i", 1)       = 1
read(3</tmp/simple.json>, "d", 1)       = 1
read(3</tmp/simple.json>, "\"", 1)      = 1
read(3</tmp/simple.json>, ":", 1)       = 1
read(3</tmp/simple.json>, "4", 1)       = 1
read(3</tmp/simple.json>, "2", 1)       = 1
read(3</tmp/simple.json>, "}", 1)       = 1
read(3</tmp/simple.json>, "\n", 1)      = 1

我们可以从所有以 =1 结尾的行中看到,程序确实在进行单字节的读取,而且数据是 JSON 内容的一部分。所以我们已经成功地用 strace 验证了问题(1)!

现在,我们如何找到这些读的地方呢?幸运的是,strace 有一个非常有用的标志 --stack-traces,可以显示进行系统调用的代码。现在我们调整 awk 程序执行,以显示与单字节读取相关的堆栈跟踪。下面是对其工作原理的解释。

  • 如果当前行以 =1 结束,我们将变量 show 设为 1(true)。
  • 如果当前行以等号结尾,后面不是数字 1(也就是说,一个读数返回多个字节),我们将变量 show 设为 0(false)。
  • show == 1 时,我们显示当前行。

下面是读取文件中尖括号的系统调用输出。

$ strace --stack-traces \
         --decode-fds=path \
         --trace=read \
         /tmp/04_unbuffered_json 2>&1 |
    awk '/= 1$/ { show = 1 } /= [^1]$/ { show = 0 } show'
read(3</tmp/simple.json>, "{", 1)       = 1
 > /usr/lib/x86_64-linux-gnu/libpthread-2.33.so(read+0x12) [0x13152]
 > /tmp/04_unbuffered_json(<std::fs::File as std::io::Read>::read+0x23) [0x1fbd3]
 > /tmp/04_unbuffered_json(<std::io::Bytes<R> as core::iter::traits::iterator::Iterator>::next+0x33) [0xca53]
 > /tmp/04_unbuffered_json(_ZN10serde_json5value2de77_$LT$impl$u20$serde..de..Deserialize$u20$for$u20$serde_json..value..Value$GT$11deserialize17h23bf1ff9e8286bd9E.llvm.16551263557485243796+0x8ec) [0xb39c]
 > /tmp/04_unbuffered_json(serde_json::de::from_reader+0x48) [0x9fb8]
 > /tmp/04_unbuffered_json(_04_unbuffered_json::main+0x78) [0xc7d8]
 > /tmp/04_unbuffered_json(std::sys_common::backtrace::__rust_begin_short_backtrace+0x3) [0xc583]
 > /tmp/04_unbuffered_json(_ZN3std2rt10lang_start28_$u7b$$u7b$closure$u7d$$u7d$17h3d55d3d7814f859cE.llvm.14713913286507758235+0x9) [0xc929]
 > /tmp/04_unbuffered_json(std::rt::lang_start_internal+0x30a) [0x2650a]
 > /tmp/04_unbuffered_json(main+0x22) [0xc912]
 > /usr/lib/x86_64-linux-gnu/libc-2.33.so(__libc_start_main+0xd5) [0x28565]
 > /tmp/04_unbuffered_json(_start+0x2e) [0x8e3e]

我们看到 read() 系统调用是在我们在 main() 函数中反序列化 JSON(serde_json::de::from_reader)时调用的。有了文件名和函数,这应该能缩小我们的搜索范围,帮助我们找到需要添加缓冲区的地方。这样就解决了问题(2)!

结语

在这篇文章中,我们可以知道:

  • Linux 中的系统调用比普通函数要慢
  • 触发过多的系统调用会对运行时性能产生非常不利的影响
  • 使用 BufReader 和 BufWriter,我们可以降低系统调用的成本。
  • 即使是有经验的程序员也可能忽略这些问题
  • 我们可以使用 strace 和 awk 来查找程序中是否有未缓冲的 I/O 发生,以及在哪儿发生。

最后我想说,如果这种类型的问题发生在你自己的程序中,不要为此感到难过 -- 我们都只是人类,我们无法避免犯错。而当错误发生时,要把它们发挥到极致。用它们作为例子来提醒你团队中的开发者,Rust 中的文件默认是没有缓冲的,这与他们可能熟悉的语言不同,比如 Python。如果你的团队中有初级开发人员,请从你的日程安排中抽出一些时间来帮助他们理解这个问题。帮助人们获得一种“spidey sense”,当他们看到 File 这个词而旁边没有 BufReader 或 BufWriter 这两个词时,就会感到刺痛。这虽不一定能防止这类问题的再次发生,但会有更多的人注意到。

在 Era,我们努力使我们的文化对技术友好以及增强技术上的创新。我们正在组建一个专家团队,在分布式系统、机器学习和数据库工程方面进行创新。如果你对我们的工作感兴趣,请查看我们的公司和工作相关页面

posted @ 2022-05-06 09:08  suhanyujie  阅读(18)  评论(0编辑  收藏  举报