一个类型提升引发的bug
GTK 2.20 中的input shape mask问题

回归多进程下载模型

Tiger Soldier posted @ 2010年2月01日 05:11 in 程序设计 with tags c osd-lyrics 多进程 多线程 , 5922 阅读

用多进程下载模型可耻地失败之后,OSD Lyrics使用了多线程模型来对歌词进行下载。在完成之后发现每当DNS超时时,程序都会崩溃。经过不断地调试,确定是curl的问题。看了文档后发现,原来curl在多线程环境下,由于DNS查找超时是使用信号实现的,所以在多线程环境下是不安全的。当时想了两个方法:在自己的程序里包含curl,启用异步DNS查询cares;或者大幅修改程序,使用curl的异步查询接口重写下载引擎,把程序重新变成一个单进程、单进程的程序。第一种方法会让代码的大小和编译时间大幅增长,第二种方法要把两个下载引擎(搜狗和千千的)给大幅改写,过于复杂。于是一直纠结……

后来请教了 @henry_ 老师,一句话把我打回原点——用多进程才是王道。

进程vs线程

重新审视自己的设计和多进程模型失败的原因:因为在子进程中使用了X的操作。为什么会有X的操作呢?看看下载一个歌词的步骤:

  1. 搜索歌词,并将搜索结果返回
  2. 如果有多个搜索结果,弹出选择窗口
  3. 下载选中的歌词
  4. 下载成功,显示歌词

之所以会有X操作,是因为第2步弹出了一个窗口。由于子线程同样不能操作GUI元素,因此多线程模型同样要在主线程中处理弹出窗口。我在设计中将下载过程给模块化了,有一个专门的下载模块,将具体的下载引擎和主程序分开。线程化后的下载过程是这样的:

  1. 主程序向下载模块注册搜索和下载回调函数
  2. 主程序发起搜索请求
  3. 下载模块建立搜索线程,搜索线程调用下载引擎的搜索函数,返回后唤醒下载模块在主线程的回调函数
  4. 下载模块的回调函数调用所有注册了的搜索回调函数
  5. 主程序的搜索回调函数弹出选择窗口
  6. 下载模块建立下载线程,下载线程调用下载引擎的下载函数,返回后唤醒下载模块在主线程的回调函数
  7. 下载模块的回调函数调用所有注册了的下载回调函数
  8. 主线程的下载回调函数显示歌词

也就是说,只是将原来的第1步和第3步线程化了,而这两步是没有X操作的。而且由于将下载过程都放在下载模块里了,因此多线程部分完全是在下载模块里实现的,主程序和下载引擎一点也不知道多线程的存在。

既然如此,为何不讲它们进程化呢?把实现中的线程改成进程,就是多进程模型了。一旦这样做的话:

  • 子进程中没有X操作
  • 只需要修改下载模块,主程序和下载引擎不需要修改,工作量小,不易出错
  • 多进程不会再有信号的问题。更进一步来说,子进程你爱挂就挂吧,对父进程半点影响都没有,多健壮啊
  • 进程之间没有公共数据,无需同步,不怕数据修改
  • 我管你子进程怎么分配内存,反正一退出全回收了
  • ……………………

明白自己要什么

首先搞清楚自己需要怎样的多进程操作:

  • Unix的fork风格
  • 异步函数调用形式,在fork时指定回调函数,子进程退出后自动调用回调函数
  • 子进程能返回数据到回调函数中,不只是退出状态

简单来说,就是要一个刚好能替代现有线程操作的方案,而且它是和现在的线程操作类似的,这样使用起来直观,而且修改难度小。

然而我遇到了一个问题。

如何从子进程中返回数据

从子进程中返回数据的问题思考了很久,因为进程间通信不像线程间通信那么简单,直接传一个指针完事。@henry_ 老师给我指明了两条路:使用管道或共享内存。

就此思考了一番。管道需要将返回数据序列化,再通过管道传送,最后反序列化还原数据。共享内存不需要序列化,但是需要自己管理内存,而且不知道要开多大的共享内存,内存的分配与释放都要仔细考虑。

最后的决定是使用管道。虽然要实现序列化操作,但是过程简单清晰。如果使用共享内存,那么设计会变得很复杂,容易出错,而且内存相互分离的优点也没了。

具体设计

把一切都想清楚之后设计就很清晰了。

  • fork前打开管道
  • fork时指定回调函数
  • 子进程使用管道写入数据,然后退出
  • 父进程从管道读入数据,然后调用回调函数并将数据传入
  • fork模块只负责传递数据,不负责序列化操作

最后的设计只有一个函数:

pid_t ol_fork (OlForkCallback callback, void *userdata);

回调函数的定义如下:

typedef void (*OlForkCallback) (void *ret_data,                                                                                  
                                size_t ret_size,                                                                                 
                                int status,                                                                                      
                                void *userdata);

同时,为子进程提供了用于返回数据的文件描述符和文件流:

extern int ret_fd;
extern FILE *fret;

实现细节

剩下的最大问题就是如何在子进程退出时调用回调函数了。最直接的想法就是处理SIGCHLD信号。由于不同的子进程可能需要调用不同的回调函数,也有不同的用户数据,因此需要有一个子进程ID和回调函数的对应表。感谢GTK、GDK、GLib,现在只需要用g_child_watch_add就一切都搞定了。

关于序列化,本来是想用protobuf的,它的C绑定似乎还直接提供了RPC的支持。由于我只是简单的需要一个IPC的数据传递,干脆自己写一个来练手好了。

序列化的协议很简单。因为只有两种结构需要序列化——数组和结构体,所以就简单地定义了一下。对于结构体,将其字段按一定顺序输出,以换行符间隔。对于数组,先输出一行总数,再输出各元素,每个元素后再加上一个换行符。由于是程序内部用的IPC数据,因此只要单元测试通过了,数据的正确性就可以完全信赖,不需要复杂的容错机制。

一些弯路

一开始在考虑子进程返回的触发时,@henry_ 提示可以用select或者poll。实现了之后发现有两个问题,一是如果子进程的返回数据是分段输出的话,回调函数可能会调用几次,每次收到一段数据;二是如果子进程没有返回数据(也就是返回数据为空),那么回调函数根本就不会运行。因此还是老老实实采用子进程退出来触发回调函数。

开始的设计是向回调函数传递一个fd,让回调函数自己读取数据。这么做是为了能让回调函数利用fgets来读取一行。思考之后觉得这样很不自然,而且回调函数要考虑的东西反而多了,复杂了,于是改成了现在这个样子。

在实现序列化的时候没有想清楚,把不需要序列化的OlMusicInfo给序列化了,白做工了。

在反序列化结构时,一开始是返回true or false,后来发现一个问题,就是在反序列化数组时,不知道一个元素反序列化后,剩下未被反序列化的数据从哪开始。于是把接口改成了返回指向剩下数据开头的指针,如果失败就返回NULL。

结论

把fork模块和序列化做好之后,修改代码就很简单了,只需要把对应的地方改一下,程序很欢快地跑了起来。

使用多进程的设计,避免了修改curl使用方式的缺点,而且使得程序更健壮。由于Linux轻量级进程的优势,多进程和多线程的性能应该没有太大的差别,而且实际上下载也不是程序的瓶颈。

此次设计进行了仔细考虑,而没有像之前边做边迭代的方法。在明确自己的目标,排除多余的东西,对各方面作出权衡和折衷之后,得到了一个比较简洁的接口和实现,自己觉得还是很满意的。

实现的过程中对新的代码做了充足的单元测试,发现了一些问题,同时修正了设计,最后用起来自己也相当放心。在代码不是立刻可以整合到系统中的时候,单元测试往往是能立刻看到成果的方式,而且也能检验自己的设计是否合理。

从多进程到多线程,最后又回到多进程,貌似绕了个大弯又回到原点。但是这两个多进程的设计高度完全不一样。在实现了多线程机制后,对整个下载的过程和实现有了充分的了解,现在的多进程流程也是在多线程的设计中定下的。如果没有多线程模型这个探索,也就很难有现在这个设计。所谓螺旋式上升嘛,说不定以后又会改成多线程,但是可以肯定的一点是,一定会比现在的要更好。


登录 *


loading captcha image...
(输入验证码)
or Ctrl+Enter