目录:网上冲浪指南

DotNet Yes!

2020/02/29

GNU 工具包提供了一个有意思的命令行工具 yes,它的功能非常的简单,就是无限复读 y 到标准输出直到进程被杀死。不过这个工具也有一个非常令人刮目相看的地方——它的输出速度非常快!网上有一位网友使用 rust 尝试着去实现这个工具,最终得到了跟 GNU yes 相差不大的结果。 于是我决定尝试使用 .Net Core 来实现同样的功能。

GNU yes

GNU yes 的性能非常强,轻轻松松就可以达到 4GB/s 的速度,这也给我带来了不小的压力。

$ yes | pv -r > /dev/null
[4.27GiB/s]

printfn

printfn 通常用来输出格式化的字符串,是 F# 内置的函数之一,首先我们就来看看使用 printfn 实现的 yes :

let rec yesPrintf () : unit =
    printfn "y"
    yesPrintf ()

函数本身很简单,因为 F# 有尾递归优化,所以可以用递归的方式来实现死循环,那么性能表现如何呢?

比赛结果
$ dotnet run -c Release | pv -r > /dev/null
[960KiB/s]

直接被 GNU yes 秒成渣。

Console.WriteLine

这种往标准输出写内容的比较肯定得有 Console.WriteLine 的姓名,让我看看这个在 IDE 中拥有内置缩写(cw)的函数是否能够跟 GNU yes 叫板。

let rec yesCW () : unit =
    Console.WriteLine "y"
    yesCW ()
比赛结果
$ dotnet run -c Release | pv -r > /dev/null
[2.16MiB/s]

printfn 强上不少,但还是远远的被 GNU yes 甩在身后。

StreamWriter

既然最常用的往标准输出写内容的方式都没有资格来挑战 GNU yes 的话,我们只能用上一些比较复杂的方法了。众所周知,标准输出在 .Net 中被抽象成了一个流(Stream),而往流中写入内容最常用的就是 StreamWriter,所以现在就让 StreamWriter 选手来挑战一下 GNU yes 吧!

let stdout = Console.OpenStandardOutput()
let sw = new StreamWriter(stdout, Text.Encoding.UTF8, 1024 * 8) (1)
sw.AutoFlush <- false (2)

let rec yesSW () : unit =
    sw.Write "y\n"
    yesSW ()
1 创建一个 StreamWriter,并设置默认的编码以及缓冲区的大小
2 关闭写入后自动刷入,让 StreamWriter 在缓冲区填满的时候自行刷入

StreamWriter 内建有一个缓冲区,可以用来批量的向流中写入数据,GNU yes 也是利用类似的方式来实现的超高性能写入,那么 StreamWriter 的表现如何呢?

$ dotnet run -c Release | pv -r > /dev/null
[259MiB/s]

这次终于赶上 GNU yes 的零头了,说明缓冲区带来的提升真的非常得劲,不过这跟我们的目标还有不小的差距,得想想办法赶超上去。

阅读 StreamWriter 的源码发现每次写入前都有对字符串进行编码的操作,如果能够把这部分的开销节省下来,岂不美哉?

ConsoleStream & ReadOnlySpan<byte>

既然编码这部分的工作都准备自己来做了,那也就没必要使用 StreamWriter 了,可以直接用 Stream 提供的 Write 来写入二进制的数据。.Net Core 提供了一种新的结构体 ReadOnlySpan,类似数组,但是存储在栈上,所以会更加高效。那么二进制的版的 yes 主要就基于这两个类型实现。

let stdout = Console.OpenStandardOutput()
let chunk = "y\n" |> Text.Encoding.ASCII.GetBytes
let bufSize = 8
let buffer = Array.zeroCreate<byte> (1024 * bufSize) (1)
for i in 0 .. chunk.Length .. buffer.Length - 2 do
    buffer.[i] <- chunk.[0]
    buffer.[i + 1] <- chunk.[1]
let rec yesBin (span: byte ReadOnlySpan inref) : unit = (2)
    stdout.Write span
    stdout.Flush ()
    yesBin &span

[<EntryPoint>]
let main argv =
    let span = Span<byte>.``op_Implicit`` (buffer.AsSpan())
    yesBin &span
    1
1 手动创建一个填满的缓冲区
2 由于 ReadOnlySpan 是结构体,为了避免复制,需要按引用传递参数

这次,我们使用了一个预先填满的缓冲区来避免写入前编码并实现批量的写入,而且通过按引用传递的方式减少了数据的复制,那么这些手段带来的提升会有多大呢?

$ dotnet run -c Release | pv -r > /dev/null
[3.87GiB/s]

结果总体上还是让人满意的,毕竟我们终于追上了 GNU yes 的脚步。至于没能超过的原因也能够从 ConsoleStream 的源码中发现端倪 —— ConsoleStream 在写入数据前总是会进行一次备份,避免外部导致数据变动,然后再将这部分的数据写入到流中。

到此为止

通过这一番实验,我们是可以通过完全不使用任何 unsafe 的方法来达到略逊色于 GNU yes 的性能的。如果真的需要追求极致的性能,那么 unsafe 肯定是绕不开的,但对于更普遍的场景而言,.Net Core 带来的新特性确实能够为我们提升性能提供更多的选择。