这是有关 C 库编写经验的一系列文档的一部分,原文见:http://davidz25.blogspot.com/2011/06/writing-c-library-part-1.html
本文只是对原文的不完全翻译及意译,对其中所有错误不负任何责任。
基层库
由于 libc 库是相当底层的库,因此在其之上有一些较高层次的库用于简化 C 程序的编写,例如 GLib 和 GTK+ 所包含的一系列库。尽管下文内容主要围绕 GLib 和 GTK+ 等库展开的,但是这些内容对基于 libc、GLib 或者其他像 NSPR、APR、Samba 等库的 C 代码编写也有助益。
大部分程序员都认为通常没必要自行实现一些基本的数据类型,诸如字符串处理、内存分配、链表、数组、哈希表或者队列,尽管你有能力去实现它们,这样做只会让他人感觉你的代码难以理解与维护。因此 GLib 与 GTK+ 这样的库便应运而生,它们提供了许多像这样的基本数据类型以补充 libc 库的不足。另外,当你确定需要一些重要的工具函数,例如 Unicode 操作, 文字渲染脚本, D-Bus 支持 或者校验码生成等,最好试问一下置那些已经被很好的测试与维护的库于不顾是否是一个好的决定。
特别是对于像加密方面的功能,选择自行实现通常不是好主意(自己发明一个算法会更糟糕),最好是使用现已经过良好测试的库,例如 NSS 库(即便如此,你也必须小心谨慎使用,确保不出错)。更有甚者,倘若你为美国政府编写商业软件,所写的库可能要经过 FIPS-140 认证。
类似的例子,对于事件通知而言,epoll 要比 poll 更有效,但是如果你构建的程序或者库如果只是依次处理 10 来个文件描述符,那么 epoll 也不会有什么助益,不妨使用 GLib 对 poll 的封装。另一方面,即便你的程序或库需要处理的文件数以千计,这并不妨碍你将 GLib 作为程序或库的主体依赖,你只需要将在专门的线程中使用 epoll 来处理这些文件即可。同样,如果你需要 O(1) 的时间复杂度的链表结点删除函数, GList 可能不再适用,但是你只需改用 embedded list 即可(译者注:很不幸阿,我自行实现了一个链表)。
无论你最终确定要使用什么库或代码,首先一定要对相关的数据类型、概念和实现细节有初步的了解。例如,GLib 提供了一些容易使用的功能,像 GHashTable, g_timeout_add() 或者 g_file_set_contents() ,你不必知道它们是如何实现的或者什么是文件描述符,你只需要知道它们能够做什么就足够了(通常 API 文档会告诉你这些信息)。另外,你一定要了解你所使用的数据类型的算法复杂度以及它们如何在现代硬件上工作。
最后,不要尝试同网络上随机的人开展库是否臃肿这种宗教信仰式的讨论,既浪费时间又浪费资源。
小结
- 不要重新发明基本的数据类型(除非出于性能方面的考虑);
- 不要仅仅因为一些标准库是可移植的便无视它们;
- 谨慎使用一些在功能上有所重叠的库;
- 尽可能的将库的使用作为内部实现细节隐藏起来;
- 用适当的工具做适当的事——不要在宗教讨论上浪费时间。
库的初始化与结束
有些库需要一个初始化函数,命名常为 foo_init(),这个函数需要在库的其他函数之前被调用,用于初始化库自身需要使用的全局变量和数据结构。另外,库也可能会提供一个结束函数,命名常为 foo_shutdown(),用于释放库占用的所有资源,这个函数主要可以取悦像 Valgrind(用于检测内存泄漏)这样的工具或者是为了释放 dlopen 之类函数所开启的库占用的全部资源。
通常应当避免库的初始化与结束函数的设计,因为这些函数可能会导致应用程序依赖链中两个不相关的库的冲突;例如,可能你察觉到没有在相应的位置调用它们,于是便在应用程序的 main 函数中调用库的初始化函数,也许你认为这样便可万事大吉,但是也许应用程序依赖链中的某些库正在使用这个未经初始化的库。
但是,如果没有库初始化函数的话,那么这个库的每个函数便不得不调用库的内部初始化函数,这种方案不实用而且存在效率问题。在现实中,一个库只是有少部分函数需要库的初始化操作,因为其他大多数函数所依赖的对象或结构通常是从库的其他函数中获取的。因此在现实中库的初始化操作只是在 _new() 函数和一些不操作对象的一些函数中进行。
例如,每个使用 GLib 类型系统的程序必须要调用 g_type_init() 函数,这也包括那些基于 libgobject-2.0 的库。以libpolkit-gobject-1 库的使用为例,如果你在调用 polkit_authority_get_sync() 之前没有调用 g_type_init(),那么你的程序可能会出现段错误。对于 GLib 栈(即 GLib 及其上的各个库)的新手经常会无可厚非的犯这样的错误。g_type_init() 是为什么 init() 函数应当尽可能避免这一规则的反面教材。
提供库的初始化函数的一条理由是库的配置,包括应用程序方面的配置或者最终用户方面的配置(argc 和 argv)。这个问题最好的解决方案是避免配置,即便是避免不了配置,那么也应当使用环境变量来实现库的配置,请参考 libgtk-3.0 的环境变量支持 和 libgio-2.0 的环境变量支持 。
如果你的库必须要有初始化函数,那么就要保证它是幂等(Idempotent)并且线程安全的,也就是说它可以被多次调用并且可同时被多个线程调用。如果你的库还需要有一个结束函数,最好要使用“初始化计数”的方式确保库只被结束一次。还有,尽可能的在你的库的初始化/结束函数中调用它所依赖的库的初始化/结束函数。
通常可以通过引入环境对象的方式消除库的初始化/结束函数——这样也可以修正全局状态(它是不受欢迎的并且经常迫害同一进程中的库的多个使用者)、锁定(引入环境对象后,便可对每个环境实例进行锁定)以及回调/通知(引入环境对象后,便可以实现线程隔离的回调与事件处理了)所带来的问题。 libudev 的 struct udev_monitor便是很好的示例。
小结
- 避免为库设计初始化与结束函数——如果你不能避免,就要保证它们是幂等的、线程安全的并且拥有引用技术机制;
- 使用环境变量作为库的初始化参数,而不是 argc 和 argv;
- 在同一进程环境中,如果某个库有两个使用者,通常不要让主程序知道这个库,这很容易实现。要保证你实现的库也能做到这一点。
- 避免像 atexit(3) 这样不安全的 API,并且如果关注可移植性的话,也要避免像库的构造与析构这样不可移植的结构(例如 gcc 的 __attribute__((constructor)) 和 __attribute__((destructor)))。
内存管理
为你的 API 分配并返回的每种对象提供相应的 _free() 函数是个好习惯。如果你的库使用了引用计数,那么使用 _unref 作为函数后缀名要比 _free 更恰当。例如在 GLib/GTK+ 栈中提供的函数是 g_object_new()、g_object_ref()和 g_object_unref() ,用于操作 GObject 类型的实例(包括继承的类型)。同样,对于 GtkTextIter 类型,相关函数是 gtk_text_iter_copy() 和 gtk_text_iter_free()。另外要注意有些对象可以采取栈内存分配(例如 GtkTextIter)而其他对象(像 GObject)只能采取堆内存分配。
注意有些面向对象的库包含了类型继承的概念,可能需要应用程序使用基类型的 unref() 函数——例如 GtkButton的实例必须使用 g_object_unref() 进行内存释放,这是因为 GtkButton 是 GObject 的子类型。此外,有些库具有浮动引用(floating reference)的概念(即分配新对象时,引用计数为 0),例如 GInitiallyUnowned、GtkWidget 和GVariant)——这使得 C 类型系统的使用有时会更为方便,例如 g_dbus_proxy_call_sync() 的示例代码中,将 g_variant_new() 构造器作为 g_debus_proxy_call_sync() 的参数,这样便不用担心 g_variant_new() 所分配的对象会被其他代码片段所占据而导致无法解除引用的问题。
除非是函数是自解释性的,否则所有函数的参数的管理都应当有文档记载。将 API 统一风格通常是个好主意。例如在 GLib 栈中的一般性的规则是由调用者来管理传入某个函数的参数(因此合格函数应当对参数的引用计数增 1,而如果希望函数在返回后继续使用这个参数,那么便在函数中复制这个参数)并且由调用者管理函数返回的参数(因此调用者需要增加参数的引用计数或者对其进行复制)。不过在这个函数被多个线程调用时,上述规则不再适用(在这种情况中,调用者需要释放函数返回的对象)。
注意,线程安全性通常标示了 API 的风格——例如,对于一个线程安全的对象池,lookup() 函数(返回一个对象)必须返回一个引用,即增加对象的引用计数(调用者必须使用 unref() 解除引用),这样做是因为你从 lookup() 那里得到的对象可能会被其他线程给咔嚓了。这方面的示例可参考g_dbus_object_manager_get_object()。
如果你为某个对象或结构体实现了引用计数,一定要保证它是原子操作或者能够在多线程环境中保护引用计数的并发修改过程。
如果某个函数的返回值的是指向一块内存区域的指针,那么不建议调用者对其进行释放或解除引用,因为对于这种函数的返回值的处理方法通常会被文档记载。例如C 库文档记载的 getenv() 函数的返回值信息:“getenv() 的返回值所指向的字符串可能是静态分配的,可被后续调用的 getenv(3)、putenv(3)、setenv(3) 或 unsetenv(3) 等函数修改”。这是很有用的信息,它表明了这种 API 不能用于多线程环境。
通常不要担心你的程序会出现内存不足的问题,如果真的担心会出现这样的问题,只需使用 abort() 函数终止程序。大部分库都是这样做的,因为这样可以让 API 更简练并且可以大幅减少代码体积。如果你在你的库中真的很担心内存不足的问题,那么请务必测试所有的代码路径,或许你的努力只是徒劳无功的。通常只是在确定你的库会被 1 号进程(即 init 进程)或者其它关键的进程使用之时,才需要考虑内存空间不足的问题。
小结
- 为你的库创建的每种类型提供一套 free() 或 unref() 函数;
- 保证你的库中内存处理的一致性;
- 要注意多线程可能会对某些 API 有影响;
- 将内存管理方式清晰的记录在文档中;
- 不要担心内存不足的问题,除非你有很好的理由要处理这种问题。
多线程与多进程
一个库应当在文档中清晰的记载它是否以及如何用于多线程。线程安全通常包含多个层次——如果这个库具有对象与对象池的概念(许多库都有),对象池的遍历与管理也许是线程安全的,但是应用程序可能会被建议应当提供锁定机制,用于多个线程并发操作某个对象。
如果你正在编写一个执行同步 I/O 操作的函数,让它具备线程安全性是个好主意,这样应用程序可在辅助线程中调用它。
如果你的库在内部使用了线程,要小心进程范围内的状态操作,例如当前目录、Local 等。你在库内去做这些操作的时候,有可能会对使用你的库的应用程序中其他的库造成不可预期的影响。
一个库应当总是使用线程安全函数(例如要使用 getpwnam_r() 而非 getpwnam()),避免使用那些线程不安全的库和代码。如果你无法做到这一点,这就表示你的库不是线程安全的,因此对于一个追求线程安全的应用程序而言,它便只能通过一个专门的辅助进程使用你的库了。
如果你的库在内部使用了线程,例如一个工作者线程池,将这一信息记录于文档中也非常重要。即使你认为这只是你的库内部的实现细节,但是它的存在会影响你的库的用户;例如 Unix 信号 在线程面前需要作不同处理,并且 fork() 一个线程化的应用程序,情况也相当复杂。
如果你的库的接口包含了可被 fork() 的进程所继承的资源,像文件描述符、锁、来自 mmap() 的内存等等,你应当尝试提供一个清晰的策略用于一个应用程序在 fork() 之前/之后如何使用你的库。通常,最简单的规则是最好的,即:只在 fork() 之后使用那些重要的库或者提供一种方法在 fork() 生成的进程中对库进行重新初始化。对于文件描述符而言,使用 FD_CLOEXEC 是个好主意。现实中大部分库在 fork() 之后都存在着未定义行为的问题,所以最安全的办法是使用 exec() 函数。
小结
- 在文档中记录你的库是否以及如何用于多线程;
- 在文档中记录你的库在 fork() 之后应当采取什么步骤或者你的库在 fork() 后不能使用;
- 在文档中记录你的库是否创建了内部的工作者线程。
转载时,希望不要链接文中图片,另外请保留本文原始出处:http://garfileo.is-programmer.com