写点什么

我用 70 行 Go 语言代码击败了 C 语言

2020 年 2 月 07 日

我用70行Go语言代码击败了 C 语言

在文章《我用 80 行 Haskell 代码击败了 C 语言》发布之后,网络上掀起了一阵讨论热潮,并且很快变成了一场试图用不同语言打败可敬的 wc 的游戏:



今天,我们将会使用Go来打败 wc。作为一个具有出色同步原语的编译语言,要达到与 C 相当的性能应该是毫无困难的。


虽然 wc 同样可以从 stdin 中读取,处理非 ASCII 文字编码,解析命令行 flag(帮助页面),但是这里将不做描述。相反,就像之前的文章提到的一样,我们将会尽力将实现简单化。


这篇文章的源代码可以在这里找到。


测试和对比

我们将使用 GNU时间工具来对比运行时间和最大驻留集大小。


$ /usr/bin/time -f "%es %MKB" wc test.txt
复制代码


我们会使用和最初文章相同的wc版本,由 gcc 9.2.1 和 -O3 编译。对于我们自己的实现,我们会使用 go 1.13.4 版本(我也尝试过 gccgo,但是结果并不是非常理想)。我们的所有测试都是在以下配置上进行的:


  • Intel Core i5-6200U @ 2.30 GHz (2 physical cores, 4 threads)

  • 4+4 GB RAM @ 2133 MHz

  • 240 GB M.2 SSD

  • Fedora 31


为了公平对比,所有的实现都会使用一个 16 KB 的 buffer 读取两个 us-ascii 编码的文本文件(一个 100 MB,一个 1 GB)的输入。


一个朴素的方法

解析参数很简单,我们只需要文件路径:


if len(os.Args) < 2 {    panic("no file path specified")}filePath := os.Args[1]file, err := os.Open(filePath)if err != nil {    panic(err)}defer file.Close()
复制代码


我们会按字节顺序遍历文本来跟踪状态。幸运的是,我们目前只需要两种状态:


  • 前一字节是空格

  • 前一字节不是空格


当从空格字符遍历到非空格的字符时,我们会增加字数计数。这种方式允许我们能够直接读取字节流,从而保持较低的内存消耗。


const bufferSize = 16 * 1024reader := bufio.NewReaderSize(file, bufferSize)lineCount := 0wordCount := 0byteCount := 0prevByteIsSpace := truefor {    b, err := reader.ReadByte()    if err != nil {        if err == io.EOF {            break        } else {            panic(err)        }    }    byteCount++    switch b {    case '\n':        lineCount++        prevByteIsSpace = true    case ' ', '\t', '\r', '\v', '\f':        prevByteIsSpace = true    default:        if prevByteIsSpace {            wordCount++            prevByteIsSpace = false        }    }}
复制代码


我们会用本地的 println() 函数来显示结果。在我的测试中,导入 fmt 库就导致可执行文件大小增加了大约 400KB!


println(lineCount, wordCount, byteCount, file.Name())
复制代码


运行之后:


输入大小运行时间最大内存
wc100 MB0.58 秒2052 KB
wc(朴素版)100 MB0.77 秒1416 KB
wc1 GB5.56 秒2036 KB
wc(朴素版)1 GB7.69 秒1416 KB


好消息是,我们的首次尝试已经让我们在性能上接近 C 了。而在内存使用方面,我们实际上做的比 C 还要好!


分割输入

虽说缓存 I/O 的读取对于提高性能至关重要,但调用 ReadByte() 并在循环中查找错误会带来很多不必要的开销。为了避免这种情况的发生,我们可以手动缓存我们的读取调用,而不是依赖 bufio.Reader。


为了做到这点,我们将输入分割到可以分别处理的多个缓冲 chunk 中。幸运的是,我们只需要知道前一 chunk 中的最后一个字符是否是空格,就可以处理当前 chunk。


接下来是一些功能函数:


type Chunk struct {    PrevCharIsSpace bool    Buffer          []byte}type Count struct {    LineCount int    WordCount int}func GetCount(chunk Chunk) Count {    count := Count{}    prevCharIsSpace := chunk.PrevCharIsSpace    for _, b := range chunk.Buffer {        switch b {        case '\n':            count.LineCount++            prevCharIsSpace = true        case ' ', '\t', '\r', '\v', '\f':            prevCharIsSpace = true        default:            if prevCharIsSpace {                prevCharIsSpace = false                count.WordCount++            }        }    }    return count}func IsSpace(b byte) bool {    return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\v' || b == '\f'}
复制代码


现在就可以分割输入到 Chunks 中,并将其返回到 GetCount 函数:


totalCount := Count{}lastCharIsSpace := trueconst bufferSize = 16 * 1024buffer := make([]byte, bufferSize)for {    bytes, err := file.Read(buffer)    if err != nil {        if err == io.EOF {            break        } else {            panic(err)        }    }    count := GetCount(Chunk{lastCharIsSpace, buffer[:bytes]})    lastCharIsSpace = IsSpace(buffer[bytes-1])    totalCount.LineCount += count.LineCount    totalCount.WordCount += count.WordCount}
复制代码


想要得到字节统计,我们可以用一个系统调用来检查文件大小:


fileStat, err := file.Stat()if err != nil {    panic(err)}byteCount := fileStat.Size()
复制代码


完成之后,可以来看看他们的表现如何:


输入大小运行时间最大内存
wc100 MB0.58 秒2052 KB
wc(缓存块)100 MB0.34 秒1404 KB
wc1 GB5.56 秒2036 KB
wc(缓存块)1 GB3.31 秒1416 KB


看起来我们在这两个方面都超过了 wc,而且这还是没有并行化程序的情况下。tokei的报告显示该程序只有 70 行代码!


并行化

不得不说,并行化的 wc 是有点杀鸡用牛刀了,但是先让我们看看我们能走多远。原文章是并行读取输入的文件;尽管这改善了运行时间,作者同样承认由并行带来的这种性能提升很可能会仅限于某些类型的存储,在其他类型上甚至会带来负面影响。


我们实现的目标是代码可以在所有的设备上都表现良好,所以我们并不会采取原文章中方案。我们会创建两个通道,chunks 和 counts。每个 Worker 会读取并处理从 chunks 中读取到的数据,直到通道关闭,然后将结果写入 counts。


func ChunkCounter(chunks <-chan Chunk, counts chan<- Count) {    totalCount := Count{}    for {        chunk, ok := <-chunks        if !ok {            break        }        count := GetCount(chunk)        totalCount.LineCount += count.LineCount        totalCount.WordCount += count.WordCount    }    counts <- totalCount}
复制代码


每个逻辑 CPU 内核都会被分配到一个 Worker:


numWorkers := runtime.NumCPU()chunks := make(chan Chunk)counts := make(chan Count)for i := 0; i < numWorkers; i++ {    go ChunkCounter(chunks, counts)}
复制代码


进入循环,从磁盘中读取并将任务分给每个 Worker:


const bufferSize = 16 * 1024lastCharIsSpace := truefor {    buffer := make([]byte, bufferSize)    bytes, err := file.Read(buffer)    if err != nil {        if err == io.EOF {            break        } else {            panic(err)        }    }    chunks <- Chunk{lastCharIsSpace, buffer[:bytes]}    lastCharIsSpace = IsSpace(buffer[bytes-1])}close(chunks)
复制代码


完成这一步之后,就可以简单的汇总每个 Worker 的计数:


totalCount := Count{}for i := 0; i < numWorkers; i++ {    count := <-counts    totalCount.LineCount += count.LineCount    totalCount.WordCount += count.WordCount}close(counts)
复制代码


运行之后,和之前的结果进行比较:


输入大小运行时间最大内存
wc100 MB0.58 秒2052 KB
wc (通道)100 MB0.27 秒6644 KB
wc1 GB5.56 秒2036 KB
wc (通道)1 GB2.22 秒6752 KB


wc 的速度现在要快得多,但是内存使用率则被大大地降低了。这是因为输入循环在每次的迭代中都要分配内存。通道是一个共享内存的一个绝佳抽象,但是对于部分用例,只要不使用通道就可以极大幅度地提高性能。


优化后的并行

这部分中我们允许每个 Worker 读取文件,并使用 sync.Mutex 来保证读取行为不会同时发生。我们可以创建一个新的 struct 来处理这一部分:


type FileReader struct {    File            *os.File    LastCharIsSpace bool    mutex           sync.Mutex}func (fileReader *FileReader) ReadChunk(buffer []byte) (Chunk, error) {    fileReader.mutex.Lock()    defer fileReader.mutex.Unlock()    bytes, err := fileReader.File.Read(buffer)    if err != nil {        return Chunk{}, err    }    chunk := Chunk{fileReader.LastCharIsSpace, buffer[:bytes]}    fileReader.LastCharIsSpace = IsSpace(buffer[bytes-1])    return chunk, nil}
复制代码


重写 Worker 函数使其直接读取文件:


func FileReaderCounter(fileReader *FileReader, counts chan Count) {    const bufferSize = 16 * 1024    buffer := make([]byte, bufferSize)    totalCount := Count{}    for {        chunk, err := fileReader.ReadChunk(buffer)        if err != nil {            if err == io.EOF {                break            } else {                panic(err)            }        }        count := GetCount(chunk)        totalCount.LineCount += count.LineCount        totalCount.WordCount += count.WordCount    }    counts <- totalCount}
复制代码


和之前一样,将这些 Worker 分配给 CPU 内核:


fileReader := &FileReader{    File:            file,    LastCharIsSpace: true,}counts := make(chan Count)for i := 0; i < numWorkers; i++ {    go FileReaderCounter(fileReader, counts)}totalCount := Count{}for i := 0; i < numWorkers; i++ {    count := <-counts    totalCount.LineCount += count.LineCount    totalCount.WordCount += count.WordCount}close(counts)
复制代码


现在来看看性能如何:


输入大小运行时间最大内存
wc100 MB0.58 秒2052 KB
wc (互斥量)100 MB0.12 秒1580 KB
wc1 GB5.56 秒2036 KB
wc (互斥量)1 GB1.21 秒1576 KB


并行实现的速度是 wc 的 4.5 倍以上,并且也降低了内存的消耗。这很重要,特别在考虑到 Go 是一种垃圾收集语言的时候。


结论

本文并没有在暗示 Go 要比 C 好,但作者希望能它能证明 Go 可以代替 C 作为系统编程语言。


原文链接


https://ajeetdsouza.github.io/blog/posts/beating-c-with-70-lines-of-go/


2020 年 2 月 07 日 15:313250

评论 2 条评论

发布
用户头像
虽然第一看到以为是个标题党,但是还是忍不住点进来了.
2020 年 02 月 07 日 16:03
回复
2020 年 02 月 07 日 16:55
回复
没有更多了
发现更多内容

192.168.52.165/25是啥意思?

书旅

IP 网络 CIDR

工业互联网网络安全渗透测试技术研究

几维安全

网络安全 数据安全;工业互联网 移动应用安全 渗透测试

Week10总结

熊威

React TypeScript 项目基本构建2

JackWangGeek

React

云图说丨手把手教你为容器应用配置弹性伸缩策略

华为云开发者社区

Docker 云计算 Kubernetes 容器 云容器引擎

合约跟单软件开发app,跟单系统开发功能和优势

WX13823153201

区块链 数字货币

libuv 异步网络编程之 TCP 源码分析

Huayra

网络编程 libuv libuv 源码分析

怎么写一个超棒的 README 文档

程序员生活志

经验总结 文档

面经手册 · 第4篇《HashMap数据插入、查找、删除、遍历,源码分析》

小傅哥

Java 小傅哥 hashmap 面经 红黑树

OpenTSDB 数据存储详解

vivo互联网技术

数据库 时序数据库

拼多多员工曝离职黑幕:要走可以,要离职证明,没有!

程序员生活志

职场 互联网公司

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第8章FlexBox布局

Geek_8dbdc1

微服务框架 Dubbo

莫莫大人

极客大学架构师训练营

Week10作业1

熊威

智能汽车安全风险及防护技术分析

几维安全

移动应用安全

如何让“哑”终端进化,你知道吗?

华为云开发者社区

操作系统 物联网 IoT 华为云 LiteOS

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第9章FlexBox实战有路网

Geek_8dbdc1

巴黎世家土味病毒营销,B端创业初期,如何用营销壮大种子用户?

北柯

创业 营销 tob

架构师0期第十周命题作业

何伟敏

为什么需要企业架构师?

周金根

超市趣味游戏关卡设计

孙志平

Spark优化之小文件是否需要合并?

华为云开发者社区

spark 数据 cpu 内存 Spark调优

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第11章有路网移动端主页实战

Geek_8dbdc1

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第6章表格与表单

Geek_8dbdc1

哈希算法的设计要点、应用场景

多选参数

哈希 hash 哈希算法

HTML5+CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第7章定位

Geek_8dbdc1

React TypeScript项目基本构建

JackWangGeek

前端科普系列(1):前端简史

vivo互联网技术

html 前端 Web

安卓移动应用代码安全加固系统设计及实现

几维安全

android 安全评估 移动应用安全

肯耐珂萨D1轮融资资方阵营揭晓,跟投方为中南资本、青发集团

人称T客

HTML5CSS3前端入门教程---从0开始通过一个商城实例手把手教你学习PC端和移动端页面开发第10章有路网PC端主页实战整合

Geek_8dbdc1

我用70行Go语言代码击败了 C 语言-InfoQ