购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

4.1 信号

4.1.1 Linux信号处理机制的设计

1.信号的设计思想

信号是一个轻量级的进程间通信机制,其特殊点在于,其他的进程间通信机制都要创造通信数据通道,而信号本身就是由内核创建的进程间的API通道。

使用API通道的一个最大的优势是,参与通信的各个部分都不需要自己设计通信所需要的数据。在实现上,信号可以通过在通用进程间通信的基础上封装一层来实现。在Linux下,信号被设计成了一种轻量级的事件导向的机制,这就意味着,信号处理所需要的数据完全可以通过函数调用栈和函数参数来完成。

Windows之前没有信号的概念,但设计了一种APC(Asynchronous Procedure Calls,异步过程调用)机制。APC机制把信号定位为异步过程调用,这与Linux对信号的事件性质定位不同。Linux很看重事件的响应速度,任何的系统调用发生都会导致信号被处理,任何的中断处理结束也会对应地去处理信号。

信号是UNIX/Linux的特色设计,其被设计为通知特定的线程(包括用户线程和内核线程)有特定的事件发生。在Linux下,有64个信号被定义,每个信号都分别代表某个事务,还预留了两个用户自定义语义的信号,如图4-1所示。

img

图4-1 Linux下的可用信号列表

2.信号的技术原理

Linux下的用户程序有应用和内核两个栈,系统调用都是在内核栈进行的。Linux设计的机制是在应用程序陷入内核的情况下检查是否有信号要处理,所以在进行信号处理时信号队列和通知都是在内核中完成的,比如在系统调用结束准备返回用户空间时,检查一下是否有信号,有的话就先进行信号处理,进行完信号处理再返回用户。

信号处理函数是用户空间的函数,由用户空间定义。当内核返回用户时系统调用还没有返回用户,只是跳到了用户空间的信号处理函数,所以信号处理函数在运行完之后会再次进入内核,然后内核把之前系统调用的执行结果返回给用户程序。系统调用是在内核栈上进行的,信号处理函数是在用户栈上进行的,所以内核在进入用户空间的信号处理函数时,偷偷地修改了用户空间的栈内容,推入了一个叫作栈帧的东西。栈帧包含恢复用户态程序执行所需要的所有信息,当中断处理结束时返回内核空间,内核空间使用栈帧来恢复用户空间。此外,栈帧还能在信号处理函数中进行访问,用于获得当前信号发生的环境,甚至修改栈帧就可以修改任意用户程序的上下文。

从内核跳转到信号处理函数,也是一次从内核到用户空间的代码切换过程,这个过程相当于完成了系统调用返回。内核返回用户空间的流程是一样的,都要清理掉这一次的系统调用的内核栈内容。在处理信号时,系统调用的上下文就已经被清理了,内核无法再使用进入系统调用时保存的上下文来恢复用户空间上下文的状态。所以内核就把结果(eax)和程序流(eip)等上下文信息一起放到用户空间的栈保存了。若系统调用的返回值和上下文在栈上,那么在读取时复原一下上下文就可以返回给用户程序了。这个信号处理使用的栈帧上下文被叫作ucontext_t,位于栈帧结构体中。可以在信号处理函数中使用getcontext()函数得到这个栈帧。因为栈帧可以完全代表用户上下文,所以通过控制修改栈帧的内容能够实现用户空间的跨函数跳转,甚至替换成不同的线程,于是可以实现用户空间的线程调度。在内核中进入信号处理的核心函数是do_signal()。

在进行信号处理时使用的是用户栈,也可以用一个单独的栈,这个单独的栈可以用sigaltstack来设置,这样所有的信号处理函数都会在这个专门的信号处理的栈上执行了。信号栈存在的目的:不让信号处理影响到用户栈的大小(会因为处理信号导致栈溢出);当发生缺页异常时,用户栈或许不可用,esp寄存器甚至指向完全错误的内容,这时需要专门的信号栈才能进行生成dump文件等灾难处理流程。

3.信号处理函数

信号处理函数是在进程接收到信号之后在用户空间执行的对应信号的响应函数。信号是在内核中接收的,但是信号处理函数可以是用户定义的。用户定义的函数不允许其在内核中执行,因此,内核必须要想尽办法让信号处理函数在用户空间执行。在设置了用户的空间栈之后,将执行权交给用户程序。这时有一个问题,在用户函数执行结束之后该如何?

一个合理的设计是,编译层面让编译器配合内核,识别特殊结构的信号栈,在信号处理函数返回时函数调用不再是标准的调用约定,而是编译器生成栈的卸载代码,然后直接在用户空间把执行权交还给用户程序。但是这样就会产生用户空间代码和内核空间代码权限混用的安全性问题,因为用户和内核之间的切换没有经过调用门。

若不依赖编译器的帮助,则内核必须在已有的调用约定的范畴,自己设计整个返回流程,而且这个返回流程必须匹配用户空间的调用约定。同时,给信号处理函数提供的函数的参数也必须满足用户空间的调用约定。

因此,Linux内核做了一个巧妙的设计—信号栈帧结构体,即把上下文和信号处理函数需要的参数都压到栈里面。这些功能都是通过如下的sigframe结构体做到的。

img
img

由上面的sigframe结构体可以看到,这个栈的结构由struct rt_sigframe所定义。第1个参数pretcode是信号处理函数的返回地址;第2个参数sig是信号处理函数的第1个参数;pinfo是信号处理函数的第2个参数,指向结构体的第5个域struct siginfo info;puc是信号处理函数的第3个参数,指向第6个域struct ucontext uc。

这个技巧巧妙地把函数的调用约定设计在栈帧之中。结构体在内存中的布局是从低地址到高地址的,而栈的增长方向是从高地址到低地址的,即栈上看到的第1个数据pretcode就是结构体的第1个域,可以将其直接用作ret指令所需要的eip的值。因此,当信号处理函数进入时,从栈帧中可以直接获得参数;当信号处理函数返回时,从栈帧中可以直接获得返回函数的地址—pretcode。一切都在cdel的函数调用约定的运行范畴内。

pretcode一般情况指向sigreturn的系统调用,即当信号处理函数执行结束返回时,仍然返回到用户空间的sigreturn的封装函数(一般在glibc内),直接触发一次系统调用,将执行权交接给内核,由内核完成栈的卸载还原工作。

4.信号相关的系统调用

用户栈上的信号帧的最大作用是在信号处理函数结束时,用于恢复被打断的上下文。信号处理函数在恢复上下文时会跳转到一段由内核注入用户程序的代码,该代码调用sigreturn()返回内核。这段被内核注入的信号处理代码原来存放在栈上,后来由于可执行栈的安全问题,栈不让其执行了,所以存放在vdso或者glibc中。sigaction函数有一个很奇怪的域,即sa_restorer域,用来指定这段信号处理代码放在哪里。

在信号处理函数中可以看到被中断的上下文,上下文也一起被压到了信号栈帧中,如下。

img
img

下面是32位内核和64位内核与信号处理相关的系统调用。

X86-32:

#define __NR_signal 48

#define __NR_sigaction 67

#define __NR_sgetmask 68

#define __NR_ssetmask 69

#define __NR_sigsuspend 72

#define __NR_sigpending 73

#define __NR_sigreturn 119

#define __NR_sigprocmask 126

#define __NR_rt_sigreturn 173

#define __NR_rt_sigaction 174

#define __NR_rt_sigprocmask 175

#define __NR_rt_sigpending 176

#define __NR_rt_sigtimedwait 177

#define __NR_rt_sigqueueinfo 178

#define __NR_rt_sigsuspend 179

#define __NR_sigaltstack 186

#define __NR_signalfd 321

#define __NR_rt_tgsigqueueinfo 335

X86-64:

#define __NR_rt_sigaction 13

#define __NR_rt_sigprocmask 14

#define __NR_rt_sigreturn 15

#define __NR_rt_sigpending 127

#define __NR_rt_sigtimedwait 128

#define __NR_rt_sigqueueinfo 129

#define __NR_rt_sigsuspend 130

#define __NR_sigaltstack 131

#define __NR_signalfd 282

#define __NR_signalfd4 289

#define __NR_rt_tgsigqueueinfo 297

由以上内容可以发现,kill系统调用号在32位内核下是37,在64位内核下是62。64位内核下的信号相关的系统调用比32位内核下精简了很多,但是功能大体没变,只是在系统调用API上做了重新组织,节省了更多的系统调用号。

从信号的设计层面能知道信号所需要的API,因为信号有64个(不同版本会有差异),不是每个线程都需要关注所有信号的,所以每个线程要么使用默认的信号处理函数,要么自定义对某个信号的处理方式,或者选择不接收此类信号。

信号处理的三个需求分别是:设置信号屏蔽,产生信号和处理信号,设置信号和检查信号。

(1)设置信号屏蔽

每个线程都可以处理信号,发送信号的一方可以发送给任意的线程。同时,每个线程也都可以设置屏蔽哪些信号或者接收哪些信号,这与设置信号处理函数不同。线程设置信号屏蔽是为了让自己根本不接收这个信号,对应的处理方式是不接收。

在一般情况下,第1个线程是不指定线程信号的处理线程。只有当第1个线程繁忙、不能处理信号的时候,进程级别的信号才会交给下一个线程处理,而下一个线程能否接收处理,取决于这个线程的信号屏蔽字。Android系统为每一个进程都设计了一个信号处理线程Signal Catcher,这个信号处理线程就是第1个线程,能够处理所有进程层面的特定信号。Signal Catcher只监听了SIGUSR1(GC)、SIGUSR2(用于打印JIT虚拟机情况)和SIGQUIT(用于ANR,即打印各个线程的堆栈)。

信号可以指定发送给某一个线程,描述一个信号的内容通过struct sig_info结构体来完成,结构体如下:

img
img
img

由以上代码可以看到,该结构体是一个union联合体。不同的信号内容描述的格式也不一样,如果是发给某一个特定线程的信号,则si_code的值是SI_TKILL。si_code表明信号发生的原因,si_signo表明信号的编号。若是发送给特定线程的信号,则info->_sifields._tgkill._pid代表接收信号的目标线程。

一个线程能不能接收特定的信号,需要进行系统调用设置。64位内核的系统调用如下。

● rt_sigprocmask:获得和设置当前线程的信号屏蔽字。

● rt_sigtimedwait:暂时替代信号屏蔽字为指定的屏蔽字,带超时的阻塞等待。

● rt_sigsuspend:暂时替代信号屏蔽字为指定的屏蔽字,阻塞等待。

rt_sigsuspend所做的事情看起来与rt_sigtimedwait所做的事情类似,但是有一个很大的语义上的区别,rt_sigsuspend的目的是挂起当前进程,除非有特定的信号到来,否则等待时间可以无限延长;而rt_sigtimedwait则是等待特定的信号出现,关注的是信号本身,可以指定信号屏蔽字,也可以指定超时,返回时可以返回发生的信号的详细信息(struct sig_info)。事实上,rt_sigsuspend与pause这两个系统调用的唯一区别是:rt_sigsuspend多了一个设置信号屏蔽字功能(返回时要复原)。由于rt_sigtimedwait目标不是暂停当前线程,而是等待特定的信号,所以在其进入内核时,会先检查要等待的这个信号是不是已经在pending中,如果在,则直接返回这个pending的信号,如果不在,则会阻塞等待指定信号的发生。

信号屏蔽字不同于忽略信号。忽略信号会直接接收该信号,但是没有任何响应。而设置信号屏蔽字,相当于人为地将屏蔽字的信号放到pending信号列表中。信号屏蔽字的意义并不在于屏蔽这个信号、不处理,而是暂时不处理该信号,将该信号放在pending列表中,等到重新开放了该屏蔽字之后再进行处理。

对于进程层面的信号屏蔽字,是搜索可用线程进行处理的。当一个线程设置了对该信号的屏蔽字不处理时,搜索过程也会直接跳过该线程,即不会将该信号加入设置了信号屏蔽字的线程pending列表中。如果是发送给指定该线程的信号,则会加入该线程的pending列表。在Linux中,每个进程组(即对应线程的进程概念)都有一个pending列表,当发送给进程层面(进程组)的信号遍历了所有线程(内核进程概念)都找不到能立刻处理的线程时,该信号就会被加入进程组的pending列表。

Linux在设计信号处理机制时,引入了一个很重要的“重复丢弃”逻辑,即一个线程的同一种类型的信号只能有一个处于pending状态,其他的直接丢弃。在进程组层面,发送给进程的信号也是同一种类型的信号,且只能有一个处于pending状态。信号分为普通信号和实时信号,实时信号一个也不会被丢弃,相当于无限增长的链表。

(2)产生信号和处理信号

pending重复丢弃设计的最大意义是弥补了信号屏蔽字机制的不足。假设没有该逻辑,那么pending信号就是链表的不丢弃的组织方式。如果一个用户调用rt_sigprocmask修改了信号屏蔽字,屏蔽了某一个信号,但是由于某个逻辑问题忘记改回来,那么发送给该线程的后续所有被屏蔽信号都会不断地被加入该线程的pending列表中。这时,内核就要面临一个问题:是允许这个链表无限增长,还是给这个链表一个最大长度?显然都不合理。

因此,Linux内核的信号机制又有一个新的语义概念—事件通知。事件分为62种,早期Linux在设计信号产生的API时,只设计了kill、tkill、tgkill这3个系统调用。kill是给一个进程组(即用户空间的进程)发送信号,tgkill是给进程组里面的进程发送信号,tkill是一个失败的API设计,已经被废弃。这3个系统调用都只产生一个信号(即一个事件),并不会设置附属的数据,这就与信号重复丢弃的语义概念匹配了。

但是随着Linux内核的发展,人们迫切希望在产生信号的同时产生附属的数据,即更改信号的事件语义为消息语义。信号的本质逐渐地变为一个消息,消息也有62种,但是每一种消息都可以携带不同的数据。

所有的信号处理函数在返回时都会调用rt_sigreturn()函数,该函数不应该由任何用户主动调用。rt_sigreturn()函数的出现是因为从内核切换到用户的信号处理函数的方式是一个实现技巧,相当于直接修改了eip,而上层的应用程序不知道如何结束这个外来的修改。“解铃还须系铃人”,信号栈的内容设置只有内核知道,所以信号处理函数返回时必须调用rt_sigreturn()函数返回给内核,整个过程不应跳出内核给应用程序设计的栈框架。

用户空间完全可以在信号处理函数中修改自己的eip,让整个逻辑直接脱离信号处理的框架,只要知道内核是如何修改信号栈的,它就可以在应用程序层面直接修改信号栈,仿佛返回了内核一样。如果变成消息,则它是比Windows下的APC机制更好的一个回调机制。

(3)设置信号和检查信号

使用rt_sigaction可以设置一个信号处理函数,因为该信号处理函数传入了额外信息,覆盖了原来的signal系统调用的功能,所以x64就不额外提供signal系统调用了。但是用户空间的libc仍然会模拟signal函数以保持程序的兼容性。以下是sigaction结构体的定义。

img

sigaction结构体用于设置信号处理handler的结构体。在这个结构体中,handler有两种指定的方法:一种是默认的handler,也是绝大多数的情况;另外一种是指定_sa_sigaction,由于该结构体是一个union联合体,所以若要区分指定的是哪一种则需要额外的标志位。

sa_mask也可以设置信号屏蔽字,即在调用信号处理函数时,可以指定当前线程的信号屏蔽字。当信号处理函数处理结束时,信号屏蔽字就会恢复。

sigaltstack用于设置专门的信号处理函数使用的栈空间。当栈空间用完时,会产生栈溢出,如果溢出的数据有保护,则会产生SIGSEGV信号。如果线程在栈空间已经用完的情况下还要执行信号,则需要一个额外的栈了。

rt_sigpending可以用于查询当前的pending信号,以辅助用户确认是否产生了新的信号。

5.实时信号

在x86到x64的发展过程中,与信号相关的系统调用已经逐渐变为以rt_为前缀的了。这是因为这里逐渐加入了实时信号,在实现上,以rt_为前缀的信号逐渐包含了普通信号的API。

实时信号的关键在于以下两点:

● 不丢事件。所有的实时信号都会被handler处理。

● 保证及时处理。所有的实时信号都会在保证的时间内完成。

4.1.2 Windows的Event语义设计

Windows的Event是一个最简单的Object,打开或创建一个Event会得到该Event Obejct的Handle。使用Event的Handle可以监听在这个Event上发生的事件,当事件发生时,发生方可以调用SetEvent来通知正在监听的线程去响应这次的Event。

Event分为手动和自动两种,等待者也分为等待单个事件(WaitForSingleObject)和等待多个事件(WaitForMultipleObjects)两种接口。Windows的事件模型就是建立在手动和自动、等待单个事件和多个事件这两组模式形成的四种组合之上的。在WaitForSingleObject下有两种简单的模式,一种是生产者/消费者模式,另一种是通知模式。生产者/消费者模式要达到的目标是当生产者产生新的消息时,要通知一个消费者去消费,有且仅有一个消费者可以响应这个通知并进行消费。通知模式是,当有一个事件到达时,要通知到所有的等待者事件已到达,所有的等待者都应该同时收到通知。在通知模式中,一个等待者不能处理多次通知,这就可能会漏掉通知。而生产者/消费者模式则没有这个问题,在消费的消费者不需要理会新的生产者,因为新的消息总会找到空闲的消费者去消费。

这两种不同模式被Windows巧妙地设计到同样的事件接口中。在落地时,Windows将事件分为Manual Reset和Auto Reset两种。Manual Reset:在事件通知等待方之后必须手动调用Reset Event函数,复位Event。Auto Reset:只需要SetEvent通知等待方,Reset事件不需要用户应用程序的参与。

Manual Reset Event的特点是多并发。当一个线程用WaitForMultipleObjects同时等待多个事件,或者多个线程同时等待同一个Manual Reset的事件时,都能触发Manual Reset Event的并发特点,即所有在等待的参与者都会被唤醒,并得到通知。即Manual Reset的Event是通知模式。

Auto Reset Event的特点是单触发。无论有多少参与者在等待Auto Reset的Event,都只会有一个参与者得到通知。

所以,当需要生产者/消费者模式时,使用Auto Reset的Event;当需要通知模式时,使用Manual Reset的Event。生产者/消费者模式的典型特点是只需要一个Event,所有的消费者都会直接监听同一个Event。通知模式的特点是有多少个参与者就需要多少个Event,每个参与者负责管理自己的Event状态。

还有两种使用模式,一种是通知模式只使用一个Manual Reset的Event,另一种是生产者/消费者模式使用多个Auto Reset的Event。在通知模式中只使用一个Manual Reset的Event也是可行的,这种做法通常用于控制事件失效的频率。可以在调用SetEvent之前先调用ResetEvent,这样每一个事件的生效时间都是两次事件发生的时间间隔,而被通知者不需要调用ResetEvent。在生产者/消费者模式中,如果是多个Auto Reset的Event,则没有意义。Manual Reset的Event和Auto Reset的Event还可以混用。前面说的大部分是Event自身的语义特性,但WaitForMultipleObjects和WaitForSingleObject也承载了语义内容。如果是简单的单个生产者/消费者或者通知模式的语义,那么只需要WaitForSingleObject即可完成,因为这个语义是承载两种不同类型的Event上的。即使是多个生产者对应多个消费者的模式,那么一个Event也够了,因为所有的生产者/消费者都设置和等待同一个Event。

WaitForMultipleObjects可以做到任务管理模式,这种模式的特点是发起者是任务调度者,其产生任务让参与者去执行,每个执行者都持有一个Event,当事件完成后就设置这个Event,让任务调度者得到通知。这种模式分为两种子模式。一种是任何一个Event发生了事件,任务管理者都能得到通知,并且知道是哪个事件发生的,这是WaitForMultipleObjescts的默认工作方式。另外一种是等待参与者全部完成的并发计算方式,在这种方式下,通常参与者的Event事件代表的内容是一样的,一般用于并发处理。

WaitForMultipleObjects还有一个作用是让生产者/消费者和通知模式同时启用。让当前线程可以同时作为消费者和被通知者,但是在通知时,丢事件的概率会加大,因为一般消费者逻辑比较重,在处理消费逻辑时不能响应通知事件。

综上,Windows通过两种Event和两种Wait操作,设计了三种完全不同的语义模型:生产者/消费者模型和通知模型都是基于事件类型实现的,而任务调度管理则是通过WaitForMultipleObjects实现的。用户在使用Event的API时,正确的做法是先抽取自己的使用模型与哪一种模型匹配,然后对应地设计事件的结构。 m6cjMRKNY6DskDJ5aUBtPGyeMgOXcmk4q+wDMHzyS5p9zgeuEpZzo7UfB+rrP6Gy

点击中间区域
呼出菜单
上一章
目录
下一章
×