Blog Go

Các mẫu đồng thời trong Go: Context

Sameer Ajmani
29 July 2014

Giới thiệu

Trong các máy chủ Go, mỗi request đến được xử lý trong goroutine riêng của nó. Các handler thường khởi động thêm goroutine để truy cập các backend như cơ sở dữ liệu và dịch vụ RPC. Tập goroutine làm việc trên một request thường cần truy cập các giá trị dành riêng cho request như danh tính của người dùng cuối, token ủy quyền và deadline của request. Khi một request bị hủy hoặc quá thời gian, toàn bộ goroutine đang làm việc trên request đó phải thoát nhanh để hệ thống có thể thu hồi mọi tài nguyên mà chúng đang dùng.

Tại Google, chúng tôi đã phát triển package context giúp dễ dàng truyền các giá trị theo phạm vi request, tín hiệu hủy và deadline qua các ranh giới API tới mọi goroutine tham gia xử lý request. Package này được công khai tại context. Bài viết này mô tả cách sử dụng package đó và cung cấp một ví dụ chạy hoàn chỉnh.

Context

Cốt lõi của package context là kiểu 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{}
}

(Mô tả này đã được rút gọn; godoc là nguồn chính xác nhất.)

Phương thức Done trả về một channel đóng vai trò là tín hiệu hủy cho các hàm chạy thay mặt cho Context: khi channel bị đóng, các hàm nên từ bỏ công việc và trả về. Phương thức Err trả về lỗi cho biết vì sao Context bị hủy. Bài viết Pipelines and Cancellation thảo luận chi tiết hơn về thành ngữ dùng channel Done.

Context không có phương thức Cancel vì cùng một lý do mà channel Done là chỉ-nhận: hàm nhận tín hiệu hủy thường không phải là hàm phát tín hiệu. Đặc biệt, khi một thao tác cha khởi động các goroutine cho các thao tác con, các thao tác con đó không nên có khả năng hủy thao tác cha. Thay vào đó, hàm WithCancel (mô tả bên dưới) cung cấp cách hủy một giá trị Context mới.

Context an toàn khi được dùng đồng thời bởi nhiều goroutine. Mã có thể truyền một Context duy nhất vào bất kỳ số lượng goroutine nào và hủy Context đó để phát tín hiệu cho tất cả.

Phương thức Deadline cho phép các hàm xác định liệu chúng có nên bắt đầu công việc ngay từ đầu hay không; nếu thời gian còn lại quá ít, công việc đó có thể không đáng làm. Mã cũng có thể dùng deadline để đặt timeout cho các thao tác I/O.

Value cho phép Context mang dữ liệu gắn với request. Dữ liệu đó phải an toàn cho việc dùng đồng thời bởi nhiều goroutine.

Context phát sinh

Package context cung cấp các hàm để phát sinh các giá trị Context mới từ các context hiện có. Các giá trị này tạo thành một cây: khi một Context bị hủy, mọi Context phát sinh từ nó cũng bị hủy theo.

Background là gốc của mọi cây Context; nó không bao giờ bị hủy:

// 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 trả về các giá trị Context phát sinh có thể bị hủy sớm hơn Context cha. Context gắn với một request đến thường bị hủy khi handler của request trả về. WithCancel cũng hữu ích để hủy các request dư thừa khi dùng nhiều bản sao. WithTimeout hữu ích để đặt deadline cho các request tới các máy chủ backend:

// 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 cung cấp cách gắn các giá trị gắn với request vào một Context:

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

Cách tốt nhất để thấy package context được dùng thế nào là thông qua một ví dụ hoàn chỉnh.

Ví dụ: Tìm kiếm web Google

Ví dụ của chúng ta là một máy chủ HTTP xử lý các URL như /search?q=golang&timeout=1s bằng cách chuyển tiếp truy vấn “golang” tới Google Web Search API và hiển thị kết quả. Tham số timeout cho máy chủ biết rằng nó phải hủy request sau khi khoảng thời gian đó trôi qua.

Mã được chia thành ba package:

  • server cung cấp hàm main và handler cho /search.
  • userip cung cấp các hàm để trích xuất địa chỉ IP của người dùng từ request và gắn nó với một Context.
  • google cung cấp hàm Search để gửi truy vấn tới Google.

Chương trình server

Chương trình server xử lý các request như /search?q=golang bằng cách trả về một vài kết quả tìm kiếm đầu tiên của Google cho golang. Nó đăng ký handleSearch để xử lý endpoint /search. Handler tạo một Context ban đầu tên là ctx và sắp xếp để nó bị hủy khi handler trả về. Nếu request bao gồm tham số URL timeout, Context sẽ bị hủy tự động khi timeout hết hạn:

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.

Handler trích truy vấn từ request và trích địa chỉ IP của client bằng cách gọi package userip. Địa chỉ IP của client là cần thiết cho các request backend, vì vậy handleSearch gắn nó vào 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)

Handler gọi google.Search với ctxquery:

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

Nếu tìm kiếm thành công, handler sẽ hiển thị kết quả:

    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
    }

Package userip

Package userip cung cấp các hàm để trích xuất địa chỉ IP của người dùng từ một request và gắn nó với một Context. Context cung cấp một ánh xạ khóa-giá trị, trong đó cả khóa lẫn giá trị đều có kiểu interface{}. Kiểu khóa phải hỗ trợ so sánh bằng, và giá trị phải an toàn cho việc dùng đồng thời bởi nhiều goroutine. Những package như userip che giấu chi tiết của ánh xạ này và cung cấp cách truy cập được định kiểu mạnh vào một giá trị Context cụ thể.

Để tránh va chạm khóa, userip định nghĩa một kiểu không export tên là key và dùng một giá trị của kiểu này làm khóa 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 trích xuất một giá trị userIP từ http.Request:

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 trả về một Context mới mang theo giá trị userIP được cung cấp:

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

FromContext trích xuất một userIP từ Context:

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
}

Package google

Hàm google.Search thực hiện một request HTTP tới Google Web Search API và phân tích kết quả mã hóa JSON. Nó nhận tham số Context tên là ctx và trả về ngay nếu ctx.Done bị đóng trong lúc request đang bay.

Request tới Google Web Search API bao gồm truy vấn tìm kiếm và IP của người dùng dưới dạng các tham số truy vấn:

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 dùng một hàm phụ trợ tên httpDo để phát request HTTP và hủy nó nếu ctx.Done bị đóng trong lúc request hoặc response đang được xử lý. Search truyền một closure vào httpDo để xử lý response 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

Hàm httpDo chạy request HTTP và xử lý response của nó trong một goroutine mới. Nó hủy request nếu ctx.Done bị đóng trước khi goroutine kết thúc:

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

Điều chỉnh mã cho Context

Nhiều framework máy chủ cung cấp package và kiểu để mang các giá trị theo phạm vi request. Chúng ta có thể định nghĩa các hiện thực mới của interface Context để làm cầu nối giữa mã dùng các framework hiện có và mã kỳ vọng một tham số Context.

Ví dụ, package github.com/gorilla/context của Gorilla cho phép handler gắn dữ liệu với các request đến bằng cách cung cấp một ánh xạ từ request HTTP sang cặp khóa-giá trị. Trong gorilla.go, chúng tôi cung cấp một hiện thực Context mà phương thức Value trả về các giá trị gắn với một request HTTP cụ thể trong package Gorilla.

Các package khác cũng đã cung cấp khả năng hủy tương tự Context. Ví dụ, Tomb cung cấp phương thức Kill phát tín hiệu hủy bằng cách đóng một channel Dying. Tomb cũng cung cấp các phương thức để chờ các goroutine đó thoát, tương tự sync.WaitGroup. Trong tomb.go, chúng tôi cung cấp một hiện thực Context bị hủy khi Context cha của nó bị hủy hoặc khi Tomb được cung cấp bị kill.

Kết luận

Tại Google, chúng tôi yêu cầu lập trình viên Go truyền một tham số Context làm đối số đầu tiên cho mọi hàm trên đường đi lời gọi giữa request đi vào và request đi ra. Điều này cho phép mã Go do nhiều đội khác nhau phát triển tương tác tốt với nhau. Nó cung cấp khả năng điều khiển đơn giản đối với timeout và việc hủy, đồng thời bảo đảm rằng những giá trị quan trọng như thông tin xác thực bảo mật được truyền đúng cách qua các chương trình Go.

Những framework máy chủ muốn xây dựng dựa trên Context nên cung cấp các hiện thực của Context để làm cầu nối giữa package của chính chúng và những package kỳ vọng một tham số Context. Các thư viện client của chúng khi đó sẽ nhận một Context từ mã gọi. Bằng cách thiết lập một giao diện chung cho dữ liệu theo phạm vi request và việc hủy, Context giúp các nhà phát triển package dễ dàng chia sẻ mã nhằm tạo ra các dịch vụ có khả năng mở rộng.

Đọc thêm

Bài tiếp theo: Go tại OSCON
Bài trước: Go sẽ có mặt tại OSCON 2014
Mục lục blog