[翻譯] channel 獨木難支

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

原文在此。遺憾的是文章只提出了問題,並沒明確提供如何解決這些問題。但無論如何,對於這種可以引起反思的文章,是不能放過的。另外,我得承認,似乎高層次的分布式系統的抽象,用函數式語言的範式來表述更容易一些(實現上其實未必)。

————翻譯分隔線————

channel 獨木難支
或者說為什麼流水線作業沒那麼容易

勇敢和聰明的 Golang 並行存取模型。

@kachayev 撰寫

概述

Go 被設計用於更容易的構建並發系統,因此它有運行獨立的計算的 goroutine 和用於它們之間通訊的 channel。我們之前都聽過這個故事。所有的例子和指南看起來都挺好的:我們可以建立一個新的 channel,可以向這個 channel 發送資料,可以從 channel 讀取,甚至還有漂亮和優雅的 select 語句(順便提一下,為什麼 21 世紀了我們還在用語句?),阻塞讀和緩衝……

主旨:99% 的情況下,我其實並不關心響應是由 channel 傳遞的,還是一隻魔法獨角獸從它的角上帶來的。

在為初學者撰寫指南的時候這確實挺酷的!但是當你嘗試實現大型的複雜系統的時候這就很痛苦了。channel 太原始了。它們是低級的構件,我相當懷疑你願意在日常工作中天天和它們打交道。

看看“進階模式”和“流水作業”。不是那麼簡單吧?有太多的東西要考慮,並且永遠記得:什麼時候、如何關閉 channel;如何傳遞錯誤;如何釋放資源。我抱怨這些是因為我曾嘗試實現一些東西,然後失敗了。而我每天都在面對這些東西。

你可能會說,對於初學者沒必要理解所有細節。不過……描述一個模式真得很“進階”?不幸的是,答案是否定的。它們是基礎和常識。

更仔細的瞭解一下流水作業問題。這真得是流水作業?不,“…對於每個來自目錄的路徑計算 MD5 校正碼,並將結果存入一個 map[string][string]…”。這隻是一個 pmap(並行 map)。或具有池化執行器的、有限的並行化的 pmap。而 pmap 不應當需要我輸入如此多行代碼。想瞭解真正的流水作業——我將在文章的最後介紹一個(參閱“構建 Twitter 分析器”的段落)。

那麼模式如何呢?

為了快速開發真實的應用,我們應當能夠提煉出比原始的 channel 層面更高的抽象。它們只是傳輸層。我們需要應用程式層的抽象來編寫程式(對比 OSI),否則你會發現你總是在低級的 channel 網路的細節上糾結,試圖在生產環境中、偶發的、沒有任何有效方法重現的找到它不工作的原因。參閱 Erlang OTP 是如何針對性的解決類似的問題的:將你保護在低級的訊息傳遞代碼之外。

低級的代碼有什麼問題?這裡有一篇超棒的文章“愛德華 C++ 手(譯註:借‘愛德華剪刀手’)”:

手裡有把剪刀並不一定總是那麼糟糕。愛德華有許多天賦:例如,他能創造勁爆的狗狗的髮型。別誤會——它展示了許多勁爆的狗狗的髮型(我是說優雅且簡單的 C++ 代碼),但是主要內容還是關於如何避免剪壞,以及在發生剪壞的情況下進行急救。

在 Kyiv Go 聚會的時候,我經曆了相同的情況:在一頁幻燈上那 20 行乾淨可讀的代碼。一個不一般的競態條件和一個可能出現的執行階段錯誤。這對於所有聽眾來說很明顯嗎?不。至少一半人不明白。

痛苦的緣由?

好,讓我們試著收集一些類似的模式。從工作的經驗中、從書中、從其他語言中(是,夥計們,我知道這有點令人難以相信,不過還有許多其他語言同樣也有並發的設計)。

Rob Pike 討論了 Fan-in、Fan-out。在許多情況下,這很有用,不過還是關於網路的 channel。而不是你的應用。在任何情況下,看看(無恥的從這裡偷的)。
Rob Pike talks about Fan-in, Fan-out. It’s useful in many ways, but still about the network of channels. Not about your application. In any case, let’s check (shamelessly stolen from here).

func merge(cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為 cs 中每個輸入的 channel 啟動一個輸出用的 goroutine。    // 從 c 中複製值出來直到 c 被關閉,然後又調用 wg.Done。    output := func(c <-chan int) {        for n := range c {            out <- n        }        wg.Done()    }    wg.Add(len(cs))    for _, c := range cs {        go output(c)    }    // 一旦所有輸出的 goroutine 完成的,就啟動一個 goroutine 來關閉 out。    // 這必須在 wg.Add 調用後啟動。    go func() {        wg.Wait()        close(out)    }()    return out}

呃…… <-chan int。在我的應用程式中重用起來沒那麼抽象(例如,遷移到庫中)……並且在每次我需要的時候都重新實現也不是那麼清晰。那麼如何讓其可以重用?<-chan interface{}?歡迎來到類型轉換和執行階段錯誤的領地。如果,希望實現一個進階的 fan-in(合并)就必須犧牲型別安全。同樣的(不幸的)是其他模式也是一樣。

我真正想要的是:

func merge[T](cs ...<-chan T) <-chan T

是,我知道 Go 沒有泛型,因為誰需要它們呢?

現在天氣如何?

回到模式。讓我們分析一個假設的項目,伺服器端開發會與實際經驗非常接近。我們需要一個伺服器接收請求,輸入一個美國的州,返回從 OpenWeatherMap 收集到的資訊。例如這樣:

$ http localhost:4912/weather?q=CAHTTP/1.1 200 OKAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: GET, POSTAccess-Control-Allow-Origin: *Connection: keep-aliveContent-Type: application/json; charset=utf-8
[{    "clouds": {        "all": 40    },    "id": 5391959,    "main": {        "temp": 288.89,        "temp_max": 291.48,        "temp_min": 286.15    },    "name": "San Francisco",    "weather": [        {            "description": "mist",            "icon": "50d",            "id": 701,            "main": "Mist"        }    ]}, {    "clouds": {        "all": 90    },    "id": 5368361,    "main": {        "temp": 292.83,        "temp_max": 296.15,        "temp_min": 289.15    },    "name": "Los Angeles",    "weather": [        {            "description": "mist",            "icon": "50d",            "id": 701,            "main": "Mist"        }    ]}]

pmap

讓我們從一些我們已經知道的東西開始。那麼,我們收到了請求 ?q=CA。我不想對從哪裡得到相關城市的列表進行解釋。我們可以用這個資料庫,在記憶體中緩衝以及其他什麼合理的東西。假設我們有一個神奇的 findCities(state) 函數,返回 chan City(像通常 go 程式表現的延遲序列那樣)。然後呢?每個城市我們都必須調用 OpenWeatherMap API 並解析結果到一個 map[City]Weather 中。我們已經討論過這個模式了。這是個 pmap。我希望My Code像這樣:

chanCities := findCities(state)resolver := func(name City) Weather { return openWeatherMap.AskFor(name) }weather := chanCities.Par.Map(resolver)

或限制並發數:

chanCities := findCities(state)pool := NewWorkers(20)resolver := func(w Worker, name City) Weather { return w.AskFor(name) }weather := chanCities.Par.BoundedMap(pool, resolver)

我希望所有這些 <-done 同步和神聖的 select 完全被隱藏起來。

Futures & Promises

擷取當前天氣可能需要很長的時間,例如,你有一個很長的城市列表。當然,你不希望重複的 API 呼叫,因此應當可以用某種方法管理並行的請求:

func collect(state string) Weather {  calc, ok := calculations.get(state) // check if it's in progress  if !ok {      calc.calculations.run(state) // run otherwise  }  return calc.Wait() // wait until done}

這也被叫做 future/promise 。Wiki 的解釋:

它們描述了一個對象對於結果扮演了代理的角色,而這在一開始是不可預知的,通常是由於它的值尚未完成計算造成的。

我已經聽過太多人說 go 的 future 很簡單:

f := make(chan int, 1)

這是錯誤的,因為所有的等待者都應當得到結果(譯註:實現 channel 的訂閱與變化值的廣播確實另人頭大)。而這個版本也是錯的:

[C]
f := make(chan int, 1)
v <- f
f <- v
// 在這裡使用 v
[/C]

由於不可能用這個方法管理資源。所以,當某個傢伙在他的代碼裡丟了 f <- v 那部分,我希望你能幸運的發現這個 bug。

將資料直接發給所有的等待者來實現 promise 沒那麼複雜(我不確定這段代碼是不是有 bug):

type PromiseDelivery chan interface{}type Promise struct {    sync.RWMutex    value interface{}    waiters []PromiseDelivery}func (p *Promise) Deliver(value interface{}) {    p.Lock()    defer p.Unlock()    p.value = value    for _, w := range p.waiters {        locW := w        go func(){            locW <- value        }()    }}func (p *Promise) Value() interface{} {    if p.value != nil {        return p.value    }    delivery := make(PromiseDelivery)    p.waiters = append(p.waiters, delivery)    return <-delivery}func NewPromise() *Promise {    return &Promise{        value: nil,        waiters: []PromiseDelivery{},    }}

如何使用他呢?

p := NewPromise()go func(){  p.Deliver(42)}()p.Value().(int) // 阻塞,當有值的時候返回 interface{} 

不過這裡有 interface{} 和類型轉換。我實際上想要什麼呢?

// 在那些經過良好測試的庫,甚至 stdlib 中type PromiseDelivery[T] chan Ttype Promise[T] struct {    sync.RWMutex    value T    waiters []PromiseDelivery[T]}func (p *Promise[T]) Deliver(value T)func (p *Promise[T]) Value() Tfunc NewPromise[T]() *Promise[T]// My Code:v := NewPromise[int]()go func(){  v.Deliver("woooow!") // 錯誤  v.Deliver(42)}()v.Value() // 阻塞並返回 42,而不是 interface{}

是的,當然,沒有人需要泛型。我在討論什麼鬼玩意啊?

也可以通過使用 select 來避免 p.Lock() 來監聽 deliver,並在一個 goroutinewait 操作。還可以引入對終端使用者極為有用的 .ValueWithTimeout 方法。還有許多許多其他“你可以……”。儘管我們實際上是在討論一個 20 行的代碼(它的長度可能在每次你發現 future/promise 互動更多細節的時候就開始增長了)。我真得需要知道(或想到) channel 為我傳遞值嗎?不!

pub/sub

假設我們想要構建一個即時服務。那麼我們的用戶端現在可以開啟一個 websocket 串連,傳遞 q=CA 請求,並即刻獲得加利福尼亞的天氣變化情況。它看起來應該像:

// deliverercalculation.WhenDone(func(state string, w Weather) {  broker.Publish("CA", w)})// clientch := broker.Subscribe("CA")for update := range ch {  w.Write(update.Serialize())}

這是一個典型的 pub/sub(譯註:公告/訂閱)。你可以從進階 Go 模式的演講中學習它,甚至可以找到即刻可用的實現。問題是,它們全都基於介面的。

有沒有可能實現:

broker := NewBroker[String, Weather]()// so thatbroker.Subs(42) // compilation failure// andbroker.Subs("CA") // returns (chan Weather) not (chan interface{})

當然!如果你能勇敢的在項目之間複製粘貼代碼,併到處進行修改。

map/filter

假設希望給與我們的使用者更多的彈性,從而引入了新的查詢參數:show,它的值可以是 all|temp|wind|icon

可能你可以從基礎開始:

ch := broker.Subscribe("CA")for update := range ch {  temps := []Temp  for _, t := update.Temp {    temps = append(temps, t)  }  w.Write(temps)}

不過,在寫了 10 個這樣的方法之後,你會意識到它沒那麼模組化,並且也很無聊。可能你需要:

ch := broker.Subscribe("CA").Map(func(w Weather) Temp { return w.Temp })for update := range ch {  w.Write(update)}

等等,我有提過 channel 是一個 functor(譯註:函子)嗎?跟 future/promise 一樣。

p := NewPromise().Map(func(w Weather) Temp { return w.Temp })go func(){  p.Deliver(Weather{Temp{42}})}()p.Value().(Temp) // Temp, not Weather

這意味著我重用了 future 的 channel 的相同代碼。你也可以用想 transducers 這樣的東西來完成它。我經常在 ClojureScript 的代碼中使用的技巧:

(->> (send url) ;; returns chan, put single value to it {:status 200 :result 42} when ready     (async/filter< #(= 200 (:status %))) ;; check that :status is 200     (async/map< :result)) ;; expose only 42 to end user;; note, that it will close all channels (including implicit intermediate one) properly

當我可以簡單的進行 x.Map(transformation) 並得到相同類型的值的時候,我真得需要關心 x 是個 channel 還是個 future 嗎?在這個例子裡,為什麼允許我建立 make(chan int) 而不能建立 make(Future int) 呢?

Request/Reply

假設我們的使用者喜歡這個服務,並且頻繁的使用它。那麼就需要引入一些簡單的 API 限制:每天、每個 IP 請求的數量。收集這個數量儲存在一個 map[string]int 中很簡單。Go 的文檔說“不要通過共用記憶體來通訊,用通訊來共用記憶體”。好吧,聽起來是個好主意。

req := make(chan string)go func() { // wow, look here - it's an actor!  m := map[string]int{}  for r := range req {    if v, ok := m[r]; !ok {      m[r] = 1    } else {      m[r] = v + 1    }  } }()go func() {  req <- "127.0.0.2"}()go func() {  req <- "127.0.0.1"}()

這很容易。現在可以計算每個 IP 請求的數量了。不但如此……同時也可以要求執行請求需要許可權。

type Req struct {  ip string  resp chan int}func NewRequest(ip string) *Req {  return &Req{ip, make(chan int)}}requests := make(chan *Req)go func() {  m := map[string]int{}  for r := range requests {    if v, ok := m[r.ip]; !ok {      m[r.ip] = 1    } else {      m[r.ip] = v + 1    }    r.resp <- m[r.ip]  } }()go func() {  r := NewRequest("127.0.0.2")  requests <- r  fmt.Println(<- r.resp)}()go func() {  r := NewRequest("127.0.0.1")  requests <- r  fmt.Println(<- r.resp)}()

我不會再問你要泛型的解決方案(沒有寫死的 string 和 int)。換而言之,我希望你檢查一下這段代碼中是不是都正確?真得這麼簡單嗎?

你確定 r.resp <- m[r.ip] 是個好辦法?不,肯定不是!我希望有任何人等待那些很慢的用戶端。是嗎?而如果我有許多很慢的用戶端的時候會怎麼樣呢?可能我需要對此進行一些處理。

requests <- r 這部分簡單嗎?如果我的 actor(伺服器)過載無法響應的時候呢?可能我需要在這裡處理逾時……

時不時的我就需要特定的初始化和清理過程……都需要逾時機制。並且需要能保持請求,直到初始化完成。

那麼調用的優先權呢?例如,當我需要為了分析系統實現 Dump 方法,但是又不想讓所有的使用者暫停來收集需要的資料。

還有……看看 Erlang 中的 gen_server。為了保險起見,我希望它一被實現就可以是具有良好的文檔的,經過高度覆蓋測試的庫。98% 的情況下,我不希望看到這樣的介紹:make(chan int, ?) 而我不想思考到底我應該將 ? 替換成多少。

99% 的情況下,我其實並不關心響應是由 channel 傳遞的,還是一隻魔法獨角獸從它的角上帶來的。

數不勝數

還有許多其他常見的並發的情況。我想你已經明白了。

苦難

你可以說,這些模式都不常見。不過……我在我的項目中不得不實現它們中的大多數。每!一!次!可能我不怎麼走運,而你的項目會跟寫給初學者的指南一樣簡單。

我知道,你們中的大多數會說“世界是艱辛的,編程是苦難的”。我會繼續打擊你:至少有一些語言展示了部分解決這些問題的樣本。至少,在嘗試解決它。Haskell 和 Scala 的類型系統提供了構建強大的進階抽象的能力,甚至自訂控制流程來處理並發。而另一陣營的 Clojure 利用動態類型鼓勵和共用進階的抽象。Rust 有 channel 和泛型。

讓它工作 -> 讓它優雅 -> 讓它可重用。

現在,第一步已經完成。接下來呢?不要誤會,go 是一個有遠見的語言:channel 和 goroutine 比起例如 pthread 來說更好,不過是不是真得就停留在此?

補充:構建 Twitter 分析器

關於真實的流水作業。

你可能已經看過 Twitter 的分析了,它真得很棒。假設它尚未出現,而我們需要自己的分析工具:提供一個使用者名稱,來統計有多少使用者看過(至少是理論上)他的 tweet。應該如何做呢?其實不難:讀取使用者的時間軸,過濾掉所有的 retweet 和回複,然後請求其他 tweet 的 retweeter,為每個 retweeter 請求 follower 的列表,合并所有 retweeter 的 follower 在一起,然後加上這個使用者的 follower。對於這個步驟我想要的結果是:
map[TweetId][]Username(retweeter)和 map[Username][]Username。這些用於構造一個向要求者展示的魔幻的表格是足夠了。

有一些技術細節你應當留意:

      Twitter API 需要每個調用都使用 OAuth,並且設定了很強的限制(每個使用者每 15 分鐘 450 次調用)。為了對付這個限制,我們將用預定義的一個 OAuth token 列表(例如 50 個)組織在一個池中供 worker 使用,每個 worker 在達到限制之前都可以讓自己休息一會。
      大多數 Twitter API 呼叫通過 since_id 或 max_id 使用了結果分頁。因此你不能依賴一個請求就可以擷取完整的結果。

一個粗糙的實現的例子。注意,你沒必要理解這個檔案中所有的內容。相反,如果你無法理解的話,這恰恰說明我們做對了。

那麼我們現在有什嗎?

  • 一些步驟的計算:TimelineReader -> RetweetersReader -> FollowersReader -> FinalReducer。
  • 自供訊息。由於分頁所有階段都是遞迴的。這意味著每個步驟都會向下一個階段和其本身發出訊息。在這個情況下,很難處理取消的情況。甚至無法發現某個步驟的工作全部完成。
  • 儘早傳播。至少有兩種情況:首先為了通過 TweetId 來收集 []Username,我們需要將收集到的資訊直接從 RetweetersReader 發送到 FinalReducer。然後,一開始我們就知道,需要獲得初始使用者的 follower,因此他的使用者名稱應當從 TimelineReader 傳遞到
    RetweetersReader 步驟。
  • 中間收縮。FollowersReader 不只是一個管道。它會過濾我們已經見過的使用者名稱(因為總不想重複工作吧)。
  • 持續工作的 worker。在許多情況下,你無法等待 worker 退出。例如,當你實現了一個服務,它會同時響應許多用戶端的時候。
相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在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.