《Programming in Go》第七章並發編程譯文

來源:互聯網
上載者:User

標籤:

 

中文譯名

Go 編程

外文原文名

Programming in Go

外文原文版出處

the United States on recycled paper at RR Donnelley in Crawfordsville, Indiana.

譯 文:

第七章 並發編程

7.1主要概念

7.2例子

    7.2.1:過濾器

    7.2.2:並發尋找

    7.2.3:安全執行緒表

    7.2.4:Apache 報告

7.2.5:找重複

   並發編程能夠讓開發人員實現並行演算法,以及利用多處理器和多核寫程式。但是在大多主流變成語言中(例如C,C++和Java)相對單線程程式,很難實現、維護以及調試並發程式。進一步的,並不總是可能分開處理、值得去使用多線程。無論如何,由於線程本身的開銷或者僅僅因為更容易在多線程程式中出錯,不是總能夠實現期待中的效能優勢。

   一個方法是完全地避開線程。例如我們通過多處理器能夠把負擔轉移作業系統。然而,缺點是,作業系統讓我們負責控制處理序間通訊,大多時候比共用記憶體並發開銷更大。

   Go的解決辦法有三部分。第一,Go對並發編程提供高程度支援,讓其更容易正確地實現;第二,是在比線程更輕量級的協程完成並發處理;第三,有時自動記憶體回收機制減輕程式員的極其複雜的,並發程式所需的記憶體管理。

   Go 是基於CSP(訊息佇列進程),內建進階的應用程式介面提供給並發程式實現。這意味著顯示上鎖,能夠避免所有需要關注的在正確時間上鎖和解鎖,利用發送和接收資料通過安全執行緒的通道實現同步。這極大的簡化了並發程式的編寫。另一方面,幾十個線程就能夠讓一個典型的台式電腦負荷,同樣的機器卻能夠輕鬆處理上百上千,甚至上萬的協程。Go的方法讓程式員去思考他們想要並發程式想要實現什麼,而不是上鎖和其他底層細節方面。

   大多其他語言支援非常低級的並行作業(原子增加,對比和交換),和一些低級的工具互斥鎖,沒有其他主流語言像Go提供這般內建的高程度並發支援。(或許除了附加庫,不是語言的組成部分)

 

   除了高程度支援並發是這章的主題,Go也提供和其他語言一樣的低層級的功能。在最低級,標準庫的sync/atomic 包提供完成原子加法,比較和交換操作的函數。這些先進的函數被設計去支援安全執行緒的同步演算法和資料結構的實現,他們不適用於應用程式程式員。Go的sync包提供常見的低級並發基本體:等待條件和互斥鎖。這些是和他在其他語言等同層級的,所以應用程式員經常被迫去使用他們。

   Go 應用程式員並發編程將使用Go的進階工具——通道和協程。另外,無論多少次調用,sync.Once 類型僅僅能夠用來調用一次函數,sync.WaitGroup 類型提供進階同步機制,後面將會看到。

   我們已經在第五章覆蓋了基本的文法以及通道和協程的使用。這些不會在這裡重複,被假設已經瞭解,所以重讀,至少略讀對後續是有用的。

   這一章開始敘述一些在Go並發變成裡面的主要概念。然後這章講展示五個完整的程式詮釋Go並發編程,展示一些基本用法。第一個例子展示如何建立管道,每部分管道執行各自協程最大的輸送量。第二個例子展示如何把工作分開到一定數目的協程中,輸出他們互相獨立的結果。第三個例子展示如何不使用看得見的鎖或者低級基本元素,來建立安全執行緒資料結構。第四個例子,用三個不同方法展示如何在一定數目的協程中獨立完成工作的每一部分,並把結果合并在一起。第五個例子展示如何依賴與處理過程建立一定數目的協程,並如何把這些協程的工作合并到一個單獨的結果集。

 

7.1 主要概念

   在並發編程中,我們典型地想要分離那些需要超過一個或者更多的協程的處理(有別於主協程),以及要麼當計算完輸出結果,要麼在最後合并這些結果輸出。

   即使用Go的進階並發方法,這裡也有我們必須避免的陷阱。其中一個陷阱是程式將立刻結束但是沒有產生任何結果。在主協程終止的那一刻,即使其他協程還在運行,Go 程式自動地終止,所以我們必須足夠小心讓主協程保持足夠的時間到所有的工作完成。

   另外一個陷阱我們必須避免的是死結。一種形式上這個問題本質上和第一個陷阱相反:主線程以及所有處理協程都保持活動著,即使所有的工作都已經完成。這通常是完成處理報告失敗。另一種死結是兩個不同的協程(或者線程)使用鎖去保護資源以及請求相同的鎖在同一時間,在圖7.1中展示。這種死結只有當使用鎖才會出現,所以這是其他語言的一種普遍的風險,但相當少出現在Go,因為Go應用能夠使用通道來避免使用鎖。

 

   一般避免提前終止和不結束的方法是讓煮協程等待一個“完成”通道來報告工作已經完成(我們靜馬上看到,也可以在7.2.2,7.2.4看到)。(也可以在最後的“結果”發送一個標誌值,但這相對於其他方法顯得笨拙)

   另外一個避免陷阱的方法是使用sync.WaitGroup 等待所有的處理協程報告他們結束。然而,使用sync.WaitGroup本身也會導致死結,特別地,當所有處理協程都是堵塞的(例如,等待從通道接收),在主協程中出現sync.WaitGroup.Wait()的調用。 我們待會將會看見如何使用sync.WaitGroup。(7.2.5)

   在Go中,即使我們僅僅使用通道而不使用鎖,依然可能產生死結。例如,假設我們有一系列的協程能夠互相訪問,執行函數(例如互相發送請求)。現在如果其中一個被請求的函數向其中一個正在執行的協程發送,例如發送一些資料,我們就會產生死結。在圖7.2中展示(我們將會在337,340看見這種死結是可能的)

 

  通道提供一種對正在並行地啟動並執行協程,無鎖的通訊手段。(在底下鎖也是能夠使用的,但這些我們不用關心他們本身的實現細節。)當一個通道通訊發生,在同一時刻發送和接收通道(以及他們各自的協程)是同步的。

   預設的通道是雙向的,也就是說,我們能夠發送資料進入通道也能夠通過他們擷取資料。然而,把通道放入一個結構中或者把通道想參數傳遞來當成單項通道使用也是相當普遍的,也就是要麼只能發送資料,要麼只能接收資料。在這些情況下,我們能夠通過表達的語意(以及強制編譯器來檢查)來區分通道的方向。例如,類型 chan<-Type 是一個只發送訊息的通道,類型<-chan Type是一個只接收訊息的通道。在前面的章節我們不使用這些文法,是因為不需要,我們總可以用chan類型來代替,以及有很多其他要學習。但現在起,我們在適當的時候將會使用單向通道,因為他們產生額外的編譯時間檢查,和最佳的實踐。

   發送像bool,int,float64的數值穿過通道,是內在安全的,因為這些是拷貝的,所以對相同的數值沒有疏忽並發的風險。同樣的,發送string類型也是安全的,因為他們是不可變的。

   發送指標或者引用(例如slice 或者map)通過通道不是內在安全的,因為指向或者引用到的數值能夠被發送所在的協程或者接收所在的協程同一時間改變,得不到期望得結果。       

所以,當傳來指標或者引用,我們必須確保他們在同一時刻只能夠被僅僅一個協程使用,也就是說使用權必須被序列化。例外的是,在檔案特別地說明它通過指標是安全的,例如 同樣的 *regexp.Regexp 能夠安全地用在多個協程如果我們需要,因為沒有方法使用值,改變值的狀態。

序列化訪問的其中一個方法是使用互斥鎖。另外一個方法是應用一種方針,指標或者引用只被發送一次,一旦發送,發送方將不再使用它。這使得接收方隨時可以接收指向或者引用到的值,並且提供相同的方針發送指標或者引用給發送方。(我們等會會看見一個基於這種方針的例子;7.2.4.3。) 不好的是基於這種方針的方法是需要訓練的。第三種使用指標或者引用工作安全的方法是提供不能改變指向或者引用到的值的出口方法,而非出口方法能夠執行改變。這種指標或者引用能夠通過他們的出口方法同時被傳遞和訪問,且只有一個協程使用他們的非出口方法(例如,在她們自己的,包在第九章節有講解)。

也能夠發送介面值,也就是說,滿足特定介面的值,通過通道。唯讀介面的值能夠安全地用在任意多的協程(除非檔案說明不可以),但帶值的介面,包含能夠改變值的狀態的方法必須像指標一樣對待,訪問序列化。

例如,當我們使用 image.NewRGBA()函數建立一個新的圖片,將會得到一個*image.RGBA。這個類型同時滿足image.Image 介面(只有get方法,也就是唯讀),以及draw.Image 介面(有所有image.Image 方法再加上一個Set()方法)。所以,傳輸相同的*image.RGBA值在多個協程中是安全的,提供我們傳輸給接收一個image.Image的函數。(不幸的是,這種安全會被破壞,如果接收方法使用一種斷言,draw.Image 介面,所以不允許這類事情是非常明智的。)當我們想要在多個協程中使用同樣的,能夠被改變的 *image.RGBA值的時候,我們應該要麼發送*image.RGBA或者 draw.Image,任一個我們都要確保是被序列化的。

其中一個最簡單的使用並發的方法是使用一個協程去準備這些工作,然後另一個協程做這些工作,讓主協程以及一些通道安排所有的事情。例如,這裡有我們如何在主協程中建立一個“jobs”通道以及一個“done”通道。

 

這裡我們建立一個無緩衝的jobs 通 道 來傳遞自訂Job類型的值。我們也能夠建立一個帶緩衝的done通道,緩衝大小和 []Job類型(初始化沒有展示)的jobList變數的長度一致。

隨著這些通道和job list 建立,我們可以開始。

 

這些片段建立了第一個額外的協程。它迭代jobList切片並且發送每一個job給jobs通道。因為通道是無緩衝的,協程將馬上堵塞,保持堵塞直到另一個協程從jobs通道接收資料。一旦所有的jobs已經被發送到jobs通道,通道將關閉,所以接受者將知道不再有jobs的時間。

   這些片段的語義並不是十分顯著的。for迴圈運行直到完成,緊接著關閉jobs通道,但這些和程式裡的其他協程是並發地發生的。此外go 聲明一下就立馬返回,讓代碼在自己的協程中執行,當然,在此刻沒有任何其他協程試著去解說jobs,所以協程堵塞了。所以,就在go聲明之後,程式就有了兩個協程,主協程繼續下一個聲明,最近建立的協程堵塞等待另一個協程去接收jobs通道裡面的資料。因此,它需要一些時間在for迴圈之前完成以及關閉通道。

 

     這些片段建立第二個額外的協程。這個協程迭代jobs通道,接收每一個job,處理job(這裡只是print出來),然後針對每個job發送true到done通道表示完成。(我們也能夠發送false,因為我們值關心在done通道有多少個發送被執行,而不是發送了什麼值。)

   就像第一個go的聲明,這個聲明立馬返回,for聲明堵塞等待發送。所以,在這一刻,三個並發協程正在執行,主協程以及兩個另外的協程,7.3描述的一樣。

 

    當我們已經得到一個 send等待(在#1協程中),job 馬上被接受(被協程#2)以及執行。期間 #1協程再次被堵塞,這次等待發送第二個job。一旦#2協程已經完成執行,它發送到done通道,這個通道是有緩衝的所以不會在發送的時候堵塞。控制轉移到#2協程額for迴圈,以及下一個job從#1協程被發送,以及被#2協程接收,一直下去,知道所有的工作都完成。

 

這是最後的片段,在另外兩個額外的協程已經被建立以及準備執行之後,準備立馬執行。這代碼是在主協程中,它的目的是確保主協程直到所有的工作完成才終止。

   for迭代和jobs一樣多次,在每次迭代都完成從done通道接收(把結果丟掉),來確保每一個迭代都是同步的以及每個job的完成。如果沒有東西能夠接收(因為某個job正在被執行但還沒結束),接收將會堵塞。一旦所有的jobs被完成,從done通道發送和接收的數量將會和迭代的次數相同,for迴圈將會完成。在此刻,主協程將會結束,因此整個程式終止,我們確信所有的處理都完成。

    兩個拇指規則通常都適用於通道。第一,我們只需要關閉通道,當我們將檢查它是否過會被關閉(使用for ... range 迴圈,select,或者檢查接收方使用<-操作)。第二,一個通道應該被發送方協程關閉,而不是被接收方協程關閉。不關閉通道是非常明智的,這樣就從不用去檢查通道是否被關閉,通道是非常輕量級的,所以他們是不佔用資源的,比如開啟一個檔案。

    在這個例子中,根據我們的拇指規則,jobs通道用for...range來迭代迴圈,所以我們在發送方協程中完成關閉了它。在另一方面,我們不需要擔心是否關閉了done通道,所以沒有聲明取決於他被關閉後。

   這個例子展示了一個在Go並發變成普通模式,儘管在這個特別的例子使用並發並不是真的很好。下面模組的例子用了和這個展示相類似的模式,也充分地使用了並發性。

7.2  例子

   雖然Go使用了相當少得文法來提供協程和通道(<-,chan,go,select),但這已經足夠在多種方式實現並發。事實上,有很多不同的方法是可行的,在這一章講解每一種變化是不切實際的。所以,我們會關注通常使用在並發變成中的三中模式,通道,多個獨立並發工作(同步與不同步結果),和多個相互依賴的並發工作,然後看看各自使用Go的並發支援來實現的獨特方式。

   當中,會在這展樣本子,以及在最後提供足夠的練習去洞悉和練習Go編程,這些以及其他方法都能夠安全地使用在新程式中。

7.2.1 例子:filter

第一個例子被設計展示一個獨特的並發變成模式。程式能夠很容易適應於其他得益於程式的並發性的工作。

那些使用Unix環境的人可能已經發現Go的通道,聯想於Unix管道(除了通道是雙向,而管道是單向的)。這些管道能夠用來建立用來把一個程式的輸出傳遞給另外一個程式,當做另一個程式的輸入,再把輸出返回給第三個程式等等的通道。例如,使用Unix管道命令 find $GOROOT/src -name "*.go" | grep -v test.go,我們可以獲得包含在Go資源樹的所有Go檔案清單(不包括測試檔案)。這個方法的一個亮點是容易擴充。例如,我們能夠增加 |xargs wc -l 來得到並列出每一個檔案所包含的行數(在最後加上總數),增加|sort -n 來根據行數排序(最少到最多)。

真正的Unix型管道是能夠被建立的,使用標準庫的 io.Pipe()函數。例如,Go標準庫使用這個函數來對比映像(看檔案 go/src/pkg/image/png/reader_test.go)。

   除了使用io.Pipe()來建立Unix型管道,也能夠使用channel來建立管道,後一

種技術我們將在這裡回顧。

filter例子程式(在filter/filter.go檔案),接收一些命令列參數(例如,檔案的最大最小長度以及能夠接收的檔案尾碼),檔案清單和輸出和給定命令列匹配的檔案清單。這裡兩行代碼是程式中main()函數的內容。

 

     handleCommandLine()函數(沒有展示出來)使用標準庫的flag包來處理命令列參數。管道的工作,從最裡面的函數(source(file))調用最外面的(sink())。這裡是相同的管道,用容易明白的方式展示。

 

 

     source()函數,獲去一個檔案名稱字切片以及返回chan類型的通道,被命名為channel1變數。source()函數輪流發送每個檔案名稱到通道中。兩個filter函數每個有一個filter標準和一個chan string,每個返回自己的chan string。在這個例子中,第一個filter的返回通道被命名為channel2,第二個為channel3。filter迭代通道接收的項,並且發送每一個和他們的條件匹配的項到他們已經返回的通道中。sink()函數迭代取出通道裡面的內容,並且把每一個都列印出來。

 

圖7.4提供如何發生的。在這個例子中,filter程式,sink()函數在main協程中執行,每個管道函數(例如 source(),filterSuffixes(),filterSize()在各自的協程中執行)。這意味著每一個管道函數調用直接返回並且很快執行到sink()函數。此刻,所有的協程是並發地執行的,等待發送或者等待接收直到所有的檔案已經被處理。

 

 

   這個函數建立了通道,用來傳遞檔案名稱。它使用了緩衝通道,因為在測試中這個提升了輸送量。(我們經常使用記憶體的消耗換來速度的提升)。

   一旦輸出通道已經建立,我們建立一個協程來迭代檔案,並且把每一個發送到通道中。當所有的檔案已經被發送,我們關閉通道。和往常一樣,go聲明立馬返回,所以從發送第一個項到發送最後一個項並且關閉通道之間有相當長的間隔。第二個通道並沒有堵塞(至少,前1000個檔案,以及少於1000個檔案),但如果發送更多就會堵塞,知道一個或者更多被從通道接收走。

   我們之前就知道,預設的通道是雙向的,但我們能夠強迫一個通道變成單向的。回憶前面部分,chan<-Type 類型是一個只發送類型通道,<-chan Type 是一個只接收通道。在函數最後,雙向out通道,被單做只接收通道返回,所以檔案名稱只能被接收。我們當然也能恢複為一個雙向通道,但這種方式能更好地表達我們的意圖。

   在執行go聲明後開始匿名函數,在它自己的協程內處理,函數立馬返回通道,協程的函數往它傳送檔案的名字。所以一旦source()函數被調用,就有兩個協程執行,住協程和另外一個在函數中建立的。

 

 

這是兩個filter 函數的第一個,只有一個被展示,因為filterSize函數是在結構上基本一樣。

   in通道參數,是只能被接收通道或者雙向通道,但無論哪種情況,在filterSuffixes函數內,型別宣告確保它能夠只接收。(我們知道從source()函數的傳回值,是in通道,事實上是一個只能被接收的通道。)相應地,我們返回雙向out通道作為只接收通道,就像我們在source()函數做的一樣。在兩種情況中,我們省略<-s,函數也一樣工作。然而, 通過包含反向,我們已經精確地表達了我們想要得功能的語義,並確保編譯器執行它們。

   filterSuffixes 函數從建立一個帶和輸入通道一樣大小緩衝的輸出通道開始,以致最大生產量。函數接著建立一個協程來處理。在協程裡面,in通道被迭代(檔案名稱輪流被接收)。如果沒有指定尾碼,任何尾碼都能簡單地發送到輸出通道。如果匹配到任何可接收的檔案尾碼檔案名稱的小寫尾碼,將被發送到輸出通道,否則丟棄。(filepath.Ext()函數返迴文件名的擴充,也就是它的尾碼,包括前面部分,或者Null 字元串的名字沒有擴充)

   就像source()函數,一旦所有的處理結束,輸出通道被關閉,儘管它可能花費一些時間達到這個點,但協程建立輸出通道的協程被返回,以至於下一個函數的管道能夠接收檔案名字。

    在此刻,三個協程正在運行,主協程,source()函數的協程,和本函數的協程。調用filterSize()函數之後,將會有第四個協程,所有這些都並行地工作。

 

source 函數,以及兩個filter函數在自己的並發協程中處理,通過通道通訊。sink()函數在主協程中對最後一個唄別的函數返回的通道操作,迭代得出成功從filter傳遞過來的檔案名稱,並且輸出他們。

      

     

                                                           

 

sink()函數的range 聲明迭代只接收通道,答應出檔案名稱或者被堵塞直到通道被關閉,所以確保主協程沒有終止直到所有其他協程的處理都完成。

   自然地,我們往管道增加另外的函數,要麼過濾檔案名稱,要麼處理已經通過過濾器的檔案,當每一個新的函數接收一個輸入通道(前面函數的輸出通道)以及返回自己的輸出通道。當然,如果我們想傳遞更多的複雜的值通過管道,我們也能夠把通道基於一個結構,而不是一個字串。

在本節中所展示的管道是一個很好的管道架構執行個體,在每個階段做特定的處理真的很

少受益於管道方法。這類管道能夠受益於並發性,一個管道的每個階段可能有很多工作做,可能取決於正在處理的項目,這樣儘可能多的時間使得協程都是忙碌的。

 

 

 

 

 

 

 

 

 

 

 

 

 

《Programming in Go》第七章並發編程譯文

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.