一、概述
二、基础知识
1. 进程相关概念
1)程序和进程
程序,是指编译好的二进制文件,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁…)
进程,是一个抽象的概念,与操作系统原理联系紧密。进程是活跃(运行起来的)的程序,占用系统资源,在内存中执行。(程序运行起来,产生一个进程)。
程序 → 剧本(纸) ,进程 → 戏(舞台、演员、灯光、道具…),同一个剧本可以在多个舞台同时上演。同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)
如:同时开两个终端。各自都有一个bash,但彼此ID不同。
进程和线程的区别:
阮一峰大佬的文章
- CPU是工厂、
- CPU时间片是电力资源、
- 进程是车间、
- 线程是车间工人~
2)并发
并发,在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间的状态。但,任一个时刻点上仍只有一个进程在运行。理论依据:时钟中断
例如,当下,我们使用计算机时可以边听音乐边聊天边上网。 若笼统的将他们均看做一个进程的话,为什么可以同时运行呢,因为并发。
并发和并行的区别:
Erlang 之父 Joe Armstrong 用一张5岁小孩都能看懂的图解释了并发与并行的区别
- 并发是两个队列交替使用一台咖啡机,
- 并行是两个队列同时使用两台咖啡机,
- 串行是一个队列使用一台咖啡机,
3)单道程序设计
所有进程一个一个排队执行。若A阻塞,B只能等待,即使CPU处于空闲状态。而在人机交互时阻塞的出现时必然的。所有这种模型在系统资源利用上及其不合理,在计算机发展历史上存在不久,大部分便被淘汰了。
4)多道程序设计
在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证。
时钟中断 即为多道程序设计模型的理论基础。 并发时,任意进程在执行期间都不希望放弃cpu。因此系统需要一种强制让进程让出cpu资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。 操作系统中的中断处理函数,来负责调度程序执行。
在多道程序设计模型中,多个进程轮流使用CPU (分时复用CPU资源)。而当下常见CPU为纳秒级,1秒可以执行大约10亿条指令(1s = 1000ms, 1ms = 1000us, 1us = 1000ns)。由于人眼的反应速度是毫秒级,所以看似同时在运行。
实质上,并发是宏观并行,微观串行(伪并行)!推动了计算机蓬勃发展,将人类引入了多媒体时代。
5)CPU和MMU
程序中用到的所有的内存都是虚拟内存,但是虚拟内存在计算机中是不实际存在的,存储的数据都是存储在物理内存中。
6)进程控制块PCB
每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体。
- 进程id。系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。
- 进程的状态,有初始化、就绪、运行、挂起、停止等状态。
- 进程切换时需要保存和恢复的一些CPU寄存器的值。
- 描述虚拟地址空间的信息。
- 描述控制终端的信息。
- 当前工作目录(Current Working Directory)。
- umask掩码。
- 文件描述符表,包含很多指向已经打开的文件的file结构体的指针的一个数组。 (注:pcb中有一根指针,指针存储的是文件描述符表的首地址)
- 和信号相关的信息。
- 用户id和组id。
- 会话(Session)和进程组。
- 进程可以使用的资源上限(Resource Limit)。
ulimit -a
列出所有当前资源极限
7)进程状态
进程基本的状态有5种。分别为初始态,就绪态,运行态,挂起态与终止态。细分可以分成七种状态:
2. 环境变量
(1)定义:
环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。
- 通常具备以下特征:
- ① 字符串(本质)
- ② 有统一的格式:名=值[:值]
- ③ 值用来描述进程环境信息。
- 存储形式:与命令行参数类似。char *[]数组,数组名environ,内部存储字符串,NULL作为哨兵结尾。
- 使用形式:与命令行参数类似。
- 加载位置:与命令行参数类似。位于用户区,高于stack的起始位置。
- 引入环境变量表:须声明环境变量。extern char ** environ;
(2)常见环境变量:
按照惯例,环境变量字符串都是name=value这样的形式,大多数name由大写字母加下划线组成,一般把name的部分叫做环境变量,value的部分则是环境变量的值。环境变量定义了进程的运行环境,一些比较重要的环境变量的含义如下:
- PATH
可执行文件的搜索路径。ls命令也是一个程序,执行它不需要提供完整的路径名/bin/ls,然而通常我们执行当前目录下的程序a.out却需要提供完整的路径名./a.out,这是因为PATH环境变量的值里面包含了ls命令所在的目录/bin,却不包含a.out所在的目录。PATH环境变量的值可以包含多个目录,用:号隔开。在Shell中用echo命令可以查看这个环境变量的值:$ echo $PATH
- SHELL
当前Shell,它的值通常是/bin/bash。
- TERM
当前终端类型,在图形界面终端下它的值通常是xterm,终端类型决定了一些程序的输出显示方式,比如图形界面终端可以显示汉字,而字符终端一般不行。
- LANG
语言和locale,决定了字符编码以及时间、货币等信息的显示格式。
- HOME
当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该程序时都有自己的一套配置。
3. 相关函数
1)getenv
- 函数作用:获取当前进程环境变量
- 头文件
参数说明: - name环境变量名
返回值
- 成功:指向环境变值得指针
- 失败:返回NULL
2)setenv
- 函数作用:设置环境变量。
- 头文件:
参数说明: - name 环境变量名
- value 要设置的环境变量值
- overwrite取值: 1:覆盖原环境变量。0:不覆盖
返回值:
- 成功:0;
- 失败:-1
3)unsetenv
- 函数作用:删除环境变量name的定义
- 头文件:
参数说明: - name 环境变量名
返回值
- 成功:0;
- 失败:-1
注意:name不存在仍返回0(成功),当name命名为"ABC="时则会出错。因为“=”是构成环境变量中的一个组成部分。
1)fork
- 函数作用:创建子进程
- 头文件
返回值 - 成功:两次返回,父进程返回子进程的id,子进程返回0
- 失败:返回-1给父进程,设置errno
结果分析:为何会打印两次begin?
这是由于 printf("Begin ...");
执行之后并不会打印到屏幕,而是在缓冲区,因此fork之后子进程在执行 printf("End ...
");
遇到
则全部打印出来。
如果修改为 printf("Begin …
");
(在遇到
时会将缓冲区内容打印到屏幕。)则子进程不会打印begin…
2)getpid与getppid
- 函数作用:获取进程id
- 头文件
返回值:
- getpid 获得当前进程的pid,getppid获取当前进程父进程的pid。
(1)查看进程信息:
init进程是所有进程的祖先。
ps命令:
- ps aux
- ps ajx —可以追溯进程之间的血缘关系
kill命令:
- SIGKILL/9 信号
- kill -SIGKILL pid
- kill -9 pid
(2)循环创建n个子进程:
一次fork函数调用可以创建一个子进程, 那么创建N个子进程应该怎样实现呢?
> 执行结果:总共产生4个进程,但是本来想产生2个,因此将代码中的break打开,在fork进程之后,将子进程退出。
从上图可以很清晰的看到,当n为2时候,循环创建了(2^n)-1个子进程,而不是n的子进程。需要在循环的过程,保证子进程不再执行fork ,因此当(fork() == 0)
时,子进程应该立即break;
才正确。
如何修改成预期创建两个线程?
将代码中的break解注释,当为子线程的时候直接退出。
重点:通过该练习掌握框架,循环创建n个子进程,使用循环因子i对创建的子进程加以区分。
(3)进程先创建先退出?
3)getuid
- uid_t getuid(void); --> 获取当前进程实际用户ID
- uid_t geteuid(void); --> 获取当前进程有效用户ID
4)getgid
- gid_t getgid(void); --> 获取当前进程使用用户组ID
- gid_t getegid(void); --> 获取当前进程有效用户组ID
父子进程之间在fork后,有哪些相同,那些相异之处呢?
刚fork之后:
- 父子相同处(0-3G的用户区及3-4G的内核区大部分): 全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
- 父子不同处(3-4G中的内核区的PCB区): 1.进程ID 2.fork返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器)(定时器是以进程为单位进行分配,每个进程有且仅有一个) 6.未决信号集。
似乎,子进程复制了父进程0-3G用户空间内容,以及父进程的PCB,但pid不同。真的每fork一个子进程都要将父进程的0-3G地址空间完全拷贝一份,然后在映射至物理内存吗?
- 当然不是!
- 父子进程间遵循 读时共享写时复制 的原则(针对的是物理地址)。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销。
练习:编写程序测试,父子进程是否共享全局变?
结论:父子进程不共享全局变量。
父子进程共享:1. 文件描述符(打开文件的结构体) 2. mmap建立的映射区 (进程间通信详解)
特别的,fork之后父进程先执行还是子进程先执行不确定,取决于内核所使用的调度算法,即随机争夺。
如上图:如果有一个全局变量 i = 5,当fork出子进程之后,此时父子进程指向同一片物理内存,父子进程读到的 i = 5,但是当子进程或者父进程去修改全局变量(i = 10),则此时系统会开辟一片新内存,则父子进程的 i 就不是同一个值。
这样做为了减少系统开销,也就是 读时共享,写时复制。
5)gdb调试
使用gdb调试的时候,gdb只能跟踪一个进程。可以在fork函数调用之前,通过指令设置gdb调试工具跟踪父进程或者是跟踪子进程。默认跟踪父进程。
set follow-fork-mode child 命令设置gdb在fork之后跟踪子进程
set follow-fork-mode parent 设置跟踪父进程
注意,一定要在fork函数调用之前设置才有效。
6)exec函数族
fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。也就是调用完exec函数中的命令之后,原来函数后面的代码就不会执行。
其实有六种以exec开头的函数,统称exec函数:
7)execlp
参数说明:
- file 需要加载的程序的名字
- arg 一般是程序名
- … 参数名,可变参数
返回值:
- 成功:无返回
- 失败:-1
注意:p是PATH的缩写, execlp加载一个进程,借助PATH环境变量 (不用写该命令的绝对路径,会到当前进程的环境变量中去找),当PATH中所有目录搜索后没有参数1则出错返回。
该函数通常用来调用系统程序。如:ls、date、cp、cat等命令。比如:execlp("ls", "ls", "-l", "-F", NULL);
使用程序名在PATH中搜索。
注意:int execlp(const char *file, const char *arg, ...); ---> int execlp(const char *file, const char *argv[]);
因此 arg 就相当于第一个参数(argv[0])。
7)execl函数
其中 l 是 list 的缩写,基本同execlp函数,只是该函数在加载程序式,需要写绝对路径。
比如:execl("/bin/ls", “ls”, “-l”, “-F”, NULL); 使用参数1给出的绝对路径搜索。
8)execvp函数
加载一个进程,使用自定义环境变量env
-
变参形式: 1)… 2)argv[] (main函数也是变参函数,形式上等同于 int main(int argc, char *argv0, …))
-
变参终止条件:1)NULL结尾 2)固参指定
execvp与execlp参数形式不同,原理一致。
9)exec函数族一般规律
exec函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec函数调用后直接调用perror()和exit(),无需if判断。
- l (list) 命令行参数列表
- p (path) 搜素file时使用path变量
- v (vector) 使用命令行参数数组
- e (environment) 使用环境变量数组,不使用进程原有的环境变量,设置新加载程序运行的环境变量
事实上,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节,其它函数在man手册第3节。
这些函数之间的关系如下图所示。
10)回收子进程
-
孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。
-
僵尸进程:子进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
特别注意,僵尸进程是不能使用kill命令清除掉的。因为kill命令只是用来终止进程的,而僵尸进程已经终止。
思考!用什么办法可清除掉僵尸进程呢?
- 方一:wait函数。
- 方二:杀死他的父进程使其变成孤儿进程,进而被系统处理。
孤儿进程:
僵尸进程:
11)wait
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量$?查看,因为Shell是它的父进程,当它终止时Shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。
父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:
- 阻塞等待子进程退出
- 回收子进程残留资源
- 获取子进程结束状态(退出原因)
当进程终止时,操作系统的隐式回收机制会:1.关闭所有文件描述符 2. 释放用户空间分配的内存。内核的PCB仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号)
可使用wait函数传出参数status来保存进程的退出状态(status只是一个整型变量,不能很精确的描述出状态),因此需要借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:
- WIFEXITED(status) 为非0 → 进程正常结束
WEXITSTATUS(status) 如上宏为真,使用此宏 → 获取进程退出状态 (exit的参数) - WIFSIGNALED(status) 为非0 → 进程异常终止
WTERMSIG(status) 如上宏为真,使用此宏 → 取得使进程终止的那个信号的编号。 - WIFSTOPPED(status) 为非0 → 进程处于暂停状态
WSTOPSIG(status) 如上宏为真,使用此宏 → 取得使进程暂停的那个信号的编号。
WIFCONTINUED(status) 为真 → 进程暂停后已经继续运行
- wait 函数作用:1)阻塞等待 2)回收子进程资源 3)查看死亡原因
- 头文件
参数说明: - status传出参数,用来获取子进程退出的状态。
返回值:
- 成功:返回终止的子进程pid
- 失败:返回-1,设置errno
子进程的死亡原因:
- 正常死亡 WIFEXITED,如果WIFEXITED为真,使用WEXITSTATUS得到退出状态。
- 非正常死亡WIFSIGNALED,如果WIFSIGNALED为真,使用WTERMSIG得到信号。
wait回收子进程:
wait查看子进程死亡原因:
wait回收多个子进程:
12)waitpid
- 作用同wait,但可指定pid进程清理,可以不阻塞。
参数说明:
- pid:
- < -1 组id
- -1 回收任意
- 0 回收和调用进程组id相同组内的子进程
- >0 回收指定的pid
- options
- 0与wait形同,也会阻塞
- WNOHANG 如果当前没有子进程退出的,会立即返回。
返回值:
- 如果设置了WNOHANG,那么如果没有子进程退出,返回0。
- 如果有子进程退出,返回退出子进程的pid
- 失败: 返回-1(没有子进程),设置errno
注意:一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。
waitpid回收子进程:
waitpid回收多个子进程:
三、练习
- 父进程fork 3 个子进程,三个子进程一个调用ps命令, 一个调用自定义程序1(正常),一个调用自定义程序2(会出段错误)。父进程使用waitpid对其子进程进行回收?
- 验证子进程是否共享文件描述符,子进程负责写入数据,父进程负责读数据?