Go Channel: Recieve
Phân tích mã nguồn
Trước tiên, chúng ta hãy xem mã nguồn liên quan đến việc nhận giá trị từ channel. Sau khi hiểu quá trình nhận cụ thể, chúng ta sẽ đi vào nghiên cứu chi tiết dựa trên một ví dụ cụ thể.
Có hai cách viết phép nhận, một cách có “ok” để phản ánh xem channel có bị đóng hay không; một cách không có “ok”, trong cách viết này, khi nhận giá trị zero value của kiểu tương ứng, không thể biết được giá trị đó là từ người gửi thực sự gửi đến, hay là giá trị zero value mặc định được trả về khi channel bị đóng. Cả hai cách viết đều có các trường hợp sử dụng riêng của chúng.
Sau khi được xử lý bởi trình biên dịch, hai cách viết này tương ứng với hai hàm trong mã nguồn:
Hàm chanrecv1
xử lý trường hợp không có “ok”, chanrecv2
trả về giá trị “received” để phản ánh xem channel có bị đóng hay không. Giá trị nhận được được “đặt vào” địa chỉ mà tham số elem
trỏ tới, giống như cách viết trong C/C++. Nếu mã không quan tâm đến giá trị nhận được, tham số elem
ở đây sẽ là nil.
Dù sao đi nữa, cuối cùng chúng ta sẽ chuyển đến hàm chanrecv
:
Khi chúng ta quan sát rằng channel không sẵn sàng để nhận:
- Đối với channel không có bộ đệm, không có goroutine nào đang chờ trong hàng đợi gửi
- Đối với channel có bộ đệm, nhưng không có phần tử trong bộ đệm
Sau đó, chúng ta lại quan sát được closed == 0, tức là channel chưa được đóng.
Vì channel không thể được mở lại, nên trong lần quan sát trước đó, channel cũng chưa được đóng, do đó trong trường hợp này, chúng ta có thể thông báo rằng việc nhận thất bại và trả về nhanh chóng. Vì không được chọn và không nhận được dữ liệu, giá trị trả về là (false, false).
- Các hoạt động tiếp theo, đầu tiên chúng ta sẽ khóa một lần, phạm vi khá lớn. Nếu channel đã được đóng và không có phần tử trong mảng vòng lặp buf. Tương ứng với trường hợp đóng không có bộ đệm và đóng có bộ đệm nhưng không có phần tử trong buf, chúng ta trả về giá trị zero value tương ứng, nhưng cờ received là false, thông báo cho người gọi rằng channel này đã được đóng và giá trị bạn nhận không phải là dữ liệu được gửi bình thường từ người gửi. Nhưng nếu nằm trong ngữ cảnh của câu lệnh select, trường hợp này đã được chọn. Rất nhiều tình huống sử dụng channel như tín hiệu thông báo đều rơi vào đây.
- Tiếp theo, nếu có hàng đợi gửi đang chờ, điều đó có nghĩa là channel đã đầy, có thể nhận dữ liệu bình thường trong cả hai trường hợp: channel không có bộ đệm hoặc channel có bộ đệm nhưng buf đã đầy.
Sau đó, gọi hàm recv:
Nếu channel là không có bộ đệm, chúng ta sẽ sao chép trực tiếp từ ngăn xếp của người gửi vào ngăn xếp của người nhận.
Nếu là channel có bộ đệm và buf đã đầy. Điều này có nghĩa là chỉ mục gửi và chỉ mục nhận đã trùng nhau, vì vậy chúng ta cần tìm chỉ mục nhận trước:
Sao chép phần tử tại vị trí đó vào địa chỉ nhận. Sau đó, sao chép dữ liệu đang chờ gửi từ người gửi vào vị trí chỉ mục nhận. Như vậy, quá trình nhận và gửi dữ liệu đã hoàn thành. Tiếp theo, tăng chỉ mục gửi và chỉ mục nhận lên một đơn vị, nếu “vòng lặp” xảy ra, quay trở lại 0.
Cuối cùng, lấy goroutine từ sudog và gọi goready để thay đổi trạng thái của nó thành “runnable”, chờ đợi goroutine gửi được đánh thức và chờ lịch trình của bộ lập lịch.
- Sau đó, nếu buf của channel còn chứa dữ liệu, điều đó có nghĩa là có thể nhận dữ liệu một cách bình thường. Lưu ý rằng, ngay cả khi channel đã đóng, cũng có thể điều này xảy ra. Bước này khá đơn giản, chỉ cần sao chép dữ liệu tại vị trí chỉ mục nhận trong buf vào địa chỉ nhận dữ liệu.
- Cuối cùng, khi đến bước cuối cùng, điều này có nghĩa là chúng ta sẽ bị chặn. Tất nhiên, nếu giá trị block được truyền vào là false, thì không bị chặn và chỉ cần trả về.
Đầu tiên, chúng ta tạo một sudog, sau đó lưu trữ các giá trị khác nhau. Lưu ý rằng, địa chỉ nhận dữ liệu sẽ được lưu trữ trong trường elem
, khi bị đánh thức, dữ liệu nhận được sẽ được lưu trữ tại địa chỉ mà trường này trỏ đến. Sau đó, thêm sudog vào hàng đợi recvq của channel. Gọi hàm goparkunlock để đưa goroutine vào trạng thái đợi.
Phần còn lại của mã là các công việc cuối cùng sau khi goroutine được đánh thức.
Phân tích ví dụ
Chúng ta sẽ sử dụng ví dụ sau để giải thích quá trình nhận và gửi dữ liệu từ channel:
Đầu tiên, chúng ta tạo một channel không có bộ đệm, sau đó khởi chạy hai goroutine và truyền channel đã tạo vào. Sau đó, chúng ta gửi dữ liệu 3 vào channel này và sau đó sleep 1 giây trước khi chương trình kết thúc.
Dòng 14 trong chương trình tạo một channel không có bộ đệm, chúng ta chỉ xem một số trường quan trọng trong cấu trúc của chan để hiểu tổng quan về trạng thái của chan. Ban đầu, không có gì trong chan:
Tiếp theo, dòng 15 và 16 tạo hai goroutine và mỗi goroutine thực hiện một phép nhận. Dựa vào phân tích mã nguồn trước đó, chúng ta biết rằng cả hai goroutine (sau đây gọi là G1 và G2) đều bị chặn ở phép nhận. G1 và G2 sẽ được treo trong hàng đợi recq của channel, tạo thành một danh sách liên kết hai chiều vòng.
Trước dòng 17 của chương trình, cấu trúc dữ liệu của chan nhìn tổng thể như sau:
buf
trỏ đến một mảng có độ dài 0, qcount là 0, cho thấy không có phần tử nào trong channel. Chú ý đến recvq
và sendq
, chúng là cấu trúc waitq, và waitq thực tế là một danh sách liên kết hai chiều, các phần tử trong danh sách là sudog, trong đó có trường g
, g
đại diện cho một goroutine, vì vậy sudog có thể coi là một goroutine. recvq lưu trữ các goroutine bị chặn khi cố gắng đọc từ channel, trong khi sendq lưu trữ các goroutine bị chặn khi cố gắng ghi vào channel.
Lúc này, chúng ta có thể thấy rằng recvq có hai goroutine treo, đó là G1 và G2 đã khởi chạy trước đó. Vì không có goroutine nhận, và channel là loại không có bộ đệm, nên G1 và G2 bị chặn. Không có goroutine nào bị chặn trong sendq.
Cấu trúc dữ liệu của recvq
như sau:
Tiếp tục nhìn tổng thể trạng thái của chan:
G1 và G2 đã bị treo, trạng thái là WAITING
. Điều này không phải là trọng tâm của bài viết hôm nay, nhưng chắc chắn sẽ có bài viết liên quan sau này. Ở đây, tôi chỉ giải thích đơn giản rằng goroutine là một coroutine ở mức người dùng, được quản lý bởi Go runtime. So với luồng kernel được quản lý bởi hệ điều hành, goroutine nhẹ hơn nhiều, cho phép chúng ta dễ dàng tạo hàng ngàn goroutine.
Một luồng kernel có thể quản lý nhiều goroutine, khi một trong số chúng bị chặn, luồng kernel có thể lập lịch để chạy các goroutine khác, luồng kernel không bị chặn. Đây chính là mô hình M:N
thông thường:
Mô hình M:N
thường bao gồm ba phần: M, P, G. M là luồng kernel, chịu trách nhiệm chạy goroutine, P là ngữ cảnh, lưu trữ ngữ cảnh cần thiết để chạy goroutine, nó cũng duy trì danh sách goroutine có thể chạy (runnable), G là goroutine đang chờ chạy. M và P là cơ sở để G chạy.
Tiếp tục với ví dụ. Giả sử chúng ta chỉ có một M, khi G1 (go goroutineA(ch)
) chạy đến val := <- a
, nó chuyển từ trạng thái running sang trạng thái waiting (kết quả sau khi gọi gopark):
G1 không còn liên quan đến M nữa, nhưng lập lịch viên không để M rảnh rỗi, nên nó tiếp tục lập lịch để chạy một goroutine khác:
G2 cũng gặp phải tình huống tương tự. Bây giờ G1 và G2 đều bị treo, đang chờ một sender gửi dữ liệu vào channel để được giải thoát.