Go 部落格

Go 併發模式:Context

Sameer Ajmani
2014 年 7 月 29 日

引言

在 Go 伺服器中,每個傳入請求都在自己的 goroutine 中處理。請求處理程式通常會啟動額外的 goroutine 來訪問後端,例如資料庫和 RPC 服務。處理請求的 goroutine 集合通常需要訪問特定於請求的值,例如終端使用者的身份、授權令牌和請求的截止日期。當請求被取消或超時時,所有處理該請求的 goroutine 都應該快速退出,以便系統可以回收它們正在使用的任何資源。

在 Google,我們開發了一個 context 包,可以輕鬆地在 API 邊界上傳遞請求範圍的值、取消訊號和截止日期,傳遞給所有參與處理請求的 goroutine。該包作為 context 公開可用。本文介紹瞭如何使用該包並提供了一個完整的示例。

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 操作的超時。

Value 允許 Context 攜帶請求範圍的資料。該資料必須安全地供多個 goroutine 同時使用。

派生上下文

context 包提供了從現有 Context 值中派生Context 值的函式。這些值形成一個樹:當一個 Context 被取消時,所有從它派生的 Context 也會被取消。

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 伺服器,它透過將查詢“golang”轉發到 Google 網頁搜尋 API 並呈現結果來處理像 /search?q=golang&timeout=1s 這樣的 URL。timeout 引數告訴伺服器在該持續時間過去後取消請求。

程式碼分為三個包

  • server 提供 main 函式和 /search 的處理程式。
  • userip 提供從請求中提取使用者 IP 地址並將其與 Context 關聯的函式。
  • google 提供 Search 函式,用於向 Google 傳送查詢。

伺服器程式

server 程式透過提供 golang 的前幾個 Google 搜尋結果來處理諸如 /search?q=golang 之類的請求。它註冊 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,並使用此型別的值作為上下文鍵

// 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

FromRequesthttp.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)
}

FromContextContext 中提取 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.Search 函式向 Google 網頁搜尋 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 將參加 OSCON 2014
部落格索引