从计算机启动到关闭,CPU做的仅仅是处理一系列顺序的指令,一次一条。这个顺序指令叫做CPU的控制流。
迄今为止已经学习了软件方面的控制流改变:1,branches/jumps 2,call/return。这两种改变都是针对“程序状态”
对于“系统级状态”的改变,控制流应该如何处理呢?比如磁盘数据上载到内存,或者程序除以0,或者键盘按下ctrl+c,或者系统计时器终止等等,控制流需要靠“异常控制流”的机制应对。
存在于计算机的各个层级,分别如下
异常是指为了响应某一事件event,控制权由当前执行的程序转移到操作系统内核(OS kernel)
内核:操作系统内存常驻部分
常见事件event:除以0,计算溢出,页缺失(page faults),I/O请求完成,键入ctrl+c等等
事件处理过程如下
可以发现:在kernel的异常处理器处理完成了,有三种情况。
可以发现:内核中有一张异常表,每一种事件都对应着一个编号,供查到对应的处理程序。
发生在处理器外部的事件引起的异常,通过处理器的中断引脚(interrupt pin)传给处理器,并且异常处理器(exception handler)完成后,会接着处理原控制流的下一条指令(next)
举例:
可以发现:timer interrupt可以帮助内核掌握控制权。
由于执行某一条指令发生的异常
分类:
陷阱traps,错误faults,中止aborts。
可以发现:traps可以恢复,且是当前程序主动请求的,比如请求system call系统调用,来调用系统函数,faults不是主动请求的而是不可能预测的,发生faults时,程序后能会在原处(不是next)接着重新运行,也可能中断
常见system call符号:
上图为系统调用举例
上图为页缺失举例:当前内存没有所需要的地址a[500],从磁盘中调用
上图为页缺失的另一种情况:当为错误地址时,发出signal
定义:进程是指运行程序的一个实例(因为运行的程序可能有多种存在形式,比如.c,.o,.text或者加载到了内存中)
两大关键抽象
多进程的假象:看上去同时运行着多个进程,每个进程都在使用CPU和内存。
传统单核处理器处理多进程:交错处理,进行多进程转换时,先把当前寄存器中的值和地址空间存在内存中,然后转到另一个进程执行,当执行另一个进程时,把那个进程之前存好的寄存器值和地址空间读取出来即可
现代多核处理器处理多进程:依然是共享内存和一些缓存,但是每一个核都可以处理一个进程了,进程数超过核心数,也要进行上下文切换
多进程的并发性:如果在进程A的逻辑控制流核进程B的逻辑控制流时间交叠,则称这两个进程是并发的(实际在物理层面,它们并没有交叠)比如下图
A/B,A/C并发,B/C顺序
上下文切换:由操作系统内核控制着进程及上下文切换(注意内核不是单独运行的进程,而是作为某些进程的一部分在运行着)
调用系统函数(system call)规则:一定要检查系统函数的返回状态!除了那些返回值为void的系统函数
处理系统函数调用错误:通常系统会会设置errno来表示错误原因,并返回-1。如下图调用fork()
可以使用错误报告函数unix_error来简化错误报告。
也可以使用错误处理包装来包装系统调用函数
从程序员角度,我们可以把进程状态分为三种:
进程终止原因:
exit()函数:特殊的函数,一旦调用,永远不会返回。(但是可以从main函数中return 一个状态值)
产生进程:使用fork()函数产生新的子进程(child process)
fork()函数详解
使用进程图,可以方便我们分析当前并发程序(假并发)中,语句的执行顺序
可以发现语句执行顺序为拓扑结构,由于可以进行上下文切换,有多种可行的执行顺序。
当子进程运行完后,依然占用着系统的资源(包括exit status退出状态,OS表等),称为僵尸(半死半活)
父进程需要对僵尸进行回收:使用wait/waitpid函数,父进程能够得到子进程的退出状态(exit status),然后内核清理僵尸子进程。
如果父进程没有回收,父进程终止后,那些僵尸进程会被init 进程(pid == 1)回收,但是我们需要显式的回收长期运行的进程(比如shell/servers)
上图为子进程长期运行的例子(可知我们必须显式的终止长期运行的进程)
我们可以使用wait来回收子进程(与子进程同步):
当child_status非空时,会指向一个子进程的退出状态,根据此状态,可以确定有某个子进程终止,还能确定退出状态(终止状态?)
execve()允许当前进程的上下文中加载并允许新的程序
其中:filename:为可执行文件:如.o文件或者脚本文件(通过解释器如shell,bash等允许命令行,通常是以#! intepreter 比如#! bin/bash开头)
argument[]以null结尾,指向一系列参数字符串,通常第一个为需要指向的文件名,之后为命令行指令参数。
envp[]以null结尾,指向一系列环境变量,形式:“name = value”
重点:会覆盖原有进程的code/data/stack!!!但是保留了PID,打开的文件,信号上下文??
特点:此函数如果不报错(也就是只要filename指向的程序能够运行)就不会在执行进程中的原程序了,也就是call once and never returns。
在Main的stack frame的头顶,会存放着我们调用execve()是的参数,比如argu[]和envp[]等(与传统直接调用Main不同的点?)
可以发现执行”bin/ls -lt /usr/include“这条命令,会分成三段放在argv[]里面,(注意调用系统函数还是要检查返回值。。)
其中:init进程的PID为1,所有其他进程是init的子进程
Daemon:系统中长期运行的其他进程(辅助进程)
login shell:用户登录后会有一个与身份对应的shell进程(shell本身是一个程序)
shell本身是一个应用程序,可以代表user来跑其他程序,常见的shell有sh/csh/bash,其中bash为linux默认shell
其中:从stdin把用户输入的命令行写入cmdline,首先判断是否输入的是否为ctrl+d,如果是,则退出shell,否则,执行输入的命令行(解释)(eval)
其中:buf:保存着修改后的comdline,
argv[]保存着可执行文件的文件名以及参数
如果argv是内置的指令,则执行,不是内置指令,则fork一个子进程,然后在子进程中调用execve来执行目标文件。
bg:判断是否为后台进程,如果不是后台(是前台)进程,则得一直等待进程的结束,才能执行之后的命令。
可以发现:对于前台进程,shell可以回收,但是对于后台进程,当它们终止后会变成僵尸进程,并且一直不会回收(因为shell是一直运行的)从而占用内存资源。所以我们需要一种机制,来提醒shell回收进程———signal!
信号是用来提醒进程:现在系统中发生了一些事情
信号的特点及常见ID
可以发现:信号是内核与进程中的异常处理机制(软件级别)不包含其他信息。
由内核发送给目标进程,做的仅仅是更新了进程上下文的几个state(bits)。
通常内核发出信号的原因有:
目标进程当它被内核要求对发出的信号做出反应时,称为信号被接受。
通常接受信号时目标进程有以下反应:
可以发现:用户可以自己定义signal-handler来处理信号,与硬件级别发生中断很像,只是exception handler是内核处理,此处的handler是一个用户定义的函数。
信号待定是指:一个信号已经被发送,但是还没有被接受,最多只能由一个被待定的同类型信号,不存在”排队“的概念。如果一个进程已经存在了一个信号K的待定,则其他发送给此进程的信号K,都会被丢弃。
信号阻塞:给某进程的信号K如果被阻塞,则此信号K仍可以发送,只是在信号K的阻塞解除前,都不能接收到此信号。
内核在每一个进程都维护着待定和阻塞的位向量(bit vector)(其实就是一个32位int)。pending set:表示正在待定的信号们,blocked set:表示已经阻塞了的信号们(可以用sigprocmask来设置此进程哪些信号阻塞了)
每一个进程都属于唯一的进程群
kill():指定目标进程的PID,以及想要发送的信号。
由于接受信号需要对上下文进行一定的修改(上下文转换),所以接受信号发生在内核在exception handler return的时候,准备把控制权转交给进程p的时候,如果发现signal set已经被修改(有信号send),此时会让进程p对信号进行响应。
如上图:kernal如果发现进程B有信号pending,则会强制让进程B响应。
可以发现:内核会根据pending和blocked的情况,计算出pnb,然后处理所有非零位(从高到低)(即处理所有信号),处理完成后,再进行p的下一条指令。
有四种默认的响应方式
除了默认响应外,允许用户自定义signal handler来响应信号,具体过程如下:
信号相应函数和main函数一样,也是独立的逻辑流,与main程序并发运行(不同于进程间的并发,这是进程内的并发)
可以发现:与普通的函数不同的是,当控制权交给handler函数后,需要先把控制权交给内核,再由内核把控制权交给main函数
嵌套信号处理函数:
当某种pending信号正在被处理时,其他所有同类型信号都会被阻塞
使用sigpromask函数
可以调用sigpromask函数,显式的阻塞某一特定信号。
信号处理函数缺点:
信号处理函数编写准则
异步信号安全函数:是可重入的或者是不能被信号打断的。
可重入是指:函数所有变量都存在于stack frame上,没有使用全局变量或全局函数,从而没有锁,不会互斥,可被多个进程同时调用。
种类:117个
典型错误:死锁,如调用printf,由于不是信号异步函数,handler调用printf可能会出现死锁(此时printf被main锁住了)
因为只能由一个Pending的同种类信号,其余信号都会被丢弃。
对于一些公共数据的处理,需要防止handler和main程序的资源竞争,具体做法是在处理公共资源的前后,加上sigpromask来阻止信号中断。
上图的代码出现了并发进程的典型错误(虽然这两个是同一进程,但也算并发了),那就是无法确定是handler是先运行还是main先运行,如果handler先运行,则会删除joblist中不存在的PID。
解决办法:在fork子进程之前,屏蔽了SIGCHID,这样main函数运行时,就算子进程已经回收发出了SIGCHID信号,也会被pending,待将joblist更新后,在来reap child,这样就能保障顺序(同步)。
我们不能决定child和parent谁先运行,但是我们可以在main中parent中暂时屏蔽信号。
具体做法:设置一个flag称为pid,当调用了某个信号处理函数,更改pid,从而可以确定某个信号已经处理完毕。
可以发现:1,首先利用sigpromask保证main先把pid设置成0
2,使用while(!pid)来判断是否接受到了特定信号
3,程序很浪费:一直在打印,占用资源
想到了其他办法:
1,使用pause():可以暂停,一直到信号接受,但是有bug,因为信号可以在while和pause中间产生中断,从而忽略了此次pid。
2,使用sleep(),但时间不好控制,时间太短,会一直占用CPU,时间太长,会使Main程序变慢。
解决办法:使用sigsuspend(原子性,不能被中断)
可以发现:sigsuspend函数,可以先解除对sigchild的屏蔽,然后在屏蔽
使用sigsuspend函数显式的等待某项信号。
C语言可以允许一个函数(callee)不返回caller,而返回其他并没有调用这个callee的函数,称为非本地跳转(longjump…)
具体见教材。