Cách sử dụng Context trong Golang (Deadlines, Cancellation, và Passing Values)

12 phút đọc2 lượt xem

Trong bài viết này, chúng ta sẽ tìm hiểu về package context của Go và cụ thể hơn là cách chúng ta có thể sử dụng nó để cải thiện hiệu suất ứng dụng của mình.

Mục đích chính của việc sử dụng Context là để quản lý các tiến trình chạy lâu dài (long-running processes) (như HTTP Request, gọi đến cơ sở dữ liệu ...) theo cách không lãng phí quá nhiều tài nguyên.

Nếu được sử dụng đúng cách, context package có thể giúp bạn:

  1. Cancel các long-running processes không cần thiết
  2. Truyền dữ liệu có request-scoped và cancellation signals giữa các lệnh gọi hàm
  3. Đặt deadlines cho các process để hoàn thành

Khi nào chúng ta sử dụng Context??

Công dụng chính của phiên bản context là truyền dữ liệu có common scope trong ứng dụng của chúng ta. Ví dụ:

  • Request IDs của lệnh gọi hàm và goroutine, đó là một phần của cùng một HTTP request
  • Lỗi gặp phải khi fetching data từ databasec
  • Tín hiệu hủy (Cancellation signals) được tạo khi thực hiện các thao tác bất đồng bộ bằng goroutines
bối cảnh đề cập đến dữ liệu có phạm vi phổ biến trong goroutines hoặc lệnh gọi hàm

Sử dụng Context là cách thông thường để truyền thông tin giữa các hoạt động này, chẳng hạn như:

  1. Cancellation và deadline signals để chấm dứt thực thi
  2. Dữ liệu cần thiết ở mọi lệnh gọi hàm được gọi bởi quá trình thực thi

Tạo Context mới

Chúng ta có thể tạo một context mới bằng hàm context.Background() :

ctx := context.Background()

Hàm này trả về một instance trống của context và không có giá trị nào liên quan đến nó.

Trong nhiều trường hợp, chúng ta sẽ không tạo ra một context instance mới mà lại sử dụng instance hiện có.

Ví dụ: khi xử lý một HTTP request, chúng ta có thể sử dụng hàm http.Request.Context() để lấy context của request:

// Create an HTTP server that listens on port 8000
http.ListenAndServe(":8010", 
  http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Get the request's context
    ctx := r.Context()
    // ...
}))

Tạo Derived Context

Khi bạn nhận được Context từ nguồn bên ngoài, bạn có thể thêm các giá trị và cancellation signals của riêng mình vào bằng cách tạo Derived Context.

Chúng ta có thể thực hiện việc này bằng cách sử dụng các decorator function như context.WithValue , context.WithCancel hoặc context.WithDeadline :

bối cảnh dẫn xuất

Mỗi  decorators này có các tác dụngg khác nhau trên context instance. Chúng ta hãy nhìn vào từng loại trong số chúng.

Context Cancellation Signals

Một trong những trường hợp sử dụng phổ biến nhất của package Context là truyền tín hiệu hủy qua các lệnh gọi hàm.

Tại sao chúng ta cần hủy bỏ?

Nói tóm lại, chúng ta cần hủy bỏ để ngăn hệ thống của chúng ta thực hiện những công việc không cần thiết.

Hãy xem xét tình huống phổ biến này, khi máy chủ HTTP thực hiện lệnh gọi đến cơ sở dữ liệu và trả về dữ liệu được truy vấn cho máy khách:

Sơ đồ mô hình máy khách máy chủ

Sơ đồ thời gian, nếu mọi thứ hoạt động hoàn hảo, thì sẽ như thế này:

sơ đồ thời gian với tất cả các sự kiện kết thúc

Tuy nhiên, điều gì sẽ xảy ra nếu khách hàng hủy request giữa chừng? Ví dụ: điều này có thể xảy ra nếu khách hàng đóng trình duyệt của họ giữa lúc request đang được gọi.

Nếu không hủy bỏ, application server và database sẽ tiếp tục thực hiện công việc của mình, mặc dù kết quả của công việc đó sẽ bị lãng phí:

sơ đồ thời gian với yêu cầu http bị hủy và các quy trình khác vẫn đang diễn ra

Trường hợp lý tưởng nhất, chúng ta muốn tất cả các thành phần tham gia vào process dừng lại, nếu chúng ta biết rằng process đó (trong ví dụ này là HTTP request) đã tạm dừng:

sơ đồ thời gian với tất cả các quy trình bị hủy khi yêu cầu HTTP bị hủy

Bây giờ chúng ta đã biết lý do tại sao cần cancellation, hãy cùng tìm hiểu cách bạn có thể triển khai tính năng này trong Go.

Bởi vì "cancellation" phụ thuộc nhiều vào context của thao tác đang được thực hiện nên cách tốt nhất để triển khai thao tác này là thông qua context.

Có hai mặt của context cancellation:

  1. Lắng nghe cancellation signal
  2. Phát đi cancellation signal

Listen cancellation signal

Loại Context này cung cấp method Done(). Điều này sẽ trả về một channel, channel này nhận được empty struct{} mỗi khi context nhận được cancellation signal.

Vì vậy, để listen cancellation signal, chúng ta cần đợi <- ctx.Done().

Ví dụ: hãy xem xét một máy chủ HTTP mất hai giây để xử lý một sự kiện. Nếu request bị hủy, chúng ta muốn return ngay lập tức:

func main() {
	// Create an HTTP server that listens on port 8000
	http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		// This prints to STDOUT to show that processing has started
		fmt.Fprint(os.Stdout, "processing request\n")
		// We use `select` to execute a piece of code depending on which
		// channel receives a message first
		select {
		case <-time.After(2 * time.Second):
			// If we receive a message after 2 seconds
			// that means the request has been processed
			// We then write this as the response
			w.Write([]byte("request processed"))
		case <-ctx.Done():
			// If the request gets cancelled, log it
			// to STDERR
			fmt.Fprint(os.Stderr, "request cancelled\n")
		}
	}))
}

Bạn có thể test điều này bằng cách chạy đoạn code trên và mở localhost:8000 trên trình duyệt của mình. Nếu bạn đóng trình duyệt trước 2 giây, bạn sẽ thấy dòng chữ “yêu cầu đã bị hủy” được in trên cửa sổ terminal.

Emitting Cancellation Signal

Nếu bạn có một thao tác có thể bị hủy, bạn sẽ phải emit 1 tín hiệu hủy thông qua context.

Điều này có thể được thực hiện bằng cách sử dụng WithCancel trong context package , hàm này trả về một context object và một function.

ctx, fn := context.WithCancel(ctx)

Hàm này không nhận đối số và không trả về bất cứ thứ gì và được gọi khi bạn muốn cancel context.

Hãy xem xét trường hợp của 2 dependent operations dưới đây. Ở đây, “dependent” có nghĩa là nếu một cái thất bại thì cái còn lại sẽ không hoàn thành. Nếu chúng ta biết sớm rằng một trong các hành động không thành công, chúng ta muốn hủy tất cả các hành động phụ thuộc.

func operation1(ctx context.Context) error {
	// Let's assume that this operation failed for some reason
	// We use time.Sleep to simulate a resource intensive operation
	time.Sleep(100 * time.Millisecond)
	return errors.New("failed")
}

func operation2(ctx context.Context) {
	// We use a similar pattern to the HTTP server
	// that we saw in the earlier example
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("done")
	case <-ctx.Done():
		fmt.Println("halted operation2")
	}
}

func main() {
	// Create a new context
	ctx := context.Background()
	// Create a new context, with its cancellation function
	// from the original context
	ctx, cancel := context.WithCancel(ctx)

	// Run two operations: one in a different go routine
	go func() {
		err := operation1(ctx)
		// If this operation returns an error
		// cancel all operations using this context
		if err != nil {
			cancel()
		}
	}()

	// Run operation2 with the same context we use for operation1
	operation2(ctx)
}

 Output

halted operation2

Cancellation Signals kèm theo nguyên nhân

Trong ví dụ trước, việc gọi cancel() không cung cấp bất kỳ thông tin nào về lý do context bị hủy. Có một số trường hợp bạn có thể muốn biết lý do hủy.

Ví dụ: hãy xem xét rằng bạn có một hoạt động chạy mất thời gian và phụ thuộc vào lệnh gọi database. Nếu gọi đến CSDL không thành công, bạn muốn biết rằng thao tác đã bị hủy do lỗi cơ sở dữ liệu chứ không phải vì một số lý do khác.

Trong những trường hợp này, chúng ta có thể sử dụng context.WithCancelCause thay thế. Hàm này trả về một context object và một hàm có đối số là 1 error.

Hãy xem ví dụ tương tự như trước, nhưng với hàm WithCancelCause:

func operation1(ctx context.Context) error {
	time.Sleep(100 * time.Millisecond)
	return errors.New("failed")
}

func operation2(ctx context.Context) {
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("done")
	case <-ctx.Done():
    // We can get the error from the context
    err := context.Cause(ctx)
		fmt.Println("halted operation2 due to error: ", err)
	}
}

func main() {
	ctx := context.Background()
	ctx, cancel := context.WithCancelCause(ctx)

	go func() {
		err := operation1(ctx)
		if err != nil {
      // this time, we pass in the error as an argument
			cancel(err)
		}
	}()

	// Run operation2 with the same context we use for operation1
	operation2(ctx)
}

Output

halted operation2 due to error:  failed

Hãy tóm tắt mô hình lan truyền lỗi trong ví dụ này:

  1. Method context.WithCancelCause cung cấp cho chúng ta function cancel mà chúng ta có thể gọi khi có lỗi.
  2. Khi gặp lỗi, chúng ta gọi hàm cancel nhận error làm đối số.
  3. Bây giờ context đã bị hủy,channel ctx.Done() sẽ nhận được một message.
  4. Chúng ta có thể nhận được error từ context bằng cách sử dụng context.Cause.
hủy có lý do

Context Deadlines

Nếu chúng ta muốn đặt deadline để một procecss hoàn tất, chúng ta nên sử dụng tính năng hủy dựa trên thời gian .

Các function gần giống như ví dụ trước, có một vài bổ sung:

// The context will be cancelled after 3 seconds
// If it needs to be cancelled earlier, the `cancel` function can
// be used, like before
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)

// Setting a context deadline is similar to setting a timeout, except
// you specify a time when you want the context to cancel, rather than a duration.
// Here, the context will be cancelled on 2009-11-10 23:00:00
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))

Ví dụ: hãy xem xét thực hiện lệnh gọi HTTP API tới một service bên ngoài. Nếu service mất quá nhiều thời gian, tốt hơn hết bạn nên hủy sớm và hủy request:

func main() {
	// Create a new context
	// With a deadline of 100 milliseconds
	ctx := context.Background()
	ctx, _ = context.WithTimeout(ctx, 100*time.Millisecond)

	// Make a request, that will call the google homepage
	req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
	// Associate the cancellable context we just created to the request
	req = req.WithContext(ctx)

	// Create a new HTTP client and execute the request
	client := &http.Client{}
	res, err := client.Do(req)
	// If the request failed, log to STDOUT
	if err != nil {
		fmt.Println("Request failed:", err)
		return
	}
	// Print the status code if the request succeeds
	fmt.Println("Response received, status code:", res.StatusCode)
}

Dựa vào tốc độ trang chủ Google phản hồi yêu cầu của bạn, bạn sẽ nhận được:

Response received, status code: 200

hoặc

Request failed: Get http://google.com: context deadline exceeded

Bạn có thể thử các thời gian chờ khác nhau để đạt được cả hai kết quả trên.

Context Values

Bạn có thể sử dụng biến context để lan truyền (propagate ) các giá trị trong request scoped trong một thao tác. Đây là giải pháp thay thế hoàn hảo hơn cho việc truyền chúng qua lại các hàm dưới dạng đối số trong suốt lệnh gọi hàm của bạn.

Một số ví dụ về các giá trị mà bạn có thể muốn propagate bằng biến context là:

  1. Request IDs để bạn có thể theo dõi việc thực hiện request qua nhiều lệnh gọi hàm.
  2. Lỗi gặp phải khi tìm nạp dữ liệu từ cơ sở dữ liệu
  3. Authentication tokens và thông tin người dùng

Chúng ta có thể triển khai chức năng tương tự bằng cách sử dụng hàm context.WithValue 

// we need to set a key that tells us where the data is stored
const keyID = "id"

func main() {
	// create a request ID as a random number
  rand.Seed(time.Now().Unix())
  requestID := rand.Intn(1000)

  // create a new context variable with a key value pair
	ctx := context.WithValue(context.Background(), keyID, requestID)
	operation1(ctx)
}

func operation1(ctx context.Context) {
	// do some work

	// we can get the value from the context by passing in the key
	log.Println("operation1 for id:", ctx.Value(keyID), " completed")
	operation2(ctx)
}

func operation2(ctx context.Context) {
	// do some work

	// this way, the same ID is passed from one function call to the next
	log.Println("operation2 for id:", ctx.Value(keyID), " completed")
}

Output

2023/07/05 23:13:50 operation1 for id: 8151872133066627850  completed
2023/07/05 23:13:50 operation2 for id: 8151872133066627850  completed

Ở đây, chúng ta đang tạo một biến context mới trong hàm main và một cặp key value được liên kết với nó. Sau đó, giá trị này có thể được sử dụng bởi các lệnh gọi hàm tiếp theo để lấy thông tin theo ngữ cảnh.

main tạo một bối cảnh mới được truyền cho các hàm khác

Việc sử dụng biến context để truyền thông tin trong phạm vi hoạt động rất hữu ích vì một số lý do:

  1. Đó là thread safe : Bạn không thể sửa đổi giá trị của context key một khi nó đã được set. Cách duy nhất đặt giá trị khác cho một khóa là tạo một biến ngữ cảnh khác bằng cách sử dụng context.WithValue
  2. Nó mang tính quy ước : Context package được sử dụng xuyên suốt các package và ứng dụng chính thức của Go để truyền tải dữ liệu trong phạm vi hoạt động. Các nhà phát triển và thư viện khác thường sử dụng tốt pattern này.

Gotchas và hãy cẩn thận

Mặc dù tính năng cancel context trong Go là một công cụ linh hoạt nhưng có một số điều bạn nên lưu ý trước khi tiếp tục. Điều quan trọng nhất trong số đó là context chỉ có thể bị hủy một lần.

Nếu có nhiều lỗi mà bạn muốn propagate trong cùng một thao tác thì việc sử dụng tính năng context cancellation có thể không phải là lựa chọn tốt nhất.

Cách thông thường nhất để sử dụng tính năng hủy là khi bạn thực sự muốn hủy một cái gì đó chứ không chỉ thông báo cho các processes khác rằng đã xảy ra lỗi.

Một cảnh báo quan trọng khác liên quan đến việc gói cùng một context nhiều lần.

Việc gói context đã có thể hủy bằng WithTimeout hoặc WithCancel sẽ kích hoạt nhiều vị trí trong code của bạn mà context của bạn có thể bị hủy và điều này nên tránh.

Tham khảo

  1. https://www.sohamkamani.com/golang/context/

Chủ đề

Bạn thấy bài này thế nào?