2 điểm bởi GN⁺ 2023-07-18 | 1 bình luận | Chia sẻ qua WhatsApp
  • Chỉ với goroutine và channel, Go vẫn có những mẫu đồng thời hóa khá gượng ép; coroutine là một đề xuất giúp tổ chức chương trình bằng cách chuyển quyền điều khiển luồng thực thi một cách tường minh mà không cần song song hóa
  • Coroutine trao đổi quyền thực thi bằng resumeyield, và vì chỉ có một coroutine chạy tại một thời điểm nên có thể tránh race trên dữ liệu dùng chung, đồng thời điểm chuyển đổi trở thành điểm đồng bộ hóa
  • Python generator và CLU iterator trông giống coroutine nhưng vị trí yield bị giới hạn, nên nếu bê nguyên cách duyệt cây lồng nhau kiểu Lua thì sẽ có khác biệt là một số giá trị bị mất
  • coro.New của Go cũng có thể được biểu diễn bằng channel và goroutine, còn coro.Pull biến push iterator thành pull iterator, mỗi lần gọi sẽ lấy ra một giá trị khi gọi push iterator đó
  • Cách cài đặt dựa trên channel mất khoảng 190ns cho mỗi lần chuyển đổi, còn chuyển đổi trực tiếp trong runtime giảm xuống khoảng 20ns mỗi lần chuyển đổi và khoảng 40ns cho mỗi giá trị của coro.Pull, với mục tiêu đủ nhanh để không trở thành nút thắt trong sử dụng thực tế

Mô hình thực thi của coroutine

  • Coroutine trông giống lời gọi hàm, nhưng chạy trên các stack khác nhau và không chạy đồng thời
    • F có khởi động G, G cũng không chạy ngay mà chỉ chạy khi F gọi resume một cách tường minh
    • Trong lúc chạy, G có thể trả lại quyền thực thi cho F bất kỳ lúc nào bằng yield
    • Khi G trả về, nó được dọn dẹp, và F nhận tín hiệu rằng không nên resume G thêm nữa
  • Trong mô hình này, mỗi lần chỉ có một coroutine chạy, còn bên gọi sẽ chờ trên một stack khác
  • Việc chuyển thực thi chỉ xảy ra ở những điểm cụ thể của chương trình, nên nhiều luồng xử lý thay phiên nhau theo cách đã được phối hợp

Coroutine qua ví dụ Lua

  • Ví dụ Lua 5 so sánh xem hai cây nhị phân có cấu trúc khác nhau có cùng chuỗi giá trị hay không
    • t1t2 chứa 1, 2, 3, 4, 5
    • t3 chứa 1, 2, 3, 4, 6
  • visit(t) duyệt cây theo thứ tự trung vị và xuất từng giá trị bằng coroutine.yield(t.value)
  • Hàm so sánh tạo hai coroutine visit rồi luân phiên coroutine.resume để đọc giá trị tiếp theo
    • Nếu trạng thái kết thúc của hai coroutine khác nhau hoặc giá trị khác nhau thì trả về false
    • Nếu cả hai cùng kết thúc thì trả về true
  • Cách viết Lua tự nhiên hơn là dùng coroutine.wrap để lấy hàm next che giấu đối tượng coroutine
    • Khi coroutine kết thúc, hàm next sẽ trả về nil
    • Toàn bộ mã nằm trong Gist

Giới hạn của Python generator và CLU iterator

  • Python generator nhìn qua thì giống coroutine Lua, nhưng không phải cùng một mô hình
  • Nếu dịch trực tiếp ví dụ Lua sang Python thì visit(t['left']) không thực sự duyệt cây mà chỉ tạo ra đối tượng generator rồi bỏ đi
    • Khi có yield trong thân hàm, def visit không còn định nghĩa một hàm thường mà là một generator
    • Ví dụ dịch đơn giản đó chỉ in ra 4 từ cây và làm mất 1, 2, 3, 5
  • Mã Python đúng phải lặp trực tiếp qua các generator lồng nhau rồi yield lại
    • yield from của Python 3.3 giúp đơn giản hóa mẫu này
  • Đối tượng generator trong Python chỉ chứa trạng thái của một lần gọi visit
    • Giá trị biến cục bộ và dòng đang thực thi được lưu trong đối tượng generator
    • Khi được tiếp tục, trạng thái đó được đưa lên call stack, rồi lại tách ra khỏi stack vào đối tượng generator ở điểm yield
    • yield chỉ có thể dùng ở frame lời gọi cấp cao nhất
  • CLU gọi kiểu trừu tượng này là iterator và phân biệt tĩnh giữa iterproc
    • Nhờ có thông tin kiểu, compiler có thể chẩn đoán việc dùng sai iterator như thể nó là hàm thường
    • Bài báo năm 1977 của Barbara Liskov và cộng sự “Abstraction Mechanisms in CLU” giải thích rằng iterator là một dạng coroutine bị giới hạn nên có thể cài đặt chỉ bằng stack của chương trình

Khác biệt giữa coroutine, thread và generator

  • Cả ba khái niệm đều cung cấp một dạng đồng thời hóa nào đó, nhưng sức mạnh và chi phí khác nhau
  • Coroutine

    • Cung cấp đồng thời hóa không có song song hóa
    • Khi một coroutine đang chạy, coroutine đã resume nó hoặc coroutine mà nó nhường quyền cho sẽ không chạy
    • Vì điểm chuyển đổi là tường minh nên không phát sinh race khi chia sẻ dữ liệu
    • Các lần chuyển như coroutine.resume hay gọi next trở thành điểm đồng bộ hóa, tạo ra happens-before edge
    • Vì được lập lịch tường minh mà không cần hệ điều hành, việc chuyển đổi có thể xuống mức khoảng dưới 10ns
  • Thread

    • Mạnh hơn coroutine, và sức mạnh bổ sung đó chính là song song hóa
    • Cái giá phải trả là overhead lập lịch, context switch đắt hơn và nhu cầu về một dạng tiền nhiệm hóa nào đó
    • Chuyển thread thông thường ở mức vài micro giây
  • Go goroutine

    • Trong cách phân loại này, nó gần với thread giá rẻ hơn
    • Go runtime đảm nhận một phần việc lập lịch nên chuyển đổi ở mức vài trăm ns
    • Giống thread ở chỗ cung cấp song song hóa và tiền nhiệm hóa
    • Lightweight thread mới của Java về cơ bản cũng giống goroutine
  • Generator

Những trường hợp Go cần coroutine

  • Thư viện đồng thời hóa hiện có của Go không trực tiếp cung cấp mẫu coroutine
  • Goroutine thường đủ giống để dùng thay thế, nhưng vì có song song hóa và tiền nhiệm hóa nên có thể cho kết quả khác với coroutine
  • Bài trình bày năm 2011 của Rob Pike “Lexical Scanning in Go” nói về thiết kế lexer và parser ban đầu của gói text/template
    • Lexer và parser chạy trên các goroutine riêng và được nối với nhau bằng channel
    • Đây là một cấu trúc mô phỏng không hoàn chỉnh một cặp coroutine
    • Lexer nhìn trước token tiếp theo trong lúc parser xử lý token gần nhất
    • Generator không đủ dùng cho lexer vốn cần yield giá trị từ nhiều hàm
    • Tính song song của goroutine tạo ra race, và cuối cùng thiết kế được đổi sang lưu trạng thái lexer trong một đối tượng
    • Nếu có coroutine đúng nghĩa thì có thể tránh race và hiệu quả hơn goroutine
  • Một trường hợp sử dụng trong tương lai là duyệt collection generic
    • Go từng có thảo luận về hỗ trợ range trên hàm
    • Điều này có thể khuyến khích collection và tác giả của các lớp trừu tượng cung cấp các hàm iterator kiểu CLU
  • Hiện tại trong Go cũng có thể dùng giá trị hàm để cài đặt push iterator
    • Ví dụ: func (t *Tree[V]) All(yield func(v V))
    • Hiện giờ có thể gọi như t.All(func(v V) { fmt.Println(v) })
    • Trong tương lai có thể có dạng for v := range t.All
  • Vấn đề nằm ở những kiểu duyệt không khớp với một vòng for đơn lẻ
    • Có những trường hợp như so sánh cây nhị phân, nơi cần đan xen hai lần duyệt vào nhau
    • Coroutine có thể biến push iterator như (*Tree).All thành pull iterator trả về một giá trị cho mỗi lần gọi

coro.New được biểu diễn bằng Go thuần

  • Nếu thêm coroutine vào Go, điều đó phải khả thi mà không cần thay đổi ngôn ngữ, đồng thời phải có thể hiểu và triển khai bằng mã Go thông thường
  • Một coro.New đơn giản có thể được biểu diễn bằng channel và goroutine
    • cin truyền giá trị đầu vào
    • cout trả lại giá trị đầu ra
    • resume gửi giá trị vào cin và chờ kết quả từ cout
    • goroutine mới ban đầu bị chặn ở <-cin, nên không có cơ hội chạy song song
  • Khi thêm yield, f có thể phát ra giá trị trong lúc đang chạy, và bên gọi có thể đưa giá trị vào lại ở lần resume tiếp theo
    • yield(out) gửi giá trị vào cout rồi chờ đầu vào tiếp theo từ cin
    • Đây cũng là một cặp send-receive, nên không có tính song song
  • Mẫu giao tiếp này giới hạn goroutine để nó hoạt động như coroutine
    • Về bản chất vẫn là goroutine, nhưng resumeyield đóng vai trò như các phép chuyển ngữ cảnh

Ví dụ parser chuỗi

  • Bài toán trong “Storing Data in Control Flow” là chạy func parseQuoted(read func() byte) bool trong một luồng điều khiển riêng, rồi cấp từng byte một qua phương thức Write
  • Dùng coro.New cho phép viết ở mức trừu tượng cao hơn so với bản cài đặt tạm thời dựa trên channel ở bài trước
    • Init định nghĩa hàm coparse
    • read gọi yield với NeedMoreInput, rồi trả về byte do bên gọi gửi tới
    • Kết quả boolean của parseQuoted(read) được đổi thành BadInput hoặc Success
    • p.resume(0) chạy tiến tới lần đọc đầu tiên của parseQuoted
    • Write(c byte) trở thành một wrapper mỏng gọi p.resume(c)
  • Toàn bộ mã có tại Go Playground

Ví dụ sàng số nguyên tố

  • Sàng nguyên tố đồng thời của Doug McIlroy là một pipeline với một coroutine cho mỗi số nguyên tố p
    • Mỗi bộ lọc nhận số từ hàng xóm bên trái, và nếu không chia hết cho p thì chuyển sang hàng xóm bên phải
    • Bộ đếm ở đầu bên trái cung cấp 2, 3, 4, ...
    • Coroutine đầu ra ở đầu bên phải đọc số nguyên tố để in ra và tạo coroutine bộ lọc mới
  • counter là một hàm bọc vòng lặp yield giá trị bằng coro.New
    • more bool truyền việc có tiếp tục tạo số hay không
    • yield(i) phát ra giá trị và nhận về việc có tiếp tục hay không cho lần kế tiếp
  • filter(p, next) lấy giá trị từ next(true) của coroutine bên trái, và chỉ yield(n) khi n%p != 0
  • main giữ đầu ra pipeline hiện tại trong next
    • Đọc số nguyên tố p
    • In p
    • Thêm một bộ lọc mới ở bên phải pipeline để loại các bội số của p
  • Quan hệ gọi giữa các coroutine có thể thay đổi trong lúc chạy
    • yield đầu tiên của counter đi tới main, nhưng các yield sau đó đi tới bộ lọc 2
    • Đầu ra đầu tiên của mỗi bộ lọc p đi tới main như số nguyên tố tiếp theo, còn các đầu ra sau đó đi tới bộ lọc kế tiếp
  • Toàn bộ mã có tại Go Playground

Quan hệ giữa goroutine và coroutine

  • Luồng điều khiển được tạo ở đây, nói строго, là goroutine
    • Nó có thể làm mọi việc mà goroutine thông thường làm được, như mutex, channel, chờ system call, v.v.
  • coro.New tạo ra một goroutine có thể dùng các phép chuyển coroutine bên trong yieldresume
  • Câu lệnh go tạo ra luồng điều khiển mới có tính đồng thời và song song, còn coro.New tạo ra luồng điều khiển mới có tính đồng thời nhưng không song song
    • Nếu chạy 10 câu lệnh go, sẽ có thể có 11 goroutine chạy đồng thời nếu tính cả main
    • Nếu gọi coro.New 10 lần, sẽ có 11 luồng điều khiển nhưng mức độ song song của chương trình không đổi, và mỗi lần chỉ có một luồng chạy
  • Goroutine nào đóng vai trò coroutine “không song song” có thể thay đổi trong lúc chạy
    • Điều này cũng giống như việc goroutine nào đang gửi hoặc nhận trên channel có thể thay đổi trong khi thực thi

resume vững chắc hơn

  • coro.New ban đầu sẽ gây deadlock nếu gọi resume sau khi hàm đã kết thúc
  • Để sửa điều này, resume trả về thêm một bool cùng với kết quả
    • true nghĩa là kết quả đến từ yield
    • Khi hàm return, resume trả về giá trị trả về và false
    • Nếu gọi resume sau khi coroutine đã kết thúc, nó trả về zero value và false
  • Biến running theo dõi xem f có đang chạy hay không
    • resume và coroutine chạy luân phiên nhau, nên việc chia sẻ running không tạo ra race
  • Ví dụ in ra "hello" true, "world" true, "done" false, "" false

Chuyển iterator bằng coro.Pull

  • coro.Pull biến push iterator thành pull iterator
  • Dạng của push iterator đầu vào như sau
    • push func(yield func(V) bool)
    • Giá trị boolean trả về của yield biểu thị có tiếp tục hay không
  • Dạng của pull iterator đích như sau
    • pull func() (V, bool)
    • Nó trả về giá trị và trạng thái kết thúc lặp giống như channel receive hoặc map lookup
  • Để dừng sớm, Pull không chỉ trả về pull mà còn trả về stop
  • Phần triển khai chỉ cần tạo một wrapper nhỏ chạy push iterator bằng coro.New
    • pull gọi resume(true)
    • stop gọi resume(false)
  • Phương thức All của cây được đổi để dùng kết quả bool của yield
    • Duyệt trái, yield giá trị hiện tại, và duyệt phải được nối bằng && để lan truyền việc dừng sớm
  • Hàm so sánh cây tạo hai coro.Pull và so sánh từng giá trị một
    • Dùng defer stop1()defer stop2() để dừng coroutine khi kết thúc sớm
    • Nếu giá trị hoặc trạng thái kết thúc khác nhau thì trả về false
    • Nếu cả hai cùng kết thúc thì trả về true
  • Toàn bộ mã có tại Go Playground

Lan truyền panic và hủy

  • Panic xảy ra trong coroutine có thể được trả về cho bên gọi đã resume coroutine đó gần nhất
    • Với goroutine thông thường, rất khó biết cần báo cho goroutine nào, và liệu goroutine đó đã sẵn sàng nhận hay chưa
    • Với coroutine, bên gọi đang bị chặn và chờ tại resume, nên đích nhận panic là rõ ràng
  • Cách triển khai dùng cout để truyền thông điệp chứa giá trị hoặc panic
    • defer của coroutine mới sẽ bắt panic
    • resume đang chờ sẽ panic lại với đúng cùng giá trị panic đó
  • Trong ví dụ, coroutine yield ra "hello" rồi panic với "world"
    • Panic được lan truyền tới goroutine main, và trong stack trace nó trông như thể phát sinh từ lời gọi resume
    • Toàn bộ mã có tại Go Playground
  • Một hàm cancel được thêm vào để báo cho coroutine khi bên gọi kết thúc sớm
    • cancel tương tự resume, nhưng khiến yield panic thay vì trả về một giá trị
    • Panic do hủy dùng một wrapper error riêng biệt thỏa mãn ErrCanceled
    • Panic do cancel gây ra sẽ không được lan truyền lại, nhưng nếu coroutine phát sinh panic khác trong lúc hủy thì vẫn lan truyền
    • Nếu resume chưa từng được gọi, cancel sẽ khiến f hoàn toàn không chạy
  • Với việc dừng iterator, một bool tường minh rõ ràng hơn panic, nên Pull vẫn giữ cơ chế dừng dựa trên bool

Xem lại sàng số nguyên tố: dọn dẹp và lan truyền lỗi

  • Trong API mới, counterfilter cùng trả về hàm resume và hàm cancel
  • primes(n int) tạo counter và đăng ký defer cancel()
    • Đọc và in ra từng số nguyên tố
    • Mỗi khi thêm một filter mới, cancel của filter đó cũng được đăng ký bằng defer
  • Khi hàm lấy đủ n số nguyên tố rồi trả về, các lời gọi cancel trì hoãn sẽ dọn dẹp những coroutine đã được tạo ra
  • Nếu có coroutine nào panic, nó sẽ lan truyền tới coroutine đang chờ
    • Nếu đó là coroutine mà primes trực tiếp tiếp tục bằng next, panic sẽ quay về primes
    • Nếu đó là coroutine mà một filter tiếp tục bằng next, panic sẽ đi ngược lên qua chuỗi filter tới p := next(true) trong primes
    • Sau đó, các lệnh cancel trì hoãn của primes sẽ dọn dẹp các coroutine còn lại
  • Toàn bộ mã có tại Go Playground

Hình dạng API cuối cùng

  • New tạo một coroutine mới đang tạm dừng và chuẩn bị chạy hàm f
    • Coroutine mới là một goroutine nhưng không tự chạy
    • Nó chỉ chạy khi một goroutine khác gọi resume hoặc cancel và đang chờ
  • resume(in) dừng goroutine gọi và chuyển sang coroutine mới
    • Lần gọi đầu tiên sẽ bắt đầu f(in, yield)
    • resume sẽ bị chặn cho đến khi f gọi yield(out) hoặc trả về out
    • Khi yield được gọi, resume trả về out, true
    • Khi f trả về, resume trả về out, false
    • Lần resume(in) tiếp theo sẽ làm cho yield đang bị chặn trả về in
  • cancel dừng việc thực thi f và kết thúc coroutine
    • Nếu resume chưa từng được gọi lần nào thì f sẽ không chạy
    • Nếu không, yield đang bị chặn sẽ panic với một error thỏa mãn ErrCanceled
  • Nếu f phát sinh panic mà không recover, panic đó sẽ chuyển sang goroutine đang chờ trong resume hoặc cancel và panic lại với cùng giá trị
    • Tuy nhiên, cancel sẽ không panic lại với panic hủy mà chính nó gây ra
  • Khi f trả về hoặc panic, coroutine sẽ không còn tồn tại nữa
    • Những lần gọi resume sau đó sẽ trả về zero value và false
    • Những lần gọi cancel sau đó chỉ đơn giản là trả về
  • resume, cancel, yield có thể được truyền sang goroutine khác để sử dụng
    • Kết quả là goroutine nào được xem là “coroutine” có thể thay đổi động
  • New tạo một goroutine mới nhưng luôn duy trì bất biến rằng có đúng một goroutine đang bị chặn tại resume, cancel, yield hoặc ở trạng thái chờ ban đầu
    • Bất biến này được giữ cho tới khi f trả về
    • Kết quả là coro.New tạo ra tính đồng thời mới, nhưng không tạo ra tính song song mới
  • Chữ ký cuối cùng như sau
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

Hiệu năng

  • Cần có khả năng định nghĩa coroutine bằng Go thuần, nhưng để dùng thực tế thì cần một hiện thực runtime được tối ưu hóa
  • Trên MacBook Pro 2019, coro.New dựa trên channel mất khoảng 190ns mỗi lần chuyển đổi cho một vòng giá trị qua lại
    • Với coro.Pull, con số là khoảng 380ns cho mỗi giá trị
  • coro.Pull không phải là cách dùng iterator tiêu chuẩn
    • Cách tiêu chuẩn là gọi trực tiếp iterator, khi đó không có overhead coroutine
    • coro.Pull chỉ cần khi phải xử lý giá trị dần dần thay vì trong một vòng for đơn lẻ
  • Nỗ lực tối ưu đầu tiên là để compiler đánh dấu các cặp send-receive và để lại gợi ý cho runtime gộp chúng thành một thao tác
    • Runtime của channel có thể bỏ qua scheduler và nhảy trực tiếp sang coroutine khác
    • Mất khoảng 118ns mỗi lần chuyển đổi, và khoảng 236ns cho mỗi giá trị được pull
    • Nhanh hơn 38% so với hiện thực channel ban đầu
  • Hiện thực thứ hai tránh hoàn toàn channel và thêm trực tiếp việc chuyển coroutine vào runtime
    • Việc chuyển coroutine được rút xuống còn 3 thao tác atomic compare-and-swap
    • Một cho cấu trúc dữ liệu coroutine, một cho scheduler status của coroutine bị chặn, và một cho scheduler status của coroutine được tiếp tục
    • Mất khoảng 20ns mỗi lần chuyển đổi, và khoảng 40ns cho mỗi giá trị được pull
    • Nhanh hơn khoảng 10 lần so với hiện thực channel ban đầu
  • Chi phí 40ns mỗi giá trị được xem là đủ nhỏ để không trở thành nút thắt cổ chai trong những đoạn mã cần coro.Pull

1 bình luận

 
GN⁺ 2023-07-18
Ý kiến trên Hacker News
  • Có vẻ nhiều người đang bỏ lỡ điểm cốt lõi ở đây. Đúng là thư viện coroutine là một cách tệ hơn và rườm rà hơn để xử lý đồng thời so với từ khóa go
    Trường hợp sử dụng thực sự mang sự phức tạp này vào là hàm iterator, tức là cho phép dùng range với các hàm kiểu func() (T, bool). Đây là điều đã được thảo luận từ lâu trong cộng đồng Go và ý nghĩa của nó có lẽ khá trực quan với đa số lập trình viên Go
    Bài viết này bàn về vấn đề tiếp theo, tức là nếu hàm iterator được thêm vào ngôn ngữ thì sẽ viết iterator để dùng trong vòng lặp for như thế nào. Nó bắt đầu từ nhận xét rằng iterator kiểu push thường dễ viết hơn, rồi mở rộng sang bộ chuyển đổi push-pull, và bộ chuyển đổi đó được xây trên coroutine
    Nếu tất cả đều được đưa vào, thì việc dùng coroutine cho mục đích ngoài lặp có lẽ sẽ trở thành một thực hành tệ, giống như dùng channel/goroutine ở những chỗ mutex là đủ

    • Cũng đáng nói là trong một số trường hợp sử dụng cụ thể, coroutine hiệu quả hơn nhiều so với goroutine đầy đủ. Khi chuyển sang coroutine thì không cần chuyển ngữ cảnh hay lập lịch lại
      Khi hai tác vụ hợp tác một cách đồng bộ về mặt logic, ví dụ như iterator, thì việc chạy tất cả trên cùng một CPU sẽ hiệu quả hơn nhiều. Kernel không cần lập lịch lại gì cả hoặc tắt rồi đánh thức các lõi CPU, và dữ liệu cũng ở lại vừa với cache của CPU nên độ trễ cache và tỷ lệ trúng cache tốt hơn
      Với goroutine thì điều đó cũng có thể tình cờ xảy ra, nhưng không được đảm bảo, và ít nhất vẫn có chi phí đi qua bộ lập lịch goroutine của runtime Go. Nó nhanh, nhưng không nhanh bằng việc chạy một ngữ cảnh mã khác bên trong cùng một goroutine
      Với coroutine, ta biết rằng tác vụ A chuyển trực tiếp sang tác vụ B nên hành vi lập lịch dễ dự đoán hơn. Ở phần cuối bài, Russ cho thấy một triển khai coroutine tối ưu trong runtime nhanh hơn 10 lần so với việc mô phỏng bằng goroutine
      Ở Google có các bản vá kernel nội bộ triển khai kiểu đa luồng hợp tác này, và bên trong họ gọi là fibers. Nó tồn tại để có độ trễ tốt hơn và lập lịch dễ dự đoán hơn. Paul Turner cũng có một bài nói tại LPC khoảng 10 năm trước giải thích động cơ này: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • Tôi không hiểu có vấn đề gì với cách viết for { next := getNext(); ... }. Tôi muốn biết lợi ích của việc viết for next := range getNext { ... } là gì
    • Coroutine trong Go có vẻ có thể giúp dùng Go như một ngôn ngữ chủ để định nghĩa gọn gàng mô phỏng sự kiện rời rạc. Hiện tại cách nhường quyền cho actor khá gượng gạo
    • Tôi không hiểu vì sao không thể range hoặc switch trên channel, rồi chạy một goroutine để đẩy giá trị vào channel. Tôi vẫn chưa bị thuyết phục vì sao lại cần coroutine
    • Dù vậy, cuối cùng thì chuyện này vẫn giống kiểu bổ sung mọi thứ kể cả bồn rửa bát
      Lần này nó không giống kiểu suy nghĩ thận trọng và giải pháp 80/20 tốt, mà giống “muốn làm cái này cho đúng thì chắc cần coroutine, thôi thêm luôn vậy”
      Khi thêm generics, họ đã suy nghĩ cực kỳ lâu và sâu, rồi đưa ra một phương án thỏa hiệp đổi mới nhưng rất cân bằng
      Ở đây tôi đã kỳ vọng một cách tiếp cận như “thêm một tính năng vào goroutine để có thể kiểm soát trong một số tình huống nhất định”. Nó có vẻ tốt hơn là “xử lý toàn diện như Rust cho vấn đề này rồi cứ thế thêm vào”
  • Tôi đã dùng Go chuyên nghiệp nhiều năm, nhưng không muốn nó trở thành giống Python Twisted / Tornado / các framework khác
    Từ khóa go ngăn chặn khá tốt vấn đề tô màu hàm vốn rất đau đầu
    Trong các ngữ cảnh hiệu năng cao, đôi khi tôi muốn làm những thứ như sharding dữ liệu theo từng lõi CPU, nhưng đề xuất này không gãi đúng chỗ ngứa đó

    • Coroutine và goroutine lấp vào các ngách khác nhau. Goroutine đã lấp vào khu vực mà những thứ như Twisted từng chiếm giữ. Ở đây không có nội dung nào muốn kéo kiểu async/await vào Go
      Coroutine sẽ lấp vào một khu vực khác, gần với generator của Python hơn. Trong những chỗ cần ghép các pipeline thành phần có thể kết hợp, nó thường có thể giảm mạnh mức dùng bộ nhớ và độ phức tạp mã nguồn. Thiết kế này có vẻ hoàn toàn mang tính đồng bộ
    • Không có chỗ nào trong bài có đề xuất gì giống vấn đề tô màu hàm
    • Nếu phải viết goroutine và dùng Context, thì đó đúng theo nghĩa đen là tô màu hàm
    • “Vấn đề” của các framework bất đồng bộ/phân luồng khác có thể là chúng xếp threading lên trên iterator/coroutine. Trong trường hợp này thì hai thứ đó trực giao với nhau, nên có lẽ không tệ như người ta nghĩ
      Tất nhiên, gần như chắc chắn sẽ có một lập trình viên thông minh nào đó ở đâu đó trên thế giới dùng cái này để tạo ra thứ ngớ ngẩn, rồi nó lại trở nên cực kỳ phổ biến. Dự đoán của tôi là họ sẽ trừu tượng hóa goroutine và coroutine thành một
    • Tôi muốn hỏi liệu ai có thể chia sẻ gợi ý về các mẫu quản lý channel hoặc framework tốt hơn không
      Thông thường mã liên quan đến goroutine trở nên lộn xộn vì channel, và tôi không biết làm sao để nó “sạch” hơn. Nếu có gợi ý về các mẫu đã được kiểm chứng là dễ bảo trì thì tôi thực sự rất cảm kích
  • Các hệ thống đa nhiệm đã mang đến cho chúng ta tiến trình
    Nhưng chúng quá nặng
    Vì vậy thread xuất hiện, tức là các tiến trình chia sẻ không gian địa chỉ, bảng tệp và một số thứ khác. Bộ lập lịch có thể chuyển đổi giữa các thread dễ hơn so với giữa các tiến trình, và việc chia sẻ dữ liệu giữa các thread không cần tuần tự hóa
    Nhưng như vậy vẫn quá nặng
    Vì thế thread không gian người dùng ra đời. Đó là các luồng thực thi logic mà runtime vận hành hoàn toàn trong không gian người dùng. Runtime либо chèn các hook lập lịch vào mọi hàm I/O của thư viện chuẩn, либо dùng API hệ thống như tín hiệu Unix để preempt các thread logic. Không cần chuyển ngữ cảnh ở mức hệ thống và có thể làm rất nhỏ gọn
    Nhưng như vậy vẫn quá nặng
    Vì thế coroutine ra đời. Chúng cho phép lập trình viên định nghĩa các “thread” logic tương tác với nhau theo kiểu hợp tác. Không giả định có bộ lập lịch tồn tại. Lập trình viên либо tự viết event loop, либо gọi event loop của thư viện từ một thread logic “thực sự”
    Tò mò không biết tiếp theo sẽ là gì. Từ góc nhìn của [Communicating Sequential Processes][1], có lẽ coroutine hợp tác đã là mức thấp nhất có thể đi xuống
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • Coroutine cũng không cứu lập trình viên khỏi nỗi kinh hoàng phải mô tả cho máy tính mọi chi tiết có thể xảy ra. Đặc biệt là phải mô tả sao cho việc thực thi không kéo dài mãi mãi nếu đầu vào chỉ hơi khác với điều lập trình viên dự tính
    • Cũng hơi bất ngờ là không có nhiều ngôn ngữ tự động song song hóa mã càng nhiều càng tốt, nhưng chỉ trong phạm vi mà hiệu năng đo được thực sự có lợi
      Cần có ngữ nghĩa hỗ trợ đồng thời. Ví dụ, việc duyệt lẽ ra mặc định không nên có thứ tự, và dù điều này cũng phụ thuộc vào phân tích luồng toàn chương trình, tôi không thấy có rào cản nguyên tắc nào
      Chẳng phải Microsoft đã nghiên cứu một ngôn ngữ như vậy vài năm trước sao? Cũng có ParaSail do một người trong cộng đồng Ada tạo ra. Không biết các dự án như vậy giờ ra sao, hay là chẳng ai dùng
    • Tôi không nghĩ còn có thể đi xuống thấp hơn nữa. Thay vào đó có lẽ còn chỗ để đi lên theo hướng điện toán phân tán. Nếu nhớ không nhầm, ở một trong những bản alpha đầu tiên của Go, channel còn hoạt động được cả giữa các máy
    • Bước tiếp theo có thể là dạng “hãy xử lý tập mục công việc này theo cách bạn muốn”. Nheo mắt nhìn thì mọi thực thi đồng thời đều có thể xem như một chuỗi tác vụ, và bản thân tác vụ cũng có thể là một tập hợp. Ngay cả khi chỉ có một hoặc hai tác vụ cũng vậy
  • Tôi từng nghĩ cốt lõi của green thread là có được cơ chế lập lịch hợp tác tốt mà không cần dùng từ khóa như yield của Python
    Tôi thấy quyết định thiết kế của Go là chèn điểm gọi và điểm tiếp tục tại những vị trí cụ thể là một sự đánh đổi rất hay
    Nó đang phơi bày ngày càng nhiều quyền điều khiển sát phần cứng hơn. Từ một thời điểm nào đó, tôi không biết có phải chỉ là đang tạo lại Zig hay không. Tiếp theo là garbage collector tùy chọn chăng?

    • Ở đây yieldresume không phải từ khóa mà là biến thông thường. Chúng chỉ là các tham chiếu closure thông thường được đặt tên như vậy cho mục đích minh họa
      Điểm lạ là phần tạo và dùng callback hủy. Tôi không làm Go nhiều nên không rõ đó là tối ưu hiệu năng để thu hồi trạng thái iterator bị bỏ đi nhanh hơn, hay là cần thiết vì Go không garbage-collect goroutine đang chờ trên channel khi goroutine đó giữ tham chiếu duy nhất đến channel ấy
      Trong Lua thì không cần thứ như vậy. Coroutine/thread cũng được garbage-collect như các đối tượng khác, nên khi mọi tham chiếu biến mất thì nó sẽ được thu hồi, ngay cả khi thao tác cuối cùng là yield chứ không phải hàm entry trả về
    • Garbage collector của Go là tùy chọn. Bạn có thể tắt garbage collection bằng cách đặt biến môi trường GOGC=off
      Chi tiết hơn về GOGC: https://dave.cheney.net/tag/gogc
  • Có lẽ tôi sẽ thích coroutine được hỗ trợ ở mức ngôn ngữ hơn là chỉ là thư viện
    Tôi hình dung một thứ như x := co func(){ var z int; for { z++; yield z } }, hoặc một dạng tương đương
    Làm được chỉ bằng Go thuần đã rất ngầu, và tôi cũng hiểu sức hấp dẫn của việc cung cấp nó dưới dạng một gói thư viện chuẩn có runtime tối ưu hóa thay vì làm đặc tả ngôn ngữ phức tạp hơn. Suy cho cùng, nếu làm được bằng Go thuần thì các hiện thực khác cũng có thể bootstrap nhanh hơn
    Với tư cách là người dùng Go hằng ngày ở $work, tôi đều hoan nghênh cả hai hướng, nhưng vẫn nghiêng về phương án tích hợp vào ngôn ngữ hơn. Các nguyên thủy đồng thời của Go vốn luôn là thế mạnh, nên cứ đẩy tiếp theo hướng đó

    • Coroutine cần có hỗ trợ của ngôn ngữ. Nếu stack bị cạn trong một coroutine thì sẽ làm sao?
      Với lời giải ở mức thư viện, bạn либо phải giết coroutine, либо phải giết cả chương trình. Nếu muốn stack tự mở rộng một cách trong suốt thì chỉ mã được sinh ra mới làm được. Vì nó phải theo dõi lượng stack sử dụng và mở rộng khi cần. Tôi hiểu là goroutine có cơ chế như vậy
      Có lẽ lời giải ở mức thư viện cũng có thể đặt guard page ở cuối stack. Khi chạm tới đó, trình xử lý lỗi có thể thử mở rộng stack. Nhưng nếu có chỗ nào giữ con trỏ tới biến trên stack thì có lẽ sẽ không hoạt động
    • Tôi thích hơn phương án thêm tham số yield vào hàm để tương tác tốt hơn với hệ thống kiểu hiện tại
      Hàm muốn yield giá trị thì phải có cái gọi là tham số yield, và chỉ được phép yield bên trong các hàm có cùng chữ ký yield hoặc trong các hàm không có chữ ký
      Ví dụ có thể đổi thành dạng x := func(:z int) { for { z++; :- z } }. : dùng để thêm chữ ký yield, còn :- dùng để yield giá trị
      Hàm chỉ yield giá trị X thì chỉ cần : X hoặc : name X. Nếu khi resume cần nhận giá trị kiểu Y thì chữ ký sẽ đổi thành :[Y] X hoặc :[Y] name X
      Việc chấp nhận nên là yếu. Ở nơi mong đợi một hàm resume bằng Y và yield X, cũng nên chấp nhận hàm chỉ yield X mà không resume
      Nếu các hàm đặc biệt của gói co cung cấp chức năng resumeNew thì có thể giữ đúng phong cách Go. Cú pháp range có thể được mở rộng để truyền giá trị resume bằng -:, còn nếu không truyền giá trị thì sẽ resume bằng giá trị zero mặc định
    • Cái này thực sự khủng khiếp. Hoàn toàn không trực quan, và cũng không hợp với một trong những điểm mạnh của Go. Muốn viết hay đọc kiểu mã này thì sẽ phải học riêng ngữ nghĩa coroutine của Go
  • Tôi không thấy thuyết phục lắm. Nhìn các ví dụ thì có vẻ nó khiến việc đọc và lần theo ngôn ngữ trở nên khó hơn nhiều. Tất nhiên cũng có thể do đầu óc và định kiến của tôi
    Hơn nữa, cũng không có vẻ như nó làm được điều gì mà các channel chặn hoặc state hiện tại không làm được

    • Đúng như đã nói. Những người bênh vực cái này có vẻ chỉ nhìn được ngay trước mắt
    • Tôi không rõ bạn đang nói đến thay đổi ngôn ngữ nào. Đây chỉ là một đề xuất nhằm chuẩn hóa và làm hiệu quả hơn việc mà mọi người vốn đã làm bằng “state”
      Tôi từng dùng các iterator tương tự như trong bài này để tránh cấp phát trên các đường đi mã cốt lõi. Cách này sẽ khiến kiểu mã đó bớt gượng gạo hơn nhiều. Đặc biệt là khi đi cùng với thay đổi ngôn ngữ về iterator range sắp tới
    • Cũng không phải là dễ hơn khi hiện có hàng chục cách triển khai iterator không tương thích với nhau như trong thư viện chuẩn hiện nay
      Channel chậm một cách vô cớ khi không dùng để bao bọc các phép toán chặn
  • Đọc các bình luận mà thấy chua chát
    Nhiều người xem coroutine và green thread gần như là một, nhưng cả hai đều có ưu và nhược điểm riêng
    Thật buồn khi việc thiếu iterator trong cộng đồng Go lại có thể được chấp nhận. Có vẻ như người ta cố tình từ chối bất kỳ tính năng nào có thể làm ngôn ngữ phức tạp thêm dù chỉ một chút, nhân danh sự đơn giản. Dù vậy, ít nhất họ cũng đã rút lại lập trường về generic
    Điều đó lại khiến tôi nghĩ rằng Go đúng là không phải ngôn ngữ dành cho tôi

    • Tôi dùng Go hằng ngày, nhưng thành thật mà nói generic không làm thay đổi code của tôi nhiều
      Bây giờ tôi có dùng nó đôi chút, nhưng chủ yếu vì tính tiện cú pháp ở chỗ gọi tốt hơn một chút. Ở chỗ định nghĩa thì đúng như dự đoán, nó vẫn xấu, chỉ là cú pháp Go thuộc loại gần như ổn nhất trong số những gì tôi từng thấy ở các ngôn ngữ khác
      Rốt cuộc, tôi nghĩ họ đã đánh đổi khá nhiều sự đơn giản chỉ để dập tắt những lời phàn nàn kiểu “không có generic”. Đó không phải một vụ trao đổi tốt
    • Không nên nhầm HN với “cộng đồng Go”. Tác giả bài này là người đứng đầu nhóm Go
      Gói coro được đề xuất trong bài có được thêm vào y nguyên không? Có thể, nhưng chắc là không hoàn toàn đúng như vậy. Có thứ gì đó tương tự sẽ được thêm vào không? Nếu được cá cược thì tôi sẽ chọn có
      Sẽ mất bao lâu? Ít nhất là 1 năm, tức khoảng bản phát hành Go 1.23 vào tháng 8 năm 2024. Có thể còn lâu hơn một chút. Tôi không nghĩ có thể nhanh hơn nhiều so với thế
    • Không phải họ đã rút lại lập trường về generic
      Generic được cộng đồng chấp nhận vì nó hoàn toàn tương thích ngược với code hiện có, và nếu không cần thì có thể an toàn bỏ qua
      Đúng như dự đoán, phần lớn code Go vẫn như vậy. Ngoài vài dạng “collection” khác nhau ra, không dễ tìm thấy nhiều chỗ dùng generic thực sự thiết thực. Ngay từ đầu, đa số code vốn cũng chẳng bao giờ đụng quá một kiểu, còn vượt quá hai kiểu thì đã là hiếm rồi
      Ngược lại, việc thêm generic đã cho cộng đồng rộng lớn hơn thấy rằng những tính năng được tung hô mà người ta đòi là “bắt buộc” thực ra lại ít cần thiết đến mức nào trong một ngôn ngữ vốn đã rất tốt và được chấp nhận rộng rãi
    • Generic không thay đổi nổi đến 20% codebase của tôi, và trong 20% đó còn có cả thư viện. Không biết là thói quen Go hay thói quen C, nhưng với tôi generic giống như thêm một thư viện nữa nơi một lập trình viên nào đó đã giải quyết sẵn một vấn đề nào đó
    • Tôi cứ tưởng cộng đồng Go sẽ chào đón một giao diện iterator thống nhất
  • Việc cuối cùng cũng để ý đến các ngôn ngữ lập trình như CLU có vẻ là điều tốt
    Mặt khác, với trải nghiệm từng dùng coroutine của .NET và C++, cùng với Active Object trong Symbian C++ và Active Oberon, tôi không chắc đây có thật sự là thứ nên thêm vào Go hay không
    Ngay cả đội .NET cũng đã thừa nhận tại BUILD năm nay rằng, nếu có thể quay ngược thời gian, họ thà để runtime xử lý theo kiểu Go còn hơn. Vì rất nhiều lập trình viên đến giờ vẫn gặp khó khăn trong việc hiểu async/await

  • Tôi không chắc cái này có thực sự cần thiết không. Hầu hết trường hợp trong Go đều đã được goroutine xử lý ổn, còn ngữ nghĩa yield/resume thì chỉ cần 2 channel chặn là đủ
    Nó có vẻ như đang thêm độ phức tạp chỉ vì độ phức tạp, và tôi cũng không chắc nó có thực sự bổ sung năng lực mới nào mà Go trước giờ chưa có hay không

    • Goroutine và channel thêm vào một lượng overhead khổng lồ. Dùng chúng làm iterator thì thực tế là vô lý
    • Điều đó đã được bài viết đề cập
  • Để so sánh, trong một bài trình bày gần đây, họ đã demo việc khởi chạy 1 triệu thread Elixir (BEAM VM) và gửi cho tất cả thông điệp "Hello!", sau đó mỗi thread đợi một khoảng thời gian ngẫu nhiên từ 0 đến 2 giây rồi gửi lại "Process received message !"
    Đồng thời họ mở Erlang observer bên cạnh để quan sát mức tiêu thụ CPU và bộ nhớ, cũng như tốc độ hồi phục sau khi dọn rác
    Nút thắt lớn nhất ở đây là khả năng theo kịp của terminal, nhưng observer có vẻ phản ánh tình hình thực tế khá chính xác
    https://www.youtube.com/watch?v=yxyYKnashR0
    Mã đã dùng: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    Kiểu khả năng này đã được tích hợp sẵn trong Erlang từ thập niên 1980, và vì thế cả Elixir cũng vậy. Có lẽ nhiều người đã nghe về actor model hoặc cách triển khai “huyền thoại” của Erlang, nhưng tôi không biết có bao nhiêu người thật sự từng thấy nó hoạt động ngoài đời cùng với công cụ giám sát đang chạy
    Sẽ rất hay nếu Go cung cấp mức hỗ trợ ở cấp ngôn ngữ như thế này, nhưng cách BEAM VM triển khai thread cực kỳ hiệu quả về tài nguyên cả lúc tạo lẫn khi chạy, cộng thêm sự dễ dàng của lập trình đồng thời nhờ chỉ cho phép giá trị bất biến, khiến tôi nghĩ Go sẽ rất khó mà theo kịp