70行Go程式碼打敗C
- 2019 年 12 月 10 日
- 筆記
作者 | Ajeet D'Souza
譯者 | 蘇本如,編輯 | maozz
來源 | CSDN(ID:CSDNnews)
Chris Penner最近發表的這篇文章——用80行Haskell程式碼擊敗C(https://chrispenner.ca/posts/wc),在互聯網上引起了相當大的爭議,從那以後,嘗試用各種不同的程式語言來挑戰歷史悠久的C語言版wc命令(譯者註:用於統計一個文件中的行數、字數、位元組數或字元數的程式命令)就變成了一種大家趨之若鶩的遊戲,可以用來挑戰的程式語言列表如下:
- Ada
- C
- Common Lisp
- Dyalog APL
- Futhark
- Haskell
- Rust
今天,我們將用Go語言來進行這個wc命令的挑戰。作為一種具有優秀並發原語的編譯語言,要獲得與C語言相當的性能應該很容易。
雖然wc命令被設計為可以從標準輸入設備(stdin)讀取、處理非ASCII文本編碼和解析命令行標誌(wc命令的幫助可以參考這裡),但我們在這裡不會這樣做。相反,像上面提到的文章一樣,我們將集中精力使我們的實現儘可能簡單。
如果你想看這篇文章用到的源程式碼,可以參考這裡(https://github.com/ajeetdsouza/blog-wc-go)。
比較基準
我們將使用GNU的time工具包,針對兩種語言編寫的wc命令,從運行耗費時間和最大常駐記憶體大小兩個方面來進行比較。
$ /usr/bin/time -f "%es %MKB" wc test.txt
用來比較的C語言版的wc命令和在Chris Penner的原始文章里用到的版本相同,使用gcc 9.2.1和-O3編譯。對於我們自己的實現,我們將使用go 1.13.4(我也嘗試過gccgo,但結果不是很好)來編譯。並且,我們將使用以下系統配置作為運行的基準:
- 英特爾[email protected] 處理器(2個物理核,4個執行緒)
- 4+4 GB記憶體@2133 MHz
- 240 GB M.2固態硬碟
- Fedora 31 Linux發行版
為了確保公平的比較,所有實現都將使用16 KB的緩衝區來讀取輸入。輸入將是兩個大小分別為100 MB和1GB,使用us-ascii編碼的文本文件。
原始實現(wc-naïve)
解析參數很容易,因為我們只需要文件路徑,程式碼如下:
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()
我們將按位元組遍歷文本和跟蹤狀態。幸運的是,在這種情況下,我們只需要知道兩種狀態:
- 前一個位元組是空白;
- 前一個位元組不是空白。
當從空白字元變為非空白字元時,我們給字計數器(word counter)加一。這種方法允許我們直接從位元組流中讀取,從而保持很低的記憶體消耗。
const bufferSize = 16 * 1024 reader := bufio.NewReaderSize(file, bufferSize) lineCount := 0 wordCount := 0 byteCount := 0 prevByteIsSpace := true for { 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庫(註:Go語言的格式化庫)會導致可執行文件的大小增加大約400 KB!
println(lineCount, wordCount, byteCount, file.Name())
讓我們運行這個程式,然後看看它與C語言版wc的運行結果比較(見下表):

好消息是,我們的第一次嘗試已經使我們在性能上接近C語言的版本。實際上,我們在記憶體使用方面做得比C更好!
拆分輸入(wc-chunks)
雖然緩衝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' }
現在,我們可以將輸入分成幾個chunk(塊),並將它們傳送給GetCount函數。
totalCount := Count{} lastCharIsSpace := true const bufferSize = 16 * 1024 buffer := 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()
現在我們已經完成了,讓我們看看它與C語言版wc的運行結果比較(見下表):

從上表結果看,我們在這兩個方面都超過了C語言版wc命令,而且我們甚至還沒有開始並行化我們的程式。tokei報告顯示這個程式只有70行程式碼!
使用channel並行化(wc-channel)
不可否認,將wc這樣的命令改成並行化運行有點過分了,但是讓我們看看我們到底能走多遠。Chris Penner的原始文章里的測試採用了並行化來讀取輸入文件,雖然這樣做改進了運行時,但文章的作者也承認,並行化讀取帶來的性能提高可能僅限於某些類型的存儲,而在其他類型的存儲則有害無益。
對於我們的實現,我們希望我們的程式碼能夠在所有設備上執行,所以我們不會這樣做。我們將建立兩個channel – chunks和counts。每個worker執行緒將從chunks中讀取和處理數據,直到channel關閉,然後將結果寫入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 * 1024 lastCharIsSpace := true for { 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得到的計數(count)匯總來得到總的word count:
totalCount := Count{} for i := 0; i < numWorkers; i++ { count := <-counts totalCount.LineCount += count.LineCount totalCount.WordCount += count.WordCount } close(counts)
讓我們運行它,並且看看它與C語言版wc的運行結果比較(見下表):

從上表可以看出,我們的wc現在快了很多,但在記憶體使用方面出現了相當大的倒退。特別要注意我們的輸入循環如何在每次迭代中分配記憶體的!channel是共享記憶體的一個很好的抽象,但是對於某些用例來說,簡單地不使用channel通道可以極大地提高性能。
使用Mutex並行化(wc-mutex)
在本節中,我們將允許每個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 }
與前面一樣,我們現在可以為每個CPU核心生成一個worker執行緒:
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)
讓我們運行它,然後看看它與C語言版wc的運行結果比較(見下表):

可以看出,我們的並行實現運行速度比wc快了4.5倍以上,而且記憶體消耗更低!這是非常重要的,特別是如果你認為Go是一種自動垃圾收集語言的話。
結束語
雖然本文絕不暗示Go語言比C語言強,但我希望它能夠證明Go語言可以作為一種系統程式語言替代C語言。
如果你有任何建議和問題,歡迎在評論區留言。
原文鏈接:
https://ajeetdsouza.github.io/blog/posts/beating-c-with-70-lines-of-go/