Huỷ bỏ context diễn ra như thế nào?
Gói context không dài, tệp context.go
chỉ có ít hơn 500 dòng code, trong đó còn có nhiều chú thích dài, vì vậy mã thực tế có khoảng 200 dòng. Đây là một thư viện code rất đáng nghiên cứu.
Hãy xem một bức tranh tổng quan:
Kiểu | Tên | Chức năng |
---|---|---|
Context | Interface | Định nghĩa giao diện Context với bốn phương thức |
emptyCtx | Struct | Triển khai giao diện Context, thực chất là một context trống |
CancelFunc | Function | Hàm hủy bỏ |
canceler | Interface | Giao diện hủy bỏ context, định nghĩa hai phương thức |
cancelCtx | Struct | Có thể bị hủy bỏ |
timerCtx | Struct | Sẽ bị hủy bỏ khi hết thời gian |
valueCtx | Struct | Có thể lưu trữ cặp khóa-giá trị |
Background | Function | Trả về một context trống, thường được sử dụng làm context gốc |
TODO | Function | Trả về một context trống, thường được sử dụng trong giai đoạn tái cấu trúc khi không có context phù hợp |
WithCancel | Function | Tạo một context có thể hủy bỏ dựa trên context cha |
newCancelCtx | Function | Tạo một context có thể hủy bỏ |
propagateCancel | Function | Truyền xuống quan hệ hủy bỏ giữa các nút context |
parentCancelCtx | Function | Tìm nút cha đầu tiên có thể hủy bỏ |
removeChild | Function | Xóa nút con khỏi nút cha |
init | Function | Khởi tạo gói |
WithDeadline | Function | Tạo một context có hạn chế thời gian |
WithTimeout | Function | Tạo một context có thời gian chờ |
WithValue | Function | Tạo một context lưu trữ cặp khóa-giá trị |
Bảng trên hiển thị tất cả các hàm, giao diện và cấu trúc của context, cho phép nhìn tổng quan toàn bộ. Bạn có thể xem lại sau khi đọc bài viết.
Biểu đồ lớp tổng thể như sau:
Interface
Context
Bây giờ chúng ta có thể xem mã nguồn trực tiếp:
Context
là một giao diện, xác định 4 phương thức, tất cả đều là idempotent
. Điều này có nghĩa là kết quả của việc gọi liên tiếp nhiều lần cùng một phương thức sẽ là giống nhau.
Done()
trả về một kênh, có thể biểu thị tín hiệu của việc hủy bỏ context: khi kênh này đóng, có nghĩa là context đã bị hủy bỏ. Lưu ý rằng đây là một kênh chỉ đọc. Chúng ta cũng biết rằng đọc một kênh đã đóng sẽ trả về giá trị zero của kiểu tương ứng. Và không có nơi nào trong mã nguồn mà giá trị được đưa vào kênh này. Nói cách khác, đây là một kênh chỉ đọc. Do đó, trong goroutine con, sau khi đọc giá trị (giá trị zero) từ kênh này, chúng ta có thể thực hiện một số công việc cuối cùng và thoát càng sớm càng tốt.
Err()
trả về một lỗi, biểu thị lý do kênh đã đóng. Ví dụ như bị hủy bỏ hay vượt quá thời gian chờ.
Deadline()
trả về thời hạn chót của context, qua đó hàm có thể quyết định liệu có tiếp tục thực hiện các hoạt động tiếp theo hay không. Nếu thời gian quá ngắn, ta có thể không tiếp tục và tránh lãng phí tài nguyên hệ thống. Tất nhiên, cũng có thể sử dụng thời hạn chót này để đặt thời gian chờ cho một hoạt động I/O.
Value()
lấy giá trị đã thiết lập trước đó cho khóa tương ứng.
canceler
Tiếp theo, chúng ta xem một giao diện khác:
Nếu một Context đã triển khai hai phương thức được định nghĩa ở trên, điều đó có nghĩa là Context đó có thể bị hủy. Trong mã nguồn, có hai loại cấu trúc đã triển khai giao diện canceler: *cancelCtx
và *timerCtx
. Lưu ý rằng đó là con trỏ của hai cấu trúc này đã triển khai giao diện canceler.
Lý do thiết kế giao diện Context như vậy:
- Hoạt động “hủy bỏ” nên là một lời đề nghị, không bắt buộc
Người gọi không nên quan tâm hoặc can thiệp vào tình huống của người được gọi, quyết định làm thế nào và khi nào trả về là trách nhiệm của người được gọi. Người gọi chỉ cần gửi thông tin “hủy bỏ”, người được gọi sẽ dựa trên thông tin nhận được để đưa ra quyết định tiếp theo, do đó giao diện không định nghĩa phương thức hủy bỏ.
- Hoạt động “hủy bỏ” nên có thể chuyển tiếp
Khi “hủy bỏ” một hàm nào đó, các hàm liên quan cũng nên bị “hủy bỏ”. Do đó, phương thức Done()
trả về một kênh chỉ đọc, tất cả các hàm liên quan đều lắng nghe kênh này. Khi kênh đóng, thông qua “cơ chế phát sóng” của kênh, tất cả người nghe đều nhận được thông báo.
Struct
emptyCtx
Sau khi định nghĩa giao diện Context
trong mã nguồn và cung cấp một cài đặt, chúng ta có thể thấy:
Nhìn vào đoạn mã này, rất đơn giản và dễ hiểu. Mỗi phương thức được triển khai một cách rất đơn giản, hoặc trả về trực tiếp, hoặc trả về giá trị nil.
Vì vậy, thực tế đây là một context trống, không bao giờ bị hủy, không lưu trữ giá trị và không có thời hạn chót.
Nó được đóng gói thành:
Và được tiếp cận từ bên ngoài thông qua hai hàm xuất (viết hoa chữ cái đầu):
background
thường được sử dụng trong hàm main làm nút gốc cho tất cả các context.
todo
thường được sử dụng trong trường hợp không biết truyền context gì. Ví dụ, gọi một hàm yêu cầu tham số context, nhưng không có context nào có sẵn để truyền, bạn có thể truyền todo
. Điều này thường xảy ra trong quá trình tái cấu trúc, khi thêm một tham số context vào một số hàm, nhưng không biết truyền gì, bạn có thể sử dụng todo
để “đặt chỗ” và sau đó thay thế bằng context khác.
cancelCtx
Tiếp theo, chúng ta xem một context quan trọng khác:
Đây là một Context có thể bị hủy, triển khai giao diện canceler. Nó trực tiếp nhúng giao diện Context như một trường ẩn danh, cho phép nó được coi là một Context.
Trước tiên, chúng ta xem cài đặt của phương thức Done()
:
c.done được tạo ra theo cách “lười biếng”, chỉ được tạo khi phương thức Done() được gọi. Một lần nữa, lưu ý rằng phương thức trả về một kênh chỉ đọc và không có nơi nào ghi dữ liệu vào kênh này. Do đó, đọc trực tiếp từ kênh này sẽ chặn goroutine. Thông thường, nó được sử dụng với câu lệnh select. Khi kênh đóng, giá trị zero sẽ được đọc ngay lập tức.
Cài đặt của phương thức Err()
và String()
khá đơn giản, không cần nói nhiều. Tôi khuyến nghị bạn xem mã nguồn, rất đơn giản.
Tiếp theo, chúng ta tập trung vào cài đặt của phương thức cancel()
:
Tổng quan, phương thức cancel()
đóng vai trò đóng kênh c.done, hủy bỏ tất cả các con của nó đệ quy và xóa bỏ chính nó khỏi cha. Kết quả là thông qua việc đóng kênh, tín hiệu hủy bỏ được truyền cho tất cả các con của nó. Cách goroutine nhận tín hiệu hủy bỏ là thông qua việc chọn đọc c.done
trong câu lệnh select.
Tiếp theo, chúng ta xem cách tạo một Context có thể hủy:
Đây là một phương thức được tiếp cận từ bên ngoài, nhận một Context cha (thường là background
làm nút gốc) và trả về một Context mới, kênh done của Context mới được tạo ra (như đã đề cập trước đó).
Khi hàm CancelFunc
được gọi hoặc kênh done
của nút cha bị đóng (hàm CancelFunc
của nút cha được gọi), kênh done
của context con cũng sẽ bị đóng.
Lưu ý đối số được truyền vào hàm WithCancel()
, đối số đầu tiên là true
, có nghĩa là khi hủy bỏ, nó cần được xóa khỏi nút cha. Đối số thứ hai là một loại lỗi hủy cố định:
Chú ý rằng khi gọi phương thức cancel()
của context con, đối số đầu tiên removeFromParent
là false
.
Hàm removeChild()
sẽ xóa context hiện tại khỏi context cha:
Dòng quan trọng nhất là:
Khi nào chúng ta sẽ truyền giá trị true
? Câu trả lời là khi gọi phương thức WithCancel()
, tức là khi tạo một context con có thể hủy, hàm cancelFunc
trả về sẽ truyền giá trị true
. Kết quả là khi gọi cancelFunc
trả về, context này sẽ bị “loại bỏ” khỏi nút cha của nó, vì nút cha có thể có nhiều context con, nếu bạn tự hủy bỏ, tôi sẽ cắt đứt mối quan hệ với bạn, không ảnh hưởng đến người khác.
Trong hàm hủy bỏ, tôi biết rằng tất cả các context con của tôi sẽ trở thành “tro bụi” do c.children = nil
. Tôi không cần phải làm bước này nữa, cuối cùng tất cả các context con của tôi sẽ bị cắt đứt mối quan hệ với tôi, không cần phải làm từng cái một. Ngoài ra, nếu trong quá trình lặp qua các context con, gọi hàm child.cancel()
với đối số true
, sẽ gây ra việc lặp và xóa một bản đồ đồng thời, gây ra vấn đề.
Hãy xem hàm propagateCancel()
:
Phương thức propagateCancel()
có tác dụng tìm kiếm context cha có thể “gắn kết” và “gắn kết” nó. Điều này cho phép truyền tín hiệu hủy từ trên xuống, hủy đồng thời tất cả các context con đã được “gắn kết”.
Chúng ta cần giải thích tại sao có trường hợp else
xảy ra. else
đề cập đến trường hợp khi context hiện tại không tìm thấy context cha có thể hủy. Trong trường hợp này, một goroutine mới sẽ được khởi tạo để theo dõi tín hiệu hủy từ context cha hoặc context con.
Có một câu hỏi đặt ra: Tại sao lại có trường hợp else
này? Vì parentCancelCtx()
chỉ nhận diện ba loại context: *cancelCtx
, *timerCtx
, *valueCtx
. Nếu nhúng context vào một loại khác, nó sẽ không nhận diện được.
Vì mã nguồn của gói context không nhiều, tôi đã sao chép nó và thêm một số câu lệnh in ra trong phần else
để kiểm tra những gì đã được nói:
Tôi không đưa ra các câu lệnh in ra trong phần else
mà tôi đã thêm vào. Bạn có thể thử nghiệm bằng cách thêm chúng vào và xem kết quả. Chúng ta hãy xem kết quả in ra của ba context:
Đúng như dự đoán, mctx, childCtx không giống với parentCtx bình thường, vì nó là một loại cấu trúc tùy chỉnh.
Phần mã else
này cho thấy nếu chúng ta ép buộc context vào một cấu trúc khác và sử dụng nó làm context cha, Go sẽ khởi tạo một goroutine mới để theo dõi tín hiệu hủy, rõ ràng là một sự lãng phí.
Hãy nói thêm về việc không thể loại bỏ hai case trong câu lệnh select:
Trường hợp đầu tiên cho biết nếu context cha bị hủy, thì hủy context con. Nếu loại bỏ trường hợp này, tín hiệu hủy từ context cha sẽ không được truyền đến context con.
Trường hợp thứ hai là nếu context con tự hủy, thì thoát khỏi câu lệnh select này và không quan tâm đến tín hiệu hủy từ context cha. Nếu loại bỏ trường hợp này, có thể xảy ra tình huống context cha không bao giờ bị hủy, và goroutine này sẽ bị rò rỉ. Tuy nhiên, nếu context cha bị hủy, việc hủy lặp lại context con không gây ảnh hưởng đáng kể.
timerCtx
timerCtx
dựa trên cancelCtx
, chỉ khác nhau là nó có một time.Timer
và một deadline
. Timer
sẽ tự động hủy bỏ context khi đến thời hạn.
timerCtx
ban đầu là một cancelCtx
, vì vậy nó có khả năng hủy bỏ. Hãy xem phương thức cancel()
:
Phương thức tạo timerCtx
:
Hàm WithTimeout
gọi trực tiếp WithDeadline
, truyền thời hạn là thời điểm hiện tại cộng với timeout
, tức là thời gian từ bây giờ cho đến khi hết timeout
. Nói cách khác, WithDeadline
cần thời gian tuyệt đối. Hãy xem nó:
Vẫn cần gắn kết context con với context cha, để khi context cha hủy bỏ, tín hiệu hủy bỏ được truyền xuống context con. Có một trường hợp đặc biệt là nếu deadline của context con muộn hơn deadline của context cha, tức là nếu context cha tự động hủy bỏ khi hết hạn, thì context con sẽ bị hủy bỏ chắc chắn, vì nó đã bị hủy bỏ trước khi deadline đến.
Dòng mã quan trọng nhất là:
c.timer
sẽ tự động gọi hàm hủy sau khoảng thời gian d
, và truyền lỗi DeadlineExceeded
vào hàm hủy:
Đó là lỗi vượt quá thời hạn.