Linux 下的实时信号更类似于软件层次的“中断”,它可能发生在任何时刻,而这与程序的运行必然存在一定的冲突。本文将针对这种状况,介绍相应的处理方案。
前言
Linux 下的信号分为可靠信号和不可靠信号,或称为实时信号和非实时信号,对应于 Linux 的信号值为 1-31 和 34-64。对于他们的分类以及应用的时的区分并不在本文的讨论范围之内,读者可参考文献 1,对其应用做初步的了解。本文仅针对在应用实时信号处理函数时,如何解决其重入问题进行一些探索。
Linux 下的实时信号更类似于软件层次的“中断”,它可能发生在任何时刻,而这与程序的运行必然存在一定的冲突,即重入性问题。在一次应用 Linux RT 信号编写程序的过程中,碰见了这个问题,尽管这是个老话题,几乎所有的文章都强调,不要将不可重入的代码段置于信号处理函数中,但是由于特定场合的需要,在一个信号函数中无法避免地处理某个临界区。在一般情况下,在处理多线程临界区时,采用加、解锁的办法达到对临界区串行的访问目的,是最简单、实用的解决方法。那么有没有办法,通过使用的锁来解决信号函数的重入的问题呢?答案是可以的,但是在信号函数中要格外注意锁的正确用法。本文将针对这种较为特殊的状况,介绍相应的处理方案,由于 LinuxThread 和 NPTL 在信号处理上的不同,本文仅讨论 NPTL 的情况。
另外本文在提及的所有代码可在下载中获取。
信号函数与主程序间潜在的重入性问题
在 Linux 中,对于实时信号,推荐使用如下函数装载信号函数:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
其中,sigaction 的定义如下:
struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };
对于实时信号而言,在实时信号抵达时,如果该信号不在 sa_mask指定的信号集之内,即信号未被屏蔽,信号函数 sa_sigaction才会被执行。反之,如果实时信号存在于 sa_mask指定的信号集中,该信号会被加入到该进程的信号队列中,直到进程解除对该信号的屏蔽。由于实时信号的默认动作是打出该信号值,并退出当前进程,用户还需根据自己的实际情况屏蔽不必要的信号,可以使用下面方法在 sa_mask里面添加信号:
struct sigaction act; sigemptyset( & act.sa_mask); // empty signal set, sigaddset( & act.sa_mask, SIGRTMIN+1 ); // add SIGRTMIN+1 to set. sigaddset( & act.sa_mask, SIGRTMIN+2 ); …
在默认情况下,在 sa_flags中不会设置 SA_NODEFER选项,当进程正在处理信号时,若再有新信号(相同信号)抵达,该信号会加入到进程的信号队列中,直到进程的信号处理过程结束;反之,程序会立即处理新信号。如果用户确定自己的信号函数可重入,也可以在 sa_flags里面设置 SA_NODEFER选项,此选项可以使得信号函数在收到信号时被立即执行。但是当到达的信号达到一定的频度之后,程序将因为无法及时处理信号函数而导致信号函数套嵌,积累到一定程度就会出现问题。
在默认的情况下,当内核派发信号到这个程序时,信号函数会按照串行化方式被执行。由于信号的发生是非预期的,它可能在主程序运行到任何时刻发生,而信号函数的执行总是以中断当前主程序的运行为代价的,也可以认为,执行信号的进程与主程序进程本身就是同一个进程,但是它们并非是按照一定顺序顺次执行的。设想,如果主程序正在处理一个临界区,而到来的信号函数也要处理同一个临界区,就会面临类似于多线程程序的重入性问题。在普通的多线程应用中,各个线程之间的关系是并行的。我们一般可以用锁来解决这种问题,但是,如果简单的将这种方案引入信号函数处理过程中,就会出现一个比较讨厌的问题——死锁:
清单 1. 信号函数与主程序间的死锁
void signal_test_func( int signo, siginfo_t * siginfo, void * ptr ) { … sem_wait( &semlock ); // acquire lock // do something here. crit_value = 0; printf( "signal handled, crit_value = %d. \n", crit_value ); sem_post( &semlock ); // release lock … } int main() { // create semaphone lock init value = 1 … // link signal handler … sem_wait( &semlock ); // acquire lock // use sleep to simulate main thread is doing some work. crit_value = 1; sleep(10); printf( "main thread, crit_value = %d. \n", crit_value ); sem_post( &semlock ); // release lock sleep(10); printf( "main thread, job done. \n", crit_value ); }
在程序清单 1 中,信号函数与主函数都会访问 crit_value这一临界资源,于是在访问这一共享资源之前,用锁进行访问的互斥(实际上,printf函数也是不可重入的,也意味着主程序与信号函数对它的访问同样需要注意临界区的问题,如果出于安全性的考虑,应该使用 write 替换 printf,在本文中它不是讨论的主要目的,请读者不必对 printf较真)。编译并运行该程序(编译时需要连接 rt 库,以及给 gcc 增加参数 -lrt),如果不做任何动作,则程序在 20 秒之后退出,并输出:
xxx@xxx-desktop:~$ ./test main thread started, use "kill -35 18283" to trigger dead lock. \ Otherwise, program will exit within 10secs. main thread, crit_value = 1. main thread, job done.
若按照程序的指示在前 10 秒内用‘ kill ’向本程序发送信号 35(注,用户可能得到的信号值不一定是 35)。程序将无法自行退出,输出为:
xxx@xxx-desktop:~$ ./test & … xxx@xxx-desktop:~$ kill -35 18466 enter signal handler. If there is no exit message, a dead lock happened.
此时程序进入死锁状态,需要用 kill或者 Ctrl+C强制使程序退出。该死锁发生的机制可以进行如下解释:如图 1 所示,当主程序请求并持有锁(sem_lock),开始做一些工作时。如果此时有信号的发生,主程序会被中断执行并跳转至信号函数 signal_test_func执行。进入信号函数之后,由于信号函数也需要访问共享资源,进而请求锁,由于锁仍然被主程序持有,信号函数就会一直等待锁的释放。然而,因为主程序的运行已经被信号函数抢占,在信号函数完成之前无法运行,也就无法继续执行解锁动作,于是信号函数在请求锁时变成了死等待。
图 1. 在信号函数与主函数间加锁
方案 1,使用测试加锁
如何解决这种问题呢?第一种思路可以将信号函数的加锁动作替换为测试加锁动作,例如使用:
int sem_trywait(sem_t *sem); int sem_timedwait(sem_t *restrict sem, const struct timespec *restrict abs_timeout);
该函数是 sem_wait的非阻塞版本,如果加锁失败或超时则返回 -1。使用 sem_trywait修改函数 signal_test_func如下:
清单 2. 使用 sem_trywait 代替 sem_wait
void signal_test_func( int signo, siginfo_t * siginfo, void * ptr ) { … if ( sem_trywait( &semlock ) != 0 ) { msg = "exit signal handler. Lock failed\n"; write( 1, msg, strlen(msg)); return; } // do something here. crit_value = 0; printf( "signal handled, crit_value = %d. \n", crit_value ); sem_post( &semlock ); … }
修改后的程序在运行时,无论信号发生在任何时刻都不会导致程序的死锁,信号函数在请求锁时一旦失败立即返回,而并不是死等在锁的请求上。当主程序有机会继续运行时,将锁进行释放,从而避免了死锁。执行结果如下:
xxx@xxx-desktop:~$ ./test & … xxx@xxx-desktop:~$ kill -35 18748 enter signal handler. If there is no exit message, a dead lock happened. exit signal handler. Lock failed main thread, crit_value = 1. xxx@xxx-desktop:~$ kill -35 18748 … signal handled, crit_value = 0. …
前后两次发送信号发生在不同的阶段,第一个信号发生于主函数持有锁的过程中,信号函数由于未能获得锁而退出;第二个信号发生在主函数释放锁之后,信号函数成功获得锁。虽然这种方法避免了死锁,但却是以丢弃信号作为代价,因此这并不是一个好的解决办法,是否有更好的办法避免这个问题呢,下面引入第二节,来探讨这个问题。
方案 2,使用双线程处理信号与锁
参考 Linux 系统、NPTL 对实时信号的实现,当系统派发一个信号给一个进程时,会选择该进程的某个线程进行处理,前提是这个线程未屏蔽该信号。而被选中的线程将先中断自己的执行并跳转至信号函数执行,当信号函数执行完毕后,信号就被处理完毕,并进行释放,最后被中断的线程返回到中断处继续运行。但是,在被选中线程处理信号的过程中,其他线程并不会停止运行,而是和信号处理线程处于平行关系的执行顺序,与线程间的执行关系完全相同,即信号函数的执行空间是在线程内的。如图 2
图 2. 双线程时,信号的处理
这就给我们以一个提示,如果我们用 MainThread线程作为主线程运行,并使其屏蔽该信号,Thread1线程作为信号处理线程执行,专门用于信号函数的处理,并对 MainThread和 SignalHandler之间临界区的访问进行加锁,这样既避免死锁的问题,又避免了临界区访问重入问题,同时也避免了信号丢失。其中,需要使用到线程信号处理函数:
int pthread_sigmask(int how, const sigset_t *newmask, sigset_t *oldmask);
该函数的作用与 sigprocmask 函数颇为类似,但它处理的范围仅限于调用线程。how指定了处理 signalmask的方法,可以为 SIG_SETMASK,SIG_BLOCK或者 SIG_UNBLOCK,顾名思义,SIG_SETMASK使用 newmask参数替换原有 signal mask;SIG_BLOCK将 newmask里面的 signal mask标志位置为 block状态;SIG_UNBLOCK与前者功能相反。oldmask里面用来存放替换前的 signal mask状态,如果程序不需要恢复原来的 signal mask状态,可将这个参数置为 NULL。
按照思路修改程序,其中关键程序片段如下:
清单 3. 使用 pthread_sigmask 屏蔽主线程接收信号
int main() { … if ( pthread_create( &Thread1_pt, NULL, &thread1_func, NULL ) != 0) perror( "Creating Child thread failed\n" ); // Blocking RT_TEST_SIG in Main Thread, always using thread1. sigset_t sigmask; sigemptyset( &sigmask ); sigaddset( &sigmask, RT_TEST_SIG ); // Should not use sigprocmask, it will block signals over process instead thread. pthread_sigmask( SIG_BLOCK, &sigmask, NULL ); … }
在程序执行过程中,需要自己用 kill 随机触发 RTMIN+1(35)信号,每次触发该信号时,都会打印
[signal] enter signal handler.
从程序的运行结果可以看得出来,对于 Thread1而言,虽然表面上它不做任何事情,但实际上,它在运行过程中,会因为信号的到来跳转至信号函数运行;因为信号函数由 Thread1运行,处在与 MainThread平行的状态,所以如果 MainThread(即主函数)处于临界区访问时,信号函数会一直等待到 MainThread退出临界区才继续运行,这样它们都有被系统调度运行的机会,于是不会造成死锁的问题。
似乎,到现在对于信号与锁的问题得到了圆满的解决,但是如果我们扩展一下思路,将 Linux 系统对实时信号的处理特性扩展到多线程信号处理的情况下,就是我下面的扩展。
多线程信号并行处理机制的实现
设想现在有一个这样的应用:由于需要处理的信号比较多,出于对效率的考虑,如果能够将信号的处理并行化,将有效的提高程序的效率。如何编写这样的模型呢?基于双线程的一些讨论,我们可以得到以下理论模型,如图 3:
图 3. 多线程信号处理模型
在这个模型中,程序中存在多个线程,当有多个信号同时抵达时,系统会随机的选择其中一个线程完成信号的处理,当一个线程正在处理这个信号时,新抵达的信号并不会被屏蔽,而是被分配给下一个可以处理信号的线程,同时由于信号处理的过程是在线程运行空间内完成的,这样在各个线程处理信号的过程中就达到了一个并行的目的,根据重入性的需要,在处理函数中使用锁来达到互斥的目的。因此这个模型可以大大提高信号的处理能力,其同时可处理信号的个数将等于线程的数量,根据应用的需要,对于 SMP 系统应该根据 CPU 的数目确定线程的数目。
对于这个模型的实现程序,请读者自行实践,本文限于篇幅不给出详细的代码。而最简单的实现方法即是在方案 2 提供的程序中加入一个新的空线程。
总结
本文针对信号函数、锁及重入性问题进行一些有益的探讨,通过单线程程序、信号函数之间重入性的矛盾引入,扩展到 Linux 对于多线程程序信号函数处理的行为对双线程程序与信号重入性的影响,最后引申到多线程程序并行信号处理模型。本文中提供了解决在信号函数中调用不可重入程序片段矛盾的方法,希望为使用信号函数的开发人员提供有益的参考。