Lập trình viên Go 4-chan
(dolthub.com)- DoltHub cố tình lồng quá nhiều
chanvốn thường dùng trong lập trình đồng thời của Go, tạo ra một ví dụ đùa vui về việc gửi channel qua channel - Trong đoạn mã được kế thừa ngoài đời thực từng có
chan chan struct{}, được dùng cho mẫu fan-out để chuyển channel mới cho các worker goroutine, nhưng vì khó suy luận và quản lý nên đã được viết lại - Ví dụ này mở rộng trò đùa “lập trình viên 4 sao” với
int****trong họ ngôn ngữ C sangchancủa Go, dùng_4chan:= make(chan chan chan chan int)làm channel cao nhất - Với
factor = 3, mỗi tầng channel đều phân nhánh producer và consumer, rồi cộng các giá trịintở cuối để in ra 243, tức 3 mũ 5 - Trong thực tế, cách này không phù hợp vì độ khó khi triển khai và debug, xử lý đóng channel, nhu cầu dùng
sync.WaitGroup, cùng rò rỉ goroutine; ví dụ cũng dựa vàotime.Sleep()thay vì logic kết thúc
Trường hợp lồng channel từ Dolt
- DoltHub đang viết Dolt, cơ sở dữ liệu SQL có quản lý phiên bản đầu tiên trên thế giới, bằng Go
- Giống như nhiều codebase Go thông thường, họ dùng channels và goroutines để triển khai thực thi đồng thời
- Vì bản thân lập trình đồng thời đã khó, nên thông thường người ta xử lý channel và goroutine theo cách đơn giản và trực quan
- Có thời điểm, đoạn mã lấy từ một dự án mã nguồn mở khác từng có channel gửi channel như sau
var c chan chan struct{}
- Cấu trúc này là cách truyền channel giữa các goroutine để triển khai mẫu fan-out cho worker goroutine
- Channel trung gian đóng vai trò môi giới, chuyển channel mới tạo cho worker thực hiện công việc thật sự
- Nó có hoạt động, nhưng rất khó suy luận và xử lý, đặc biệt khi còn phải tính đến rò rỉ goroutine
- Đoạn mã đó sau này đã được viết lại và
chan chan struct{}cũng biến mất
Phiên bản Go của trò đùa “lập trình viên 4 sao”
- Vào thời C và các ngôn ngữ phái sinh của nó còn được dùng rộng rãi, có một trò đùa gọi những người mới khó hiểu con trỏ là “lập trình viên 4 sao”
- Ví dụ tiêu biểu là đoạn mã dùng nhiều mức gián tiếp con trỏ như
int**** - Vì Go cũng phần lớn bắt nguồn từ C, bạn vẫn có thể viết kiểu mã tương tự bằng con trỏ
- Truyền lần lượt
*int,**int,***int,****int - Nếu hàm cuối cùng thực hiện
****i = 100thì chương trình sẽ ini is now 100
- Truyền lần lượt
- Nhưng Go có
chan, thứ mà C không có, nên có thể mở rộng trò đùa này thành gián tiếp qua channel
Tính lũy thừa bậc 5 bằng channel 4 tầng
- Channel cao nhất được khai báo như sau
_4chan := make(chan chan chan chan int)
- Vì định danh trong Go không thể bắt đầu bằng số, ví dụ dùng tên
_4chan - Giá trị được gửi vào
_4chanlà một channel 3 tầng_3chan := make(chan chan chan int)
- Cứ tiếp tục hạ dần theo cách đó cho đến cuối cùng là channel giá trị
chan int - Ở mỗi tầng gián tiếp, ví dụ tạo ra producer theo hằng số
factor- Trong ví dụ là
const factor = 3 sendChanChanChankhởi chạy producer 3-channel dưới dạng goroutine
- Trong ví dụ là
- Phía consumer cũng nhận channel đi vào ở mỗi tầng rồi khởi chạy consumer tầng kế tiếp với số lượng
factorreceiveChanChanChannhận_3chantừ_4chan, rồi khởi chạy consumer 3-channel
Truyền giá trị và cộng dồn ở tầng cuối
- Ở tầng thấp nhất, thứ được truyền đi không còn là channel nữa mà là giá trị
intthật sự - Hàm
sendgửi_1chanvào_2chan, rồi khởi chạy số producerintbằngfactor - Mỗi producer
intlại tạo rafactorgoroutine để thực hiện_1chan <- 1 - Consumer cộng các số nguyên nhận được vào biến toàn cục
sumsumđược khai báo làatomic.Int32receive(c chan int)nhận giá trị từ channel rồi thực hiệnsum.Add(int32(s))
Kết quả chạy và số nhánh được tạo
- Toàn bộ chương trình tạo
_4chan, khởi chạy tầng gửi và tầng nhận trong các goroutine riêng, rồi đợi500 * time.Millisecond - Kết quả đầu ra của ví dụ là
3 ^ 5: 243
- Đây là một ví dụ khái quát hóa việc tính lũy thừa bậc 5 của một con số theo cách phân tán tối đa
- Bạn có thể xem ví dụ chạy được trên Go Playground, và bản có tô sáng cú pháp trên GitHub Gist
- Nếu dùng
factorlớn hơn, bạn có thể phải tăng thời gianSleepđể chương trình kịp chạy xong - Khi bật log, có thể thấy số lượng phân nhánh ở từng tầng sản xuất/tiêu thụ channel
starting 3chan producer: 3 lầnstarting 2chan producer: 9 lầnstarting 3chan consumer: 9 lầnstarting 2chan consumer: 27 lầnstarting chan producer: 27 lầnstarting 1chan consumer: 81 lầnstarting int producer: 81 lầnsending int: 243 lầnreceived int: 243 lần
Vì sao nên tránh trong mã thực tế
- Cách này quá khó để triển khai và debug nếu dùng trong mã thực tế
- Khi gửi channel qua channel, rất khó xác định nên đóng từng channel vào thời điểm nào
- Trong trường hợp sử dụng thực tế thì cần phải đóng channel, nhưng để thêm logic kết thúc, bạn phải theo dõi khi nào toàn bộ việc gửi channel đã hoàn tất
- Để triển khai xử lý kết thúc, tác giả phải thêm
sync.WaitGroupở nhiều nơi, và điều đó làm ví dụ đùa vui trở nên khó đọc - Ví dụ cuối cùng được đơn giản hóa bằng cách dùng
time.Sleep()thay cho logic kết thúc, và chấp nhận để lại nhiều rò rỉ goroutine
1 bình luận
Ý kiến trên Hacker News
Ở góc nhìn của một nhà khoa học làm việc gần với các kỹ sư phần mềm chuyên nghiệp thực thụ, rất nhiều việc họ làm trông giống như thế này, nên thật sự khó hiểu nổi vì sao họ lại làm vậy
Tôi từng thấy một dòng code trước khi thực sự được gọi phải đi lần lượt qua 4 hàm interface, và các hàm đó lại rải rác trong các file khác nhau ở các thư mục khác nhau
Vì thế việc đọc để hiểu code đang làm gì trở nên rất mệt mỏi; đi vào vài tầng rồi thì bắt đầu nghi ngờ không biết mình có đang nhìn đúng chỗ không, và liệu có lúc nào sẽ tới được nơi phép tính thực sự diễn ra hay không
Cảm giác rằng nó quá mức và rối rắm là không sai; khi lần đầu viết code “thú vị”, nó có thể trông phức tạp về mặt kỹ thuật, thậm chí tao nhã, nhưng trong phần mềm thật sự phải phát triển lớn lên thì nó trở thành một cơn ác mộng kỹ thuật
Tôi từng mất gần 2 năm để dọn dẹp việc lạm dụng channel trong code Go; channel trên thực tế hiếm khi thật sự cần, nhưng lúc ban đầu lại rất dễ dùng cho đủ thứ mục đích, nên mới thành vấn đề
Tiêu chí để xem có đang dùng channel đúng hay không là: có thể trả lời “không” cho các câu “không làm bằng gọi hàm trực tiếp được à?”, “không làm bằng wait group hay mutex được à?”, và trả lời “có” cho câu “lợi ích về concurrency/parallelism có đủ lớn để biện minh cho độ phức tạp khi debug code đồng thời không?” hay không
Có những trường hợp thiếu hiểu biết và năng lực một cách đáng sợ ở đúng nơi đáng ra phải có chúng
Hồi đại học, tôi từng pair programming khoảng 30 phút với một tiến sĩ khoa học máy tính, và đó là một trải nghiệm khá khai sáng
Ông ấy hoàn toàn không hiểu phần mềm, đến mức còn chỉ ra rằng ta không kiểm tra xem kích thước của cấu trúc dữ liệu trong thư viện chuẩn có phải là số không âm hay không
Tuy vậy, đôi khi những cấu trúc như thế cũng có lý do. Có lúc chúng thật sự hợp lý, có lúc lại là cách để đối phó với một codebase điên rồ do những người trước để lại
Tôi từng đọc code được dùng trong các bài báo nghiên cứu; phần toán lý thuyết thường đã vượt quá tầm hiểu, nhưng khi đi vào code để xem logic thì nhiều khi còn tệ hơn và khó nhận ra hơn nhiều
Rốt cuộc, chúng ta quen với cách mình vẫn làm, còn những cách khác thì cảm thấy xa lạ
Dù vậy, các tầng gián tiếp bổ sung thường được biện minh trong tổng thể kiến trúc, và nếu là trường hợp hợp lý thì khi chỉ nhìn cục bộ có thể khó nhận ra giá trị của chúng
“Nét chạm nhẹ nhàng và sự tĩnh lặng của các ngôn ngữ lập trình thuở đầu luôn khiến người ta vui thích. Không có nhiều văn bản, nhưng làm được nhiều việc. Các chương trình xưa đọc lên không giống một cuộc tranh luận với trình biên dịch, mà giống một cuộc đối thoại thầm lặng giữa một nhà nghiên cứu ăn nói khéo léo và một cộng sự máy móc được huấn luyện tốt. Ai mà biết được rằng sự tinh vi lại mua về thứ tiếng ồn này?” — Dick Gabriel
Ta đang nhìn thấy cả hai thái cực. Những codebase có quá nhiều abstraction nên bị phân tán quá mức, và những codebase gần như chỉ là script để đạt mục tiêu vì không có abstraction nào cả, đều khó làm việc
Nhiều script Python, JS, PHP được viết với thái độ “thôi kệ, cứ đưa tôi kết quả tôi muốn là được”, còn những người làm việc với code hằng ngày thì cần abstraction giúp cộng tác và tăng khả năng phục hồi
Meme ở phần đầu thật sự khiến tôi bật cười với tư cách một lập trình viên C đang trong quá trình cai nghiện
Nhìn những ví dụ bẻ cong ngôn ngữ theo kiểu này khá thú vị; C thì đầy rẫy cơ hội như vậy, và thật thú vị khi thấy điều đó cả trong Go
Bài nói là “một trò đùa lập trình cũ từ thời C và các ngôn ngữ phái sinh của nó thống trị”, nhưng chúng ta vẫn đang sống trong thời đó
Thật mỉa mai khi thread này lại phê phán đây là điển hình của quá nhiều abstraction
Lý do trong C hiếm khi thấy biến ba dấu sao không phải vì chuỗi con trỏ hiếm, mà vì hiếm khi cần thao tác hơn hai tầng cùng lúc
Trong các ngôn ngữ như Python, Java, JavaScript, nơi hầu hết mọi thứ về cơ bản gần giống con trỏ, chắc chắn cũng có các chuỗi con trỏ dài hơn 4 rất nhiều
Các phần sâu thường được giấu bên trong những struct mà ta không cần quan tâm nội bộ, tức là đã được abstraction hóa
Channel trong code đồng thời cũng đại khái đóng vai trò mà con trỏ đóng trong code tuần tự, nên nếu ở đây có khiếm khuyết nghiêm trọng thì có thể không phải là quá nhiều abstraction, mà ngược lại là thiếu abstraction
Dù vậy, vì không biết codebase nên biết đâu
chan chanlại chính là abstraction phù hợp với thứ họ định viết. Trong các ngôn ngữ mạnh về concurrency như Erlang, việc gửi PID cho một PID khác để xác định process sẽ nhận phản hồi là chuyện rất phổ biếnNhưng không phải vậy; đó là địa chỉ của một mảng chuỗi. Vì là C nên mới như thế, và đến nay sau khoảng 20 năm tôi vẫn nghĩ đó là giải pháp trực quan nhất
Nói đỡ cho đồng nghiệp thì có lẽ lúc đó không có comment. Phần đó là trách nhiệm của tôi
Làm tôi nhớ đến tác phẩm kinh điển vượt thời gian của Buena Vista Social Club https://www.youtube.com/watch?v=o5cELP06Mik
chan chan Valuehaychan struct{resp chan Value}là một pattern tôi đã thực sự dùng trong những tình huống rất cụ thểCũng có thể dùng message bus, nhưng như vậy lại phải xử lý thêm message bus
chan chanđược dùng khá thường xuyên. Đó là trường hợp khi gửi một thông điệp tới server nội bộ hoặc actor, đồng thời gửi kèm cả channel để nhận phản hồi trong thông điệp đóTrên thực tế, thay vì dạng
chan chanđúng từng ký tự có thể tìm bằng grep, nó thường có dạngchan struct { ... thứ gì đó chứa channel ... }, nhưng nguyên lý thì giống nhauĐây là một pattern rất hữu ích và tôi xem nó là một trong những kiến thức cơ bản của Go
Tuy nhiên, theo tôi biết thì
chan chan chanchưa từng được dùngChuyển toàn bộ những thứ như vậy sang message bus là quá mức cần thiết. Đặc tính hiệu năng cũng rất khác, và channel của Go gần với thành phần cấu thành bên trong một tiến trình hệ điều hành hơn, nên nếu là giao tiếp nội bộ trong tiến trình thì không đáng để “nâng cấp” lên message bus
[0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
Channel của channel là một pattern bình thường, nhưng thường xuất hiện nhiều hơn dưới dạng channel của giá trị struct, trong đó kiểu struct đó có trường channel
Có thể dùng để gửi các request sẽ được hoàn tất, rồi chờ các giá trị đó không phụ thuộc vào thứ tự, nhằm tránh chặn đầu hàng
Ví dụ như
type request struct { params, reply chan response }: gửi request qua channel, worker xử lý công việc rồi đưa kết quả vào channelreplyNhưng mức hữu ích mà tôi từng thấy chỉ đến hai tầng, và tôi chưa thấy use case nào cần
chan chan chanCó một blog phản ví dụ triển khai dynamic dispatch bằng channel dùng để gửi channel. Không phải Go mà là Limbo, nhưng khái niệm thì giống nhau. Có lẽ chính độ phức tạp lại chứng minh cho luận điểm này https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...
Tôi nhớ đến “My favorite Erlang Program” của Joe Armstrong
https://joearms.github.io/published/2013-11-21-My-favorite-e...