- 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() và 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() và 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
- StringView và Span 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 Result là kiể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() và 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() là 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() và 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
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_ptrcủ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ểuRcvàArcshared_ptrcủa C++ không dùng mutex mà dùng atomic operationNó tương tự
Arccủ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íshared_ptrsẽ không thread-safeNó 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í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
Trong trường hợp đó tôi không rõ mutex mang lại lợi ích gì
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 quan và trang chính thức
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ề
UniquePtrhoặc sao chépSharedPtrcó 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ưaUniquePtrthì khả thi vì có thể trả về struct theo giá trịNhưng việc sao chép
SharedPtrkhông tự động xử lý tăng bộ đếm tham chiế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ụ
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ị
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
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
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ơnTrong khi đó, nếu dùng C++ theo phong cách C thì không có ràng buộc như vậy
Cách này không tương thích với xử lý ngoại lệ dựa trên
setjmp/longjmpThay vào đó có thể tích hợp bằng cặp macro cleanup lấy cảm hứng từ
pthread_cleanup_pushcủa POSIXDùng
cleanup_push(fn, type, ptr, init)vàcleanup_pop(ptr)để triển khai routine dọn dẹp theo kiểu stackCá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.hthật sự của safeclibXem header của safeclib
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.hcung cấpNim 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/unlikelyv.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 code C cũng biên dịch được bằng C++ thì nó sẽ hoạt động tốt
Nó còn đi kèm cả package manager
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