1 điểm bởi GN⁺ 2025-04-24 | 1 bình luận | Chia sẻ qua WhatsApp
  • Ngôn ngữ Go hầu như không có hành vi không được định nghĩa và có ngữ nghĩa GC (garbage collection) đơn giản
  • Trong Go có thể thực hiện quản lý bộ nhớ thủ công, và điều này có thể được thực hiện theo cách phối hợp với GC
  • Arena là một cấu trúc dữ liệu giúp cấp phát hiệu quả bộ nhớ có cùng vòng đời, và bài viết giải thích cách triển khai điều này trong Go
  • Giải thích cách GC quản lý bộ nhớ thông qua thuật toán Mark and Sweep
  • Có thể cải thiện hiệu năng cấp phát bộ nhớ bằng cách dùng Arena, và điều này khả thi thông qua nhiều tối ưu hóa khác nhau
  • Cố gắng tăng hiệu năng và giảm tối đa gánh nặng cho GC thông qua loại bỏ write barrier, tái sử dụng bộ nhớ, chunk pooling v.v.
  • Đưa ra các mẫu an toàn và nhanh khi xử lý bộ nhớ quy mô lớn trong thực tế thông qua các tính năng như triển khai Realloc, tái sử dụng Arena và khởi tạo lại (Reset)

Tổng quan về cấp phát bộ nhớ thủ công dựa trên Arena trong Go

  • Go là một ngôn ngữ an toàn nhờ hành vi GC rõ rànggần như không có Undefined Behavior
  • Có thể tận dụng gói unsafe để điều khiển trực tiếp bộ nhớ theo cách phù hợp với cách GC được triển khai bên trong
  • Bài viết này giải thích cách tạo một trình cấp phát bộ nhớ dựa trên cấu trúc Arena có thể phối hợp với GC trong Go

Định nghĩa và sự cần thiết của Arena

  • Arena là một cấu trúc để cấp phát hiệu quả các đối tượng có cùng vòng đời
  • Nếu append thông thường mở rộng mảng theo cấp số nhân, thì Arena thêm block mới và cung cấp con trỏ
  • Giao diện tiêu chuẩn như sau:
    • Alloc(size, align uintptr) unsafe.Pointer

Con trỏ và cách GC hoạt động

  • GC hoạt động theo cách đánh dấu (mark) và thu hồi (sweep) bộ nhớ
  • Để có GC chính xác, nó sử dụng siêu dữ liệu gọi là pointer bits để cho biết vị trí của con trỏ
  • Nếu xử lý con trỏ sai trong Arena, GC có thể không theo dõi được con trỏ, dẫn tới khả năng xảy ra lỗi Use-After-Free

Cách thiết kế Arena

  • Cấu trúc Arena có các trường sau:
    • next, left, cap, chunks
  • Mọi cấp phát đều được xử lý với căn chỉnh 8 byte, và nếu không đủ thì tạo chunk mới bằng nextPow2
  • Chunk được cấp phát dưới dạng kiểu struct { A [N]uintptr; P *Arena } thay vì []uintptr, để GC có thể theo dõi Arena

Cách đảm bảo an toàn con trỏ cho Arena

  • Khi chỉ sử dụng các con trỏ được cấp phát bên trong Arena, GC sẽ giữ toàn bộ Arena tồn tại
  • Thiết lập để con trỏ tham chiếu tới Arena, qua đó đảm bảo toàn bộ Arena sống sót dưới GC
  • Phương thức cấp phát của Arena thực hiện như sau:
    • lưu con trỏ Arena ở cuối chunk trong allocChunk()

Kết quả benchmark hiệu năng

  • So với new mặc định, cấp phát bằng Arena cho thấy cải thiện hiệu năng trung bình hơn 2~4 lần
  • Ngay cả trong tình huống tải GC lớn, cách làm bằng Arena vẫn cho hiệu năng tốt hơn tới hơn 2 lần
  • Các tối ưu như loại bỏ write barrier, tận dụng uintptr v.v. mang lại cải thiện hiệu năng tới 20% ở các cấp phát nhỏ

Chiến lược tái sử dụng chunk và loại bỏ Heap

  • Có thể tái sử dụng chunk bằng sync.Pool
  • Thông qua runtime.SetFinalizer(), khi Arena biến mất thì chunk được trả lại pool
  • Hiệu năng cải thiện rất nhiều ở các cấp phát nhỏ, nhưng ở cấp phát lớn có thể chậm hơn new

Chức năng khởi tạo lại và tái sử dụng Arena

  • Có thể đưa Arena trở về trạng thái ban đầu bằng phương thức Reset()
  • Mức độ rủi ro cao, nhưng có thể tái sử dụng cùng cấu trúc mà không cần cấp phát lại bộ nhớ
  • Ngay cả khi tái sử dụng, việc tái sử dụng chunk cũng giúp tăng hiệu năng đáng kể

Triển khai chức năng Realloc

  • Triển khai chức năng realloc trong Arena để mở rộng động cho lần cấp phát gần nhất
  • Nếu không thể, sẽ cấp phát bộ nhớ mới rồi sao chép dữ liệu

Kết luận và cung cấp toàn bộ mã

  • Bằng việc hiểu sâu cơ chế GC của Go và dựa trên cách triển khai nội bộ, bài viết hoàn thiện một bộ quản lý bộ nhớ dựa trên Arena
  • Đây là một cấu trúc vừa an toàn vừa hiệu năng cao, và nếu dùng đúng cách thì rất hữu ích khi xử lý các cấu trúc dữ liệu quy mô lớn
  • Mã triển khai đầy đủ bao gồm struct Arena và các thành phần New, Alloc, Reset, allocChunk, finalize v.v.

1 bình luận

 
GN⁺ 2025-04-24
Ý kiến trên Hacker News
  • Bài này đọc khá thú vị

    • Nếu bạn thích bài này hoặc muốn kiểm soát việc cấp phát bộ nhớ trong Go tốt hơn, hãy xem qua package tôi viết
    • Tôi rất mong nhận được phản hồi hoặc có người dùng thử
    • Package này tự cấp phát bộ nhớ riêng, tách biệt khỏi runtime, nên bỏ qua GC hoàn toàn
    • Khi cấp phát, nó không cho phép kiểu con trỏ, nhưng thay vào đó dùng kiểu Reference[T] cung cấp cùng chức năng
    • Việc giải phóng bộ nhớ được thực hiện thủ công, nên không thể dựa vào garbage collector
    • Trong Go, các custom allocator như thế này thường hướng tới arena, hỗ trợ các nhóm cấp phát được tạo và hủy cùng nhau
    • Tuy nhiên, package offheap nhắm đến việc xây dựng các cấu trúc dữ liệu lớn tồn tại lâu dài để đưa chi phí garbage collection về 0
    • Ví dụ như cache trong bộ nhớ quy mô lớn hoặc cơ sở dữ liệu
  • Gần đây khi tinh chỉnh hiệu năng trong Go, tôi cũng đã dùng một thiết kế arena rất giống như vậy để đẩy hiệu năng lên mức tối đa

    • Tôi dùng byte slice làm buffer và chunk thay cho con trỏ unsafe
    • Tôi đã thử như vậy, nhưng nó không nhanh hơn mà còn phức tạp hơn nhiều
    • Tôi cần kiểm tra lại trước khi dám chắc 100%
  • Một vài điểm có thể cải thiện dễ dàng

    • Nếu bắt đầu bằng slice nhỏ và có lúc thêm một lượng lớn payload, hãy tự viết hàm append để tăng cap mạnh tay hơn trước khi gọi append tích hợp sẵn
    • unsafe.String hữu ích để truyền chuỗi từ byte slice mà không cần cấp phát
    • Cần đọc kỹ các cảnh báo và hiểu rõ mình đang làm gì
  • Hơi ngoài chủ đề, nhưng tôi thích minimap ở bên cạnh

    • Nó hữu ích khi đọc lướt qua các bài kỹ thuật dài hoặc quay lại tham chiếu phần đã đọc trước đó
    • Tôi tự hỏi làm sao có thể thêm nó vào trang của mình
    • Thật sự rất hay
  • Tóm tắt cho những ai ngại đọc bài dài

    • OP đã dùng unsafe trong Go để xây dựng một arena allocator nhằm tăng tốc công việc cấp phát
    • Nó đặc biệt hữu ích khi cấp phát nhiều thứ được tạo ra và hủy đi cùng nhau
    • Vấn đề chính là GC của Go cần biết bố cục của dữ liệu, đặc biệt là vị trí các con trỏ, để hoạt động đúng
    • Nếu cấp phát byte thô bằng unsafe.Pointer, GC sẽ không nhìn thấy đúng những gì được trỏ tới trong arena và có thể giải phóng nhầm
    • Tuy nhiên, để nó vẫn hoạt động miễn là các con trỏ trỏ tới thứ khác trong cùng arena, tác giả giữ toàn bộ arena còn sống nếu vẫn còn tham chiếu đến một phần của nó
    • Bằng cách (1) giữ một slice (chunk) trỏ đến mọi khối bộ nhớ lớn mà arena lấy từ hệ thống
    • Và (2) dùng reflect.StructOf để tạo một kiểu mới có thêm các trường con trỏ vào những khối này
    • Vì vậy, khi GC tìm thấy con trỏ tới các chunk, nó cũng sẽ tìm thấy các back pointer, từ đó đánh dấu arena là còn sống và giữ lại slice chunk
    • Sau đó bài viết trình bày một số kỹ thuật tối ưu hóa thú vị để loại bỏ nhiều kiểm tra nội bộ và write barrier
  • Liên quan: thảo luận về việc thêm "vùng nhớ" vào thư viện chuẩn

    • Đề xuất arena trước đây
  • Nội dung khá thú vị

    • Tôi tò mò không biết những người xây dựng allocator kiểu off-heap hoặc arena trong Go thường kiểm thử hay benchmark thế nào về độ an toàn bộ nhớ và tương tác với GC
  • Go ưu tiên việc không làm vỡ hệ sinh thái

    • Điều này khiến người ta có thể giả định rằng luật Hyrum sẽ bảo vệ một số hành vi quan sát được cụ thể của runtime
    • Nếu lập luận đó đúng, thì Go với tư cách một ngôn ngữ là ngõ cụt về mặt tiến hóa
    • Trong trường hợp đó, tôi không chắc Go còn thú vị hay không
  • Ghi chú meta ngắn

    • Bài này thực sự quá dài nên tôi không có thời gian đọc hết chi tiết phần nền tảng
    • Ví dụ, mục "Mark and Sweep" chiếm hơn 4 trang trên màn hình laptop của tôi
    • Mục đó bắt đầu sau hơn 5 trang kể từ đầu bài viết
    • Tôi tự hỏi có phải AI đã hỗ trợ viết các mục này nên chúng trở nên quá bao quát hay không
    • Việc tạo nội dung thì dễ, nhưng lại không có những quyết định biên tập để giữ lại phần quan trọng
    • Tôi chỉ muốn biết về phần arena allocator, không cần một bài hướng dẫn về garbage collection