Go 部落格

Go 並發模式:背景

Sameer Ajmani
2014 年 7 月 29 日

簡介

在 Go 伺服器中,每個收到的要求都會在其各自的 goroutine 中處理。要求處理常式會啟動其他 goroutine 存取後端,例如資料庫和 RPC 服務。處理要求的 goroutine 組合通常需要存取要求特定的值,例如終端使用者的身分、授權代碼和要求的截止時間。當要求取消或逾時時,處理該要求的所有 goroutine 都應該快速結束,如此一來,系統才能回收它們所使用的任何資源。

在 Google,我們開發了一個 context 套件,它能輕鬆地跨越 API 介面將要求範圍的值、取消訊號和截止時間傳遞給所有處理要求的 goroutine。該套件作為 context 公開提供。本文描述如何使用該套件,並提供一個完整的實際範例。

背景

context 套件的核心是 Context 類型

// A Context carries a deadline, cancellation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

(此說明已濃縮;godoc 具權威性。)

Done 方法會傳回一個通道,該通道會充當代表 Context 執行函式的取消訊號:當通道關閉時,函式應放棄其工作並傳回。Err 方法會傳回錯誤,指出 Context 被取消的理由。管道和取消 一文更詳細地探討 Done 通道慣用模式。

Context 沒有Cancel 方法,原因與 Done 通道只接收一樣:收到取消訊號的函式通常不會發送訊號。特別是,當母項操作為子作業啟動 goroutine 時,這些子作業不應能夠取消母項。反之,WithCancel 函式(如下所述)提供了一種取消新 Context 值的方法。

Context 能讓多個 goroutine 同時安全地使用。程式碼可以傳遞單一 Context 給任意數量的 goroutine,然後取消該 Context 以發出訊號給所有 goroutine。

Deadline 方法能讓函式判斷是否應開始工作;如果剩餘時間太少,可能不值得執行工作。程式碼也可以使用截止時間為 I/O 作業設定逾時。

ValueContext 能夠攜帶特定請求範圍的資料。這些資料必須讓多個 goroutine 同時安全地使用。

衍生 context

context 套件提供了函式,能從現有的 Context衍生出新的 Context 值。這些值會形成一個樹狀結構:當一個 Context 被取消時,所有衍生自該 ContextContext 也會被取消。

Background 是任何 Context 樹狀結構的根;它永遠不會被取消。

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level Context for incoming requests.
func Background() Context

WithCancelWithTimeout 會傳回衍生的 Context 值,這些值可以比母 Context 更快地被取消。與特定傳入請求相關聯的 Context 通常會在請求處理常式傳回時被取消。當使用多個複製品來取消重複的請求時,WithCancel 也會很有用。WithTimeout 有助於對傳送到後端伺服器的請求設定截止時間。

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue 提供了一種與 Context 關聯特定請求範圍值的設定方式。

// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

要了解如何使用 context 套件的最佳方法是透過實際範例。

我們用一個範例來說明,在這個範例中,HTTP 伺服器處理類似 /search?q=golang&timeout=1s 的 URL,將查詢字元串「golang」轉發給 Google 網路搜尋 API,並呈現其結果。timeout 參數會指示伺服器在超過此時間後取消該要求。

該程式碼分為三個套件

  • server 提供給 /searchmain 函式和處理常式。
  • userip 提供從要求中擷取使用者 IP 位址並將其與 Context 關聯的函式。
  • google 提供 Search 函式,用於將查詢傳送給 Google。

伺服器程式

server 程式處理 /search?q=golang 類型的要求,方式是提供 golang 的前幾個 Google 搜尋結果。它註冊 handleSearch 以處理 /search 端點。處理常式會建立名為 ctx 的初始 Context,並安排在處理常式傳回時予以取消。如果要求含有 timeout URL 參數,Context 會在逾時時自動取消

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctx is the Context for this handler. Calling cancel closes the
    // ctx.Done channel, which is the cancellation signal for requests
    // started by this handler.
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // The request has a timeout, so create a context that is
        // canceled automatically when the timeout expires.
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // Cancel ctx as soon as handleSearch returns.

處理常式會從要求中擷取查詢字元串,並呼叫 userip 套件來擷取用戶端的 IP 位址。後端要求需要用戶端的 IP 位址,所以 handleSearch 會將其附加到 ctx

    // Check the search query.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Store the user IP in ctx for use by code in other packages.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

處理常式會以 ctxquery 呼叫 google.Search

    // Run the Google search and print the results.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

如果搜尋成功,處理常式會呈現結果

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

userip 套件

userip 套件提供從要求中擷取使用者 IP 位址並將其與 Context 關聯的函式。Context 提供鍵值對應,鍵和值皆為 interface{} 類型。鍵類型必須支援相等性,而值必須能夠安全供多個 goroutine 同時使用。像 userip 這樣的套件會隱藏這個對應的詳細資料,並提供強類型存取特定 Context 值的權限。

為了避免發生鍵衝突,userip 會定義一個未匯出的 key 類型,並將此類型的值做為 context 鍵

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

FromRequest 會從 http.Request 擷取 userIP

func FromRequest(req *http.Request) (net.IP, error) {
    ip, _, err := net.SplitHostPort(req.RemoteAddr)
    if err != nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

NewContext 會傳回一個新的 Context,其中載有提供的 userIP

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContext 會從 Context 擷取 userIP

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value returns nil if ctx has no value for the key;
    // the net.IP type assertion returns ok=false for nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

google 套件

函式 google.SearchGoogle 網路搜尋 API 發出 HTTP 要求,並剖析 JSON 編碼的結果。它接受 `Context` 參數 `ctx`,並在要求執行期間,如果 `ctx.Done` 已關閉,就會立即傳回。

Google 網路搜尋 API 要求包含搜尋查詢及使用者 IP,做為查詢參數

func Search(ctx context.Context, query string) (Results, error) {
    // Prepare the Google Search API request.
    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // If ctx is carrying the user IP address, forward it to the server.
    // Google APIs use the user IP to distinguish server-initiated requests
    // from end-user requests.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Search 使用輔助函式 `httpDo` 發出 HTTP 要求,並在在處理要求或回應時 `ctx.Done` 已關閉,就取消它。`Search` 傳送封閉函式到 `httpDo` 來處理 HTTP 回應

    var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // Parse the JSON search result.
        // https://developers.google.com/web-search/docs/#fonje
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    // httpDo waits for the closure we provided to return, so it's safe to
    // read results here.
    return results, err

函式 `httpDo` 在新的 goroutine 中執行 HTTP 要求,並處理其回應。在 goroutine 結束之前,如果 `ctx.Done` 已關閉,它就會取消這個要求

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Run the HTTP request in a goroutine and pass the response to f.
    c := make(chan error, 1)
    req = req.WithContext(ctx)
    go func() { c <- f(http.DefaultClient.Do(req)) }()
    select {
    case <-ctx.Done():
        <-c // Wait for f to return.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

改寫適合 Context 的程式碼

許多伺服器架構會提供套件及型別來傳送要求範圍內的數值。我們可以定義 `Context` 介面的新執行,在使用現有架構的程式碼與需要 `Context` 參數的程式碼之間建立橋樑。

例如,Gorilla 的 github.com/gorilla/context 套件允許處理常式與進入的請求關聯資料,透過提供從 HTTP 請求到關鍵字值配對的對應。在 gorilla.go 中,我們提供一個 `Context` 執行,其 `Value` 方法傳回與 Gorilla 套件中特定 HTTP 請求關聯的數值。

其他套件已提供與 `Context` 類似的取消支援。例如,Tomb 提供一個 `Kill` 方法,會透過關閉 `Dying` 通道發出取消訊息。`Tomb` 也提供方法來等待那些 goroutine 結束,這類似於 `sync.WaitGroup`。在 tomb.go 中,我們提供一個 `Context` 執行,當其父 `Context` 已取消或提供的 `Tomb` 已結束時,便會取消它。

結論

在 Google,我們要求 Go 程式設計師將 `Context` 參數傳遞為所有進入請求及傳出請求呼叫路徑上每個函式的第一個引數。如此一來,可以使不同團隊所開發的 Go 程式碼能良好地一起運作。它能簡單地控制逾時及取消,並確保重要的數值(如安全性認證)能適當地傳送到 Go 程式中。

欲建置在 `Context` 上的伺服器架構,應提供 `Context` 執行,在套件與需要 `Context` 參數的套件之間建立橋樑。其用戶端函式庫將會接收呼叫程式碼中的 `Context`。透過建立請求範圍資料及取消的共用介面,`Context` 使套件開發人員更容易共享建立可擴充服務的程式碼。

後續閱讀

下一篇:OSCON 上的 Go
上一篇文章:Go 將在 2014 年 OSCON 中亮相
部落格索引