Garbage Collection dành cho lập trình viên hệ thống (2023)
(bitbashing.io)- 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
deletephiê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-epochcủ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à
deletephiê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()và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óngoldvà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-epochvà 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_ptrhoặcArc - 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_ptrvàArckhô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
nchu 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
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
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
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ầnmallockế tiếp, mà chỉ cho phép theo chu kỳKhi cấp phát 800 byte rồi lập tức
freelặ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à 6276762767 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
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ì
freekhô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Đ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
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ý
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
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ể
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ệ
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
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
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
Đố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/Arclà đ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 collectionTrong 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
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
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
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ý
(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.
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...
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
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
IDisposablekhông hề tầm thường trong .NET thì cần những gì là đủ thấyTheo 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
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ớ
Ví dụ, cuối cùng Rust cũng có an toàn I/O, nên các file handle như
OwnedFdtrên Unix hay các handle nhưOwnedHandletrên Windows trở thành các object có sở hữu, chứ không phải những số nguyên như số 4Nhì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
Tôi thực sự cảm nhận rất rõ giá trị của RAII
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 đủ
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