1.进程相关概念
1.1 程序和进程
程序,是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁....)
进程,是一个抽象的概念,与操作系统原理联系紧密。进程是活跃的程序,占用系统资源。在内存中执行。(程序运行起来,产生一个进程)
程序 → 剧本(纸)进程 → 戏(舞台、演员、灯光、道具...)
同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)
如:同时开两个终端。各自都有一个bash但彼此ID不同。
1.2 并发
并发,在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但,任一个时刻点上仍只有一个进程在运行。
例如,当下,我们使用计算机时可以边听音乐边聊天边上网。 若笼统的将他们均看做一个进程的话,为什么可以同时运行呢,因为并发。
分时复用cpu
1.3 单道程序设计
所有进程一个一个排对执行。若A阻塞,B只能等待,即使CPU处于空闲状态。而在人机交互时阻塞的出现时必然的。所有这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分便被淘汰了。
1.4 多道程序设计
在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。
时钟中断即为多道程序设计模型的理论基础。 并发时,任意进程在执行期间都不希望放弃cpu。因此系统需要一种强制让进程让出cpu资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。
在多道程序设计模型中,多个进程轮流使用CPU (分时复用CPU资源)。而当下常见CPU为纳秒级,1秒可以执行大约10亿条指令。由于人眼的反应速度是毫秒级,所以看似同时在运行。
1s = 1000ms, 1ms = 1000us, 1us = 1000ns 1000000000
实质上,并发是宏观并行,微观串行!-----推动了计算机蓬勃发展,将人类引入了多媒体时代。
1.5 CPU和MMU
32位的操作者系统中的寄存器是4字节(4x8位)
虚拟内存中使用的是虚拟地址,当需要真正的运算的时候是需要到物理内存中去运算的,而物理内存中有物理地址,虚拟内存中的地址是可用地址,但并不是都可以全部用上,这取决于物理内存的大小。而MMU是充当桥梁,实现虚拟内存到物理内存的映射,MMU是以4k为单位的。
与虚拟地址空间和虚拟地址相对应的则是物理地址空间和物理地址,大多数时候我们的系统所具备的物理地址空间只是虚拟地址空间的一个子集。这里举一个最简单的例子直观地说明这两者,对于一台内存为256M的32bit x86主机来说,它的虚拟地址空间范围是0~0xFFFFFFFF(4G),而物理地址空间范围是0x00000000 ~ 0x0FFFFFFF(256M)。
两个不同程序中的内核空间(虚拟内存)中的PCB进程块(结构体),映射到物理内存中的同一块地方,这样就能实现两个程序之间的通信
都知道虚拟内存(外存 -- 硬盘之类)中有分内核空间和用户空间,而用户空间要借助系统调用才能到内核空间(物理上的操作,速度很慢),内存条(物理内存)上并存在像虚拟内存一样分为两块空间,那它怎么知道哪些是内核哪些用户 --- 设置内存访问限制,windows会将MMU分为4级,而linux会分为2级(最核心的0级和最外层的3级),用户空间到内核空间之所以慢,就是因为要借助MMU实现权级的切换,然后再到物理内存上去操作
1.6 进程控制块PCB
我们知道,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体。
/usr/src/linux-headers-3.16.0-30/include/linux/sched.h文件中可以查看struct task_struct 结构体定义。其内部成员有很多,重点掌握以下部分即可:
- 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
- 进程的状态,有就绪、运行、挂起、停止等状态。
- 进程切换时需要保存和恢复的一些CPU寄存器。
- 描述虚拟地址空间的信息。
- 描述控制终端的信息。
- 当前工作目录(Current Working Directory)。
- umask掩码。
- 文件描述符表,包含很多指向file结构体的指针。
- 和信号相关的信息。
- 用户id和组id。
- 会话(Session)和进程组。
- 进程可以使用的资源上限(Resource Limit)。
1.7 进程状态
进程基本的状态有5种。分别为初始态,就绪态,运行态,挂起态与终止态。其中初始态为进程准备阶段,常与就绪态结合来看。
2.环境变量
环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。通常具备以下特征:
- 字符串(本质)
- 有统一的格式:名=值[:值]
- 值用来描述进程环境信息。
存储形式:与命令行参数类似。char *[]数组,数组名environ,内部存储字符串,NULL作为哨兵结尾。
使用形式:与命令行参数类似。
加载位置:与命令行参数类似。位于用户区,高于stack的起始位置。
引入环境变量表:须声明环境变量。extern char ** environ;
2.1 常见的环境变量
按照惯例,环境变量字符串都是name=value这样的形式,大多数name由大写字母加下划线组成,一般把name的部分叫做环境变量,value的部分则是环境变量的值。环境变量定义了进程的运行环境,一些比较重要的环境变量的含义如下:
PATH:可执行文件的搜索路径。ls命令也是一个程序,执行它不需要提供完整的路径名/bin/ls,然而通常我们执行当前目录下的程序a.out却需要提供完整的路径名./a.out,这是因为PATH环境变量的值里面包含了ls命令所在的目录/bin,却不包含a.out所在的目录。PATH环境变量的值可以包含多个目录,用:号隔开。在Shell中用echo命令可以查看这个环境变量的值:
$ echo $PATH
env 可以直接查看当前所有环境变量
SHELL:当前Shell,它的值通常是/bin/bash。
TERM:当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。
LANG:语言和locale,决定了字符编码以及时间、货币等信息的显示格式。
HOME:当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该程序时都有自己的一套配置。
2.2 getenv函数
获取环境变量值
char *getenv(const char *name);
成功:返回环境变量的值;失败:NULL (name不存在)
2.3 setenv函数
设置环境变量的值
int setenv(const char *name, const char *value, int overwrite);
成功:0;失败:-1
参数overwrite取值:
1:覆盖原环境变量
0:不覆盖。(该参数常用于设置新环境变量,如:ABC = haha-day-night)
2.4 unsetenv函数
删除环境变量name的定义
int unsetenv(const char *name);
成功:0;失败:-1
注意事项:name不存在仍返回0(成功),当name命名为"ABC="时则会出错。
3.进程控制
3.1 fork函数
创建一个子进程。
pid_t fork(void);
失败返回-1;成功返回:① 父进程返回子进程的ID(非负)
②子进程返回 0
pid_t类型表示进程ID,但为了表示-1,它是有符号整型。(0不是有效进程ID,init最小,为1)
注意返回值,不是fork函数能返回两个值,而是fork后,fork函数变为两个,父子需【各自】返回一个。
fork前后的内容父、子进程都有,但只有父进程是执行过的,子进程并没有执行过,而fork后的内容父、子进程都执行,因此最后一个printf会被打印两次
3.1.1 创建n个子进程
一次fork函数调用可以创建一个子进程。那么创建N个子进程应该怎样实现呢?
简单想,for(i = 0; i < n; i++) { fork() } 即可。但这样创建的是N个子进程吗?
从上图我们可以很清晰的看到,当n为3时候,循环创建了(2^n)-1个子进程,而不是N的子进程。需要在循环的过程,保证子进程不再执行fork ,因此当(fork() == 0)时,子进程应该立即break;才正确。
练习:通过命令行参数指定创建进程的个数,每个进程休眠1S打印自己是第几个被创建的进程。如:第1个子进程休眠0秒打印:“我是第1个子进程”;第2个进程休眠1秒打印:“我是第2个子进程”;第3个进程休眠2秒打印:“我是第3个子进程”。
3.2 getpid函数
获取当前进程ID
pid_t getpid(void);
3.3 getppid函数
获取当前进程的父进程ID
pid_t getppid(void);
区分一个函数是“系统函数”还是“库函数”依据:
是否访问内核数据结构
是否访问外部硬件资源
二者有任一 → 系统函数;二者均无 → 库函数
3.4 进程共享
父子进程之间在fork后。有哪些相同,那些相异之处呢?
刚fork之后:
父子相同处: 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式...
父子不同处: 1.进程ID 2.fork返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器) 6.未决信号集
似乎,子进程复制了父进程0-3G用户空间内容,以及父进程的PCB,但pid不同。真的每fork一个子进程都要将父进程的0-3G地址空间完全拷贝一份,然后在映射至物理内存吗?(早期linux就是这样,但现在不是了)
父子进程间遵循读时共享写时复制的原则。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。如果子进程结束了,那么除了PCB资源残留外其它数据全部被回收,如果父进程的变量pid在子进程中被赋值过,由于子进程已经被回收,父进程是无法读取到的,还是原先的默认值(子进程中的.text发生了改变,有了独立的物理空间,pid在该段上更改,导致无法写时复制) --- 可以利用管道解决
练习:编写程序测试,父子进程是否共享全局变。 【fork_shared.c】
重点注意!躲避父子进程共享全局变量的知识误区!
【重点】:父子进程共享:1. 文件描述符(打开文件的结构体) 2. mmap建立的映射区 (进程间通信详解)
特别的,fork之后父进程先执行还是子进程先执行不确定。取决于内核所使用的调度算法。
3.5 gbd调试
使用gdb调试的时候,gdb只能跟踪一个进程。可以在fork函数调用之前,通过指令设置gdb调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。
set follow-fork-mode child 命令设置gdb在fork之后跟踪子进程。
set follow-fork-mode parent 设置跟踪父进程。
注意,一定要在fork函数调用之前设置才有效。
4.exec函数族
fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆和栈
将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。
其实有六种以exec开头的函数,统称exec函数:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
4.1 execlp函数
加载一个进程,借助PATH环境变量
int execlp(const char *file, const char *arg, ...);
成功:无返回;失败:-1
参数1:要加载的程序的名字。该函数需要配合PATH环境变量来使用,当PATH中所有目录搜索后没有参数1则出错返回。
该函数通常用来调用系统程序。如:ls、date、cp、cat等命令。
上述代码中是存在一个错误现象的:
int execlp(const char *file,const char *arg,...);
参数arg是从argv[0]开始算的,并不是从argv[1]开始,如
./a.out aa bb cc
ls -l -d -h
其中./a.out就是agrv[0],aa是argv[1];ls是argv[0],-l是argv[1]....
因此execlp("ls","-l","-d","-h")应该改为:
execlp("ls","ls","-l","-d","-h")
第一个ls是指定文件,第二个ls是argv[0],这样就实现打印当前文件下的目录:
ls -l -d -h
# 不说明路径默认是 . 当前文件路径
同时else if的父进程中应该再加一个sleep防止父进程先终止,父进程的父进程bash回到了前台造成了阻塞
bash当中就有exec函数,实现了父进程(bash的子进程)去执行我们所写的a.out文件
4.2 execl函数
加载一个进程, 通过 路径+程序名 来加载。
int execl(const char *path, const char *arg, ...);
成功:无返回;失败:-1
对比execlp,如加载"ls"命令带有-l,-F参数
execlp("ls", "ls", "-l", "-F", NULL); 使用程序名在PATH中搜索。
execl("/bin/ls", "ls", "-l", "-F", NULL); 使用参数1给出的绝对路径搜索。
练习:将当前系统中的进程信息,打印到文件中。
open,exec,dup2
4.3 execvp函数
加载一个进程,使用自定义环境变量env
int execvp(const char *file, const char *argv[]);
变参形式: ①... ② argv[] (main函数也是变参函数,形式上等同于 int main(int argc, char *argv0, ...))
变参终止条件:① NULL结尾 ② 固参指定
execvp与execlp参数形式不同,原理一致。
4.4 一般规律
exec函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
l (list)命令行参数列表
p (path)搜素file时使用path变量
v (vector)使用命令行参数数组
e (environment)使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量
事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示。
执行exec后,进程ID没有改变。新程序从调用进程继承了下列属性:进程ID和父进程ID实际用户ID和实际组ID附属组ID进程组ID会话ID 控制终端闹钟尚余留的时间
当前工作目录根目录文件模式创建屏蔽字 文件锁进程信号屏蔽未处理信号资源限制nice值 tms_utime、tms_stime、tms_cutime以及tms_cstime值
注意:进程中每个打开的文件描述符都有一个执行时关闭标志。若设置了此标志,则执行exec时会关闭该文件描述符;否则该文件描述符仍然保持打开。系统默认行为是不设置执行时关闭标志进程的 实际用户ID 和 实际组ID 不变,有效用户ID 和 有效组ID 是否改变取决于所执行程序文件的设置用户ID位和设置组ID位是否设置1)若新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;2)否则有效用户ID不变2)若新程序的设置组ID位已设置,则有效组ID变成程序文件所有组的ID;2)否则有效组ID不变
5.回收子进程
5.1 孤儿进程
孤儿进程: 父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。
kill -9 进程号
5.2 僵尸进程
僵尸进程: 进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。(僵尸进程是每个进程都会经历的)
特别注意,僵尸进程是不能使用kill命令清除掉的。因为kill命令只是用来终止进程的,而僵尸进程已经终止。思考!用什么办法可清除掉僵尸进程呢? --- 杀死父亲的进程,子进程就进入孤儿院,就会被隐式回收
5.3 wait函数
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。
父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:
① 阻塞等待子进程退出
父进程执行完了自己的内容后调用wait函数一直等待子进程结束后将其回收,防止子进程变成孤儿进程
② 回收子进程残留资源
子进程东西已经执行完了,父进程调用wait回收子进程在内核中(PCB)中的残留资源,反正父进程未结束无法回收僵尸进程(子进程)
③ 获取子进程结束状态(退出原因)。
pid_t wait(int *status);
成功:清理掉的子进程ID并返回该子进程ID;失败:-1 (没有子进程)
参数:用于获取子进程结束状态,使用wait提供的一些宏函数去查看
当进程终止时,操作系统的隐式回收机制会:1.关闭所有文件描述符 2. 释放用户空间分配的内存。内核的PCB仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号)
可使用wait函数传出参数status来保存进程的退出状态。借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:
1. WIFEXITED(status) 为非0→ 子进程正常结束
WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态 (exit的参数)
# 子进程那return 10;可以在父进程通过WEXITSTATUS获取到该exit时返回的值(前提是子进程正常退出)
2. WIFSIGNALED(status) 为非0 → 进程异常终止
WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号(kill -9的参数9)。
# 所有程序异常终止都是因为信号,比如crtl+c就是一个信号,终止掉进程,或者是kill -参数
*3. WIFSTOPPED(status) 为非0 → 进程处于暂停状态
WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。
WIFCONTINUED(status) 为真 → 进程暂停后已经继续运行
eg:
int status;
pid_t pid;
pid = wait(&status)
5.4 watipid函数
作用同wait,但可指定pid进程清理,可以不阻塞。
pid_t waitpid(pid_t pid, int *status, in options);
返回值:
清理掉的子进程ID
-1(无子进程),失败
0,前提是参数options为WNOHANG,表示子进程正在运行,watipid不阻塞进行等待立刻返回0
# 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。 默认的行为
是挂起调用进程,直到有子进程终止 。在等待子进程终止的同时,如果还想做些有用的工
作,这个选项会有用。
特殊参数pid:可以指定等待哪个子进程终止
> 0 回收指定ID的子进程
-1 回收任意子进程(相当于wait)
0 回收和当前调用waitpid一个组的所有子进程(默认父进程和所有的子进程是在同一个组的)
< -1 回收指定进程组内的任意子进程
注意:一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。
options参数:可以通过该参数时调用不阻塞,设为0时表示阻塞(相当于wait),为WNOHANG表示非阻塞
pid参数:
注意一次waitpid还是只能回收一个子进程,要想回收多个得通过循环来进行回收
pid指定-1回收任意的子进程,指定options参数为WNOHANG,返回0,说明父进程要回收的子进程目前还在运行,不阻塞进行等待,父进程结束
上述为错误情况,waitpid的option指定为0,阻塞等待指定子进程的结束,pid = getpid是在子进程当中去执行的(获取当前进程的id),并不是在父进程当中,因此赋值给pid的是子进程中的pid,用户空间代码段发生更改,有了自己独立的物理空间,与父进程使用的不是同块空间,因此父进程的pid没发生改变
虚拟映射到物理内存上,父子进程只有内核才是共享物理空间(MMU)的,用户空间各自一块物理空间
更改如下:
父进程调用wait、waitpid时可能出现几种情况:
- 如果所有子进程都还在运行,则阻塞
- 如果一个子进程已终止,正等待父进程获取其终止状态,则取得子进程的终止状态立即返回
- 如果没有任何子进程,则出错返回
- 对于waitpid,如果指定的进程或进程组不存在,或参数pid指定的进程不是调用进程的子进程,都可能出错