一. 系统调用
CPU 既可以运行在用户态下,也可以运行在内核态下, 所有标准的 Unix 内核都利用了内核态和用户态.
当一个程序在用户态下执行时, 它不能直接访问内核数据结构或内核的程序. 然而, 当应用程序在内核态下运行时, 这些限制不再有效. 每种 CPU 模型都为从用户态到内核态的转换提供了特殊的指令, 反之亦然 (即从内核态到用户态). 一个程序执行时, 大部分时间都处在用户态下, 只有需要内核所提供的服务时才切换到内核态. 当内核满足了用户程序的请求后, 它让程序又回到用户态下.
内核本身并不是一个进程, 而是进程的管理者. 进程/内核模式假定: 请求内核服务的进程使用所谓系统调用 (system call) 的特殊编程机制. 每个系统调用都设置了一组识别进程请求的参数, 然后执行与硬件相关的 CPU 指令完成从用户态到内核态的转换.
如下图所示, 用户态进程在对内核发出系统调用请求的时候, 内核调用系统调用处理程序让进程从用户态切换为内核态, 并执行系统调用接口.
二. getpid() && getppid()
每个进程都有一个非负整型表示的唯一进程 ID, 虽然是唯一的, 但是进程 ID 是可复用的. 当一个进程终止后, 其进程 ID 就成为复用的候选者. 大多数 UNIX 系统实现延迟复用算法, 使得赋予新建进程的 ID 不同于最近终止进程所使用的 ID. 这用于防止将新进程误认为是使用同一 ID 的某个已终止的先前进程.
用户程序想要获取进程 ID, 需要调用系统调用 getpid()
, 与 getpid()
类似的系统调用 getppid()
用于获取发出该系统调用进程的父进程 ID.
通过如下一段代码对上述两个接口进行测试.
c
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("I am a process.....:PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
当运行生成的可执行程序后, 该进程的 PID 和 PPID 被循环打印。
通过 ps
命令查看该进程的信息, 即可发现通过 ps
命令得到的进程 PID 和 PPID 与使用系统调用函数 getpid
和 getppid
所获取的值相同.
注意: 系统调用 getpid() 和 getppid() 的返回值类型都为 pid_t, pid_t 本质是对 int 的 typedef, 所以可以认为两个系统调用接口的返回值类型就为 int. 使用 pid_t 的时候需要包含 <sys/types.h> 头文件, 因为在该头文件中有对 pid_t 的宏定义.
三. fork()
Unix 操作系统紧紧依赖进程创建来满足用户的需求, 例如, 只要用户输入一条指令, shell 进程就创建一个新进程, 新进程执行输入的指令.
一个现有的进程可以调用 fork()
创建一个新进程, 由 fork()
创建的新进程被称为子进程 (child process).
通过如下一段代码对 fork()
进行测试.
c
#include <stdio.h>
#include <unistd.h>
int main()
{
fork();
while (1)
{
printf("I am a process.....PID:%d, PPID:%d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
当运行生成的可执行程序时, 发现子进程与父进程分别循环打印自己的 PID 和 PPID.
出现这样的现象的原因是, 父进程 fork()
创建子进程后, 子进程与父进程共享同一份正文代码.
fork() 的返回值
当 fork()
调用成功时, 父进程的返回值是新建子进程的 ID, 子进程的返回值是 0; 而当调用 fork()
失败时, 父进程的返回值是 -1, 没有子进程被创建.
基于 fork()
的这一特性 (fork()
被调用一次, 但返回两次), 用 if 语句进行分流, 让父进程和子进程执行不同的代码块, 完成不同的任务.
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
pid_t ret = fork();
if (ret < 0) {
perror("fork");
return 1;
}
else if (ret == 0) {
while (1) {
printf("I am child process.....:PID:%d, ret:%d\n", getpid(), ret);
sleep(1);
}
}
else {
while (1) {
printf("I am father process.....:PID:%d, ret:%d\n", getpid(), ret);
sleep(1);
}
}
return 0;
}
当运行生成的可执行程序时, 发现父进程会进入到 else 语句的循环打印当中, 打印父进程 ID 和父进程接收到的 fork()
返回值; 而子进程会进入到 else if 语句的循环打印当中, 打印子进程 ID 和子进程接收到的 fork()
返回值.
注意: 一般来说,在
fork()
之后是父进程先执行还是子进程先执行是不确定的, 这取决于内核所使用的调度算法.
写时复制 (Copy-On-Write)
在早期的 UNIX 系统中, 创建进程很简单. 调用 fork()
时, 父进程由用户态切换为内核态.
内核会为新创建的子进程分配 PCB (task_struct
) 并对其进行填充, 并将父进程的部分数据结构拷贝给子进程, 比如复制父进程的页表, 比如将父进程的地址空间中的内容按页复制到子进程的地址空间中, 糟糕的是, 这种按页复制方式是十分耗时的.
现代 Unix 系统采取了更优的实现方式. 在现代 Unix 系统如 Linux 中, 采用了写时复制 (Copy-On-Write, COW) 的技术, 而不是对父进程部分数据结构进行整体复制.
写时复制是一种基于惰性算法 的优化策略, 这样的策略是为了避免复制时的系统开销. 其前提假设为: 如果有多个进程要读取 它们所共有的那部分资源, 那么复制行为是不必要的, 每个进程只要保存一个指向该资源的指针即可. 只要没有一个进程修改自己的 "副本", 每个进程就好像独占了那部分资源, 从而避免了复制带来的开销. 如果某个进程想要修改 (写入) 自己的那份资源 "副本", 就会开始复制该资源, 并把副本提供给这个进程. 复制过程对进程而言是 "透明" 的. 这个进程后面就可以反复修改其持有的副本, 而其他进程还是共享原来那份没有修改过的资源. 这就是 "写时复制" 这个名称的由来: 只有在对资源进行写入时才执行复制.
写时复制的主要好处在于: 如果进程从未修改资源, 则都不需要执行复制. 一般来说, 惰性算法的好处就在于它们会尽量延迟代价较高的操作, 直到必要时才执行.
在使用虚拟内存的场景下, 写时复制是以页为基础执行的. 所以, 只要进程没有修改其全部地址空间, 就不需要复制整个地址空间. 在 fork()
调用结束后, 父进程和子进程都以为自己有唯一的地址空间, 实际上它们共享父进程的地址空间, 这些以页为单位存放的父进程地址空间又会被其他的进程共享.
写时复制在内核中的实现非常简单. 存放进程地址空间的这些页被标记为只读 , 如果有进程试图修改某个页, 就会产生缺页中断. 内核处理缺页中断的处理方式就是对该页进行一次 "透明" 的复制. 这时, 会对该页中的引用计数进行--, 每触发一次缺页中断, 引用计数就会--, 直到引用计数被减至 0, 清空该页的写时复制属性, 表示该页不再被共享.
对于调用 fork()
创建进程的场景, 写时复制有着更大的优势. 比如当一个进程要执行一个不同的程序时, 这对 shell 进程来说是常见的情况. 在这种情况下, 子进程被 fork(
) 创建后后立即调用 exec 系列函数, 在用 exec 进行进程程序替换时, 子进程的地址空间会被交换出去 , 若不采用写时复制, 则会在调用 fork()
时把父进程的地址空间中的内容按页复制到子进程的地址空间, 这样的行为没有意义, 且增加了系统额外的开销, 写时复制可以对这种情况进行优化.