《深入理解计算机系统》| 异常控制流
每次从一条指令过渡到另外一条指令的过程称为控制转移,这样的一个控制转移序列叫做控制流,如果每条指令都是相邻的,这样的过渡就是平滑序列。如果一条指令与另外一条指令不相邻,这样突发性的过渡称为异常,也就是我们这一章要学到的异常控制流(Exceptional Contro Flow)。学习这些知识将有助于我们并发这一重要的概念,异常控制流其实发生在系统的各个层次,我们将从硬件层的ECF:异常、操作系统层的ECF:进程和信号、应用层的ECF非本地跳转三大部分讲起,逐步带领大家打牢一些重要的系统概念的真正含义,理解应用程序与操作系统的交互(syscall)、编写Web服务器的方法、并发、软件异常工作原理等等。废话不多说,开始飙车了。
1.1 异常(硬件层)
处理器状态中的事件,触发了从应用程序到异常处理程序的突发性控制转移就是:异常
这一过程如上图所示,应用程序本来在执行Icur指令,但是有些事件(定时器信号、算术溢出等)会使得处理器的状态发生变化,这时候处理器会通过一张异常跳转表,进行跳转到专门的异常处理程序中,异常处理程序执行完任务以后:可能返回当前正在执行指令、返回当前下一条指令或者终止被中断的程序。
举一个小时候的例子:在我很小的时候,经常和一些小朋友弹弹珠,这时候玩儿的正起劲儿(正在执行当前指令),突然母亲大人在门口一声大喊:“xxx 回家吃饭了”(突发性事件)。我就必须要放下手头上正在玩儿的游戏,一溜小跑回家吃饭(异常处理程序)。吃完饭以后,我可以选择继续玩,玩其他游戏,或者不玩游戏(异常处理程序完成以后)。
① 异常处理
我们来描述一下异常处理的这个过程,当你按下计算机的启动按钮的时候,操作系统分配和初始化一张异常跳转表,包含k个异常条目和其处理程序的地址,如下图:
当运行本机上的一个应用程序的时候,如果处理器检测到了一个事件,并且确定了异常号4,处理器就会触发异常,通过异常表中的地址4跳转到相应的异常处理程序中执行。这一过程如下:
(说明:1>异常表的起始地址存放在异常表基址寄存器中,通过异常号就能计算出具体的异常条目地址;2>异常处理模式不同于普通的函数调用,不需要将返回地址压入栈中,也不需要保存额外的一些状态,由于是运行在内核模式下,意味着拥有对所有资源的访问权限)
② 异常分类
中断:是异步发生的,来自处理外部的I/O设备信号的结果。如我目前在打字的键盘:
我的电脑上也在播放音乐,当在听歌的同时输入字符到屏幕上的时候,处理器就会注意到I/O设备键盘上的中断引脚电压变高了,当前指令执行完毕以后就会从系统总线中读取异常号,然后调用中断处理程序,输入完字符以后,中断处理继续执行听歌程序的下一条指令。由于这个过程相当的快,就好像什么都没有发生一样。
陷阱(陷入内核):实现系统调用,在用户程序和内核之间提供一个像函数调用一样的接口。
比如要读一个文件的内容(read),这些内核服务受到控制的访问,处理器提供的是syscall n指令来响应用户的请求,导致一个陷阱异常,这个异常程序对参数解码并调用内核程序。这个异常处理程序运行在内核模式中。(后面详解这一过程)
故障:能够被故障处理程序修正的错误。
故障发生时,处理器将控制转移到故障处理程序中,由故障处理程序修正错误。如果能够修正就返回当前指令重新执行,如果不能修正就返回到内核的abort中。
终止:通常是由一些硬件引起的不可恢复的致命错误
直接返回到abort中,终止该应用程序。
③ Linux系统中的异常
IA32有高达256种不同的异常,0-31的号码是Intel架构师定义的;32-255号是操作系统定义的中断和陷阱。
系统调用:陷入到内核中执行(不知道为啥翻译成了陷阱)
Linux系统上有数百个系统调用,比如常用的读文件、写文件、创建进程等等,这些系统调用都有一个唯一的整数编号:
由于历史的原因,系统调用通过异常128来处理(0x80)提供。我们来看看使用系统级函数写出的hello world程序:
我们直接来看看hello程序的汇编语言版本:
所有Linux系统调用的参数都是通过寄存器而不是栈来传递的。按照惯例:行9:eax中包含调用号;行10-12:设置参数;行13:使用int 指令来调用系统调用。(write系统调用)
1.2 进程基础知识(操作系统层)
我们讲了一些异常的基础知识,下面来看看异常是怎样实现了计算机科学上最伟大的一个成就:进程。对于进程最经典的定义就是一个执行中的程序实例。进程是一个伟大的魔术师,她提供给每个运行的程序一种假象,好像每个程序都在独占处理器和地址空间。我们接下来不会讨论进程的实现细则,我们关注的是进程提供给程序两个关键抽象:逻辑控制流和私有地址空间。把这两点理解清楚也就够了:
进程控制流:
上图是一个运行了三个进程A、B、C的系统,处理器的控制流分成了3个,每一个进程1个。随着时间的增加,进程A先运行了一小段(①),然后进程B运行直到结束(②),随后进程C运行了一小段(③)后切换到进程A运行直到A结束(④),最后切换到进程C运行直到结束(⑤)。这样一来每个进程执行它的流的一部分,然后被抢占。由于CPU总是毫秒级别的转移我们什么都不会察觉到。就提供了一种每个程序独占的假象。
至于并发就很好理解了,说白了就是某进程开始执行以后并未完成以后,跳转到其他进程执行,这两个就是并发。如上图的进程A和进程B,进程A和进程C。由于进程B执行结束以后才开始进程C,所有B和C不算是并发。
时间片:进程A执行它控制流的一部分的每个时间片段,就叫时间片。
私有地址空间:
进程为每一个程序提供它自己的私有地址空间
这个私有的地址空间最上部是内核保留的,最下部是预留给用户程序的。代码始终是从0x08048000处开始(32位系统)。
用户模式和内核模式:
处理器为了安全起见,不至于损坏操作系统,必须限制一个应用程序可执行指令能访问的地址空间范围。就发明了两种模式用户模式和内核模式,其中内核模式(上帝模式)有最高的访问权限,甚至可以停止处理器、改变模式位,或者发起一个I/O操作,处理器使用一个寄存器当作模式位,描述当前进程的特权。进程只有当中断、故障或者陷入系统调用时,才会将模式位设置成上帝模式,得到内核访问权限,其他情况下都始终在用户权限中,就能够保证系统的绝对安全。
上下文切换机制:
内核中有一个专门的调度程序,当从进程A切换到进程B的时候,内核调度器为每个进程保存一个上下文状态(运行环境保存):包含程序计数器、用户栈、状态寄存器等,然后切换到另外一个进程处开始执行。
当内核代表用户执行系统调用的时候,就会发生上下文切换,如上图所示,当进程A调用read函数的时候,内核代表进程A开始执行系统调用读取磁盘上的文件,这需要耗费相对很长的时间,处理器这时候不会闲着什么都不做,而是开始一种上下文切换机制,切换到进程B开始执行。当B在用户模式下执行了一段时间,磁盘读取完文件以后发送一个中断信号,将执行进程B到进程A的上下文切换,将控制权返回给进程A系统调用read指令后面的那条指令,继续执行进程A。(注:在切换的临界时间内核模式其实也执行了B一个小段时间)
1.3 进程的控制
① fork函数为例:封装系统调用错误处理
系统级别的函数遇到错误时,通常会返回-1,并设置全局变量errno来标识是什么地方出错了,通过,strerror(errno)可以解析出错误描述的字符串。
我们看上面的错误检查程序,有时候觉得每次系统调用函数都这样处理就太臃肿了,于是我们来简化上面的这种错误检查的方法,定义unix_error函数,如下:
这样当我们要检查fork函数是否有错误的时候就可以这样使用:
这里就会输出对应的fork error系统调用错误,我们同样的认为上面的函数有点儿臃肿,于是在每个系统调用函数中,用首大写的Fork函数来定义如下的检查错误调用方法:
这样一来,错误就会通过pid = Fork();这样就会显示的将错误信息打印出来,并返回值。
② 创建和终止进程
我们使用fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,除了PID不同外,子进程可以读写父进程中打开的任何文件。一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。fork函数有一个特别的地方,虽然只被调用一次,却能返回两次。我们来看:
我们使用的是封装好错误处理的首字符大写的Fork函数,第一次一次返回是在父进程中,返回的是子进程的PID;一次是在子进程中,返回的是0;因为PID总是非零,返回值为0就说明在子进程中执行了。
编译生成fork可执行文件,下面是结果:
然而fork函数这样的运行方式令人疑惑的,我们来分析一下这输出的两个不同的x的值。当调用pid = Fork();的时候,第一次返回的是进程的子ID,由于不为0,所以继续执行main函数中的printf,打印输出x = 0;第二次就在子进程中执行了,返回的pid为0表示在子进程中执行,由于两个进程有相对独立的地址空间,子进程得到的只是父进程的一个拷贝,所以x的初始值仍然是1,输出的结果就是x=2了。(至于为啥都输出在屏幕上了,是因为这两个进程共享已经被打开的stdout文件,子进程是继承父进程的,因此输出也是指向屏幕的)
我们来看看进程分析图:(以三次调用fork函数为例)
第一次调用,红色部分分出的子进程打印出4次printf,第二次调用fork蓝色线,打印出2个hello;第三次调用土黄颜色,打印出2个hello。
③ 回收子进程:waitpid函数
当我们使用fork函数创建了一个子进程的时候,子进程就会在独立的地址空间运行,我们并不是放任其一直运行,而是希望在某些时候回收子进程,了解子进程的状态,而不是让其消耗存储器的资源(僵尸状态)。这时候就需要用到waitpid函数,我们看看函数定义:
如果调用waitpid函数时,等待的子进程已经运行结束,该函数会立即返回。否则父进程会被阻塞,暂停运行。参数详解如下:
pid : 等待的集合成员(pid>0为单独的一个子进程ID号,pid=-1等待所有子进程);
status:检查已回收子进程的退出状态,有了这个信息父进程就可以了解子进程为什么会推出,是正常推出还是出了什么错误。
options : 修改默认的行为如果不想使用这些选项,则可以把这个参数设为0。
返回值:如果成功就返回子进程ID;如果没有失败返回-1(没有子进程的失败设置ECHILD;被中断设置EINTR)
还有一个简化的版本wait函数,wait(&statue)相当于waitpid(-1,&statue,0)。
waitpid函数有点儿复杂,我们来看看两个使用示范:
第一个我们编写如下waitpid1.c文件
说明:在for循环内,我们创建了N=2个进程,并且保证在子进程中运行的时候使用exit返回分别为100,101两个值;在接下来的while循环内,父进程使用waitpid函数作为循环测试检测每个子进程的状态,当子任意一个进程返回的时候,waitpid会返回其pid值,并将退出子进程的状态保存到status中去,也就是在while循环体内输出的status值。当回收了所有子进程以后waitpid就会返回-1,不再执行while循环了。我们运行的结果就是:
至于返回的顺序,是不定的。甚至在同一系统两次不同的执行都有不一样的结果。如果想规定回收的顺序。就只有在11行,显示的保存下每个进程的pid,然后在第16号,按照进程的pid进行回收。
④ 让进程休眠:sleep函数和pause函数
⑤ 加载并运行程序:execve函数(调用一次,从不返回)
其中,filename是execve加载并运行的可执行文件名,argv是参数列表,envp是环境变量列表。结构如下:
当开始一个新程序的时候,用户栈结构如下:
⑥ 利用fork和execve运行程序(一个简单的壳的实现)
我们接下来展示一个简单的外壳程序(read/evaluate),使用100行左右的代码。主要完成两个操作,读取(read)命令行和求值(evaluate)运行程序,来看看主要部分:
简单的读取用户输入的命令行,使用求值函数(eval)解析命令行,并代表用户运行程序。如上图的Fgets函数将读取的命令行保存到cmdline中,然后传递给求值函数(eval)。
求值函数(eval)中,首要任务是使用parseline函数,解析以空格分割的命令行参数,并将最后的结果保存在argv向量中。然后使用Fork函数创建进程,在进程中调用execve函数执行
我们来使用我们自己写的壳程序来运行一下,我们之前写过的waitpid1程序:
看到了,这就是使用了fork和execve写的一个壳,shellex来运行我们自己的程序。我们没有做好的就是不回收子进程,接下来我们学习信号,来弥补这个缺陷。
1.4 信号
信号是一种更高层次的软件形式的异常,它允许进程中断其他进程。一个信号就是一个消息,我们列出Linux系统上30个不同种类的信号:
正在运行的前台子进程,当键入ctrl-c,发送序号2(SIGINT);当一个进程发送信号9(SIGKILL)就会强制终止另外一个进程;当子进程终止时,就会发送信号17(SIGCHILD)给父进程。
举个例子:信号就像你每天早上起床而设置(调用kill函数)的闹钟一样,你接收到这个闹钟以后,被强迫要处理这个信号(一直闹也没办法睡觉啊),这时候你有三种选择:继续睡觉(忽略)、关闭闹钟(终止)、起床(执行闹钟给我的命令:信号处理程序)。
如何发送信号
1> 使用/bin/kill程序发送信号(使用完整路径)
发送9号(SIGKILL)信号给进程6279终止该进程,如果使用-6279就是该进程组的所有进程;
2> 从键盘发送消息(ctrl-c)
外壳程序,允许一个前台程序和多个(或者0)个后台程序。形成如下图的结构:
我们使用:ls | sort
就会创建两个进程组组成的前台作业,两个进程分别是ls和sort,都属于同一个前台进程组20。那么如何使用键盘发送信号给进程呢?我们使用ctrl-z组合键来看看(发送SIGTSTP挂起前台作业):我们在命令行输入top命令:
这是top命令运行时的状态,注意,当我们按下ctrl-z组合键的时候看最下角显示的内容:
表示接收到了我们使用键盘发送的ctrl-z(SIGTSTP)信号。挂起top程序。
3> 调用kill函数发送信号(可以发送给自己)
当子进程运行到Pause函数的时候,将等待信号的到达,主进程这时候使用kill函数发送的是,SIGKILL信号,这将终止子进程的运行,所以子进程中的printf函数从来不会被执行,界面无任何显示。
4> 使用alarm函数发送信号
主函数main中使用Signal函数将SIGALRM信号,与处理函数handler绑定,接收到了SIGALRM信号以后就会跳到handler函数处开始运行。Alarm函数(第19行)发送一个SIGALRM信号,在handler函数中异步处理这个信号。打印出5个BEEP和一个BOOM!。
接收信号
当程序运行的时候,如果接收到了信号,就会把控制权转移到信号处理程序中,执行完信号处理程序以后才返回程序的下一条指令继续运行,如下图:
信号有预定义的默认行为:
进程终止;进程终止并转存储器;进程停止直到SIGCONT重启;忽略;
然而我们可以使用signal修改程序的默认行为:
这里定义了handler函数,和SIGINT函数绑定,当我们在键盘上输入ctrl-c的时候,打印出一行字“Caught SIGINT”并退出。
我们来看看运行的效果:
我们不做任何输入的时候,由于main中的pause,将等待信号的发送。这时候我们使用键盘上输入ctrl-c组合键,就会看到:
打印了一句我们handler函数中的那段话,并退出。
信号处理原则
我们前面的例子中,程序只是捕获一个信号进行处理,当有多个信号到达时,如何处理,遵循下列原则,请看:
1> 待处理信号被阻塞:拿我们在接收信号处理程序中的sigint1中的程序为例,当我们的程序正在处理handler函数时,如果又捕获到了一个SIGINT信号,这时候并不会停止handler函数的处理,而是将这个SIGINT信号放到带处理程序的位置(阻塞),直到handler函数执行完毕返回以后才接受这个待处理信号;
2> 待处理信号不会排队等待:还是以sigint1函数为例,如果正在处理handler函数,接受到了2个信号,这时候先到的那个信号会变成待处理信号被阻塞,最后的那个信号直接被丢弃;
3> 系统调用可以被中断:诸如read、wait函数,会阻塞进程一段时间,当处理程序捕获到一个信号时,被中断的系统调用在处理程序返回的时候就不会再执行了。
我们之前开发过一个简单的壳程序,当时我们说由于没有回收子进程,我们僵死的子进程会占用内存空间,不利于壳程序的长久运行。我们学习了这么多基础知识,来尝试升级我们的壳处理程序:
版本1:
在这个程序中,我们为了让父进程可以自由做自己想做的事情,就决定对SIGCHLD信号进行捕获并处理在handler1函数中回收资源(子进程终止的时候会发送该消息)。设置好信号处理程序以后,我们使用for循环创建了3个子进程,并打印出子进程的pid号,每个子进程运行1秒并终止。同时父进程将等待终端的一个输入行,随后处理它(我们模拟为无限期处理while循环)。
我们来看看运行效果:
主进程并没有阻塞,我们还可以输入内容,我输入的sjljf字符串。我们注意到很有意思的一点,我们创建的子进程并没有完全被回收,最后一个pid为6480的子进程没有被回收,而是变成了一个僵尸进程,这是怎么回事呢?我们是没有处理好原则【 待处理信号不会排队等待】解释一下这个过程:当handler1函数正在回收6478号进程的时候,收到了6479号进程的回收请求信号,这时候6479被加入到待处理信号位置,又过了1秒钟,6480进程的回收信号也来了。由于已经有了待处理信号6479,所以6480进程的回收信号将被简单的丢弃掉。这就是问题所在。
版本2:
改进的核心主要是在handler函数中,我们使用一个while循环,尽可能多的回收我们的子进程
我们看看运行效果,已经将所有子进程全部回收完毕
版本3中针对原则3系统调用中断后重启read系统调用问题进行了修正,由于我们没有Solaris系统,这里就不做实验了。更新的代码如下:
1.5 并发编程初步介绍:同步流(避免并发错误)
并发编程是一个很深奥且重要的问题,我们将在12章花费一章节讲述,这里我们来初窥一下其中的奥秘介绍其中的:竞争(一个经典的同步错误实例)
说明:先执行delete再执行addjob,之间的竞争
1> 父进程调用fork函数,创建新子进程并运行该子进程;
2> 在父进程能够再次运行前,子进程就终止的话,子进程就会变成一个僵尸进程,内核传递一个SIGCHLD信号给父进程;
3> 父进程可运行前会检测到右SIGCHLD信号,将跳转到handler函数中去;
4> handler函数调用deletejob函数,却什么也做不了,因为主进程中并没有使用addjob函数;
5> 从handler函数返回以后,父进程再调用addjob函数就会添加错误的子进程在列表中去。
我们尝试修复在这个竞争引起的同步错误。我们接下来将使用方法阻塞SIGCHLD信号,使得程序始终保持addjob在前,deletejob在后的状态。由于子进程进程了父进程中的这种阻塞,所以在子进程中首要任务是解除阻塞。具体修改的代码如下:
主要使用Sigprocmask(SIG_BLOCK)阻塞SIGCHLD信号。
1.6 非本地跳转(应用层)
本地跳转是我们非常熟悉的goto语句,然而有些弊端的是不能跳到函数外部去。这时候非本地跳转的概念就因运而生了。这样做有一个好处是如果多层调用的函数最内层出现了错误,可以直接跳转到特定区域执行我们的错误分析函数。来看一个例子:
我们来解释一下代码的意思,由于setjmp和longjmp都要使用到jmp_buf,我们将其设置成全局变量。setjmp函数第一次调用的时候,是保存当前的调用环境到buf的缓冲区,并返回0,作为接下来longjmp跳转的目标地址;通过if的条件判断rc == 0成立,就会调用foo()函数,foo函数继续调用到bar函数,判断error2 = 1成立执行一次longjmp跳转,将跳转到setjmp保存的调用环境中去。并把2返回给rc。这时候在执行判断就会输出错误的类型,并将结果打印出来:
非本地跳转还有一个应用就是实现软重启,类似于我们重启某种服务一样。我们来看代码:
这段代码相当简单,使用的是信号版本的sigsetjmp和siglongjmp函数。当sigsetjmp函数第一次被调用的时候保存调用环境和信号向量(阻塞和待处理的),并返回0,所以执行if里面的语句打印出“starting”字符串,然后就开始在主函数中的while循环中执行了。这时候如果输入ctrl-c就会捕获到这个信号,通过handler处理函数进行了一次siglongjmp跳转,跳转的目的地是sigsetjmp处的位置,这时候执行if中的else语句打印出restarting字符串,并继续主函数的执行。结果如下图: