在Linux 系统中, 为了实现某个任务或功能, 图形界面中的一个动作, 命令行中的一个命令, 都能触发某个进程的产生并执行, 而我们重点从代码的角度, 看用户的程序代码如何分化出多个进程执行线路, 重点学习4 个重要的系统调用, 体验进程在生命线中如何运行直至消亡。
1. 新建进程——系统调用fork
在文件编程部分已经接触过系统调用的概念, 不再赘述。 fork 系统调用内部实际上对应的就是由内核提供的新建进程的内核功能。 顾名思义, fork 运行就发生分叉, 变成两个并发的进程。 系统内核实现fork 新建进程功能, 理解的核心就是一句话: “克隆产生孩子,一次调用, 两个返回值。”fork 产生的新进程和原进程这两进程各有各的执行线路, 但存在父子关系。 下面我们通过一个程序实例来理解fork 的实现原理。
fork 函数原型为:
其中pid_t 是一个long 类型的量。
【例5-6】
结合图5.15 分析该程序的执行过程如下。
图中表现了双份代码, 左侧的代表编译程序后执行起来的第一个程序(父进程)的功能代码, 右侧的代表fork 调用发生后克隆产生的子进程也具有同样的功能代码。 其执行过程如下。
(1) 首先是执行程序产生第一个进程(后面会做父进程), 图示中表现了其在内存中的大概状态: 内核空间中有其进程控制块PCB, 用户空间中有其分配到的内存存放代码和数据。
(2) 当父进程执行到fork 调用时, 由此系统调用进入内核代码, 基于Linux 内核对fork 的设计, 内核根据父进程快速克隆出一个子进程, 这意味着子进程与父进程在内存中有相同的用户态代码和数据, 所以它们各自有各自独立的pid、 message 和x 变量。
还要注意的是, 其复制的不仅是父进程的代码和数据, 还包括父亲进程PCB 控制块中的核心信息(进程号肯定不同), 如PC 指针寄存器的值。 也就是说子进程根据它的PCB中的信息, 也会认为他和父亲一样, 已经执行完了fork 调用。 所以, 不需要担心子进程会无限分叉, 此程序运行并不会出现一直持续分叉产生子进程的现象。
(3) fork 触发的内核代码执行完毕后, CPU 从内核代码返回, 虽然fork 只调用了一次, 但现在产生了两个并发的进程, fork 的返回值要写入到两个pid 变量中。 基于其设计,父进程的pid 变量系统写入了子进程的进程号, 而子进程的pid 变量系统写入了0。
(4) 返回后CPU 调度到哪个进程取决于内核的调度算法。 但无论父、 子进程谁先占有CPU 开始工作, 都会从fork 后的下一条指令开始执行。 根据它们自己用户内存空间中pid 的取值不同, 它们会进入执行不同的分支语句。 分析可知, 父进程空间中的x 会被赋值10, 而子进程空间中的x 会被赋值0。 message 字符串赋值也会不同。
(5) 最后的输出语句利用getpid, getppid 获取了进程及其父亲的进程号。 利用该语句输出, 可以帮助验证以上分析逻辑是否正确。
图5.15 fork 产生的父子进程执行分析图
执行结果如下。
父进程比子进程先创建, 从输出看26364 比26365 小1, 肯定是早执行的父进程。 进程输出x=10, 也符合分析结果。 message 提示说他的父亲是24817 是因为该程序在Shell下运行, 所以父进程的父进程是Shell 进程24817。
子进程输出my father is 1, 主要原因是父进程先执行, 执行了最后的输出就结束了,父进程关系着子进程死亡时的PCB 内存的回收, 所以系统只好为变成孤儿进程的子进程新找了1 号祖先进程做父亲。 为了输出正确的进程间亲子关系, 可修改父进程的代码, 加入sleep 语句, 人工干预执行顺序, 让它晚于子进程结束。
父进程执行的分支中加入sleep 语句后执行的结果如下。
2. 执行新工作——系统调用exec
执行exec 的进程将载入另一个可执行文件的内容到自己的空间, 如果是某个子进程调用它, 则子进程的Pid 不变, 但进程功能代码已经不再是克隆自父进程。 因为新载入的内容已经覆盖了其原来的内存空间。
需要注意的是exec 函数执行成功会进入新进程执行不再返回。 所以子进程代码中exec后的代码, 只有exec 调用失败返回-1 才有机会得到执行。
exec 函数族包括以下若干函数。
函数中的参数含义如下:
(1)path——要执行的程序名(有或没有全路径);
(2)arg——被执行程序所需的命令参数, 以arg1, arg2, arg3…形式表示, NULL 为结束;
(3)argv——命令行参数以字符串数组argv 形式表示;
(4)envp——环境变量字符串。
这些函数中只有execve 是真正的系统调用, 其他函数形式都是将要执行程序的路径、命令行参数和环境变量3 个参数传递给execve, 最终由系统调用execve 完成工作。 函数名中的字母可以按如下理解:
(1)p——利用PATH 环境变量查找可执行的文件;(www.daowen.com)
(2)l——希望接收以逗号分隔的形式传递参数列表, 列表以NULL 指针作为结束标志;
(3)v——希望以字符串数组指针( NULL 结尾)的形式传递命令行参数;
(4)e——传递指定参数envp, 允许改变子进程的环境, 后缀没有e 时使用当前的程序环境
【例5-7】 实现查看passwd 文件信息的三种方法如下。
①execl 举例。
②execlp 举例。
③execv 举例。
3. 进程的消亡——系统调用exit
其函数原型为:
Linux 系统中, 程序执行结束或调用exit 后并不是马上消失, 而是变为僵死状态——放弃了几乎所有内存空间, 不再被调度, 但保留有PCB 信息供wait 收集, 这些状态信息就存在status 量中, 而wait 对进程死亡原因的调查是比较常见的。
【例5-8】 修改【例5-6】中父亲的代码, 最后加sleep(20), 使其睡眠20 秒。 在父进程睡眠时, 子进程已经执行完毕走向死亡, 会进入僵死状态, 父亲睡眠的时间正是留给操作者观察僵死态进程的时机。 在执行程序的命令后加入后台执行符号&, 程序将后台执行,在Shell 前端能输入命令查看状态为Z 的僵死进程。
4. 进程间的等待——系统调用wait
其函数原型为:
顾名思义, 该系统调用会使调用它的进程等待, 进入阻塞状态, 而被唤醒的时机则是其有亲缘的子进程死亡进入僵死状态时, 系统会重新把它从阻塞态唤醒。 唤醒后的工作就是收集僵死子进程信息, 以便做某些处理, 再有就是释放子进程PCB, 防止内存回收不到位导致的内存浪费。 该调用成功时, 返回被收集子进程的PID; 如果没有子进程, 返回-1。
【例5-9】 多个子进程与父进程的执行等待实例, 其代码如下。
其执行效果如下。
结合图5.16 分析理解多进程的执行关系及过程。
图5.16 多个亲缘进程间执行关系图
(1) 进程间的亲属关系。
程序执行创建第一个主进程, 主进程中两次调用fork 会产生两个子进程: 其中第二个子进程从第二个fork 调用后执行, 所以不会再产生新进程; 而第一个子进程从第一个fork调用位置后执行它克隆得到的代码, 它会继续执行第二次fork, 所以还会产生一个子进程(可以说相对主进程的是孙进程)。 根据fork 的原理, 我们可以分析得到每个进程的pc1、pc2 变量取值的特点, 也就能分析出来哪个进程应该输出什么身份信息。 一般新建进程号都会顺序增加, 根据程序运行结果, 检查运行抓到的进程号表现的进程间关系是否和我们输出的推断身份一致。
(2) 程序执行顺序分析。
从输出结果的顺序看, 由于父亲先wait 阻塞了, 所以一定是子孙进程先执行。
前三句子孙进程的输出可以看出, 孙进程一定晚于其父亲的输出, 而两个子进程的输出因为是并发执行, 谁先谁后由系统调度决定, 实际上并不固定。 还有就是确实是未睡眠的子进程2 先结束。
子进程1 输出信息后睡眠10 秒的过程中, 子进程2 和孙进程实际上已经结束, 且子进程2 的死亡已经触发唤醒了在第一个wait 上阻塞等待的父亲, 但父进程被唤醒后接着就进入了第二个wait 阻塞等待下一个孩子的死亡。 所以父亲10 秒睡眠后才输出并结束。
通过学习进程相关系统调用, 我们了解了进程从产生到执行到消亡的整个过程。 推广到整个操作系统上运作的所有进程, 其运行机制是类似的, 每个进程都是多道进程并发执行中的一员, 各自独立, 但又有相互的关系。 进程间的关系有亲缘间的关系, 也有执行线路上的相互影响。 整个系统中的多道进程就是在这交叉并发、 走走停停中完成了各自代码赋予的功能。
免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。