控制转移
控制流
系统必须能对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获,也不一定和程序的执行相关。
现代系统通过使控制流 发生突变对这些情况做出反应。我们称这种突变为异常控制流
( Exceptional Control Flow,ECF
)
异常控制流
发生在系统的各个层次。
理解ECF
很重要
ECF
将帮助你理解重要的系统概念。ECF
将帮助你理解应用程序如何与操作系统交互 trap
)或者系统调用(system call
)的ECF形式,向操作系统请求服务。ECF
将帮助你编写有趣的应用程序ECF
将帮助你理解并发ECF
将帮助你理解软件异常如何工作。这一章你将理解如何与操作系统交互,这些交互都围绕ECF
异常是异常控制流的一种,一部分由硬件实现,一部分由操作系统实现。
异常
(exception)就是控制流的突变,用来响应处理器状态的某些变化。状态变化又叫做事件
(event)
通过异常表
(exception table)的跳转表,进行一个间接过程调用,到专门设计处理这种事件的操作系统子程序(异常处理程序
(exception handler))
异常处理完成后,根据事件类型,会有三种情况
为每个异常分配了一个非负的异常号
(exception number)
系统启动时,操作系统分配和初始化一张称为异常表
的跳转表。
异常表
的地址放在叫异常表基址寄存器
的特殊CPU寄存器中。)
异常
类似过程调用
,不过有以下不同
异常分为一下四类:中断
(interrupt),陷阱
(trap),故障
(fault)和终止
(abort)。
前者可以叫异步中断/异常或外中断 ,后三个可以叫同步中断/异常
中断
中断
是异步发生,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成,从这个意义上它是异步的。陷阱和系统调用
陷阱
是有意的异常,是执行一个指令的结果。也会返回到下一跳本来要执行的指令。陷阱
最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用 syscall n
的指令故障
故障由错误引起,可能被故障处理程序修正。
abort
例程,进行终结。终止
abort
例程。有高达256种不同的异常
23~255 对应操作系统定义的中断和陷阱。
除法错误
机器检查
在IA32系统中,系统调用是通过一条称为int n
的陷阱指令完成,其中n可能是IA32异常表256个条目中任何一个索引,历史中,系统调用是通过异常128(0x80)提供的。
C程序可用syscall
函数来直接调用任何系统调用
研究程序如何使用int指令直接调用Linux 系统调用是很有趣的。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递。
惯例
- %eax 包含系统调用号
- %ebx,%ecx,%edx,%esi,%edi,%ebp包含六个任意的参数。
- %esp不能使用,进入内核模式后,内核会覆盖它。
系统级函数写的hello world
int main()
{
write(1,"hello,world\n",13);
exit(0);
}
汇编写的hello world
string:
"hello world\n"
main:
movl $4,%eax
movl $1,%ebx
movl $String,%ecx
movl $len,%edx
int $0x80
movl $1,%eax
movl $0,%ebx
int $0x80
异常
是允许操作系统提供进程
的概念的基本构造快,进程
是计算机科学中最深刻,最成功的概念之一。 进程
经典定义:一个执行中的程序实例. 上下文
中的。 进程
提供的假象 逻辑控制流
。地址空间
。逻辑控制流
,或者简称逻辑流
逻辑流
也有不同的形式。
一个逻辑流的执行在执行上与另一个流重叠,称为并发流
,这两个流被称为并发地运行。
多个流并发执行的一般现象称为并发
。
多任务
。时间片
。时间分片
并发
的思想与流运行的处理器核数与计算机数无关。
并行流
。你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持
并发
也不支持并行
。你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持
并发
。你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持
并行
。
并发
的关键是你有处理多个任务的能力,不一定要同时。
并行
的关键是你有同时处理多个任务的能力。
进程
为个程序好像独占了系统地址空间。
进程
为每个程序提供它自己的私有地址空间。处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式
和内核模式
。
处理器通过控制寄存器中的一个模式位
来提供这个功能。
模式位
后,进程就运行在内核模式中(有时也叫超级用户模式
) 模式位
时,进程运行在用户模式。 保护故障
。系统调用
间接访问内核代码和数据。Linux提供一种聪明的机制,叫/proc
文件系统。
/proc
文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。 /proc/cpuinfo
)/sys
文件系统。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(shedule
),由内核中称为调度器(scheduler
)的代码处理的。
当调度进程时,使用一种上下文切换
的机制来控制转移到新的进程
sleep
系统调用,显式请求让调用进程休眠。高速缓存污染和异常控制流
一般而言,硬件高速缓存存储器不能和诸如中断和上下文切换这样的异常控制流很好地交互,如果当前进程被一个中断暂时中断,那么对于中断处理程序来说高速缓存器是冷的。如果处理程序从主存访问足够多的表项,被中断的进程继续的时候,高速缓存对于它来说也是冷的,我们称中断处理程序污染了高速缓存。使用 上下文切换也会发生类似的现象。
当Unix系统级函数遇到错误时,他们典型地返回-1,并设置全局变量errno
来表示什么出错了。
if((pid=fork()<0){
fprintf(stderr,"fork error: %s\n", strerror(errno));
exit(0);
}
#include
#include
pid_t getpid(void);
pid_t getppid(void);
getpid()
返回调用进程的PID,getppid()
返回它的父进程的PID。pid_t
的值,在Linux系统下在type.h被定义为int进程总是处于下面三种状态
停止。进程的执行被挂起,且不会被调度。
SIGSTOP
,SIGTSTP
,SIDTTIN
或者SIGTTOU
信号,进程就会停止。SIGCONT
信号,在这个时刻,进程再次开始运行。信号
是一种软件中断的形式。终止。进程永远停止。
status退出状态
来终止进程(另一种设置方式在main中return )父进程通过调用fork
函数创建一个新的运行子进程
#include
#include
pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;
新创建的子进程几乎但不完全与父进程相同。
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。
fork()
函数会第一次调用,返回两次,一次在父进程,一次在子进程。
并发执行
相同但是独立的地址空间
共享文件
画进程图会有帮助。
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终结的状态,知道被它的父进程 回收(reap
)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。
一个终止了但还未被回收的进程叫做僵死进程
如果父进程没有回收,而终止了,那么内核安排init
进程来回收它们。
init
进程的的PID位1,在系统初始化时由内核创建的。一个进程可以通过调用waitpid函数来等待它的子进程终止或停止
#include
#include
pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.
waitpid
函数有点复杂。默认(option=0
)时,waitpid
挂起调用进程的执行,知道它的等待集合中的一个子进程终止,如果等待集合的一个子进程在调用时刻就已经终止,那么waitpid
立即返回。在这两种情况下,waitpid
返回导致waitpid
返回的已终止子进程的PID
,并且将这个已终止的子进程从系统中去除。
判断等待集合的成员
等待集合的成员通过参数pid确定
pid>0
,那么等待集合就是一个独立的子进程,它的进程ID
等于PID
pid=-1
,那么等待集合就是由父进程所有的子进程组成的。waitpid
函数还支持其他类型的等待集合,包括UNIX进程组等,不做讨论。修改默认行为(此处书中有问题,作用写反了)
可以通过将options
设置为常量WHOHANG
和WUNTRACED
的各种组合,修改默认行为。
PID
·检查已回收子进程的退出状态
如果status
参数是非空的,那么waitpid
就会在status
参数中放上关于导致返回的子进程的状态信息。wait.h
头文件定义解释status
参数的几个宏(函数宏):
exit
或者一个返回(return
)正常终止,就返回真。WIFEXITED
定义为真是,才会定义这个状态。WIFSIGNALED
返回真时,才会定义这个状态。错误条件
waitpid
返回-1
,并且设置errno
为ECHILD
。waitpid
函数被一个信号中断,那么它返回-1
,并且设置errno
为EINTR
。Q:凭什么输出bcac序列
A:?
wait 函数
wait函数是waitpid函数的简单版本:
#include
#include
pid_t wait(int *status);
调用wait(&status)
等价于调用waitpid(-1,&status,0)
。
waitpid实例,按顺序回收僵死进程
sleep
函数将一个进程挂起一段指定时间
#include
unsigned int sleep (unsigned int secs);
返回:还要休眠的描述
pause
让调用进程休眠,知道该进程收到一个信号
#include
int pause(void);
execve
函数在当前进程的上下文中加载并运行了一个新程序。
#include
int execve(const char *filename,const char *argv[],const char *envp[]);
execve
函数加载并运行可执行目标文件filename
,且带参数argv
和环境变量列表envp
。
只有当出现错误时,execve才会返回到调用程序
*argv[]
参数列表数据结构表示
null
结尾的指针数组。argv[0]
是可执行目标文件的名字。*envp[]
环境列表数据结构表示类似
null
结尾的指针数组。KEY=VALUE
的 键值对在execve
加载filename
以后,调用7.9节的启动代码,启动代码设置用户栈。并将控制传递给新程序的主函数。
主函数有如下原型
int main(int argc,char **argv,char **envp);
int main(int argc,char *argv[],char *envp[]);
当开始执行时,用户栈如图。
argc
: 命令行参数个数argv
: 命令行指针数组的地址envp
: 环境变量指正数组的地址Unix提供一下几个函数来操作环境数组。
getenv
#include
char *getenv(const char *name);
//getenv函数在环境变量搜索字符串“name=value"。如果找到了,它就返回一个指向value的指针,否则返回NULL。
setenv
和unsetenv
#include
int setenv(const char *name,const char *newvalue,int overwrite);
//成功返回0,错误返回-1
void unsetenv(const char *name);
//如果环境数组包含一个形如"name=oldvalue"的字符串,那么unsetenv会删除它
//,而setenv会用newvalue代替oldvalue,但是只有在overwirte非零时才会这样。
//如果name不存在,那么setenv就把”name=newvalue"添加进指针数组。
fork
与execve
区别
fork
:在新的子进程运行相同的程序。 execve
:在当前进程的上下文加载并运行一个新的程序。 PID
,并且继承了调用execve
函数时已打开的所有文件描述。Unix shell和Web服务器 这样的程序大量使用fork
和execve
函数。
shell是一种交互型的应用级程序,代表用户运行其他程序。
shell
是sh
程序。csh
,tcsh
,ksh
,bash
。其实shell
也就是一个ACM中很简单的模拟题而已。
trick
。&
来决定shell
是否waitpid
。即是否后台运行。>
,等待接收命令。eval
对命令运算。parseline
解析以空格分割的命令行参数,并将分割后的值丢入argv
中。
&
,则返回1。表示后台运行builtin_command
判断一下是否存在这样的指令。
如果bg=0
,那么等待程序结束,shell
才会继续执行。
parseline
具体代码就不贴了。
注意这个简单的shell
是有缺陷的,因为它并不回收它的后台子进程。修改这个缺陷,就必须使用信号。
研究一种更高层次的软件形式的异常, 也是一种软件中断,称为Unix
信号,它允许进程中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生一个某种类型的事件。
Linux
系统支持30多种信号。
每种信号类型对应于某种系统事件
底层的信号。
当底层发生硬件异常,信号通知 用户进程 发生了这些异常。
SIGILL
信号。SIGSEGV
信号较高层次的软件事件
SIGINT
信号SIGKILL
信号强制终止它。SIGCHLD
信号给父进程。传送一个信号到目的进程有两个步骤。
发送信号: 内核通过更新目的进程上下文的某个状态,就说发送一个信号给目的进程。
发送信号有两个原因
接收信号: 当目的进程 被内核强迫以某种方式对信号的发送做出反应。目的进程就接收了信号。
信号处理程序
(signal handler)的用户层函数捕获这个信号。 一个只发出而没有被接收的信号叫做待处理信号
(pending signal)
待处理信号
。 k
的待处理信号
。k
的信号都会被简单的丢弃。一个进程可以有选择性地阻塞接收某种信号
一个待处理信号最多被接收一次。内核为每个进程在
pending
位向量维护着待处理信号的集合,而在blocked
位向量维护着被阻塞的信号集合。只要传送一个类型为k的信号,内核就会设置pending
中的第k
位,而只要接收了一个类型为k
的信号,内核就会清除pending
中的第k
位。
Unix
系统 提供大量向进程发送信号的机制。所有这些机制都是基于进程组
(process group)。
进程组
每个进程都属于一个进程组
。
由一个正整数进程组ID
来标示
getpgrp()
函数返回当前进程的进程组ID
:
#include
pid_t getpgrp(void);
默认,一个子进程和它的父进程同属于一个进程组
一个进程可以通过setpgid()
来改变自己或者其他进程的进程组。
#include
int setpgid(pid_t pid,pid_t pgid);
如果pid是0 ,那么使用当前进程的pid。
如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。
例如:进程15213调用setpgid(0,0)
那么进程15213会 创建/加入进程组15213.
用/bin/kill
程序发送信号
/bin/kill
可以向另外的进程发送任意的信号。
比如
unix>/bin/kill -9 15213
发送信号9(SIGKILL
)给进程15213。
一个为负的PID会导致信号被发送到进程组PID中的每个进程。
unix>/bin/kill -9 -15213
发送信号9(SIGKILL
)给进程组15213中的每个进程。
用/bin/kill
的原因是,有些Unix shell 有自己的kill
命令
从键盘发送信号
作业(job)
:对一个命令行求值而创建的进程。
在任何时候至多只有一个前台作业和0个或多个后台作业
键入unix>ls|sort
两个进程通过Unix管道
链接。
shell
为每个作业创建了一个独立的进程组。
在键盘输入ctrl-c
会发送一个SIGINT
信号到外壳。外壳捕获该信号。然后发送SIGINT
信号到这个前台进程组的每个进程。在默认情况下,结果是终止前台作业
类似,输入ctrl-z
会发送一个SIGTSTP
信号到外壳,外壳捕获这个信号,并发送SIGTSTP
信号给前台进程组的每个进程,在默认情况,结果是停止(挂起)前台作业(还是僵死的)
用kill函数
发送信号
进程通过调用kill函数
发送信号给其他进程,类似于bin/kill
int kill(pid_t pid, int sig);
pid
>0,发送信号sig
给进程pid
pid
<0,发送信号sig
给进程组abs(pid)
事例:kill(pid,SIGKILL)
用alarm
函数发送信号
进程可以通过调用alarm
函数向它自己SIGALRM
信号。
#include
unsigned int alarm(unsigned int secs);
返回:前一次闹钟剩余的秒数。
alarm
函数安排内核在secs
秒内发送一个SIGALRM
信号给调用进程
如果secs=0
那么不会调度闹钟,当然不会发送SIGALRM
信号。
在任何情况,对alarm
的调用会取消待处理(pending
)的闹钟,并且会返回被取消的闹钟还剩余多少秒结束。如果没有pending
的话,返回0
一个例子:
输出
unix> ./alarm
BEEP
BEEP
BEEP
BEEP
BEEP
BOOM!
//handler是一个自己定义的信号处理程序,通过signal函数捆绑。
信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号
当内核从一个异常处理程序返回,准备将控制传递给进程p
时,它会检查进程p
的未被阻塞的待处理信号的集合(pening&~blocked
)。
如果非空,内核选择集合中某个信号k(通常是最小的k),并且强制p接收k。收到这个信号会触发进程某些行为。一旦进程完成行为,传递到p的逻辑控制流的下一条指令。
每个信号类型都有一个预定义的默认类型,以下几种.
SIGCONT
信号重启进程可以通过使用signal函数
修改和信号相关联的默认行为。
SIGSTOP
,SIGKILL
是不能被修改的例外。
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
signal
函数通过下列三种方式之一改变和信号signum
相关联的行为。
handler
是SIG_IGN
,那么忽略类型为signum
的信号handler
是SIG_DFL
,那么类型为signum
的信号恢复为默认行为。handler
就是用户定义的函数地址,这个函数称为信号处理程序 当处理程序执行它的return语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。
信号处理程序是计算机并发的又一个示例。信号处理程序的执行中断,类似于底层异常处理程序中断当前应用程序的控制流的方式。因为信号处理程序的逻辑控制流与主函数的逻辑控制流重叠,信号处理程序和主函数
并发
地运行。
自我思考:信号是一种
异常/中断
,当接收到信号的时候,会停下当前进程所做的事,立马去执行信号处理程序。并不是多线程/并行
,但确是并发
的。从下面这张图,可见一斑。
当一个程序要捕获多个信号时,一些细微的问题就产生了。
Unix
信号处理程序通常会阻塞 当前处理程序正在处理 的类型的待处理信号。k
仅仅表明至少一个一个信号k
到达过。read
,wait
和accept
这样的系统调用潜在的阻塞一段较长的时间,称为慢速系统调用。 errno
设置为EINTR
。用一个后台回收僵死子进程的程序,前台读入做例子
1.初始简单利用接收SIGCHLD
信号回收,一次调用只回收一个。
handle1-code
2.一次调用尽可能的多回收,保证在回收过程中,没有遗漏的信号。
handle2-code
3.还存在一个问题,在前台中,某些unix
系统(Solaris
系统)的read
被中断后不会自动重启,需要手动重启,Linux
一般会自动重启。
code
现在改为如果是errno==EINTR
手动重启。
或者使用Signal包装函数标准
。8.5.5会提到。
不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用是重启,还是永久放弃)是Unix信号系统的一个缺陷。
为了处理这个问题,Posix
标准定义了sigaction
函数,它允许与Linux
和Solaris
这样与Posix
兼容的系统上的用户,明确指明他们想要的信号处理语义。
#include
int sigaction(int signum,stuct sigaction *act,struct sigaction *oldcat);
//若成功则为1,出错则为-1。
sigaction
函数应用不广泛,它要求用户设置多个结构条目。
一个更简洁的方式,是定义一个包装函数,称为Signal
,它调用sigaction
。
Signal
包装函数设置了一个信号处理程序,其信号处理语义如下(设置标准): Signal
带着handler参数为SIG_IGN
或者SIG_DFL
被调用。 通过sigprocmask
函数来操作。
#include
int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);
sigprocmask
函数改变当前已阻塞信号的集合(8.5.1节描述的blocked
位向量)。
具体行为依赖how
值
SIG_BLOCK
:添加set
中的信号到blocked
中。SIG_UNBLOCK
: 从blocked
删除set
中的信号。SIG_SETMASK
: blocked=set
。如果oldset
非空,block位向量
以前的值会保存到oldset
中。
还有以下函数操作set集合
#include
int sigemptyset(sigset_t *set);
//置空
int sigfillset(sigset_t *set);
//每个信号全部填入
int sigaddset(sigset_t *set,int signum);
//添加
int sigdelset(sigset_t *set,int signum);
//删除
//成功输出0,出错输出-1
int sigismember(const sigset_t *set,int signum);
//判断
//若signum是set的成员,输出1,不是输出0,出错输出-1。
如何编写读写相同存储位置的并发流程序的问题,困扰着数代计算机科学家。
所谓同步流
就是。以某种方式同步
并发流,从而得到 最大的可行交错的集合 ,每个交错集合都能得到正确的结果。
并发编程是一个很深奥,很重要的问题。在第12章详细讨论。
现在我们只考虑一个并发相关的智力挑战。
code
如果发生以下情况,会出现同步错误。
fork
函数,内核调度新创建的子进程运行,而不是父进程。SIGCHLD
信号给父进程deletejob
.addjob
。显然deletejob
必须在addjob
之后,不然添加进去的job永久存在。这就是同步错误。
这是一个称为竞争
(race)的经典同步错误的示例。
main
中的addjob
和处理程序中调用deletejob
之间存在竞争。addjob
赢得进展,结果才是正确的,否则就是错误的。但是addjob
不一定能赢,所以有可能错误。即为同步错误。Q:如何消除竞争?
A:可以在fork之前,阻塞SIGCHLD
信号,在调用addjob
后取消阻塞。
一个暴露你的代码中竞争的简便技巧
制造一个fork的包装函数Fork,通过随机+休眠,在fork的那一瞬间,让子进程,父进程都有50%机会先运行
C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump)
。
非本地跳转是通过setjmp
和longjmp
函数来提供。
#include
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用
//参数savesigs若为非0则代表搁置的信号集合也会一块保存
setjmp
函数在env
缓冲区保存当前调用环境,以供后面longjmp
使用,并返回0
#include
STRACE
(痕迹):打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
-static
编译,能得到一个更干净,不带有大量共享库相关的输出的轨迹。PS
(Processes Status): 列出当前系统的进程(包括僵死进程)
TOP
(因为我们关注峰值的几个程序,所以叫TOP):打印当前进程使用的信息。
PMAP
(rePort Memory map of A Process): 查看进程的内存映像信息
/proc
:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构。
"cat /proc/loadavg
,观察Linux系统上当前的平均负载。异常控制流(ECF)
发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
在硬件层,异常
是处理器中的事件出发的控制流中的突变。控制流传递给一个异常处理程序,该处理程序进行一些处理,然后返回控制被中断的控制流。
有四种不同类型的异常:中断,故障,终止和陷阱。
中断
会异步发生。返回到Inext
一条指令的执行可能导致故障
和终止
同时出现。
故障
可能返回调用指令。终止
不将控制返回。陷阱
用于系统调用
。结束后,返回Inext
在操作系统层,内核用ECF
提供进程的基本概念。进程
给应用两个重要抽象:
在操作系统和应用程序接口处,有子进程,和信号。
最后,C语言的非本地跳转
完成应用程序层面的异常处理。
至此,异常
贯穿了从底层硬件,到抽象的软件层次。