1 điểm bởi GN⁺ 2024-08-29 | 1 bình luận | Chia sẻ qua WhatsApp
  • DoltHub cố tình lồng quá nhiều chan vố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 sang chan củ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ào time.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 channelsgoroutines để 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 = 100 thì chương trình sẽ in i is now 100
  • 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 _4chan là 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
    • sendChanChanChan khởi chạy producer 3-channel dưới dạng goroutine
  • 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 factor
    • receiveChanChanChan nhận _3chan từ _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ị int thật sự
  • Hàm send gửi _1chan vào _2chan, rồi khởi chạy số producer int bằng factor
  • Mỗi producer int lại tạo ra factor goroutine để 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 sum
    • sum được khai báo là atomic.Int32
    • receive(c chan int) nhận giá trị từ channel rồi thực hiện sum.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 đợi 500 * 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 factor lớn hơn, bạn có thể phải tăng thời gian Sleep để 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ần
    • starting 2chan producer: 9 lần
    • starting 3chan consumer: 9 lần
    • starting 2chan consumer: 27 lần
    • starting chan producer: 27 lần
    • starting 1chan consumer: 81 lần
    • starting int producer: 81 lần
    • sending int: 243 lần
    • received 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

 
GN⁺ 2024-08-29
Ý 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

    • Đây thật sự là một thói quen rất tệ, gần với cách một kỹ sư junior quá hăng hái viết phần mềm hơn
      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òn có những thứ tệ hơn. Đây không phải là một ví dụ phóng đại quá đáng: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      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
    • Ở một đầu của phổ coder là các nhà khoa học, đầu kia là các kỹ sư phần mềm. Thứ duy nhất có thể cứu chúng ta là sự cân bằng
      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ạ
    • Overengineering là một nguyên nhân phổ biến. Giải pháp đơn giản thường bị giấu ở nơi khó tìm
      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
    • Ngược lại, tôi đồng ý rằng khi kỹ sư phần mềm không thật sự hiểu, họ thường đẩy abstraction đi quá xa, nhưng tôi cũng không đánh giá đặc biệt cao code do những người không phải kỹ sư phần mềm chuyên nghiệp tạo ra
      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 chan lạ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ến

    • Tôi vẫn nhớ hồi đầu sự nghiệp từng bị mắng vì một commit có ba dấu sao. Kiểu như ai mà cần con trỏ của con trỏ của con trỏ chứ
      Như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 Value hay chan 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ạng chan 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 chan chưa từng được dùng
      Chuyể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
    • Có lẽ đây là pattern tương tự server chat đồng thời trong gopl. Ban đầu hơi bất ngờ, nhưng vẫn khá dễ đọc
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • Tôi cũng đã dùng rồi. Nó hữu ích để triển khai ngữ nghĩa tương tự Promise trong Go
  • 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 channel reply
    Như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 chan

  • Có 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...

    • Nói thêm cho những ai tò mò, Limbo là một ngôn ngữ tiền thân của Go và được dùng trong hệ điều hành Inferno, hậu duệ của Plan 9
  • Tôi nhớ đến “My favorite Erlang Program” của Joe Armstrong
    https://joearms.github.io/published/2013-11-21-My-favorite-e...