10 điểm bởi GN⁺ 2025-11-19 | 1 bình luận | Chia sẻ qua WhatsApp
  • safe_c.h là một tệp header tùy chỉnh dài 600 dòng, bổ sung các tính năng an toàn và tiện dụng của C++ và Rust cho ngôn ngữ C, và được dùng để triển khai grep an toàn luồng (cgrep) không rò rỉ bộ nhớ
  • Thông qua RAII, smart pointer, thuộc tính dọn dẹp tự động (cleanup), việc quản lý tài nguyên được tự động hóa mà không cần gọi free() thủ công
  • Với vector, view, kiểu Result, macro hợp đồng, có thể xử lý an toàn tràn bộ đệm, xử lý lỗi và kiểm tra điều kiện tiên quyết
  • Với tự động mở khóa mutex, macro sinh luồng, tối ưu dự đoán nhánh, vẫn đảm bảo an toàn trong khi duy trì tính đồng thời và hiệu năng
  • Kết quả cho thấy có thể viết mã C không rò rỉ, không segfault với cùng mức hiệu năng (cấp độ -O2)

Tổng quan về safe_c.h

  • safe_c.h là một tệp header mang các tính năng của C++ và Rust sang mã C
    • Ngay cả với các trình biên dịch không hỗ trợ thuộc tính C23 [[cleanup]] (GCC 11, Clang 18, v.v.), nó vẫn cung cấp hành vi RAII (dọn dẹp tự động) tương đương
    • Macro CLEANUP(func) tự động giải phóng tài nguyên khi hàm kết thúc
    • Macro LIKELY()UNLIKELY() dùng để tối ưu dự đoán nhánh trên hot path

Quản lý bộ nhớ: UniquePtr và SharedPtr

  • UniquePtr là smart pointer sở hữu đơn, tự động gọi free() khi ra khỏi phạm vi
    • Khi khai báo bằng macro AUTO_UNIQUE_PTR(), bộ nhớ sẽ được giải phóng tự động ngay cả khi xảy ra lỗi hoặc return sớm
  • SharedPtr là cấu trúc đếm tham chiếu tự động, tự động hủy tài nguyên khi tham chiếu cuối cùng được giải phóng
    • shared_ptr_init()shared_ptr_copy() tự động xử lý việc tăng/giảm tham chiếu
    • Được dùng để quản lý các struct chia sẻ an toàn giữa các luồng

Ngăn tràn bộ đệm: Vector và View

  • Macro DEFINE_VECTOR_TYPE() tạo vector tự mở rộng, an toàn kiểu
    • Tự động xử lý cấp phát lại, quản lý dung lượng và dọn dẹp (cleanup)
    • Khi khai báo bằng AUTO_TYPED_VECTOR(), vector sẽ tự động được giải phóng khi ra khỏi phạm vi
  • StringViewSpan là các struct tham chiếu không sở hữu, xử lý lát cắt chuỗi và mảng mà không cần malloc riêng
    • DEFINE_SPAN_TYPE() định nghĩa Span theo từng kiểu
    • Có kèm kiểm tra biên để bảo đảm truy cập mảng an toàn

Xử lý lỗi: kiểu Result và RAII

  • Struct Resultkiểu trả về phân biệt thành công/thất bại tương tự Result<T, E> của Rust
    • DEFINE_RESULT_TYPE() tạo struct kết quả theo từng kiểu
    • RESULT_IS_OK()RESULT_UNWRAP_ERROR() giúp xử lý lỗi rõ ràng
  • Kết hợp với thuộc tính CLEANUP để tự động giải phóng tài nguyên khi hàm kết thúc
    • Macro AUTO_MEMORY() tự động dọn dẹp vùng nhớ được malloc

Hợp đồng và chuỗi an toàn

  • Macro requires() / ensures() dùng để khai báo điều kiện trước/sau của hàm
    • Khi thất bại sẽ in ra thông báo lỗi rõ ràng
  • safe_strcpy()hàm sao chép có kiểm tra kích thước bộ đệm, giúp ngăn tràn bộ đệm
    • Khi thất bại trả về false để xử lý lỗi an toàn

Đồng thời: tự động mở khóa và macro luồng

  • Hàm tự động mở khóa mutex dựa trên CLEANUP giúp ngăn deadlock
    • Khi ra khỏi phạm vi sẽ tự động gọi pthread_mutex_unlock()
  • Macro SPAWN_THREAD()JOIN_THREAD() giúp đơn giản hóa việc tạo và join luồng
    • Được dùng để triển khai thread pool xử lý tệp của cgrep

Tối ưu hiệu năng

  • Macro LIKELY() / UNLIKELY() cung cấp dự đoán nhánh trên hot path
    • Đạt hiệu quả tối ưu ở mức PGO ngay cả trong bản build -O2
  • Dù bổ sung các tính năng an toàn, không có suy giảm hiệu năng

Kết luận

  • cgrep sử dụng safe_c.h có 2.300 dòng mã C, đã loại bỏ hơn 50 lần gọi free() thủ công
  • Vẫn giữ nguyên assembly và tốc độ thực thi, đồng thời hiện thực hóa mã C an toàn không rò rỉ bộ nhớ và không segfault
  • Đây là một ví dụ kết hợp tính đơn giản và sự tự do của C với tính an toàn hiện đại
  • Tác giả cho biết trong bài viết tiếp theo sẽ đề cập vì sao cgrep nhanh hơn ripgrep hơn 2 lần và dùng ít bộ nhớ hơn 20 lần
  • safe_c.h được đánh giá là phù hợp cho dự án mới, nhưng cũng có nhắc tới khả năng làm tăng độ khó khi debug vì dựa trên macro
  • Độ chính xác và an toàn đã được kiểm chứng bằng nhiều bộ phân tích tĩnh khác nhau (GCC analyzer, ASAN, UBSAN, Clang-tidy, v.v.)

1 bình luận

 
GN⁺ 2025-11-19
Bình luận trên Hacker News
  • Bài viết này cho thấy vấn đề chi phí phát sinh khi triển khai safe abstraction trong C
    Việc triển khai shared pointer dùng POSIX mutex nên (1) không độc lập nền tảng và (2) ngay cả trong môi trường đơn luồng vẫn phải trả chi phí mutex
    Tức là đây không phải là ‘zero-cost abstraction’
    shared_ptr của C++ cũng có cùng vấn đề này, nhưng Rust giải quyết bằng cách tách thành hai kiểu RcArc

    • shared_ptr của C++ không dùng mutex mà dùng atomic operation
      Nó tương tự Arc của Rust, còn cách triển khai trong blog chỉ đơn giản là kém hiệu quả
      Tuy vậy, trong C++ không có kiểu tương ứng với Rc, nên khi muốn một con trỏ đếm tham chiếu đơn giản thì vẫn phải chịu chi phí
    • Trong môi trường glibc và libstdc++, nếu không liên kết với pthreads thì shared_ptr sẽ không thread-safe
      Nó tìm symbol của pthread lúc runtime để chọn nhánh atomic hoặc non-atomic
      Tôi nghĩ thà luôn dùng atomic còn hơn
    • Tôi cảm thấy việc làm cho code không bị crash quan trọng hơn nhiều
      Tính cross-platform trong đa số trường hợp chỉ ở mức ‘có thì tốt’
      Chi phí mutex thì khó chịu thật, nhưng trên CPU hiện đại vẫn ở mức chấp nhận được
      Tôi biết Rust rất xuất sắc, nhưng hệ sinh thái C quá đồ sộ nên rất khó thay thế hoàn toàn
    • Cũng có thể triển khai bộ đếm tham chiếu bằng C11 atomic operation thay vì mutex
      Trong trường hợp đó tôi không rõ mutex mang lại lợi ích gì
    • POSIX mutex đã được triển khai trên nhiều nền tảng, nên tôi lại nghĩ đây là API phổ quát hơn
  • Có một dự án biến C thành an toàn bộ nhớ bằng garbage collector tên là FUGC do Fil (aka pizlonator) tạo ra
    Nó có thể áp dụng vào code hiện có gần như không cần sửa đổi, và biến C/C++ thành ngôn ngữ an toàn bộ nhớ
    Xem bài HN liên quantrang chính thức

    • Nhờ vậy tôi mới biết đến dự án này lần đầu. Tôi nghĩ đây là một thử nghiệm thật sự ấn tượng
    • Nhưng tôi không muốn đánh đổi bằng suy giảm hiệu năng của garbage collector
  • Bài này có vẻ diễn đạt hơi sai trọng tâm của an toàn bộ nhớ
    Chỉ tự động giải phóng biến cục bộ hay kiểm tra biên là chưa đủ
    Vấn đề thật sự là quản lý vòng đời bộ nhớ của toàn bộ chương trình
    Ví dụ như khi trả về UniquePtr hoặc sao chép SharedPtr có quên tăng bộ đếm tham chiếu không, hay vòng đời phần tử trong intrusive list do ai quản lý
    Cuối cùng tôi cảm thấy cách tiếp cận của bài này không khác nhiều so với kiểu #define xfree(p) ngày xưa

    • UniquePtr thì khả thi vì có thể trả về struct theo giá trị
      Nhưng việc sao chép SharedPtr không tự động xử lý tăng bộ đếm tham chiếu
    • Tôi tò mò vì sao mẫu #define xfree(p) lại bị xem là tệ
  • Có nói rằng C23 đã đưa vào thuộc tính [[cleanup]], nhưng thực tế đây là tiện ích mở rộng của GCC và phải viết là [[gnu::cleanup()]]
    Xem mã ví dụ

    • Khó tìm được thông tin liên quan, nhưng rốt cuộc có vẻ chỉ đổi cú pháp còn bản thân tính năng vẫn là extension
  • Từng có câu đùa kiểu “C++: hãy nhìn xem các ngôn ngữ khác phải vất vả thế nào chỉ để bắt chước một phần sức mạnh của ta”
    Tôi không rõ vì sao lại muốn bắt chước C++ bằng macro trong C, nhưng dù sao đây vẫn là một thử nghiệm thú vị

    • Tôi thấy quá trình tạo ra C an toàn hơn mà không nhồi nhét toàn bộ tính năng của C++ khá thú vị
      Tuy nhiên, khi cuối cùng vẫn mô phỏng tới cả các tính năng của C++17 thì có lẽ dùng luôn C++ sẽ hợp lý hơn
    • Tôi muốn một ngôn ngữ có thể parse được
      C vẫn tương đối dễ xử lý, còn C++ thì quá phức tạp nên khó tiếp cận nếu không có frontend
    • C đơn giản nên là ngôn ngữ thuận tiện để hack
      Khi chuyển sang C++ thì build chain, name mangling, phụ thuộc libstdc++ v.v. khiến mọi thứ phức tạp hơn
    • Dự án này có thể chỉ cho phép một phần tính năng của C++ để ép buộc cú pháp bị giới hạn
      Trong khi đó, nếu dùng C++ theo phong cách C thì không có ràng buộc như vậy
    • Việc các vendor CPU nhúng không cung cấp compiler C++ cũng là một ràng buộc thực tế
  • Cách này không tương thích với xử lý ngoại lệ dựa trên setjmp/longjmp
    Thay vào đó có thể tích hợp bằng cặp macro cleanup lấy cảm hứng từ pthread_cleanup_push của POSIX
    Dùng cleanup_push(fn, type, ptr, init)cleanup_pop(ptr) để triển khai routine dọn dẹp theo kiểu stack
    Cách này có ưu điểm là bắt được lỗi mất cân bằng ngay tại compile time

  • Không nên nhầm nó với safec.h thật sự của safeclib
    Xem header của safeclib

    • Tôi tò mò vì sao người ta còn muốn bảo trì triển khai Annex K
      Nó bị đánh giá là thiết kế thất bại do constraint handler toàn cục, và hầu hết toolchain cũng không hỗ trợ
      Xem tài liệu liên quan
  • Nếu dùng ngôn ngữ Nim thì có thể có được toàn bộ những gì safe_c.h cung cấp
    Nim biên dịch xuống C và đồng thời mang lại độ an toàn lẫn hiệu năng
    Nó mặc định cung cấp nhiều tính năng như automatic reference counting dựa trên ARC, defer, Option[T], bounds-checking, likely/unlikely v.v.
    Xem trang chính thức, giới thiệu ARC, view types, tài liệu Option, template likely

  • Nếu cách tiếp cận này nhắm đến tính di động, thì thực tế bám vào C99 sẽ an toàn hơn
    Compiler C của MSVC khá khó chịu, nhưng gần như là bắt buộc nếu muốn cross-platform
    Tôi cũng từng làm một header tương tự, nhưng vì vấn đề portability nên không đưa tiện ích cleanup vào

    • Nếu dùng macro để sinh code C++ (dựa trên destructor) thì vẫn có thể làm được mà không cần thuộc tính cleanup
      Nếu code C cũng biên dịch được bằng C++ thì nó sẽ hoạt động tốt
    • Ngay cả trên Windows vẫn có thể phát triển đầy đủ với MSYS2 + GCC
      Nó còn đi kèm cả package manager
    • Nhân tiện thì MSVC hiện đã hỗ trợ C17
  • Bài viết nhiều lần nhắc tới cgrep nhưng không có link đến mã nguồn
    Có rất nhiều dự án cùng tên trên GitHub, nhưng phần lớn lại được viết bằng ngôn ngữ khác

    • Tôi cũng không biết đang nói đến cgrep nào, và muốn tự mình thử dùng nó