进程
- 程序 vs. 进程:
程序,是指编译好的二进制文件,是静态概念,在磁盘上,不占用系统资源(cpu、内存、打开的文件、设备、锁…);
进程,是一个抽象的动态概念,与操作系统原理联系紧密。进程是活跃的程序,占用系统资源。在内存中执行。(程序运行起来,产生一个进程)
程序 → 剧本(纸)
进程 → 戏(舞台、演员、灯光、道具…)
同一个剧本可以在多个舞台同时上演;同样,同一个程序也可以加载为不同的进程(彼此之间互不影响)。
如:同时开两个终端,各自都有一个bash但彼此ID不同。 - 单道程序设计 vs. 多道程序设计
并发:系统依据时钟中断(硬件手段)让多个进程轮流利用cpu的计算资源来各自执行。 - MMU(Memory Management Unit,内存管理单元):CPU内部的MMU完成从虚拟内存到物理内存的映射过程。
- 以32位寄存器的cpu为例,可用的虚拟地址空间为4G,即地址范围最大为4G。但实际上一个程序被分配到的物理内存空间远远没有这么大,该程序实际用到多大的内存就给它分配多大的物理内存。不过要注意,由于MMU以4k大小的页(以32位cpu为例)为单位来划分物理内存,因此一个程序被分配的物理内存至少为4k,尽管有时用不了这么大的空间。程序员在写程序时用到的是虚拟内存地址,永远不会用到物理内存地址,其中的地址映射靠MMU完成。
- 同时MMU还会设置内存访问级别,因为程序的虚拟内存空间中分为内核区和用户区,相应地,在映射到物理内存时也为cpu设置了访问级别。在intel x86架构下分为4种级别,而在Linux下分为两种(分别是给内核和用户来访问的)。
- 如果一个程序在两个终端同时运行,即有两个进程,那么MMU会如何映射这两个进程的虚拟内存空间呢?对于用户区,MMU会将它们各自映射到不同的物理内存块;对于内核区,MMU会将它们映射到同一块物理内存块,因为内核区是供操作系统的核心程序使用的,而操作系统的核心程序是为了辅助所有的进程运行的,它仅此一份。
- PCB(进程控制块/进程描述符):是一个位于linux内核区中的名为
task_struct
的结构体,每个进程仅此一份,其内部有一些关于特定进程信息的重要成员变量。
-
PCB中所包含的常见内容需了解:
进程id、进程状态、进程切换时需要保存和恢复的一些cpu寄存器的值、描述虚拟地址空间的信息(即虚拟地址和物理地址的对应关系)、描述控制终端的信息、当前工作目录、umask掩码、文件描述符表(它是一个指向file结构体的指针数组,每个指针指向一个描述打开的文件信息的file结构体,实际写程序时并不会调用各个指针,而是使用它对应的键值,即一个整数(整数与文件描述符指针采用哈希映射的方式一一对应))、和信号相关的信息、用户id 和组id、会话(session)和进程组、进程可使用的资源上限(如栈空间大小)。 -
注意:前已述,MMU会将不同进程的虚拟内存空间中的内核区映射到同一块物理内存空间,而一个进程的PCB位于内核区,那么对于同一个程序的两个进程而言,MMU会将它们的PCB映射到同一块物理内存吗?不会,因为每个进程的PCB描述了该进程的信息,而各个进程是彼此独立的,因此MMU会将它们映射到内核区对应的物理内存区中的不同内存块上。(注意,区别于父子进程间的情况,它们会将各自的内核地址空间映射到同一块物理内存空间。)(关于MMU的详细映射细节参见计算机组成原理)
程序运行的四级流水示意图(也即cpu执行一条程序指令的过程。在这个过程中,MMU参与了cpu从cache中预取指令和将计算后的结果从寄存器写回到cache的过程。):
MMU的作用示意图:
-
进程四状态:就绪、运行、挂起\阻塞、停止。
-
环境变量:用户在操作系统中用来指定操作系统运行环境的一些参数,实质上是一个个字符串,存储在数组
environ
中,以NULL结尾。在引入环境变量表时,须先声明:extern char** environ;
。如,PATH
指定可执行文件的搜索路径。
相关操作函数:getenv, setenv, unsetenv
进程控制
- 创建进程:
fork
函数,父进程的fork函数返回子进程的id,子进程的fork返回0。注意返回值,不是fork函数能返回两个值,而是fork后,fork函数变为两个,父子需各自返回一个。注意:虽然返回的子进程也拥有了一份和父进程一样的程序,但是在子进程中,该程序是从该子进程被创建处,即fork函数返回处,继续往后执行的,前面的程序部分不再执行。 - 循环创建指定数量的子进程并区分每个子进程:
注意:除去父进程外,第i
次fork()
后,共有2^i-1
个子进程被创建出来,因为第一次fork
后就有不止一个进程在执行这个程序。
//循环创建5个子进程并打印出每个子进程的进程ID
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
pid_t pid;
int i;
for(i = 0; i < 5; i++) //出口1,父进程专用出口
if((pid = fork()) == 0)
break; //出口2,子进程出口,i不自增
if(5 == i){
sleep(5);
printf("I am parent, pid = %d\n", getpid());
} else {
sleep(i);
printf("I'm %dth child, pid = %d\n", i+1, getpid());
}
return 0;
}
- 进程共享:
- 读时共享写时复制原则:该原则针对的是父子进程的各自物理内存空间。读时,对于父子进程的相同部分内容是适用的,即相同的内容经MMU映射到同一块物理内存空间,这样以免相同的内容通过MMU被重复映射到内存中不同的空间,便于节省内存。不过该原则指出,在写时就要复制一份相同的内容给子进程了,使得父子进程各自修改自己的内容。此处,有一个关于程序中全局变量会干扰父子进程内容的误区。事实上,写时复制已经明确了,若是要修改父或子进程的内容,就会复制一份给子进程,从此修改一方的内容就不会再对另一方有任何影响,不管修改的是什么内容,包括全局变量。
程序示例:
#include <stdio.h>
#include <unistd.h>
int var = 0;
int main() {
pid_t pid;
pid = fork();
if(pid == -1) {
printf("fork error\n");
} else if(pid > 0) {
sleep(1);
var = 1;
printf("I am parent, pid = %d, ppid = %d, var = %d\n", getpid(), getppid(), var);
} else if(pid == 0) {
var = 2;
printf("I am child, pid = %d, ppid = %d, var = %d\n", getpid(), getppid(), var);
}
return 0;
}
- 注意,上述原则仅适用于父子进程间的用户空间数据,而父子进程的内核空间数据,即
PCB
的内容,经MMU
映射到实际物理空间中是同一块内容,这给父子进程间通信打下了基础。 - 刚
fork
之后,父子进程间相同和不同的内容:(粗略来看,虚拟内存空间中用户区内容是一样的;内核区有不同,至少其中的PCB
是不同的。)
父子相同处: 全局变量、.data
、.text
、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
父子不同处: 1.进程ID 2.fork
返回值 3.父进程ID 4.进程运行时间 5.闹钟(定时器,每个进程有一个专属的定时器) 6.未决信号集
重点:父子进程共享:1. 文件描述符(打开文件的结构体) 2.mmap
建立的映射区。
特别地,fork
之后父进程先执行还是子进程先执行不确定。取决于内核所使用的调度算法。
注意一个特殊的进程:shell进程,即命令行终端上接受用户输入命令的后台进程(正常情况下处于阻塞态,等待用户输入可执行命令)。 - 多进程
gdb
调试:
set follow-fork-mode parent //跟踪父进程(若不设置则默认跟踪父进程)
set follow-fork-mode child //跟踪子进程
//注意,上述两条指令要在fork函数调用之前设置!
注意:如果创建了不止一个子进程,那么如何跟踪其中某一个子进程呢?只要在那一个子进程的入口处设置一个条件断点,然后跟上set follow-fork-mode child
即可。
exec
函数族:
在程序运行期间执行另外一个程序,让子进程和父进程完全区分开来,执行内容与父进程无关,彻底独立出来,但子进程id不变。可理解为子进程中,执行exec
函数族中任一函数后,子进程覆盖了原来复制自父进程的代码段和数据段。
execlp
:l–list, p–path 调用系统可执行程序
execl
:l–list 调用用户的自定义可执行程序
execv
:v-- vector 即将上述list 换成命令行参数字符数组argv[]
execvp
:
execve
: e–environ
总结:
带字母l
的函数要求传入每个命令行参数,个数可变,最后一个参数应为NULL;
带字母v
的函数要求传入一个命令行参数构成的指针数组,最后一个参数应为NULL;
带字母e
的函数要求传入一份新的环境变量表;
对于字母p
:(搜索程序名时使用环境变量PATH)
带字母p
的函数,若参数1中包含/
则视为路径名,若不包含/
则在环境变量PATH
中搜索这个可执行程序;
不带字母p
的函数要求第一个参数必须是程序的相对路径或绝对路径。
exec
函数族调用示例:
char *const ps_argv[] ={"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL}; //命令行参数数组(以NULL结尾)
char *const ps_envp[] ={"PATH=/bin:/usr/bin", "TERM=console", NULL}; //环境变量表(以NULL结尾)
execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execv("/bin/ps", ps_argv);
execle("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL, ps_envp);
execve("/bin/ps", ps_argv, ps_envp);
execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execvp("ps", ps_argv);
exec
函数一旦调用成功即执行新的程序,不返回。只有失败才返回,错误值-1。所以通常我们直接在exec
函数调用后直接调用perror()
和exit()
,无需if
判断(只有它调用失败了才会执行perror()
和exit()
)。
回收子进程
- 孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程。孤儿子进程的父进程自动变为
init
进程,称为init进程领养孤儿进程。 - 僵尸进程:进程终止,父进程尚未回收,子进程残留资源(
PCB
)存放于内核中,变成僵尸(Zombie)进程。
特别注意,僵尸进程是不能使用kill
命令清除掉的。因为kill
命令只是用来终止进程的,而僵尸进程已经终止。那用什么办法可清除掉僵尸进程呢?杀死父进程即可,这样一来该僵尸进程就被划归init
进程的孤儿院并被回收。 - 回收子进程:
wait
函数,pid_t wait(int *status)
,返回值是应该回收的子进程ID,参数是应该获取的子进程退出状态,注意这个参数是一个传出参数,若不关心子进程退出状态,那么给wait函数传NULL
。注意:用int类型变量status来存子进程状态也是通过位图(bitmap)的方式,所以接下来获取子进程退出的信号要借助宏函数,这一点类似于前面的文件操作。
父进程调用wait
函数可以回收子进程终止信息。该函数有三个功能:
① 阻塞等待子进程退出
② 回收子进程残留资源
③ 获取子进程结束状态(退出原因)。
//利用wait函数回收子进程并获得其退出状态
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid, wpid;
int status;
pid = fork();
if(pid == -1) {
perror("fork error");
exit(1);
} else if(pid == 0) {
//sleep(100);
printf("I am child, pid = %d, ppid = %d\n", getpid(), getppid());
return 20;
} else if(pid > 0) {
sleep(1);
printf("I am parent, pid = %d, ppid = %d\n", getpid(), getppid());
wpid = wait(&status);
printf("wpid = %d\n", wpid);
if(WIFEXITED(status)) { //子进程正常退出
printf("exit with %d\n", WEXITSTATUS(status));
} else if(WIFSIGNALED(status)) { //子进程非正常退出
printf("killed by %d\n", WTERMSIG(status));
}
}
return 0;
}
waitpid
函数:作用同wait
,但可指定pid进程清理,可以不阻塞。
pid_t waitpid(pid_t pid, int *status, in options);
//返回值:
成功:返回清理掉的子进程ID;
失败:-1(无子进程)
特殊参数和返回情况:
对于参数pid:
> 0 回收指定ID的子进程
-1 回收任意子进程(相当于wait)
0 回收和当前调用waitpid一个组的所有子进程
< -1 回收指定进程组(该组id即这个小于1的负数的绝对值)内的任意子进程
参数3:
为WNOHANG:表示非阻塞回收(此时应采用轮询方式回收子进程);若子进程正在运行,则返回值为0。
为0:表示阻塞回收,即等价于wait函数
使用waitpid回收子进程示例:
#include <stdio.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t p;
pid_t wpid;
int status;
int n = 3;
int i;
for(i = 0; i < n; i++) {
p = fork();
if(p == -1) {
perror("fork error");
exit(1);
} else if(p == 0) { //child
sleep(i);
printf("I'm %dth child, pid = %d, ppid = %d\n", i + 1, getpid(), getppid());
if(i == 0) {
int fd = open("ps2.out", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if(fd == -1) {
perror("open error");
exit(1);
}
dup2(fd, STDOUT_FILENO);
execlp("ps", "ps", "aux", NULL);
}
if(i == 1) {
execl("./demo1", "demo1", NULL);
}
if(i == 2) {
execl("./segError", "segError", NULL);
}
}
}
if(i == n) { //parent
do {
wpid = waitpid(-1, &status, WNOHANG);
if(wpid > 0) {
printf("child %d has exited\n", wpid);
if(WIFEXITED(status))
printf("it exits with %d\n", WEXITSTATUS(status));
if(WIFSIGNALED(status))
printf("it is signalled by %d\n", WTERMSIG(status));
n--;
}
} while(n > 0);
}
return 0;
}
注意:一次wait
或waitpid
调用只能清理一个子进程,清理多个子进程应使用循环。
守护进程
-
编写一个守护进程的七步:
1,创建子进程fork()
;
2,子进程创建新会话setsid()
;(因为新会话会丢弃原有的控制终端,而守护进程不需要控制终端)
3,改变进程的工作目录chdir()
;
4,指定文件掩码umask()
;
5,将0/1/2文件描述符重定向至/dev/null
(dup2()
);
6,编写守护进程主逻辑
7,退出守护进程(一般不需要) -
创建一个会话(session)需注意:
1,调用进程不能是进程组组长,该进程变成新会话首进程
2,该进程成为一个新进程组的组长
3,需要有root权限(ubuntu不需要)
4,新会话丢弃原有的控制终端,该会话没有控制终端
5,该调用进程是组长进程,则出错返回
6,建立新会话时,先调用fork()
,父进程终止,子进程调用setsid()
-
注意:
守护进程没有控制终端,不能直接和用户交互。不受用户登录、注销的影响,一直在运行着。