多线程程序可能存在很多潜在的bug,如data race,dead lock,信号bug等,而这些bug一向很难调试,现在有很多论文都是基于多线程程序的调试技术的,比如model check,死锁检测,replay技术等,也有很多对应的工具,如intel的pinplay,微软的Zing等。关于这些技术和工具,如果感兴趣可以 google相应的论文进一步了解。这里我主要讲述的是我在对二进制翻译下多线程程序调试中经常使用的一些方法以及一些调试经验,虽然我的调试的是二进制翻译器,但是这些方法也同样适用于大多数多线程程序。
1、最直接的方法就是在源程序插入printf语句来打印出一些有用的变量。这种方法的优点是不用借助其他工具就可以对程序的运行进行观察,缺点是插入语句的位置、粒度等都需要调试者自己去权衡,如果插入过多的打印语句,则频繁的IO操作会使程序运行变慢,线程行为改变,有些bug甚至不会再出现。至于需要在什么地方插入语句,首先,只打印有必要的变量,一个语句可以打印多个变量;其次,在循环中,我们可以通过设置一些条件来降低打印的粒度,比如下面这段代码:
1 2 3 4 5 6 7 8 | while(flag){ pc = getpc(); printf(“pc is:0x%x\n”, pc);//我们插入的打印语句 ...... ...... //do somthing using pc; } |
假设我们对pc的取值很感兴趣,需要打印出所有pc取到过的值,但是大多数情况下,getpc()的返回值都同上一次的返回值相同,这样我们printf出来的就会有很多重复值。这种情况下我们可以用下面这种插桩方式来去处重复值:
1 2 3 4 5 6 7 8 9 10 11 | int lastpc; //定义为全局变量或局部静态变量 while(flag){ pc = getpc(); if(pc !=lastpc){ lastpc = pc; printf(“pc is:0x%x\n”, pc); } ...... ......//do somthing using pc } |
这样通过一个简单的判断就可以省掉很多没有必要的输出。很多别的情形,比如我们只关心某一变量等于特定值(比如0)时其他变量的状态,我们就没有必要把改变量不等于0时的状态打印出来。总之,能省则省,只打印我们需要的。
2、利用gdb的attach功能和sleep()函数。gdb是由gnu维护的功能强大的调试工具,并且支持多线程程序的调试,可以在gdb下直接运行一个多线程程序,通过thread等命令进行调试。但是很多多线程程序在其他工具(gdb,pin,strace等)监管下,原有的bug就不会出现。这的确是很让人头疼的事情,也是我十分不喜欢这个方法的原因,想象一下,一个程序直接跑就出错,但是放到gdb下就能得到正确的结果,好像故意在耍我们一样。我更喜欢使用gdb的attach功能,我们可以通过下面的命令来让gdb接管一个运行的线程:
1 | gdb attach <pid> |
这种方法的好处是能够使gdb对程序执行的影响最小,而且可以只接管程序中某一条我们所关心的线程,而其他线程不受影响。
这时有人会问,如果线程执行过快,我们还没来得及attach线程就已经执行完或者dump掉了,这种情况该怎么办?解决方法很简单,既然线程执行过快,我们就让它等一等,可以在源代码中让我们关心这个线程sleep()一小会儿,这样我们就有足够的时间来attach它,并且attach的位置我们也可以进行控制,想在哪里attach,就在哪里sleep。
3、第三种方法是利用信号处理函数来获取一些信息。在多线程程序的压力测试中,很多错误要每隔几百几千次运行才能出现一次,而这种错误的replay是很困难的,因此捕捉到这种错误的现场很重要。这里我习惯利用信号处理程序来保存这样的现场,这样你可以晚上写个脚本让程序无限跑,早上起来你会发现程序停在出错的地方,这是很惬意的事情。
多数多线程程序出错,都是访问非法内存,也就是我们常说的“段错误”(segmentation fault),程序发生非法内存的访问,系统会发给线程一个SIGSEGV信号,这个信号默认处理为core掉该线程。我们可以对这个信号进行利用,为其注册一个信号处理函数:
1 2 3 4 | struct sigaction act; act.sa_flags = SA_SIGINFO; act.sa_sigaction = signal_handler; sigaction(SIGSEGV, &act, NULL); //SIGSEGV表示该信号的值 |
信号处理函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void signal_handler(int host_signum, siginfo_t *info, void *puc){ struct ucontext *uc = (struct ucontext *)puc; int loopflag = 1; while(loopflag) //可以在gdb中手动更改loopflag的值跳出循环 sleep(1); ...... //这里可以打印一些感兴趣的变量 } |
函数参数中,puc是一个体系结构相关的指针,不同的体系结构,指针指向的结构不一样,里面存放了发生信号时线程的寄存器的值,程序地址等信息,函数内第一句话的目的就是把void类型转换成ucontext结构类型,这样在gdb中可以直接print出该结构的成员。
函数中sleep的作用是让程序停在信号处理程序中,以给我们足够的时间进行attach。如果想让程序继续运行下去,手动把loopflag修改为1即可。用while循环的目的是我们可以在运行时手动控制sleep的时间。
这种方法同样适用于其他信号带来的bug,比如SIGBUS等。在二进制翻译下,还可以使用这种方法对二进制翻译器信号处理进行跟踪和调试,具体使用读者可以自己去发掘。
4、利用strace得到我们关心的信息。大多数情况下我们用strace的目的是跟踪系统调用,但其实strace对多线程程序的调试有很大的帮助,使用strace打印多线程程序信息的命令如下:
1 | strace -F ./test |
如果我们对某些系统调用,如gettimeofday,ioctl不感兴趣,可以屏蔽掉
1 | strace -F -etrace=\!gettimeofday,ioctl ./test |
通过strace打印出的信息,我们可以对什么时候产生了一个子线程,那个线程在等待,哪个线程被唤醒,哪个线程收到信号,哪个线程core掉有一个综合的了解,这些信息对多线程调试会起到很大的作用。