回归多进程下载模型

用多进程下载模型可耻地失败之后,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轻量级进程的优势,多进程和多线程的性能应该没有太大的差别,而且实际上下载也不是程序的瓶颈。

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

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

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

程序设计 Comments(0) 2010年2月01日 05:11

一个类型提升引发的bug

今天OSD Lyrics收到了一个issue,说是在歌手不存在的情况下xmms2搜索歌词成功,下载歌词失败。之前在Exaile下也有类似的bug,不过这个bug的问题不同(应该说Exaile下也还是会有这个问题)。

OSD Lyrics对歌词文件名的查找是基于模板的,模板中有一些占位符,例如%t代表歌名,%p代表歌手。有一个函数ol_path_get_lrc_pathname,负责给定路径模板和文件名模板以及歌曲信息,输出根据模板转换后的完整文件路径。成功时返回输出的路径长度,失败则返回-1。

但是在程序日志中发现,当歌手为空时,文件名模板“%p-%t”应该是失败的,然而却依然被当作成功来使用。这样一来,匹配的结果就成了只有目录,缺少文件名的路径,把歌词下载的目标地址设成本机的一个目录,那肯定是会失败的嘛。

把ol_path_get_lrc_pathname相关的函数里外查了一遍,没有任何问题,出错时也的确返回了-1,于是目光落到调用这个函数的地方:

if ((ol_path_get_lrc_pathname (
     path_list[i], name_list[j], info, 
     file_name, MAX_PATH_LEN)) >= 0)
{
  /* do something */
}

发现把>=0改成!=-1就正常了。

为什么会这样?原因就在于ol_path_get_lrc_pathname的返回类型是size_t,而size_t在我的系统里的定义是unsigned int。任意一个unsigned类型都不会小于0。

那么为什么改成!=-1后,反而又可以了呢?任意一个unsigned类似应该都不会等于-1呀。

这就涉及到C语言的类型提升问题了。在C语言中,那个操作数的类型不同时,会把操作数都转化成两个操作数中能表示的范围最大的类型。就表示的类型来说,shot<=int<=long<float<double,这个是没有任何问题的。问题在于,当出现了unsigned后,怎样计算。具体来说,两个操作数是unsigned int 和 int时,如何转化?

如果是unsigned short int和short int就好办了,直接转成int就行。然而很可惜,在32位机子里,int = long,把unsigned int和int转成long不能解决任何问题。

怎么办呢?很简单,两个都转成unsigned int。

在这个例子中,两个规则是一样的,因为返回的-1变成了size_t后,变成了最大的unsigned long,所以-1也跟着被转成了unsigned long,变成了0xFFFFFFFF,两个刚好又相等了:)

作为一个例子,请看下面一段代码:

#include <stdio.h>

int main()
{
        unsigned long a = 0;
        long b = -1;
        printf ("%s\n", a > b ? "Greater" : "Less");
        return 0;
}

会输出什么呢?gcc 4.4.1给出的答案是Less,因为-1被转型了:(

 

 

 

程序设计 Comments(0) 2010年1月08日 02:05

来宣布一下最近的项目

三月实习回来之后就有点手痒痒,和舍友sarlmolapple一起说想做个自由小项目,一则练习一下Linux下的编程,二则有点项目经验,三则可以自己用。敲定的项目是做桌面歌词,也就是在屏幕上显示的OSD歌词,我们规划了一下,基本应该具备的功能有:

  • OSD歌词显示(这不是废话咩?)
  • 歌词自动下载功能
  • 支持多种播放器,也就是说,不是以一个插件的形式,而是以独立客户端的形式

项目用C来编写,基于GTK+。死党simplyzhao听了之后也很有兴趣,加入了进来。

 其实现在已经有了挺不错的外挂歌词脚本lrcdis,为什么我们还要弄一个呢?其一是因为想自己做点东西,丰富经验,而做项目最大的动力就是自己要用。其二是因为lrcdis的OSD功能还很弱,而且由于是用bash脚本写的,应该很难增强(lrcdis是调用gnome-osd来实现OSD的)。

至于为什么用C,其实我觉得这个项目用Python来写会比较好,用C的唯一理由是……大家都想好好学学C-_-|||

其实这玩意计划了挺久了,现在原形设计得差不多了,技术问题也基本解决了,才敢公开出来。等到有可用代码的时候我会把项目地址给放出来的^ ^

程序设计 Comments(5) 2009年5月01日 19:17

[转]在Emacs下用C、C++编程


  转自http://hi.baidu.com/yilinghl/blog/item/5dc7d55cb4271f43faf2c069.html

用emacs写程序也有5个年头了,深切地体会到Emacs的强大。程序员有三种,一种是用vi的,一种是用emacs的,还有一种是其他。或许有些夸张,但也颇能体现出emacs在程序员中的地位。

emacs最大的问题在于入门门槛较高。它看起来和多数人想象中的IDE相差甚远,很多人看到emacs的第一眼就觉得它是个记事本(还是个非常难用的记事本),稍微好些的往往觉得emacs也就是个ultraEditor而已,真是暴殄天物了。

我是个懒人,不喜欢记太多的快捷键,相信很多人和我一样。所以从我后面的叙述可以看出来,除了常用的命令都是快捷键外,其他命令多数都是用M-x执行或者用鼠标点菜单。这仅仅是个人风格问题,先说明一下。

我的基本编程环境是:

  • Debian GNU/Linux sid 操作系统
  • Gnome 2.10.0 桌面环境
  • GUN Emacs 23.0.0.1 for debian
  • 使用 Gnu tool chains(gcc,make,gdb等等)

后面的叙述都基于上述环境。另外,本文主要针对C/C++程序开发,对其他语言有些也适用,从难度上说,本文主要针对入门者。

本文肯定会有很多错误,请指正,谢谢。

基本流程

写C++程序基本上是这么几个步骤:

  1. 编辑代码
  2. 编写Makefile
  3. 编译代码,修改编译错误
  4. 调试代码,修改逻辑错误

当然,往往还需要阅读别人的代码。

根据上述步骤,本文主要针对以下几个方面:

  • 配置Emacs,建立便利的代码编辑环境和Makefile编写环境。
  • 在Emacs中编译代码,并修改编译错误。
  • 在Emacs中配合GDB调试程序。
  • 利用cscope和ecb在emacs中阅读代码。

基本环境设置

编辑环境配置

要写C++程序,当然要用到cc-mode插件。CC-Mode原本是支持C语言的,但现在也能支持很多语言,比如C++,Java, Objective-C,CORBA,AWK,Pike等等。CC-Mode是gnu-emacs的标准插件。如果您要求不高,那么默认的配置或许就能满 足。CC-Mode的各种行为都可以自由地定制,您可以参考这里的文档:CC-Mode参考文档

这里是我的.emacs文件中关于CC-Mode配置的部分,仅供参考:

;;;; CC-mode配置  http://cc-mode.sourceforge.net/
(require 'cc-mode)
(c-set-offset 'inline-open 0)
(c-set-offset 'friend '-)
(c-set-offset 'substatement-open 0)
;;;;我的C/C++语言编辑策略

(defun my-c-mode-common-hook()
  (setq tab-width 4 indent-tabs-mode nil)
  ;;; hungry-delete and auto-newline
  (c-toggle-auto-hungry-state 1)
  ;;按键定义
  (define-key c-mode-base-map [(control \`)] 'hs-toggle-hiding)
  (define-key c-mode-base-map [(return)] 'newline-and-indent)
  (define-key c-mode-base-map [(f7)] 'compile)
  (define-key c-mode-base-map [(meta \`)] 'c-indent-command)
;;  (define-key c-mode-base-map [(tab)] 'hippie-expand)
  (define-key c-mode-base-map [(tab)] 'my-indent-or-complete)
  (define-key c-mode-base-map [(meta ?/)] 'semantic-ia-complete-symbol-menu)
;;;;我的C/C++语言编辑策略

(defun my-c-mode-common-hook()
  (setq tab-width 4 indent-tabs-mode nil)
  ;;; hungry-delete and auto-newline
  (c-toggle-auto-hungry-state 1)
  ;;按键定义
  (define-key c-mode-base-map [(control \`)] 'hs-toggle-hiding)
  (define-key c-mode-base-map [(return)] 'newline-and-indent)
  (define-key c-mode-base-map [(f7)] 'compile)
  (define-key c-mode-base-map [(meta \`)] 'c-indent-command)
;;  (define-key c-mode-base-map [(tab)] 'hippie-expand)
  (define-key c-mode-base-map [(tab)] 'my-indent-or-complete)
  (define-key c-mode-base-map [(meta ?/)] 'semantic-ia-complete-symbol-menu)

注意一下,上面最后两行是代码自动补齐的快捷键。后面我会提到代码自动补齐。

  ;;预处理设置
  (setq c-macro-shrink-window-flag t)
  (setq c-macro-preprocessor "cpp")
  (setq c-macro-cppflags " ")
  (setq c-macro-prompt-flag t)
  (setq hs-minor-mode t)
  (setq abbrev-mode t)
)
(add-hook 'c-mode-common-hook 'my-c-mode-common-hook)

;;;;我的C++语言编辑策略
(defun my-c++-mode-hook()
  (setq tab-width 4 indent-tabs-mode nil)
  (c-set-style "stroustrup")
;;  (define-key c++-mode-map [f3] 'replace-regexp)
)

自动补齐

自动补齐通常用的都是hippie-expand,我也用了很长时间。不过有时候会觉得这个自动补齐“傻”了一点,常会补齐出一些毫不相干的东西,因为hippie-expand是根据你敲过的词和kill-ring等进行判断的,并不对程序语法进行分析。

所以你还需要安装一个代码分析工具,然后把它加进hippie-expand的扩展策略里去。我们可以用semantic。实际上,hippie-expand+semantic是我所发现的最好的选择了,如果您有更好的,请您也告诉我一声:)

Semantic是CEDET中的一个工具,CEDET是Collection of Emacs Development Environment Tools的缩写,它包含了好几个工具,都挺不错的。可惜我只会用其中两个。

您可以在.emacs中对Semantic进行配置,下面是我的.emacs相关的配置,仅供参考:

导入cedet:

(load-file "~/lib/emacs-lisp/cedet-1.0pre3/common/cedet.el")

配置Semantic的检索范围:

(setq semanticdb-project-roots
   (list
        (expand-file-name "/")))
 

自定义自动补齐命令,这部分是抄hhuu的,如果在单词中间就补齐,否则就是tab。

(defun my-indent-or-complete ()
   (interactive)
   (if (looking-at "\>")
    (hippie-expand nil)
    (indent-for-tab-command))
 )

(global-set-key [(control tab)] 'my-indent-or-complete)

hippie的自动补齐策略,优先调用了senator的分析结果:

(autoload 'senator-try-expand-semantic "senator")

(setq hippie-expand-try-functions-list
    '(
  senator-try-expand-semantic
  try-expand-dabbrev
  try-expand-dabbrev-visible
  try-expand-dabbrev-all-buffers
  try-expand-dabbrev-from-kill
  try-expand-list
  try-expand-list-all-buffers
  try-expand-line
        try-expand-line-all-buffers
        try-complete-file-name-partially
        try-complete-file-name
        try-expand-whole-kill
        )
)

注意一下我前面CC-Mode配置中有这么两行:

  (define-key c-mode-base-map [(tab)] 'my-indent-or-complete)
  (define-key c-mode-base-map [(meta ?/)] 'semantic-ia-complete-symbol-menu)

这样,我们在CC-Mode中就可以调用自定义的hippie补全了,快捷键是Tab。

另外,我还把快捷键“Alt + / ”绑定到了semantic-ia-complete-symbol-menu命令上,这是semantic的命令,它会根据分析结果弹出补齐的菜单,效果如图显示:

CEDET中还有一个不错的工具是speedbar,你可以用它在多个文件中快速切换。在我的.emacs配置文件里,我把speedbar关联到了F5上:

(global-set-key [(f5)] 'speedbar)

这样用F5就可以调出speedbar,效果如下:

 

不过说实话,我自己很少用到speedbar,我通常都是用dired配合bookmark使用:)

编译和调试程序

按上面的配置,写完程序和Makefile文件后,在Emacs源代码窗口中按F7就可以进行编译。因为在my-c-mode-common-hook()函数里,有这么一行:

(define-key c-mode-base-map [(f7)] 'compile)

默认情况下,emacs的compile命令是调用make -k,我把它改成了make。你也可以把它改成其他的,比如gcc之类的。改下面的“make”就行了。

'(compile-command "make")

Emacs会划分一个窗格显示编译的消息,在编译结束后,emacs会自动将编译器的输出和程序关联起来,告诉你第几行的程序有问题。直接在出错的行号上按Enter,就可以跳转到相应文件的相应行。其实我通常都是用鼠标中键去点出错行号:)

搞定了编译错误后,接着要和逻辑错误斗争了。其实对简单的程序来说,把中间结果打印到终端是最简单好用的调试办法:)不过稍微复杂点的程序就会晕菜了,这时我们就需要拿gdb跟踪程序流程了。

你用下面的命令就可以启动gdb了。

M-x gdb

通常我喜欢进入gdb-many-windows模式,这样就会把一个Frame划分为5个窗格,同时显示:gdb命令窗口,当前局部变量,程序文本,调用栈和断点。

gdb的命令就不在这里说了,它的文档几乎到处都是。emacs把gdb的命令和快捷键做了绑定,对于常用的命令,还是输入快捷键比较方便。比如,C-c C-n是Next line,C-c C-s是step in,其实用的最多的快捷键也就是这两个。

下面是我的gdb效果图:

阅读代码

在emacs下读代码通常有三种工具,最简单的是etags,最复杂的是ecb(emacs code browser),位于中间的是cscope。

etags和ctags一样,只不过前者是用于emacs的,后者是用于vi的。我个人觉得etags功能稍稍显得不够用一点,当然,也可能是我用的不好:) 欢迎大牛指导。

使用tags之前要先对源代码分析建立tags文件,在代码所在目录中运行:etags -R 即可。

我常用的就这几个命令和快捷键:

M-x visit-tags-table <RET> FILE <RET>   选择tags文件
M-. [TAG] <RET>                         访问标签
M-*                                     返回
C-u M-.                                 寻找标签的下一个定义

ecb据说功能强大,但是太复杂了,我懒得折腾它。谁搞定了教教我吧:) 下面是一张ecb的效果图。

cscope是我感觉比较合适的一个工具。它其实是一个独立的软件,完全可以脱离vi和emacs使用。但是结合emacs的强大功能, cscope就显得更加方便了。GNU Emacs默认自带cscope的支持。在使用之前,cscope也需要对代码进行索引。在emacs中可以这样做:

C-c s a             设定初始化的目录,一般是你代码的根目录
C-s s I             对目录中的相关文件建立列表并进行索引

建完索引之后,你就可以用cscope在代码里游荡了。常用的一些命令如下:

C-c s s             序找符号
C-c s g             寻找全局的定义
C-c s c             看看指定函数被哪些函数所调用
C-c s C             看看指定函数调用了哪些函数
C-c s e             寻找正则表达式
C-c s f             寻找文件
C-c s i             看看指定的文件被哪些文件include

上面这些快捷键其实我自己也常常记不全,没关系,抬头看看上面的菜单栏,有一栏就是Cscope,这些命令里头都有:)

贴一个cscope的效果图吧:

linux Comments(1) 2008年4月04日 03:57

Ubuntu配置记录-2:关于开发环境的配置

安装配置程序开发的相关环境: emacs、eclipse、GTK、doxygen、svn

阅读全文

linux Comments(2) 2008年4月04日 03:52