這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。## 介紹在過去的幾個月裡,我在幾個項目上使用過 Go,儘管我還算不上專家,但是還是有幾件事我要感謝 Go:首先,它有一個清晰而簡單的文法,我不止一次注意到 Github 開發人員的風格非常接近於舊 C 程式中使用的風格,從理論上講,Go 似乎吸收了世界上所有語言最好的特性:它有著進階語言的力量,明確的規則使得更簡單,即使這些特性有時有一點點的約束力--就是可以給代碼強加一個堅實的邏輯。這是命令式的簡單,由大小以位為單位的原始類型組成。但是沒有像把字串當成字元數組那樣操作的乏味。然而,我認為這兩個非常有用和有趣的功能是 goroutine 和 channels。## 前言為了理解 Go 為什麼能更好地處理並發性,首先需要知道什麼是並發性 <sup>[1](#1)</sup>。並發性是獨立執行計算的組成部分:是一種更好地編寫與現實世界進行良好互動的乾淨的代碼的方法。通常,即使並發不等同於並行,人們也會將並發的概念與並行的概念混淆:是,儘管它能夠實現並行性。所以,如果你只有一個處理器,你的程式仍然可以並發,但不能並行。另一方面,良好的並發程式可以在多處理器上並行運行 <sup>[2](#2)</sup>。這一特性是非常重要的。讓我們來談談 Go 如何讓程式利用在多處理器 / 多線程環境中啟動並執行優勢。或者說,Go 提供了什麼工具來編寫並發程式,因為它不是關於線程或核心的:它是關於 routine 的。### Goroutine假設我們調用一個函數 f(s):這樣的寫法就是通常的調用方式,同步運行。如果要在 goroutine 中調用這個函數,使用 go f(s) 即可。這個新 goroutine 將和調用它的 goroutine 並發執行。但是... 什麼是 goroutine 呢?這是一個獨立執行的函數,由 go 語句啟動。它有自己的呼叫堆疊,這個堆棧可以根據需要增長和縮減,而且非常節省空間的。擁有數千甚至數十萬個 goroutine 是實際存在的,但它不是線程。事實上,在一個有數千個 goroutine 的程式中可能只有一個線程。相反,goroutines 會根據需要動態複用到線程上,以保持所有的 goroutine 運行。如果你把它當成一種便宜的線程,也不會差太多。```gopackage mainimport "fmt"func f(from string) {for i := 0; i < 3; i++ {fmt.Println(from, ":", i)}}func main() {// Suppose we have a function call `f(s)`. Here's how// we'd call that in the usual way, running it// synchronously.f("direct")// To invoke this function in a goroutine, use// `go f(s)`. This new goroutine will execute// concurrently with the calling one.go f("goroutine")// You can also start a goroutine for an anonymous// function call.go func(msg string) {fmt.Println(msg)}("going")// Our two function calls are running asynchronously in// separate goroutines now, so execution falls through// to here. This `Scanln` code requires we press a key// before the program exits.var input stringfmt.Scanln(&input)fmt.Println("done")}```更多細節 <sup>[3](#3)</sup>正如我所說的,coroutine 背後的想法是複用獨立執行的函數--coroutines--在一組線程上。當一個 coroutine 阻塞的時候,比如通過調用一個阻塞的系統調用, run-time 會自動地將同一個作業系統線程上的其他 coroutines 移動到一個不同的,可啟動並執行線程上,這樣它們就不會被阻塞。這些 coroutines 被稱為 goroutines,非常便宜。它們的堆棧記憶體很少,只有幾KB。此外,為了使堆棧變小,Go 的 run-time 使用可調整大小的有界堆棧。建立的 goroutine 有幾KB,這個大小几乎總是足夠的。當空間不夠時,run-time 會自動成長(縮小)用於儲存堆棧的記憶體,從而允許許多 goroutines 生存在適量的記憶體中。每個函數調用的 CPU 開銷平均需要大約三個廉價的指令,所以在相同的地址空間中建立數十萬個 goroutine 是很實際的。如果 goroutines 只是線程,那麼系統資源將會用得更少。好吧,真的很酷,但... 為什嗎?為什麼我們要編寫並發程式?要更快地完成我們的工作(即使編寫正確的並發程式可能花費的時間比在並行環境中運行任務的時間長 XD)典型的線程情況包括分配一些共用記憶體並將其位置儲存在 p 中的主線程。主線程啟動 n 個背景工作執行緒,將指標 p 傳遞給他們,背景工作執行緒可以使用 p 來處理 p 指向的資料。但是如果線程開始更新相同的記憶體位址呢?我是說,這是電腦科學中最難的一個。好吧,讓我們從簡考慮:從作業系統的角度來看,一些原子系統調用讓你鎖定對共用記憶體地區的訪問(我是指訊號量,訊息佇列,鎖等)。從語言角度來看,通常有一組原語,調用所需的系統調用,並讓你將存取權限同步到共用記憶體地區(我是指像多處理,多線程,池等的包)。下面,我們來談談 Go 的一個工具,它可以協助您處理 goroutine 之間的並發通訊:channels。### ChannelsChannels 是一個輸入管道,你可以通過通道操作符 `<-` 發送和接收值。這就是全部:D. 你只需要知道當一個 main 函數執行 `<-c` 時,它將等待一個值被發送。同樣,當 goroutined 函數執行 `c<-value` 值時,它等待接收器準備就緒。寄件者和接收者都必須準備好,來在通訊中發揮作用。否則,我們要等到它們準備好:你不必處理訊號量,鎖等等:channels 可以同時實現通訊和同步。記住和理解這一點非常重要,也是 Go 和我所知道的其他語言之間最大的區別之一。```gopackage mainimport "fmt"func sum(s []int, c chan int) {sum := 0for _, v := range s {sum += v}c <- sum // send sum to c}func main() {s := []int{7, 2, 8, -9, 4, 0}c := make(chan int)go sum(s[:len(s)/2], c)go sum(s[len(s)/2:], c)x, y := <-c, <-c // receive from cfmt.Println(x, y, x+y)}```更多細節 <sup>[4](#4)</sup>正如官方文檔所述,channel 提供了一種機制,用於通過發送和接收指定元素類型的值來並發執行函數來進行通訊。這很簡單。我還沒有說的是,一個 channel 作為一種類型,不同於它承載的資訊類型:```ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) ElementType```可選的 `<-` 運算子指定通道方向,發送或接收. 如果沒有方向,通道是雙向的。channel 可能僅限於發送或僅通過轉換或分配接收。```chan T // can be used to send and receive values of type Tchan<- float64 // can only be used to send float64s<-chan int // can only be used to receive ints```為了協助您解決某些特定的同步問題,您還可以使用 `make(make(chan int,100))` 函數建立一個 buffered channel. 容量, 也就是按元素數量,設定 channel 中緩衝區的大小。如果容量為零或不存在,則只有在發送方和接收方都準備好的情況下,channel 才能無緩衝,並且通訊成功。否則,如果緩衝區未滿(發送)或不空閑(接收),則 channel 被緩衝並且通訊成功而不阻塞。 一個零 channel 永遠不會準備好通訊:我發現通過使用緩衝 channel,可以隱式地設定在運行時要使用的最大程式數量,這對於我的基準測試是非常有用的。### 總結總而言之,你可以在 goroutine 中調用一個函數,甚至是匿名函數, 然後把結果放在一個 channel 中,預設情況下,發送和接收阻塞,直到另一端準備好。所有這些特性都允許 goroutine 在沒有顯式鎖定或條件變數的情況下進行同步。好吧,但是... 他們表現地怎麼樣呢?## Go vs Python好吧,我是一個 Python 愛好者-我想,因為它在標題中,我不記得. md 各自的原始碼在哪裡. 所以我決定做一個比較,看看這些神奇的 Go 巧妙的語句如何真正執行。為此,我編寫了一個簡單的 go-py 程式([這裡](https://github.com/made2591/go-py-benchmark) 是代碼),它完成了對隨機整數列表的合并排序,可以在單核環境或多核環境中運行。或者,在單個_常式或多個_常式_環境中:這是因為,正如我所說的,go-routine 是一個在 Python 中停用概念,比線程更深入。請記住,不止一個 go-routine 可以屬於一個單獨的線程。相反,從 Python 的角度來看,你只能使用進程,線程以及訊號量,鎖定,鎖等等,但不可能重現完全相同的計算。我的意思是,這是正常的,他們是不同的語言,但他們最後都調用一組系統調用。無論如何,我認為當你運行這種並發性實驗時,你可以做的是儘可能地重現一個在邏輯上的等價性的計算。我們從 Go 版本開始。### Go 合并排序Go 和 Python 版本的程式都提供了兩個功能:- 單 routine;- 多個首碼數的 routine;### 簡單的 Go 版本好吧,我不會講太多關於單 routine 的方法:這很簡單。下面你可以看到我能夠考慮的最佳化版本的代碼(就 io 操作而言), [Github](https://github.com/made2591/go-py-benchmark/blob/master/main.go) 上的評論版本:```gofunc msort_sort(a []int) []int {if len(a) <= 1 {return a}m := int(math.Floor(float64(len(a)) / 2))return msort_merge(msort_sort(a[0:m]), msort_sort(a[m:]))}func msort_merge(l []int, r []int) []int {a := []int{}for len(l) > 0 || len(r) > 0 {if len(l) == 0 {a = append(a, r[len(r)-1])if len(r) > 1 {r = r[:len(r)-1]} else {r = []int{}}} else {if len(r) == 0 || (l[len(l)-1] > r[len(r)-1]) {a = append(a, l[len(l)-1])if len(l) > 1 {l = l[:len(l)-1]} else {l = []int{}}} else {if len(r) > 0 {a = append(a, r[len(r)-1])if len(r) > 1 {r = r[:len(r)-1]} else {r = []int{}}}}}}return reverse(a)}```我不認為這需要解釋:如果您有任何問題,請不要猶豫在評論中寫下意見!我會儘快回答。### 並發的 Go 版本我們來談談並發版本。我們可以拆分數組,並從主常式調用子常式,但是我們如何控制並發執行 go-routine 或工作數的最大數量?那麼,限制 Go 中的並發的一種方法 <sup>[5](#5)</sup> 是使用緩衝通道(訊號量)。正如我所說的,當你建立一個具有固定維度通道或緩衝,如果緩衝區未滿(發送)或不為空白(接收),通訊成功而不會阻塞,所以你根據你想擁有的並發單元的數量,實現一個訊號量來輕鬆地阻止執行。真的很酷,但是... 有一個問題:一個 channel 是一個 channel,即使有緩衝,頻道上的基本發送和接收也被阻止。幸運的是,Go 非常棒,讓你建立明確的非阻塞通道, 使用 select 語句 <sup>[6](#6)</sup> :因此,您可以使用 select with default 子句來實現無阻塞的發送,接收,甚至是非阻塞的多路選擇。還有一些其他的聲明來解釋,在我的首碼最大數量的並發 goroutine 版本的合并排序:```go// Returns the result of a merge sort - the sort part - over the passed listfunc merge_sort_multi(s []int, sem chan struct{}) []int {// return ordered 1 element arrayif len(s) <= 1 {return s}// split lengthn := len(s) / 2// create a wait group to wait for both goroutine call before final merge stepwg := sync.WaitGroup{}wg.Add(2)// result of goroutinevar l []intvar r []int// check if passed buffered channel is fullselect {// check if you can acquire a slotcase sem <- struct{}{}:// call another goroutine worker over the first halfgo func() {l = merge_sort_multi(s[:n], sem)// free a slot<-sem// unlock one semaphorewg.Done()}()default:l = msort_sort(s[:n])wg.Done()}// the same over the second halfselect {case sem <- struct{}{}:go func() {r = merge_sort_multi(s[n:], sem)<-semwg.Done()}()default:r = msort_sort(s[n:])wg.Done()}// wait for go subroutinewg.Wait()// returnreturn msort_merge(l, r)}```正如你所看到的,在我的預設選擇操作中,我編寫了一個調用單 routined 版本的合并排序。但是,代碼中還有一個有趣的工具:它是由 sync 包提供的 WaitGroup 對象。從官方文檔 <sup>[7](#7)</sup> 來看 ,WaitGroup 等待一系列 goroutines 完成。main goroutine 調用 Add 來設定要等待的 goroutines 的數量。然後,每個 goroutine 程式運行並完成後調用 Done。同時,Wait 可以用來阻塞,直到所有的 goroutines 都完成了。### Python 合并排序好吧,在這裡,如果你到了這裡,我會誠實的說:我不是一個並發專家,實際上我真的討厭並發,但是寫這篇文章和測試 Go channel 讓我學到了很多關於這個主題的知識:在 Python 中儘可能複製一個在邏輯上大部分相同的計算真的很難。### 簡單的 Py 版本```pythondef msort_sort(array):n = len(array)if n <= 1:return arrayleft = array[:n / 2]right = array[n / 2:]return msort_merge(msort_sort(left), msort_sort(right))def msort_merge(*args):left, right = args[0] if len(args) == 1 else argsa = []while left or right:if not left:a.append(right.pop())elif not right or left[-1] > right[-1]:a.append(left.pop())else:a.append(right.pop())a.reverse()return a```### 並發 Py 版本我不得不為這個並發版本想很多:首先,我想使用一個線程 / 進程數組,並啟動 / 加入他們,但是,後來我意識到這與我的 Go 版本不太一樣。首先,因為對多於一個線程 / 進程的調用只能在未經處理資料的一個分區上完成一次, 最終以並行合并的方式合并:這不完全是我的 Go 版本的行為,遞迴調用一個並發常式,直到訊號量接受新的並發常式,最後調用排序方法的單常式執行個體。所以我想 “我簡直不可能在 Python 中使用簡單的一次性分裂方法來實現我的合并排序的多常式(線程或進程),因為它不是計算上等同的”。出於這個原因,我嘗試的第一件事是使用 Python 中的訊號量原語重新表示 Channel 和 WaitGroup 的完全相同的行為。經過幾天的工作,我得到了它。讓我們看看代碼:```pythondef merge_sort_parallel_golike(array, bufferedChannel, results):# if array length is 1, is ordered : returnif len(array) <= 1:return array# compute lengthn = len(array) / 2# append thread for subroutinets = []# try to acquire channelif bufferedChannel.acquire(blocking=False):# if yes, setup call on the first halfts.append(Thread(target=merge_sort_parallel_golike, args=(array[:n], bufferedChannel, results,)))else:# else call directly the merge sort over the first halftresults.append(msort_sort(array[:n]))# the same, in the second halfif bufferedChannel.acquire(blocking=False):ts.append(Thread(target=merge_sort_parallel_golike, args=(array[n:], bufferedChannel, results,)))else:results.append(msort_sort(array[n:]))# start threadfor t in ts:t.start()# wait for finishfor t in ts:t.join()# append resultsresults.append(msort_merge(results.pop(0), results.pop(0)))# unlock the semaphore for another threads for next call to merge_sort_parallel_golike# try is to prevent arise of exception in the endtry:bufferedChannel.release()except:passif __name__ == "__main__":# manager to handle routine responsemanager = Manager()responses = manager.list()sem = BoundedSemaphore(routinesNumber)merge_sort_parallel_golike(a, sem, responses)a = responses.pop(0)```好吧,讓我們從 manager 開始。在主體中初始化的 Manager 對象提供了一個結構來放置調用的響應 - 或多或少類似於 Queue。BoundedSemaphore 扮演著我之前談到的有界 channel 訊號量的角色。訊號量是一個比簡單的鎖更進階的鎖機制:它有一個內部的計數器而不是一個鎖定標誌,並且只有當超過給定數量的線程試圖持有訊號才會阻塞它。根據訊號量的初始化方式,這允許多個線程同時訪問相同的程式碼片段:幸運的是,如果你失敗了,你可以嘗試獲得鎖定並繼續執行--這起到了前面提到的在 Go 版本中使用的 select 技巧, 通過使用 `blocking = False` 作為 `(bufferedChannel.acquire(blocking = False))` 的參數。有了 join,我類比了 WaitGroup 的行為,因為我認為這是在繼續最後的合并步驟之前同步這兩個線程並等待它們結束的標準方式。這裡有任何問題嗎?你想知道"它的表現怎麼樣"好吧,它遜爆了。我的意思是:非常遜。所以我試圖尋找更有效率的東西... 我找到了這個. 類似於我想到的第一個解決方案,但使用 Pool 對象。```pythondef merge_sort_parallel_fastest(array, concurrentRoutine, threaded):# create a pool of concurrent threaded or process routineif threaded:pool = ThreadPool(concurrentRoutine)else:pool = Pool(concurrentRoutine)# size of partitionssize = int(math.ceil(float(len(array)) / concurrentRoutine))# partitioningdata = [array[i * size:(i + 1) * size] for i in range(concurrentRoutine)]# mapping each partition to one worker, using the standard merge sortdata = pool.map(msort_sort, data)# go ahead until the number of partition are reduced to one (workers end respective ordering job)while len(data) > 1:# extra partition if there's a odd number of workerextra = data.pop() if len(data) % 2 == 1 else None# prepare couple of ordered partition for mergingdata = [(data[i], data[i + 1]) for i in range(0, len(data), 2)]# use the same number of worker to merge partitionsdata = pool.map(msort_merge, data) + ([extra] if extra else [])# return resultreturn data[0]```而且這個表現更好。問題是使用線程或進程更好?那麼,看看我的比較圖!好吧,因為 Python 版本不太好,這是一個只有 Go 系列的圖表## 結論Python 糟透了。 Go 完勝。對不起,Python:我愛你。完整的代碼可以在這裡找到:[go-py-benchmark](https://made2591.github.io/posts/go-py-benchmark)。謝謝大家的閱讀!<!--<span id = "anchor"> 錨點 </span> [錨點](#anchor)-->1 <span id = "1"> 這裡可以線上獲得很多關於 Go talk 的 [投影片](https://talks.Go.org/2012/concurrency.slide)!</span> 2. <span id = "2">[Rob Pike 的課程](https://vimeo.com/49718712):並發不是並行的。</span> 3. <span id = "3"> 直接來源官方 [FAQ](https://Go.org/doc/faq) 頁面。</span> 4. <span id = "4"> 更多資訊在 [這裡](https://Go.org/ref/spec#Channel_types)。</span> 5. <span id = "5"> 來源 [在這](https://medium.com/@_orcaman/when-too-much-concurrency-slows-you-down-Go-9c144ca305a)</span> 6. <span id = "6"> 看看 [這裡](https://gobyexample.com/non-blocking-channel-operations)</span> 7. <span id = "7"> 這裡更多關於 [WaitGroup](https://Go.org/pkg/sync/#WaitGroup) 的資訊 </span>
via: https://made2591.github.io/posts/go-py-benchmark
作者:Matteo Madeddu 譯者:Titanssword 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
1389 次點擊