Go Context là gì? Hướng dẫn sử dụng Context trong Go

1. Context là gì?

Trong Go, context là một package thuộc thư viện chuẩn, cung cấp cơ chế để quản lý thời hạn thực hiện công việc, gửi tín hiệu hủy tác vụ và truyền một số dữ liệu gắn với quá trình xử lý qua nhiều lời gọi hàm.

Context thường được sử dụng trong các chương trình có nhiều goroutine, các ứng dụng HTTP API và những hệ thống cần phối hợp nhiều tác vụ với nhau.

Ví dụ, khi một API nhận request để lấy thông tin sản phẩm, quá trình xử lý có thể bao gồm nhiều bước:

  1. Nhận request từ client.
  2. Gọi service để xử lý nghiệp vụ.
  3. Truy vấn dữ liệu từ database.
  4. Gọi một dịch vụ bên ngoài.
  5. Trả kết quả về cho client.

Nếu client ngắt kết nối hoặc request vượt quá thời gian cho phép, những công việc phía sau có thể không còn cần thiết. Context giúp truyền tín hiệu để các thành phần liên quan biết rằng nên dừng xử lý.

Có thể hiểu đơn giản, Context là cơ chế truyền thông tin về vòng đời của một tác vụ qua nhiều lớp xử lý trong chương trình Go.

Context không trực tiếp thực hiện công việc và cũng không tự động dừng mọi goroutine. Nó cung cấp tín hiệu để các hàm và goroutine chủ động kiểm tra rồi quyết định dừng xử lý.

2. Tại sao cần Context?

Để hiểu vai trò của Context, hãy xét một chương trình thực hiện công việc mất nhiều thời gian.

Giả sử một API nhận request để tạo báo cáo. Server cần truy vấn database và tổng hợp dữ liệu trước khi trả kết quả.

Trong quá trình đó, có thể xảy ra một số tình huống:

  • Tác vụ xử lý quá lâu: truy vấn database hoặc gọi dịch vụ bên ngoài mất nhiều thời gian hơn dự kiến.
  • Client ngắt kết nối: người dùng đóng trang hoặc ứng dụng không còn chờ kết quả.
  • Hết thời gian cho phép: hệ thống đặt giới hạn thời gian cho một request nhưng tác vụ chưa hoàn thành.
  • Tác vụ không còn cần thiết: một bước xử lý thất bại khiến những bước tiếp theo không còn ý nghĩa.

Nếu chương trình không có cơ chế thông báo và phối hợp phù hợp, công việc có thể tiếp tục chạy dù kết quả không còn được sử dụng. Điều này gây lãng phí tài nguyên và làm tăng tải cho hệ thống.

Context giúp giải quyết vấn đề bằng cách truyền tín hiệu hủy đến những thành phần liên quan.

Ví dụ, khi một request hết thời hạn, Context có thể phát tín hiệu để service dừng xử lý hoặc để truy vấn database kết thúc sớm nếu thao tác đó hỗ trợ Context.

Cần lưu ý rằng Context không bảo đảm mọi tác vụ sẽ dừng ngay lập tức. Hiệu quả phụ thuộc vào việc các hàm có kiểm tra tín hiệu hủy hay sử dụng những API hỗ trợ Context hay không.

3. Các thành phần cơ bản của Context

Để sử dụng Context, trước tiên cần import package:

import "context"

Package này cung cấp interface context.Context cùng các hàm tạo Context với những cơ chế quản lý khác nhau.

3.1. context.Background() và context.TODO()

Đây là hai hàm thường được sử dụng để tạo Context gốc.

context.Background()

Dùng khi cần một Context gốc và chưa có Context nào khác để kế thừa. Context này không có deadline, không bị hủy và không chứa giá trị nào.

Ví dụ:

ctx := context.Background()

context.TODO()

Dùng khi chưa xác định được Context nào phù hợp hoặc trong quá trình phát triển, khi cần bổ sung Context sau.

ctx := context.TODO()

Hai hàm này đều trả về Context không có deadline, không có tín hiệu hủy và không có dữ liệu gắn kèm. Khác biệt chính nằm ở ý nghĩa sử dụng: Background() thể hiện một điểm bắt đầu phù hợp, còn TODO() thể hiện rằng lựa chọn Context vẫn cần được xác định.

3.2. context.WithCancel()

context.WithCancel() tạo một Context con có thể được hủy chủ động thông qua hàm cancel.

Cú pháp:

ctx, cancel := context.WithCancel(parent)

Trong đó:

  • parent là Context cha.
  • ctx là Context con được tạo ra.
  • cancel là hàm dùng để gửi tín hiệu hủy Context con.

Ví dụ:

package main

import (
	"context"
	"fmt"
)

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

	cancel()

	fmt.Println(ctx.Err())
}

Kết quả:

context canceled

Trong ví dụ này, gọi cancel() khiến Context bị hủy. Khi đó, ctx.Err() trả về context.Canceled.

Thông thường, nên gọi defer cancel() ngay sau khi tạo Context nếu hàm hiện tại chịu trách nhiệm quản lý vòng đời của nó. Việc này giúp giải phóng các tài nguyên liên quan khi không còn cần Context nữa.

3.3. context.WithTimeout()

context.WithTimeout() tạo một Context có thời gian tồn tại tối đa được tính từ thời điểm tạo.

Cú pháp:

ctx, cancel := context.WithTimeout(parent, duration)

Ví dụ:

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	<-ctx.Done()

	fmt.Println(ctx.Err())
}

Sau khoảng hai giây, Context hết thời hạn và ctx.Done() được đóng. Khi đó, ctx.Err() trả về context.DeadlineExceeded.

WithTimeout() phù hợp khi muốn giới hạn thời gian xử lý, chẳng hạn chỉ cho phép một request chờ truy vấn database tối đa hai giây.

Lưu ý rằng việc Context hết thời hạn không tự động chấm dứt mọi công việc đang chạy. Các hàm thực hiện công việc cần phối hợp với Context để dừng khi phù hợp.

3.4. context.WithDeadline()

context.WithDeadline() cũng tạo một Context có giới hạn thời gian, nhưng thay vì chỉ định khoảng thời gian tính từ hiện tại, hàm này nhận một thời điểm cụ thể.

Cú pháp:

ctx, cancel := context.WithDeadline(parent, deadline)

Ví dụ:

deadline := time.Now().Add(3 * time.Second)

ctx, cancel := context.WithDeadline(
	context.Background(),
	deadline,
)
defer cancel()

Trong ví dụ trên, Context hết hạn tại thời điểm được xác định bởi deadline, xấp xỉ ba giây sau khi tạo.

Sự khác biệt chính giữa hai hàm:

  • WithTimeout() nhận một khoảng thời gian, chẳng hạn 3 * time.Second.
  • WithDeadline() nhận một thời điểm kết thúc cụ thể, chẳng hạn một giá trị time.Time.

Cả hai đều trả về một Context con và hàm cancel.

3.5. ctx.Done() và ctx.Err()

Đây là hai thành phần quan trọng để kiểm tra trạng thái của Context.

ctx.Done()

Trả về một channel chỉ đọc. Channel này được đóng khi Context bị hủy hoặc hết thời hạn.

<-ctx.Done()

Câu lệnh trên sẽ chờ cho đến khi Context bị hủy hoặc hết thời hạn.

ctx.Err()

Trả về nguyên nhân Context đã kết thúc:

  • context.Canceled: Context bị hủy chủ động.
  • context.DeadlineExceeded: Context đã hết thời hạn.

Nếu Context chưa bị hủy và chưa hết hạn, ctx.Err() trả về nil.

Có thể kết hợp hai thành phần này để chương trình chờ tín hiệu hủy hoặc tiếp tục thực hiện công việc khác, thay vì chờ vô thời hạn.

4. Context phối hợp với Goroutine như thế nào?

Trong Go, goroutine là đơn vị thực thi nhẹ, cho phép chương trình thực hiện nhiều công việc đồng thời. Tuy nhiên, khi một goroutine không còn cần thiết, chương trình vẫn phải có cơ chế để thông báo và phối hợp việc dừng nó.

Context có thể đảm nhiệm vai trò gửi tín hiệu hủy đó.

Ví dụ:

package main

import (
	"context"
	"fmt"
	"time"
)

func doWork(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Work stopped:", ctx.Err())
			return

		default:
			fmt.Println("Working...")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	go doWork(ctx)

	<-ctx.Done()

	fmt.Println("Main finished:", ctx.Err())
}

Cách hoạt động

  1. main() tạo Context có thời hạn hai giây.
  2. go doWork(ctx) khởi chạy công việc trong một goroutine.
  3. Trong vòng lặp, doWork() sử dụng select để kiểm tra tín hiệu từ ctx.Done().
  4. Khi Context hết thời hạn, channel ctx.Done() được đóng.
  5. Goroutine nhận biết tín hiệu hủy, in thông báo và kết thúc hàm.

Trong ví dụ này, main() chờ Context kết thúc. Tuy nhiên, việc Context kết thúc không đồng nghĩa với việc goroutine chắc chắn đã hoàn thành toàn bộ quá trình dọn dẹp. Nếu cần xác nhận goroutine đã thực sự kết thúc, chương trình nên bổ sung cơ chế đồng bộ như sync.WaitGroup hoặc một channel thông báo hoàn thành.

Một điểm cần hiểu rõ là Context chỉ cung cấp tín hiệu hủy; goroutine phải chủ động kiểm tra và phản hồi tín hiệu đó. Nếu goroutine thực hiện một thao tác chặn không hỗ trợ Context và không kiểm tra tín hiệu hủy, nó vẫn có thể tiếp tục chạy.

5. Ứng dụng Context trong HTTP và API

Context đặc biệt hữu ích trong các ứng dụng HTTP API vì mỗi request thường kéo theo nhiều thao tác xử lý liên quan.

5.1. Giới hạn thời gian xử lý

Giả sử một API cần gọi dịch vụ bên ngoài để lấy dữ liệu. Nếu dịch vụ phản hồi quá chậm, request có thể chiếm tài nguyên trong thời gian dài.

Có thể tạo Context với timeout:

ctx, cancel := context.WithTimeout(
	context.Background(),
	3*time.Second,
)
defer cancel()

Sau đó, truyền ctx đến hàm xử lý hoặc API hỗ trợ Context.

Khi hết thời hạn, các thao tác có hỗ trợ Context có thể dừng sớm và trả về lỗi timeout.

5.2. Khi client ngắt kết nối

Trong ứng dụng HTTP server của Go, mỗi request có Context riêng, có thể lấy bằng:

ctx := r.Context()

Trong đó, r là *http.Request.

Context này có thể bị hủy khi client kết nối bị đóng hoặc request bị hủy, tùy theo cách kết nối và server xử lý.

Ví dụ:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	result, err := fetchData(ctx)
	if err != nil {
		http.Error(
			w,
			"Request failed",
			http.StatusInternalServerError,
		)
		return
	}

	fmt.Fprintln(w, result)
}

Trong ví dụ trên, fetchData() nhận Context để có thể phối hợp với vòng đời của request. Đây là hàm minh họa; khi triển khai thực tế, cần định nghĩa hàm này và lựa chọn mã trạng thái phù hợp với từng loại lỗi.

Nếu fetchData() gọi database hoặc một dịch vụ hỗ trợ Context, nó có thể truyền tiếp ctx để những thao tác đó phản hồi tín hiệu hủy.

5.3. Sử dụng Context với database

Khi truy vấn database, nên sử dụng những API có hỗ trợ Context nếu cần giới hạn thời gian hoặc phối hợp việc hủy tác vụ.

Ví dụ với database/sql:

ctx, cancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer cancel()

rows, err := db.QueryContext(
	ctx,
	"SELECT id, name FROM products",
)
if err != nil {
	// Xử lý lỗi truy vấn.
	return
}
defer rows.Close()

Trong ví dụ này, truy vấn được thực hiện thông qua QueryContext(). Nếu Context hết thời hạn hoặc bị hủy, driver và database có khả năng hỗ trợ hủy sẽ phối hợp để dừng truy vấn.

Khả năng dừng và thời điểm truy vấn thực sự kết thúc còn phụ thuộc vào driver, database và thao tác đang thực hiện.

5.4. Context trong Gin-Gonic

Gin là một framework HTTP phổ biến trong Go. Trong Gin handler, cần phân biệt *gin.Context với context.Context của thư viện chuẩn.

  • *gin.Context cung cấp các chức năng của Gin để xử lý request, response, tham số và middleware.
  • context.Context cung cấp cơ chế quản lý deadline, hủy tác vụ và dữ liệu gắn với vòng đời xử lý.

Có thể lấy Context chuẩn từ HTTP request của Gin:

func getProducts(c *gin.Context) {
	ctx := c.Request.Context()

	products, err := productService.GetAll(ctx)
	if err != nil {
		c.JSON(500, gin.H{
			"error": "Unable to get products",
		})
		return
	}

	c.JSON(200, products)
}

Ví dụ trên giả định đã có productService.GetAll(ctx) và kiểu dữ liệu products phù hợp. Trong dự án thực tế, nên sử dụng mã trạng thái cụ thể cho từng loại lỗi thay vì trả 500 cho tất cả trường hợp.

Truyền Context từ handler xuống service và database giúp các tầng xử lý phối hợp với nhau trong suốt vòng đời request.

6. Những lưu ý khi sử dụng Context

6.1. Context không tự động dừng goroutine

Việc gọi cancel() hoặc để Context hết thời hạn chỉ phát tín hiệu hủy. Các goroutine phải kiểm tra ctx.Done() hoặc sử dụng API hỗ trợ Context để phản hồi tín hiệu đó.

6.2. Không truyền nil làm Context

Khi một hàm cần Context, thông thường nên truyền một Context hợp lệ như context.Background(), context.TODO() hoặc Context nhận từ request. Không nên truyền nil, vì nhiều hàm có thể cần gọi phương thức trên Context và sẽ gặp lỗi khi nhận giá trị nil.

6.3. Gọi cancel() khi phù hợp

Các hàm như WithCancel(), WithTimeout() và WithDeadline() trả về một hàm cancel. Nên gọi hàm này khi không còn cần Context để giải phóng những tài nguyên liên quan.

Cách thường gặp là:

ctx, cancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer cancel()

6.4. Không dùng Context để truyền tham số nghiệp vụ thông thường

Context không nên được sử dụng như một túi chứa mọi dữ liệu của ứng dụng. Các tham số nghiệp vụ như productID, price hoặc userInput nên được truyền qua tham số hàm rõ ràng.

Giá trị trong Context phù hợp hơn với dữ liệu gắn với request và cần đi qua nhiều tầng xử lý, chẳng hạn mã định danh request hoặc thông tin phục vụ truy vết. Việc sử dụng context.WithValue() nên có chủ đích, tránh lạm dụng.

6.5. Truyền Context qua các tầng xử lý

Trong một ứng dụng thực tế, Context thường được truyền từ tầng tiếp nhận request xuống các tầng service, repository và database.

Ví dụ:

HTTP Handler → Service → Repository → Database

Cách tổ chức này giúp các tầng phối hợp việc hủy tác vụ và giới hạn thời gian mà không cần tạo một Context hoàn toàn độc lập ở mỗi tầng.

Kết luận

Context là một cơ chế quan trọng trong Go để quản lý vòng đời của các tác vụ, đặc biệt trong những chương trình sử dụng goroutine và các ứng dụng HTTP API.

Các hàm như WithCancel(), WithTimeout() và WithDeadline() cho phép tạo Context với những điều kiện kết thúc khác nhau. Trong khi đó, Done() và Err() giúp chương trình nhận biết và phản hồi tín hiệu hủy.

Điều quan trọng nhất cần nhớ là Context không thực hiện việc hủy thay cho chương trình; nó cung cấp tín hiệu để các thành phần liên quan phối hợp dừng công việc khi không còn cần thiết.

Hiểu và sử dụng Context đúng cách giúp ứng dụng Go quản lý tài nguyên hiệu quả hơn, xử lý timeout rõ ràng hơn và phối hợp các tác vụ trong hệ thống một cách có kiểm soát.

Nguồn tham khảo

Bài viết khác

Gin-Gonic

Gin-Gonic là gì? Hướng dẫn xây dựng REST API với Go 1. Gin-Gonic là gì? Gin-Gonic, thường được gọi là Gin, là một framework Web mã nguồn mở dành cho ngôn ngữ lập trình Go. Gin cung cấp các công cụ giúp lập trình viên xây dựng HTTP server và REST API, chẳng hạn như […]

Go Channels

Go Channels là gì? Vì sao cần Channel khi sử dụng Goroutine? 1. Channel là gì? Vì sao cần Channel? Trong Go, Channel là cơ chế cho phép các Goroutine trao đổi dữ liệu và phối hợp hoạt động với nhau. Ở bài viết trước(Goroutine & Concurrency), chúng ta đã tìm hiểu Goroutine — một […]

Goroutine & Concurrency

Goroutines & Concurrency 1. Concurrency là gì? Khi viết chương trình, có những công việc phải được thực hiện lần lượt, nhưng cũng có những công việc có thể tiến triển trong cùng một khoảng thời gian. Để hiểu cách Go xử lý nhiều công việc như vậy, trước tiên cần phân biệt ba khái […]

Go Fundamentals

Go là gì? Go, còn được biết đến với tên gọi Golang, là một ngôn ngữ lập trình mã nguồn mở, được thiết kế tại Google bởi Robert Griesemer, Rob Pike và Ken Thompson. Go là ngôn ngữ biên dịch (compiled language) và có hệ thống kiểu dữ liệu tĩnh (statically typed). Khác với những […]

Use docker to run go project

Docker is a powerful tool that enables developers to create, deploy and run applications in a containerized environment. Using Docker to run Go projects has many advantages, including the ability to isolate your application from the underlying operating system, simplifying the deployment process, and allowing for greater scalability and flexibility. In this guide, we will […]

Golang the basic concept of Go Programing

Go is a statically-typed, concurrent, and garbage-collected programming language developed by Google. Here are some of the basic concepts and guidelines for writing code in Go: Syntax: Go has a simple and straightforward syntax that is easy to learn. The language has a strict coding style, with specific rules for naming variables, functions, and types, […]

Leave a Reply

Your email address will not be published. Required fields are marked *