4 điểm bởi GN⁺ 2024-03-31 | 1 bình luận | Chia sẻ qua WhatsApp
  • Ngay cả trong những đoạn mã mà hiệu năng và tính đồng thời là trọng yếu như kernel và driver, việc giải phóng bộ nhớ cũng không đơn giản chỉ kết thúc bằng một lệnh gọi free(); có thể cần đến các kỹ thuật chia sẻ không dùng khóa
  • RCU (Read, Copy, Update) là cách sao chép dữ liệu được đọc thường xuyên nhưng hiếm khi thay đổi, rồi thay thế con trỏ một cách nguyên tử để không chặn đường đọc
  • Nếu delete phiên bản cũ ngay lập tức, các luồng vẫn đang đọc có thể gặp use-after-free, nên RCU theo dõi các đoạn đọc và trì hoãn giải phóng cho đến thời điểm an toàn
  • RCU được dùng hàng chục nghìn lần trong Linux, cũng có trong thư viện C++ Folly và crossbeam-epoch của Rust; xét ở chỗ nó được dọn dẹp về sau tùy theo còn được sử dụng hay không, nó có hình thái giống GC
  • Sự phân đôi cho rằng quản lý bộ nhớ thủ công luôn nhanh hơn và dễ dự đoán hơn là yếu; vì free(), reference counting và hành vi bộ nhớ của OS cũng có chi phí và bất định, GC hiện đại cũng có thể trở thành một công cụ của lập trình hệ thống

Vì sao mã kernel dùng các kỹ thuật như RCU

  • Trong số các chương trình chạy hằng ngày, hệ điều hành thuộc nhóm có độ nhạy hiệu năng cao
  • Khi OS nhanh hơn, người dùng có thể thực hiện nhiều tính toán hơn, vì vậy các nhà phát triển kernel và driver bỏ nhiều công sức tối ưu hóa mã
  • Hệ điều hành không chỉ phải xử lý các tiến trình và luồng ở user space, mà còn nhiều luồng của chính kernel và cả các trình xử lý ngắt phần cứng
  • Vì độ trễ tăng lên sẽ lấy mất thời gian của người dùng, trong mã kernel đã xuất hiện nhiều kỹ thuật chia sẻ dữ liệu giữa các luồng mà không dùng khóa

Cách hoạt động cơ bản của RCU

  • RCU (Read, Copy, Update) là phương pháp phù hợp với dữ liệu được đọc rất thường xuyên nhưng hiếm khi được ghi
    • Ví dụ là dữ liệu hầu như không đổi nhưng vẫn có thể thay đổi, như tập hợp thiết bị USB hiện đang được kết nối
    • Thay đổi phải diễn ra một cách nguyên tử và không được chặn những reader đang đọc
  • Writer cập nhật trạng thái chia sẻ theo thứ tự sau
    • Đọc dữ liệu hiện có từ con trỏ
    • Sao chép dữ liệu hiện có, áp dụng thay đổi cần thiết để tạo phiên bản mới
    • Cập nhật con trỏ một cách nguyên tử để trỏ tới phiên bản mới
  • Reader chỉ đọc con trỏ chia sẻ, nên đường đọc đơn giản và hoạt động không phải chờ
  • Cách này dễ dùng và wait-free, nhưng nếu không dọn dẹp phiên bản cũ thì sẽ xảy ra rò rỉ bộ nhớ

Phiên bản cũ không thể giải phóng ngay

  • Nếu ngay sau khi đổi con trỏ sang phiên bản mới mà delete phiên bản cũ, sẽ có nguy cơ use-after-free
  • Vì hoạt động không dùng khóa, writer không thể biết liệu có reader nào vẫn đang đọc phiên bản cũ hay không
  • Reader có thể đánh dấu vùng tới hạn phía đọc bằng rcu_read_lock()rcu_read_unlock()
    • Reader vẫn không bị block
    • Writer không loại bỏ dữ liệu cũ cho đến khi các reader đó thoát ra
  • rcu_synchronize() không cần đợi cho đến khi mọi reader biến mất, mà chỉ cần đợi cho đến khi các reader trước đó có khả năng nhìn thấy phiên bản cũ kết thúc
    • Reader đã thấy con trỏ mới sẽ dùng phiên bản mới, nên không liên quan đến vòng đời của phiên bản cũ

Giải phóng trì hoãn và hình thái của GC

  • Ngay cả khi writer không chờ bên trong hàm cập nhật, mã vẫn hoạt động đúng nếu dữ liệu cũ được giải phóng an toàn vào một lúc nào đó
  • Một cách như rcu_defer(old) cho phép giải phóng old vào bất kỳ lúc nào sau khi các reader hiện tại rời khỏi vùng tới hạn
  • Hình thức trong đó một luồng chuyên trách định kỳ dọn dẹp các phiên bản cũ không còn được tham chiếu trông giống GC theo thế hệ
  • RCU không phải là thí nghiệm tư duy mà là kỹ thuật thực sự được dùng rộng rãi
    • Linux dùng RCU hàng chục nghìn lần
    • RCU được cung cấp trong thư viện C++ Folly của Facebook
    • Trong Rust, nó được dùng dưới tên crossbeam-epoch và trở thành nền tảng của một thư viện đồng thời phổ biến
  • Điều quan trọng hơn cuộc tranh luận phân loại xem RCU có phải “GC thật sự” hay không là cấu trúc trong đó bộ nhớ được dọn dẹp về sau tùy theo còn đang được sử dụng hay không giống với GC

Chi phí ẩn của giải phóng thủ công

  • Quan niệm phổ biến rằng GC về bản chất kém hiệu quả hơn quản lý bộ nhớ thủ công trở nên yếu đi khi nhìn vào chi tiết triển khai
  • free() không miễn phí

    • Bộ cấp phát bộ nhớ đa dụng phải quản lý trạng thái toàn cục nội bộ như các page nhận từ kernel, phân chia bucket theo kích thước và các bucket đang được sử dụng
    • Có thể phát sinh tranh chấp khi nhiều luồng cố khóa trạng thái của bộ cấp phát
    • Ngay cả khi dùng pool cục bộ theo luồng như jemalloc, vẫn cần thêm mã để đồng bộ hóa chúng
  • RAII và lifetime cũng không loại bỏ chi phí của bộ cấp phát

    • Lifetime của Rust hay RAII của C++ giúp tự động hóa và đảm bảo tính đúng đắn khi giải phóng bộ nhớ, nhưng không loại bỏ độ phức tạp trong cấu trúc nội bộ của bộ cấp phát
    • Trong nhiều kịch bản, cuối cùng vẫn phải quay về shared_ptr hoặc Arc
    • Cần thêm metadata là bộ đếm tham chiếu, và giá trị này có thể di chuyển qua lại giữa các core và cache, tạo ra chi phí
    • Nó cũng có thể làm rò rỉ các chu trình trong đồ thị liveness
  • GC cũng có thể đem lại tối ưu hóa

    • GC theo thế hệ có khả năng di chuyển sẽ định kỳ nén lại heap
    • Việc cấp phát trở nên gần giống tăng con trỏ, cho throughput cao
    • Tính cục bộ của cấp phát tuần tự tốt hơn, cũng có lợi cho hiệu năng cache

Ảo tưởng về quyền kiểm soát quản lý bộ nhớ

  • Nhiều nhà phát triển phản đối GC đang xây dựng hệ thống soft real-time
    • Họ muốn hoạt động nhanh nhất có thể, như FPS của trò chơi điện tử hay hiệu năng nén của codec streaming
    • Nhưng đó không phải yêu cầu độ trễ cứng, nơi chỉ thỉnh thoảng mất thêm 1 mili giây là hệ thống hỏng hoặc có người chết
  • Niềm tin rằng lập trình viên có thể quyết định khi nào quản lý bộ nhớ diễn ra không hề đơn giản
    • Hệ điều hành trừu tượng hóa tương tác với phần cứng
    • Theo mặc định, Linux gần như không làm gì khi có yêu cầu bộ nhớ, và có thể cấp bộ nhớ khi bạn thật sự định sử dụng nó
    • Khi madvise(), memory-mapped I/O và cache hệ thống tệp đan xen, không có câu trả lời đơn giản cho “cái gì được cấp phát khi nào”
    • Vào một ngày tệ, một truy cập con trỏ đơn giản cũng có thể dẫn tới disk I/O
  • Niềm tin rằng lập trình viên luôn biết thời điểm tốt để dừng lại phục vụ quản lý bộ nhớ cũng có giới hạn
    • Có những trường hợp rõ ràng như màn hình tải của trò chơi điện tử
    • Trong nhiều phần mềm, câu trả lời duy nhất là khi không bận làm việc quan trọng hơn
    • Mã riêng lẻ có shared_ptrArc không thể biết trước liệu mình có trở thành chủ sở hữu cuối cùng và phải gánh việc dọn dẹp hay không
  • Niềm tin rằng gọi free() là trả bộ nhớ lại cho OS ngay cũng không phải lúc nào đúng
    • Bộ nhớ được cấp phát từ OS theo đơn vị page
    • Bộ cấp phát thường giữ lại page và tái sử dụng cho đến trước khi chương trình kết thúc
    • OS cũng có thể thu hồi page thông qua swap

Vì sao có thể xem GC là công cụ lập trình hệ thống

  • Không phải mọi phần mềm đều hưởng lợi từ GC
  • Nhưng ngay cả ở thời điểm gần năm 2024, các cuộc thảo luận về GC trong giới lập trình viên hệ thống vẫn dễ bị vùi trong những phân đôi sai lầm và nỗi sợ, bất định, nghi ngờ
  • Ý nghĩ rằng ngôn ngữ dùng GC “rõ ràng” chậm hơn ngôn ngữ quản lý bộ nhớ thủ công không phải sự thật mà gần với ý thức hệ hơn
  • Ngay cả trong các nhóm xây dựng hệ thống liên quan đến sinh mạng, đã có trường hợp cung cấp độ trễ dưới micro giây bằng ngôn ngữ GC cấp phát ở gần như mọi dòng
  • Nếu một phần nào đó của hệ thống nhất thiết phải chạy trong n chu kỳ clock, có thể tách riêng chỉ phần cụ thể đó sang mã không dùng GC hoặc phần cứng
  • GC không phải lời giải vạn năng, nhưng là một trong những công cụ trong hộp đồ nghề có thể dùng mà không cần sợ hãi

1 bình luận

 
GN⁺ 2024-03-31
Các ý kiến trên Hacker News
  • Trong số các kỹ thuật garbage collection song song hiện đại đầy hứa hẹn, MPL hoặc MaPLe cùng cơ chế Automatic Management of Parallelism mới của nó là những thứ đáng xem
    Điểm cốt lõi đã nhận giải distinguished paper award tại POPL 2024 và giải ACM SIGPLAN dissertation award 2023 nằm ở hai phần: a) garbage collection song song hiệu quả có thể chứng minh được dựa trên disentanglement, b) kiểm soát độ hạt tự động hiệu quả có thể chứng minh được
    [1] MaPLe (MPL): https://github.com/MPLLang/mpl
    [2] Automatic Parallelism Management: https://dl.acm.org/doi/10.1145/3632880

    • Standard ML và cộng đồng của nó đã có những đóng góp khá ấn tượng cho tài liệu nghiên cứu về quản lý bộ nhớ
      Có cả bài báo được liên kết ở trên, và MLKit, một trong những người dùng ban đầu cũng như tiên phong của quản lý bộ nhớ dựa trên vùng
    • Tôi tò mò “hiệu quả có thể chứng minh được” ở đây nghĩa là gì
    • Tôi tò mò cách tiếp cận này so với công việc hỗ trợ song song đa lõi gần đây trong OCaml như thế nào
    • Muốn hỏi những người hiểu rõ hơn: thứ này có thể áp dụng sang các ngôn ngữ khác đến mức nào?
      Ví dụ, cách tiếp cận này có thể làm garbage collection của Go nhanh hơn đáng kể không, hay sẽ vấp phải các vấn đề trong thiết kế ngôn ngữ hiện có?
  • Các trường hợp sử dụng RCU khá thuyết phục, nhưng trải nghiệm với garbage collection trong các tình huống khác thì không tốt
    Bài viết này đọc lên giống như một lập luận rằng các giải pháp quản lý bộ nhớ tùy biến có thể đạt hiệu năng tốt nhất, hơn là lập luận rằng vòng đời tĩnh nói chung tốt hơn vòng đời động
    Tôi không tin free() trả bộ nhớ về cho hệ điều hành, mà coi nó là trả về cho allocator. Điều đó tốt hơn nhiều so với trả về cho hệ điều hành, và system call thì chậm. Tuy nhiên, cũng có thể làm như mimalloc: không cho phép dùng ngay bộ nhớ đã giải phóng cho lần malloc kế tiếp, mà chỉ cho phép theo chu kỳ
    Khi cấp phát 800 byte rồi lập tức free lặp lại 1 triệu lần và đếm số con trỏ duy nhất, glibc malloc cho ra 1, jemalloc là 1, mimalloc là 4, còn garbage collector của Julia là 62767
    62767 cái, khoảng 48MiB, không đến mức quá tệ, nhưng vẫn đủ đẩy bay L3 cache trên máy tôi. Dùng garbage collection gần như bảo đảm rằng các cấp phát mới đến từ RAM chứ không phải từ cache, và làm hỏng hiệu năng của mã có nhiều cấp phát. Không chỉ tốc độ của bản thân việc quản lý bộ nhớ là quan trọng, mà còn là bạn có thể thao tác nhanh đến đâu trên vùng bộ nhớ mà nó đưa cho
    Tôi đã đăng một benchmark minh họa điều này trong Julia: https://discourse.julialang.org/t/blog-post-rust-vs-julia-in...
    malloc/free cho bộ nhớ làm việc thực tế có cơ hội vẫn nóng trong cache nếu nó đủ nhỏ. Các allocator như mimalloc cũng được thiết kế để các cấp phát liên tiếp được đặt gần nhau, giống như garbage collection có nén; 4 con trỏ duy nhất của mimalloc mà tôi thấy cách nhau 896 byte
    Nếu tôi có nhiều trải nghiệm hơn với garbage collection có nén thì có lẽ tôi đã bớt hoài nghi hơn, nhưng tôi cho rằng garbage collection là một giải pháp phức tạp hơn rất nhiều so với những thứ như borrow checker của Rust để quản lý bộ nhớ an toàn. Vì độ phức tạp đó được đẩy sang cho trình biên dịch và nhà phát triển runtime nên người dùng thường vẫn ổn, và khi viết mã không nhạy cảm về hiệu năng thì đó là một đánh đổi chấp nhận được. RAII với vòng đời tĩnh cũng là một đánh đổi hợp lý cho những mã không cần cách tiếp cận tùy biến hơn, còn ví dụ trong bài rõ ràng là trường hợp cần một giải pháp tùy biến

    • Chỉ nói rằng garbage collection đưa ra nhiều con trỏ hơn nên cache locality kém là chưa đủ
      Garbage collection có nén hầu như luôn tận dụng cache tốt hơn malloc trong các chương trình chạy lâu, vì phân mảnh heap lãng phí các mục TLB cache và khoảng trống giữa các object. Bump allocator của garbage collection có nén sẽ đưa con trỏ mới cho mỗi lần cấp phát vì free không thu hồi bộ nhớ, nhưng các cấp phát đó là tuần tự, và nếu trường hợp chỉ chạm vào các object gần đây nhất trong khi tiếp tục tiêu thụ heap thì chúng vẫn còn trong cache. Benchmark các hiệu ứng lan tỏa của allocator và garbage collector là việc cực kỳ khó, và tôi nhìn hầu hết các benchmark tổng hợp kiểu này với thái độ hoài nghi
    • Bài viết giải thích vì sao cách này hoạt động tốt trong ngữ cảnh RCU, vì sao nói chung thì không hay lắm, rồi sau đó gạt qua và bỏ qua
      Điều mọi người lo ngại là quá trình xác định bộ nhớ có còn được dùng nữa hay không kém hiệu quả và không quyết định bằng việc nói trực tiếp với allocator rằng ta đã dùng xong tài nguyên. Tôi chưa từng thấy ai lo về bản thân việc trì hoãn giải phóng
      Việc duyệt toàn bộ tập đang sống là hiếm, và trong 30 năm qua các thuật toán garbage collection đã được cải thiện đến mức gần như có tri giác, nhưng câu này, dù cố ý hay không, đã bỏ qua điểm mà mọi người thực sự phàn nàn. Khi một dịch vụ gặp vấn đề với garbage collection, cảm giác như phải gọi một pháp sư đến tinh chỉnh khắp nơi và hy vọng đưa được linh hồn phẫn nộ trở lại thế giới bóng tối
      Nếu có cách đánh dấu rác và nhận thông báo khi nó không còn được dùng nữa, toàn bộ quá trình đó sẽ biến mất. Việc cấp phát bộ nhớ trong garbage collection có thể rất nhanh, nhưng để so sánh công bằng thì cũng phải tính gộp chi phí đánh dấu và nén theo kiểu khấu hao
      Một vấn đề lớn khác là để đạt cùng hiệu năng, nhìn chung garbage collection đòi hỏi nhiều bộ nhớ hơn rất nhiều so với quản lý bộ nhớ thủ công. Nó cũng cần thêm CPU để liên tục kiểm tra trùng lặp xem còn tham chiếu hay không, và phải chấp nhận thêm việc sao chép bộ nhớ cho nén theo hướng lạc quan
      Cuối cùng, bài viết phê phán rằng khi vòng đời không rõ ràng thì cần quản lý bộ nhớ thủ công như Arc/Rc của Rust, nhưng lại bỏ qua việc trong các ngôn ngữ có garbage collection, vì không thể tin rằng finalizer chắc chắn sẽ được gọi, nên để đóng tài nguyên bên ngoài người ta thực chất cũng xây dựng cùng một hạ tầng như vậy
      Cuộc tranh luận này đã được lặp lại đủ nhiều trong 20–30 năm qua, và bài viết này dường như không mang lại gì mới ngoài việc dùng meme để gạt bỏ những lo ngại chính đáng về garbage collection. Meme thì vui nên cũng được, nhưng câu trả lời đúng là không có câu trả lời chung. Hãy dùng công cụ phù hợp để đáp ứng các ràng buộc thiết kế của hệ thống
    • Tôi không hiểu lắm lập luận này. Với garbage collection theo thế hệ, thế hệ 0 có khả năng cao nằm trong cache, và chính ở đó phần lớn việc tạo và hủy diễn ra
      Các cấp phát sống lâu hơn khác, theo định nghĩa, không dễ kiểm soát từ góc nhìn cache. Locality là một trong những lợi thế lớn của garbage collection, và vấn đề duy nhất tôi biết là mark/sweep kiểu stop-the-world. Tôi biết garbage collection hiện đại có các luồng nền, nhưng theo tôi hiểu thì vẫn có các sự kiện stop-the-world
    • free() trả bộ nhớ về cho allocator là đúng, nhưng xử lý phân mảnh bộ nhớ trong server chạy lâu thì chẳng vui chút nào
      Đặc biệt là phân mảnh nội bộ của các page do slab allocator quản lý; đó không phải là vấn đề phổ biến, nhưng là vấn đề khó xử lý
    • Nếu việc sử dụng cache là mối quan tâm lớn đến vậy thì cấp phát arena cũng hoạt động tốt trong garbage collection như trong cấp phát bộ nhớ thủ công
      May là không có nhiều lĩnh vực mà garbage collection phải cạnh tranh với những ví dụ được sắp đặt thuận tiện như thế này
  • Ngoại trừ những trường hợp đặc biệt khi toàn bộ bộ nhớ có thể dễ dàng được xử lý bằng arena, garbage collection theo vết tốt từ lâu đã vượt qua quản lý bộ nhớ thủ công về thông lượng, và gần đây tác động độ trễ cũng đủ chấp nhận được với đa số ứng dụng
    ZGC của OpenJDK có thời gian dừng thông thường ở mức hai đến ba chữ số micro giây, và với tốc độ cấp phát hợp lý thì trường hợp xấu nhất cũng hiếm khi vượt quá 1ms, ở phạm vi tương tự các lần dừng do hệ điều hành gây ra
    Sự đánh đổi thật sự quan trọng chỉ là mức sử dụng bộ nhớ. Trừ các ngách đặc biệt, tức những trường hợp arena phù hợp tốt với mọi thứ và độ trễ tệ nhất nằm trong khoảng vài micro giây thấp, câu hỏi cốt lõi chỉ có một: ứng dụng của tôi có chạy trong môi trường bị ràng buộc bộ nhớ không, hoặc có đáng hy sinh thứ khác để giảm mức dùng RAM không

    • Theo kinh nghiệm của tôi thì ngược lại. Vòng đời riêng biệt theo từng đối tượng là trường hợp đặc biệt hiếm; trong phần lớn mã thực tế có rất nhiều đối tượng liên quan có cùng vòng đời hoặc vòng đời rất giống nhau
      Trong kiểu mã như vậy, theo dõi vòng đời từng đối tượng là quá mức cần thiết. Suy cho cùng, quản lý bộ nhớ là chuyện về vòng đời, và ít vòng đời riêng biệt luôn tốt hơn nhiều vòng đời riêng biệt, vì dù thủ công hay tự động thì cũng có ít việc phải làm hơn
      Việc không phải nghĩ về vòng đời đối tượng rất tiện lợi, và đó là lý do các ngôn ngữ có garbage collection đã thành công dù độ phức tạp bên trong của một garbage collector tốt là đáng kể
    • Thời gian dừng đã được giải quyết ở một mức nào đó, nhưng mức sử dụng CPU của garbage collection vẫn còn khá cao
      Nó cũng vẫn chịu ảnh hưởng của độ trễ đuôi khó dự đoán và nhiều tình huống ngoại lệ
    • Ở chỗ làm, chúng tôi đang benchmark ZGCShenandoah; thời gian dừng p100 thường dưới 500us
      ZGC có vẻ có ít lần dừng hơn Shenandoah, nên dường như làm nhiều việc hơn trong mỗi lần dừng, và vì vậy cho hiệu năng tốt hơn một chút
      Vẫn cần thử nghiệm trong môi trường production, nhưng cho đến nay, với ZGC, và ZGC theo thế hệ từ sau Java 21, các lần dừng do garbage collection nhìn chung có vẻ là vấn đề đã được giải quyết
    • Tiêu chí rằng toàn bộ bộ nhớ phải dễ dàng xử lý bằng arena có vẻ không công bằng. Nếu phần lớn đối tượng có thể được cấp phát bằng arena một cách dễ dàng, chỉ riêng điều đó đã loại bỏ phần lớn nhu cầu garbage collection
      Theo góc nhìn của Jai, cấp phát bộ nhớ có thể được chia thành bốn loại theo độ phổ biến: 1) sống cực ngắn và có thể đặt trên stack của hàm, 2) sống ngắn và vòng đời được xác định rõ nên có thể đặt trong arena bộ nhớ theo frame/request, 3) sống lâu và có chủ sở hữu được xác định rõ nên có thể quản lý bằng pool riêng cho subsystem, 4) sống lâu và chủ sở hữu không rõ ràng nên cần quản lý bộ nhớ động
      Để khẳng định garbage collection theo vết nhìn chung vượt trội quản lý bộ nhớ thủ công, cần so sánh với các hệ thống được viết với góc nhìn như thế này, chứ không phải với một hệ thống gọi malloc/free rải rác. So với các thực hành C++/Rust hiện đại thì có thể công bằng hơn
      Tôi đồng ý rằng trong đa số hệ thống, dựa vào garbage collection theo vết có khả năng thực dụng hơn nhiều, nhưng đó là một lập luận hoàn toàn khác
    • Cần có bằng chứng
  • Bài viết nêu động lực cho RCU rồi quay ngoắt sang bắt đầu bảo vệ garbage collection đa dụng nói chung
    Chưa đến mức là ngựa thành Troy, nhưng cảm giác như một cú chuyển hướng khá đột ngột

    • Tôi sẽ không gọi RCU là garbage collection. Vì ở bất kỳ thời điểm nào cũng không có đối tượng nào ở trạng thái rác
      Đối tượng nằm trong một trong ba trạng thái và được chuyển trạng thái nhanh nhất có thể: active, obsolete but alive for old readers, deallocated
      Tùy cách viết mã, có lẽ cũng có thể tái sử dụng an toàn các đối tượng “obsolete-but-alive” cho cấp phát “new”, nhưng tôi chưa phân tích hiệu năng đầy đủ
      Như thường thấy trong các cuộc thảo luận về garbage collection, khi nào phải “lùi về” dùng shared_ptr/Arc là điều rất mơ hồ. Trên thực tế, tránh reference count — tức chứng minh rằng đã có quyền sở hữu, hoặc tránh hẳn indirection — mới là cốt lõi của các hệ thống nghiêm túc dựa trên reference count. Không làm gì rõ ràng là tốt hơn việc “một lúc nào đó làm gì đó” của garbage collection
  • Trong phần mềm tôi viết có hai trường hợp. (1) Các hot path luôn dùng allocator tùy chỉnh và tránh cấp phát, (2) mọi thứ còn lại
    Ở (1), có garbage collection hay không cũng không khác biệt, và tôi sẽ tránh nó. Ở (2), garbage collection thật sự tiện lợi và đúng đắn

    • Đồng ý. Ở một nơi dùng cả Java/C++, chúng tôi từng cố giải quyết sự phân đôi này bằng khả năng tương tác, nhưng cuối cùng lại tạo ra nhiều vấn đề hơn những gì giải quyết được
      Những gì Java đã làm với các garbage collector hiện đại rất ấn tượng, nhưng ngay cả họ cũng gián tiếp thừa nhận qua Valhalla rằng vẫn có chỗ cho mã không cấp phát/cấp phát thấp
  • Quan sát rằng các hệ điều hành hiện đại dành cho người dùng, tức là không phải các RTOS chuyên biệt, có garbage collection tích hợp sẵn hơi lệch khỏi trọng tâm ở đây
    Chúng ta chỉ không gọi nó như vậy mà gọi là quản lý bộ nhớ. Ngôn ngữ có garbage collection tích hợp được gọi là gì? Gọi là ngôn ngữ quản lý bộ nhớ
    Thường thấy điều này trong các chương trình C kiểu cũ chạy “từ trên xuống dưới”. Cấp phát, dọn dẹp tài nguyên hệ thống nhưng không bận tâm đến free. Khi chương trình kết thúc, hệ điều hành sẽ thu hồi toàn bộ bộ nhớ đó, vậy làm làm gì cho mất công
    Ở đây có cơ hội tạo ra một garbage collector ở cấp hệ điều hành, ít bị cô lập hơn với chương trình, hoặc một hệ điều hành xử lý tài nguyên giống như garbage collector của runtime ngôn ngữ. Nhưng trong các ngôn ngữ garbage collection thông thường, garbage collector thường đan xen phức tạp vào gần như mọi dòng của runtime, nên việc chỉ làm cho một bản phân phối dành cho một hệ điều hành nào đó chuyển quyền kiểm soát ấy cho hệ điều hành là không thực tế
    Dù vậy vẫn thấy tiếc. Vì còn rất nhiều chỗ để cải thiện các vấn đề kinh niên phát sinh từ sự cô lập nhân tạo giữa quản lý bộ nhớ cấp chương trình và quản lý bộ nhớ cấp hệ điều hành

    • Khi chương trình kết thúc, hệ điều hành xử lý giúp, nên không chỉ bộ nhớ mà cả các tài nguyên khác như file, socket, thread cũng không nhất thiết phải giải phóng. Miễn đó không phải là AmigaOS
      Lý do duy nhất để giải phóng bộ nhớ là để tái sử dụng cho các cấp phát khác trong ứng dụng chạy lâu, thay vì lấy thêm bộ nhớ mới từ hệ điều hành. Với các công cụ dòng lệnh chạy một lần rồi kết thúc thì thường không cần
    • Hệ điều hành biết rằng nó chỉ có thể giải phóng bộ nhớ khi tiến trình kết thúc. File handle hay các tài nguyên khác cũng vậy
      Nếu một tiến trình được thiết kế để kết thúc sau khi xong việc, có thể dùng hệ điều hành như một garbage collector
      Nhưng chưa từng có cách nào để hệ điều hành biết bộ nhớ nào không còn được dùng bên trong một chương trình đang chạy. Có thể ngoại trừ vài hệ điều hành nghiên cứu khó hiểu nào đó. Vì vậy, thay vì nói đây là một cơ hội bị bỏ lỡ, tôi cho rằng thứ được cho là bị bỏ lỡ đó không tồn tại ở dạng có ý nghĩa
      Ngược lại, phong cách lập trình dùng các chương trình rất đơn giản và sống ngắn là hoàn toàn chính đáng. Các công cụ CLI và các ngôn ngữ script dùng để script chúng hoạt động như vậy, các web server ngày xưa cũng hoạt động như vậy với CGI, v.v., và đến nay đó vẫn là một cách tiếp cận đủ hợp lý
    • Epsilon GC của Java chính là thứ như vậy
  • (1) Việc chuyển từ RCU sang garbage collection truy vết tổng quát có cảm giác như thả mồi rồi đánh tráo
    (2) Quản lý bộ nhớ thủ công không chỉ là các lời gọi malloc/free mà còn là chuyện bố trí dữ liệu. Ví dụ như tách mảng struct, inlining, offset ngầm định, packing, v.v.

    • Về (2), Virgil có nhiều tính năng cho phép kiểm soát bố trí bộ nhớ ở nhiều mức khác nhau
      Có vẻ đang nói đến “mảng các struct”; có thể làm bằng mảng tuple và tùy target mà sẽ được làm phẳng/chuẩn hóa một cách tự nhiên. Tức là trên target native, nó trở thành mảng các struct
      Cũng có thể định nghĩa bố trí chính xác đến từng byte[1], chủ yếu dùng để liên thông với phần mềm khác hoặc phân tích các định dạng nhị phân. Có thể unbox các kiểu dữ liệu đại số, và sắp tới còn có thể kiểm soát cả cách mã hóa chính xác của các kiểu dữ liệu đại số
      Virgil sử dụng garbage collection
      [1] https://github.com/titzer/virgil/blob/master/doc/tutorial/La...
    • Tôi không đồng ý rằng mục 2 là quản lý bộ nhớ thủ công
      Rõ ràng các ngôn ngữ managed hiện đại còn thiếu khả năng kiểm soát bố trí bộ nhớ, nhưng ngay cả các ngôn ngữ cấp thấp cũng còn rất xa mới hoàn hảo, và rõ ràng vẫn có cách để tác động đến nó
  • Một điều bài này bỏ sót là async/await rất hợp với garbage collection
    Tôi không thích async/await vì lý do phong cách cá nhân hơi đặc biệt, nhưng sẽ không nói dài
    Tôi đã dùng nó nhiều trong TypeScript/JavaScript và cũng dùng trong Dart, ở đó nó hoạt động đúng như mong đợi
    Tôi cũng đã dùng trong Rust, và theo tôi thì đó là thảm họa. Việc cố nhét kiểu quản lý bộ nhớ cần thiết để dùng async/await trong runtime đa luồng đúng là địa ngục
    https://doc.rust-lang.org/std/pin/index.html

    • Trong mã bất đồng bộ thông thường của Rust, không cần pinning. Cái này dành cho người viết thư viện
  • Có một điểm thường bị bỏ sót trong nhiều bài viết bênh vực garbage collection, và bài này nhìn cũng vậy: bộ nhớ chỉ là một loại tài nguyên mà thôi
    Đặc biệt trong lập trình hệ thống, mã đúng đắn còn phải quản lý cả các tài nguyên bên ngoài như file handle, socket, v.v. Garbage collection chỉ giải quyết phần bộ nhớ của ứng dụng, nên hoàn toàn không giúp gì cho việc xử lý các tài nguyên bên ngoài này. Thậm chí nó còn có thể làm mọi thứ phức tạp hơn nhiều; chỉ cần nhìn xem để triển khai đúng một IDisposable không hề tầm thường trong .NET thì cần những gì là đủ thấy
    Theo kinh nghiệm của tôi, các cách tiếp cận như RAII hay reference counting khiến việc xử lý bộ nhớ và tài nguyên bên ngoài theo một cách thống nhất dễ hơn nhiều, từ đó cũng dễ viết mã đúng và suy luận về nó hơn
    Nói vậy không có nghĩa là tôi phản đối thẳng thừng garbage collection. Như mọi thứ khác, nó là một công cụ có ưu và nhược điểm. Cách tiếp cận RCU “GC thủ công” mà bài viết nói đến khá thú vị cho một số tác vụ nhất định

    • Có một khác biệt lớn giữa bộ nhớ và các tài nguyên khác. Bộ nhớ, cũng như năng lực xử lý, là yếu tố nền tảng của mọi phép tính
      Không phải ngẫu nhiên mà hầu hết các mô hình tính toán lý thuyết đều giả định bộ nhớ vô hạn. Trong một số phần mềm như kernel hệ điều hành hay ứng dụng hard real-time, năng lực xử lý cũng được cấp phát thủ công, nhưng rất ít ngôn ngữ yêu cầu phải cấp phát thủ công năng lực xử lý
      Vì lý do tương tự, quản lý bộ nhớ tự động rất hữu ích trong việc trừu tượng hóa tính toán. Nó không để các chi tiết bộ nhớ của subroutine rò rỉ sang caller; với mức dùng CPU, các chi tiết như vậy cũng chỉ hiếm khi lộ ra
      Mọi phép tính không tầm thường đều bao gồm một lượng xử lý và bộ nhớ không hằng nào đó, nhưng I/O thường diễn ra ở rìa hệ thống. Quản lý I/O tất nhiên cũng rất quan trọng, nhưng xét về khái niệm tính toán và vai trò trung tâm của các trừu tượng tính toán, nó không cốt lõi như xử lý và bộ nhớ
    • Đúng vậy, lập luận về an toàn bộ nhớ cũng áp dụng cho các tài nguyên khác
      Ví dụ, cuối cùng Rust cũng có an toàn I/O, nên các file handle như OwnedFd trên Unix hay các handle như OwnedHandle trên Windows trở thành các object có sở hữu, chứ không phải những số nguyên như số 4
      Nhìn bề ngoài thì có vẻ chỉ là chuyện tránh các lỗi ngớ ngẩn như thực hiện phép toán số học trên handle hoặc dùng nhầm giá trị dành riêng làm sentinel, nhưng nhờ mô hình ownership, ngay cả khi làm những thao tác khó với handle thì quyền sở hữu vẫn rõ ràng, và cũng minh bạch với người bảo trì về sau
    • Chuyển chủ yếu từ C++ sang C#, tôi thích cách quản lý bộ nhớ bằng garbage collection, nhưng không thích việc phải theo dõi file handle, socket, v.v.
      Tôi thực sự cảm nhận rất rõ giá trị của RAII
    • Một điểm thường bị bỏ lỡ khi phê phán các ngôn ngữ garbage-collected là phần lớn trong số chúng có tính năng để quản lý tài nguyên một cách tất định, nhưng nhiều người lại không học
      Có ngôn ngữ có RAII, có ngôn ngữ cung cấp keyword, có ngôn ngữ cung cấp cơ chế quản lý kiểu arena hoặc lambda với quản lý ngầm định. Có ngôn ngữ được type system hỗ trợ một phần, có ngôn ngữ pha trộn mỗi thứ một ít
      Hơn nữa, cũng như lập trình viên hệ thống phải dựa vào static analyzer, static analyzer của những ngôn ngữ đó cũng có thể kiểm tra những thứ bị bỏ sót khi chỉ type system là chưa đủ
    • RAII tất nhiên rất tuyệt, nhưng nếu một ngôn ngữ có garbage collection và cơ chế xử lý exception đúng đắn thì vẫn có thể xử lý tài nguyên một cách an toàn
      Ví dụ, câu lệnh try-with-resources của Java đảm bảo tài nguyên được giải phóng an toàn ngay cả khi xảy ra exception: https://docs.oracle.com/javase/tutorial/essential/exceptions...
      Chỉ với những khối xây dựng cơ bản như vậy cũng có thể tạo ra các hệ thống khá vững chắc và an toàn về tài nguyên