- safe_c.h คือไฟล์เฮดเดอร์แบบกำหนดเองขนาด 600 บรรทัดที่เพิ่ม ความปลอดภัยและฟีเจอร์อำนวยความสะดวกแบบ C++ และ Rust ให้กับภาษา C และถูกนำไปใช้สร้าง grep แบบ thread-safe (cgrep) ที่ไม่มี memory leak
- ใช้ RAII, smart pointer, และแอตทริบิวต์ cleanup อัตโนมัติ เพื่อทำให้การจัดการทรัพยากรเป็นอัตโนมัติโดยไม่ต้องเรียก
free() เอง
- มี vector, view, ประเภท Result, และ contract macro เพื่อจัดการ buffer overflow, การจัดการข้อผิดพลาด, และการตรวจสอบ precondition ได้อย่างปลอดภัย
- มี การปลดล็อก mutex อัตโนมัติ, macro สำหรับ spawn thread, และการปรับแต่ง branch prediction เพื่อรักษาทั้ง concurrency และประสิทธิภาพพร้อมเพิ่มความปลอดภัย
- ผลลัพธ์คือพิสูจน์ว่า สามารถเขียน โค้ด C ที่ไม่มี leak และ segfault ได้โดยยังคงประสิทธิภาพระดับเดียวกัน (
-O2)
ภาพรวมของ safe_c.h
- safe_c.h คือไฟล์เฮดเดอร์ที่ ย้ายฟีเจอร์ของ C++ และ Rust มาสู่โค้ด C
- ให้พฤติกรรม RAII (การ cleanup อัตโนมัติ) แบบเดียวกันได้ แม้บนคอมไพเลอร์ที่ยังไม่รองรับแอตทริบิวต์
[[cleanup]] ของ C23 (เช่น GCC 11, Clang 18)
- ใช้ macro
CLEANUP(func) เพื่อปล่อยทรัพยากรโดยอัตโนมัติเมื่อออกจากฟังก์ชัน
- ใช้ macro
LIKELY() และ UNLIKELY() สำหรับ ปรับ branch prediction ของ hot path
การจัดการหน่วยความจำ: UniquePtr และ SharedPtr
- UniquePtr คือ smart pointer แบบมีเจ้าของเพียงรายเดียว ซึ่งจะเรียก
free() อัตโนมัติเมื่อออกจากสโคป
- เมื่อประกาศด้วย macro
AUTO_UNIQUE_PTR() หน่วยความจำจะถูกปล่อยอัตโนมัติแม้เกิดข้อผิดพลาดหรือมีการ return ก่อนกำหนด
- SharedPtr คือโครงสร้างที่ทำ reference counting อัตโนมัติ และจะทำลายทรัพยากรเมื่อ reference สุดท้ายถูกปล่อย
- ใช้
shared_ptr_init() และ shared_ptr_copy() เพื่อจัดการการเพิ่ม-ลด reference โดยอัตโนมัติ
- ใช้สำหรับจัดการ shared struct ระหว่าง thread อย่างปลอดภัย
ป้องกัน buffer overflow: Vector และ View
- ใช้ macro
DEFINE_VECTOR_TYPE() เพื่อสร้าง vector แบบขยายอัตโนมัติที่ปลอดภัยต่อชนิดข้อมูล
- จัดการการ reallocate, ความจุ, และการ cleanup โดยอัตโนมัติ
- เมื่อประกาศด้วย
AUTO_TYPED_VECTOR() จะถูกปล่อยอัตโนมัติเมื่อออกจากสโคป
- StringView และ Span คือ โครงสร้างอ้างอิงแบบไม่เป็นเจ้าของข้อมูล สำหรับจัดการ slice ของสตริงและอาร์เรย์โดยไม่ต้อง
malloc เพิ่ม
- ใช้
DEFINE_SPAN_TYPE() เพื่อกำหนด Span สำหรับแต่ละชนิดข้อมูล
- มีการตรวจสอบขอบเขตเพื่อรับประกันการเข้าถึงอาร์เรย์อย่างปลอดภัย
การจัดการข้อผิดพลาด: ประเภท Result และ RAII
- โครงสร้าง Result เป็น ประเภทค่าที่แยกผลสำเร็จ/ล้มเหลว คล้าย
Result<T, E> ของ Rust
- ใช้
DEFINE_RESULT_TYPE() เพื่อสร้างโครงสร้างผลลัพธ์สำหรับแต่ละชนิดข้อมูล
- ใช้
RESULT_IS_OK() และ RESULT_UNWRAP_ERROR() เพื่อจัดการข้อผิดพลาดได้อย่างชัดเจน
- เมื่อนำมารวมกับแอตทริบิวต์
CLEANUP ก็จะปล่อยทรัพยากรโดยอัตโนมัติเมื่อฟังก์ชันจบการทำงาน
- ใช้ macro
AUTO_MEMORY() เพื่อ cleanup หน่วยความจำที่ได้จาก malloc โดยอัตโนมัติ
Contract และสตริงแบบปลอดภัย
- ใช้ macro
requires() / ensures() เพื่อระบุ เงื่อนไขก่อนและหลังของฟังก์ชัน
- หากล้มเหลวจะพิมพ์ข้อความผิดพลาดที่ชัดเจน
safe_strcpy() คือ ฟังก์ชันคัดลอกที่มีการตรวจสอบขนาดบัฟเฟอร์ เพื่อป้องกัน overflow
- หากล้มเหลวจะคืนค่า false เพื่อให้จัดการข้อผิดพลาดได้อย่างปลอดภัย
Concurrency: การปลดล็อกอัตโนมัติและ macro สำหรับ thread
- ใช้ฟังก์ชัน ปลดล็อก mutex อัตโนมัติ ที่อิงกับ
CLEANUP เพื่อป้องกัน deadlock
- เมื่อออกจากสโคปจะเรียก
pthread_mutex_unlock() โดยอัตโนมัติ
- ใช้ macro
SPAWN_THREAD() และ JOIN_THREAD() เพื่อ ทำให้การสร้างและ join thread ง่ายขึ้น
- ถูกนำไปใช้ใน thread pool สำหรับประมวลผลไฟล์ของ cgrep
การปรับแต่งประสิทธิภาพ
- ใช้ macro
LIKELY() / UNLIKELY() เพื่อ ทำ branch prediction สำหรับ hot path
- ช่วยให้ได้ผลของการปรับแต่งระดับใกล้เคียง PGO แม้ในบิลด์
-O2
- แม้จะเพิ่มฟีเจอร์ด้านความปลอดภัย ก็ยัง ไม่มีประสิทธิภาพลดลง
บทสรุป
- cgrep ที่ใช้ safe_c.h เป็น โค้ด C ขนาด 2,300 บรรทัด และตัดการเรียก
free() แบบเขียนเองออกไปได้มากกว่า 50 จุด
- สามารถสร้าง โค้ด C ที่ปลอดจาก memory leak และ segfault ได้ โดยยังคง assembly และความเร็วในการทำงานเท่าเดิม
- เป็นตัวอย่างของการผสาน ความปลอดภัยสมัยใหม่ เข้ากับความเรียบง่ายและอิสระของ C
- ผู้เขียนระบุว่าจะอธิบายในบทความถัดไปว่าเหตุใด cgrep จึง เร็วกว่า ripgrep มากกว่า 2 เท่า และใช้หน่วยความจำน้อยกว่า 20 เท่า
- safe_c.h ถูกมองว่า เหมาะกับโปรเจกต์ใหม่ แต่ก็มีการกล่าวถึงความเป็นไปได้ที่ macro-based design จะทำให้การดีบักยากขึ้น
- มีการตรวจสอบ ความถูกต้องและความปลอดภัย ด้วย static analyzer หลายชนิด (เช่น GCC analyzer, ASAN, UBSAN, Clang-tidy เป็นต้น)
1 ความคิดเห็น
ความเห็นจาก Hacker News
บทความนี้แสดงให้เห็นปัญหาเรื่องต้นทุนที่เกิดขึ้นเมื่อทำ safe abstraction ในภาษา C
การ implement shared pointer ใช้ POSIX mutex ทำให้ (1) ไม่เป็นอิสระจากแพลตฟอร์ม และ (2) ต้องจ่าย overhead ของ mutex แม้ในกรณี single-thread
กล่าวคือ มันไม่ใช่ ‘zero-cost abstraction’
shared_ptrของ C++ ก็มีปัญหาแบบเดียวกัน แต่ Rust แก้โดยแยกเป็นสองชนิดคือ Rc และ Arcshared_ptrของ C++ ไม่ได้ใช้ mutex แต่ใช้ atomic operationคล้ายกับ Arc ของ Rust และ implementation ในบล็อกนี้ก็แค่ไม่มีประสิทธิภาพเฉยๆ
อย่างไรก็ตาม C++ ไม่มีชนิดที่เทียบเท่ากับ Rc ดังนั้นถ้าต้องการ pointer แบบ reference counting ธรรมดา ก็ยังคงมีต้นทุนอยู่
shared_ptrจะ ไม่ thread-safeมันจะค้นหา pthread symbol ตอนรันไทม์เพื่อเลือกเส้นทาง atomic หรือ non-atomic
ฉันคิดว่าการใช้ atomic ตลอดไปน่าจะดีกว่า
ความเป็น cross-platform ส่วนใหญ่เป็นแค่ของดีถ้ามีก็เท่านั้น
overhead ของ mutex น่ารำคาญก็จริง แต่บน CPU สมัยใหม่ก็ยังพอรับได้
รู้ว่า Rust ยอดเยี่ยม แต่ ecosystem ของ C ใหญ่มากจนยากจะมาแทนที่ทั้งหมด
ในกรณีนี้ฉันไม่ค่อยเข้าใจว่าข้อดีของ mutex คืออะไร
มีโปรเจกต์ทำให้ C ปลอดภัยด้านหน่วยความจำด้วย garbage collector ชื่อ FUGC ที่สร้างโดย Fil (aka pizlonator)
สามารถนำไปใช้กับโค้ดเดิมได้แทบไม่ต้องแก้อะไร และเปลี่ยน C/C++ ให้เป็น ภาษา memory-safe
ดู โพสต์ HN ที่เกี่ยวข้อง และ เว็บไซต์ทางการ
ดูเหมือนว่าบทความนี้จะอธิบายแก่นของ memory safety คลาดเคลื่อนไปเล็กน้อย
การคืนหน่วยความจำอัตโนมัติของตัวแปรภายในฟังก์ชันหรือการตรวจขอบเขตเพียงอย่างเดียวไม่เพียงพอ
ปัญหาจริงคือ การจัดการอายุการใช้งานของหน่วยความจำ ทั้งโปรแกรม
เช่น ตอนคืนค่า UniquePtr หรือคัดลอก SharedPtr จะลืมเพิ่ม reference count หรือไม่ ใครเป็นคนจัดการอายุของสมาชิกใน intrusive list เป็นต้น
สุดท้ายแล้วแนวทางในบทความนี้ก็ให้ความรู้สึกว่าไม่ได้ต่างจากแพตเทิร์น
#define xfree(p)ในอดีตมากนักแต่การคัดลอก SharedPtr ไม่ได้จัดการการเพิ่ม reference count ให้อัตโนมัติ
#define xfree(p)ถึงถือว่าไม่ดีแม้จะบอกว่า C23 เพิ่มแอตทริบิวต์ [[cleanup]] เข้ามา แต่ในความเป็นจริงมันคือ ส่วนขยายของ GCC และต้องเขียนเป็น
[[gnu::cleanup()]]ดู โค้ดตัวอย่าง
มีมุกว่า “C++: ดูสิว่าภาษาอื่นต้องลำบากแค่ไหนเพื่อเลียนแบบพลังของฉันเพียงบางส่วน”
ฉันสงสัยว่าทำไมถึงต้องใช้แมโครมาเลียนแบบ C++ แต่ยังไงก็เป็นความพยายามที่น่าสนใจ
แต่สุดท้ายพอเห็นว่าไปเลียนแบบถึงฟีเจอร์ระดับ C++17 ก็อดคิดไม่ได้ว่าใช้ C++ ไปเลยน่าจะดีกว่าไหม
C ยังจัดการได้ง่ายอยู่ แต่ C++ ซับซ้อนเกินไปจนยากจะเข้าถึงได้โดยไม่มี frontend
พอไป C++ ก็ซับซ้อนขึ้นจาก build chain, name mangling, การพึ่งพา libstdc++ เป็นต้น
ในทางกลับกัน ถ้าใช้ C++ แบบเขียนสไตล์ C ก็ไม่มีข้อจำกัดแบบนั้น
มันไม่เข้ากันกับ exception handling ที่อิง setjmp/longjmp
แต่สามารถผสานด้วย คู่แมโคร cleanup ที่ได้แรงบันดาลใจจาก
pthread_cleanup_pushของ POSIX แทนใช้
cleanup_push(fn, type, ptr, init)และcleanup_pop(ptr)เพื่อทำ cleanup routine แบบ stack-basedวิธีนี้มี ข้อดีที่จับข้อผิดพลาดเรื่องการจับคู่ไม่สมดุลได้ตั้งแต่คอมไพล์ไทม์
ไม่ควรสับสนกับ safec.h ตัวจริงของ safeclib
ดู เฮดเดอร์ของ safeclib
มันถูกมองว่าเป็น ความล้มเหลวด้านการออกแบบ เพราะ global constraint handler และ toolchain ส่วนใหญ่ก็ไม่รองรับ
ดู เอกสารที่เกี่ยวข้อง
ถ้าใช้ภาษา Nim ก็จะได้ทุกอย่างที่ safe_c.h มีให้
Nim คอมไพล์ไปเป็น C และให้ทั้ง ความปลอดภัยและประสิทธิภาพ พร้อมกัน
มีทั้ง automatic reference counting แบบ ARC,
defer,Option[T], bounds-checking,likely/unlikelyและฟีเจอร์อีกหลายอย่างมาให้ในตัวดู เว็บไซต์ทางการ, แนะนำ ARC, view types, เอกสาร Option, เทมเพลต likely
ถ้าแนวทางนี้ตั้งเป้าเรื่อง portability ในทางปฏิบัติก็ควรหยุดที่ C99 จะปลอดภัยกว่า
C compiler ของ MSVC จุกจิกก็จริง แต่แทบจะขาดไม่ได้ถ้าต้องการ cross-platform
ฉันก็เคยทำเฮดเดอร์คล้ายกัน แต่ไม่ได้ใส่ utility เรื่อง cleanup เพราะปัญหาด้าน portability
ถ้าโค้ด C นั้นคอมไพล์เป็น C++ ได้ด้วย มันก็จะทำงานได้ดี
มี package manager มาให้พร้อม
ในบทความมีการพูดถึงโค้ดของ cgrep หลายครั้ง แต่ไม่มีลิงก์
บน GitHub มีโปรเจกต์ชื่อเดียวกันหลายตัว แต่ส่วนใหญ่เขียนด้วยภาษาอื่น