C 库的编写 [2]
这是有关 C 库编写经验的一系列文档的第二部分,讲述了事件处理、主循环、同步与异步 I/O 等功能的实现经验。原文见:http://davidz25.blogspot.com/2011/06/writing-c-library-part-2.html
本文只是对原文的不完全翻译及意译,对其中所有错误不负任何责任。
事件处理与主循环
基于事件驱动的应用程序,特别是 GUI 应用程序,通常采用“主循环(Main Loop)”方案解决各种事件的解释与分发问题。事件发生时,主循环通常会回调应用程序的功能。
例如 GLib/GTK+ 库栈是围绕 GMainContext、GMainLoop 和 GSource 类型建立的,其他库栈也提供了类似的抽象。许多内核与系统级别的程序员通常会对 GUI 程序员所说的“主循环”感到可笑——GUI 程序员也通常会对内核程序员所说的 put() 或 get() 一些东西感到奇怪。实际上主循环是一个众所周知的概念,只不过它在不同的地方拥有不同的名字,它们基本上都是像 select(2)、poll(2) 这样的操作系统原语的抽象。
要特别注意多线程应用程序可以在不同线程内运行多个主循环,因此要确保回调行为在正确的线程中出现。在 GLib 中这个可以通过 g_main_context_push_thread_default() 函数来实现,这个函数会将当前线程的主循环记录于线程局部存储中,然后在进行一些异步操作的时候(例如 g_dbus_connection_call() 或 g_input_stream_read_async())再去读取它,以确保向异步操作所传递的回调函数可在 g_main_context_push_thread_default() 之前存储的主循环所在的线程中被调用。
有些主循环,例如 GLib 的主循环,允许创建递归的主循环。这种机制被用于实现 GtkDialog 的 run() 方法。虽然这样会阻塞调用线程,但是事件依然会被处理(例如输入和重绘事件等)。值得注意的是这也意味着那些带出对话框的函数们可能会被再次调用(在回调函数中)。因此在使用像 gtk_dialog_run() 这样的函数时,你需要保证你的函数要么是可重入的,要么就在对话框显示的时间内禁止你的函数被再次调用(通常可以使用模态对话框实现)。由于这样的陷阱的存在,你必须要对你的函数是否可用于递归的主循环。
主循环并非 GUI 应用程序独有的概念——许多守护进程(例如没有 GUI 的背景进程)也需要围绕主循环的概念而建立,因为主循环的概念可以优雅的将各种事件源(文件或者像定时器、日志等同步资源)集成起来。事实上现代 Linux 系统之上的大部分系统级别的软件都是使用了 GLib 的主事件循环抽象用于事件的分发——像这样的守护进程,大部分时间都是停留在空闲状态,等待 D-Bus 的消息抵达 (从而为客户端提供服务)、定时开火 ( 开始一些周期性的任务) 或者子进程终止 (在使用辅助进程处理一些任务之时).
我们已经解释了主循环是什么,现在来看一下它与你编写 C 库有什么关系。首先,如果你的库没有向用户传递事件的需求的话,就不用无须考虑主循环的问题。不过大部分库没那么简单,例如 libudev 会在设备插入或改变时需要投递一些事件,NetworkManager 需要通知网络的变化等等。
如果你的库正在使用 GLib,那么 GLib 的主循环通常可以满足要求,只需用户运行 GLib 的主循环(如果应用程序使用了其他库提供的主循环,它也可以集成 GLib 的主循环,可仿照 Qt 的做法)并在设置回调函数时调用 g_main_context_get_thread_default()。许多基于 GObject 的库都是这么干的,像 libpolkit-gobject-1, libnm-glib 和 libgudev。例如连接 GUdevClient::uevent 信号的回调函数可在起初构建对象时的线程默认主循环中被调用。对于共享的资源,像消息总线连接,一个好办法是让回调函数在其被调用时的线程默认主循环中被调用(这种情况可参考 g_dbus_connection_signal_subscribe()),这是因为应用程序或库对于共享资源没有绝对的控制权。在任何情况下,对于那些处理回调的函数,都必须在文档中记录回调发生的环境。
如果你的库没有使用 GLib,那么一种简便的提供事件机制的方法是 a) 提供一个文件描述符,使之在进行事件处理时是可读的;b) 提供一个用于处理事件的函数(也许会调用用户提供的回调函数)。一个很好的示例是 libudev 的 udev_monitor_get_fd() 和 udev_monitor_receive_device() 函数。采用这种方式,应用程序可以很容易控制事件在哪个线程中处理。有关 libudev 与 GLib 的主循环的集成可参考这里和这里。在 libudev 库中,返回的文件描述符实际上是一个 netlink 套接字,用于来自 udevd(通过内核)事件的接收。如果没有合适的文件描述符(例如对于日志文件中的某一条的反馈事件),你的库可以使用 pipe(2) (Linux 系统中也可以是 eventfd(2))并在另一端使用一个私有的工作者线程处理信号。
如果你的库提供了回调函数,一定要使之具备可接受 user_data 的参数值,以便用户可将回调与其他对象和数据关联起来。如果你的回调范围是未定义的(例如可能回调会被多次触发或者无法取消回调与事件的连接),那么需要提供一种释放 user_data 指针所指向内存的方法,以备不再需要 user_data 之时使用,否则可能会导致内存泄漏。这方面的例子见 g_bus_watch_name()。
小结
- 提供集成主循环的 API;
- 让回调函数可接受 user_data 参数 (可能伴随一个释放函数)。
同步与异步 I/O
对于库的用户,懂得如何调用一个进行同步 I/O (也叫阻塞 I/O)操作的函数是很重要的。例如,一个应用程序提供了一个用户界面,它可以响应用户的输入,甚至可能更新用户界面的每一帧以达到平滑动画的效果(例如每秒 60 次)。为了防止应用程序的停止响应或者动画效果的不稳定,应用程序的 UI 永远也不要调用任何进行同步 I/O 处理的函数。
即使从本地磁盘载入文件也是需要很长的时间——有时是数十秒钟。例如文件可能不在页缓存中并且文件系统所在的硬盘的电源可能是关闭的——或者文件可能位于 NFS 这样的网络文件系统中。有关阻塞 I/O 的其他例子还包括一些本地的 IPC(进程通信),像 D-Bus 和 Unix 域套接字。
如果知道一个操作(同步或者其他)会耗用很长的时间,那么如果提供一种可以容易将其关闭的方法(可能是在另一个线程中来做)会更好一些。例如 GLib 库栈中的 GCancellable 类型。另外一种优雅的方式(尽管是基于 GCancellable 类型实现的)是为长时间运行的操作设定超时限定——例如 g_dbus_connection_send_message_with_reply() 和 g_dbus_proxy_call(),后者提拓宽了超时机制的对象范围,只需要进行一次超时设定。
有些库同时提供了同步与异步的两个版本的函数,前者会阻塞调用线程,后者则不会。通常异步 I/O 操作会使用工作者线程实现(让工作者线程进行同步 I/O 操作),也可以基于 IPC(例如 D-Bus)甚至 TCP/IP 与其它进程通信的方式实现。在 libgio-2.0 中同步文件 I/O 便是简单的通过工作者线程(使用了一个 GThreadPool)中的同步 I/O(例如基本的 read(2) 和 write(2) 函数)实现的,这是因为 Linux 内核没有提供足够的机制以便实现库的异步 I/O 操作(这一点可参考有关Asynchronous I/O 的一份精彩的笔记)。对于上层而言,异步 I/O 的实现只是内部的细节,并且 libgio-2.0 的异步 I/O 的实现将来也可以迁移为非线程的实现。
异步 I/O 通常会提供回调机制(至少也是某种事件通知机制),因而它会包含主循环。如果一个库提供了这样的回调机制,它应当在文档中讲清楚回调会发生在哪个线程中以及是否需要应用程序运行一个(某种特定的)主循环。详情见上一节有关主循环的介绍。
对于线程安全并且具备异步 I/O 功能的库,那么应用程序通常很容易实现异步 I/O 处理,只需调用同步 I/O 函数的工作者线程版本的函数即可。如果使用 GLib 的话,g_io_scheduler_push_job() 函数便可以。
有些库的同步 I/O 功能是通过递归的主循环实现的(通常使用异步 I/O 函数)——应当避免这种方案,由于函数的可重入性以及直至同步操作完成后方可进行的事件处理等方面的问题,这种方案会导致各种各样的问题。
有些库,像 GLib 库栈,提供了 GAsyncResult、GSimpleAsyncResult、GAsyncReadyCallback 和 GCancellable 类型,实现了异步 I/O 方面的函数的一致性。由于它们实现了一些比较重要的功能,像生命周期(例如,可担保回调总是会发生,甚至在异步 I/O 关闭、超时或出错的时机),这使得它们在应用程序中的使用以及高层语言绑定中非常容易使用。
小结
转载时,希望不要链接文中图片,另外请保留本文原始出处:http://garfileo.is-programmer.com