Linux 进程———详解
1、各种进程相关的基本概念
1.1 区分程序和进程
程序: 是存储在存储设备(磁盘)上的数据,包含了可执行机器指令(二进制代码)和数据的静态实体。我们说程序不占用系统资源,这里的系统资源指CPU、内存等,但是不包括磁盘
进程: 运行的程序会变成进程,是已经被 OS 从磁盘加载到内存上的、动态的、可运行的指令与数据的集合
1.2 MMU 内存管理单元
存在于CPU中,他有两个功能:
① 完成虚拟内存地址到物理内存地址的映射
② 设置 / 修改内存的访问级别
1.3 虚拟内存
每个进程都有独立的虚拟地址空间,进程访问的虚拟地址并不是真正的物理地址。虚拟内存可以看作是由软件产生的,并不真实存在,他和物理内存相反,物理内存就是内存条等真实存在的存储空间。虚拟地址可通过每个进程的页表与物理地址进行映射,获得真正的物理地址。在32位系统上,虚拟内存的大小是4G,它被分为用户存储空间和内核存储空间两大块。
虚拟内存中保存了进程运行的所有数据,如:代码,变量,栈(里面有函数及函数里的变量等),堆(malloc分配的存储空间等),PCB等。程序中的变量等使用的都是虚拟内存,但是,虚拟内存实际上并不存在,他们的虚拟内存地址实际上是被MMU映射到了真实的物理内存中。
进程在实际的物理内存中也远远没有4G那么大,进程运行时需要多大内存,才把相应的数据映射到物理内存中。
虚拟地址空间如何分布?
由低地址到高低值分别为:
1、只读段: 该部分空间只能读,不可写;(包括:代码段、rodata 段(C常量字符串和#define定义的常量) )
2、数据段: 保存全局变量、静态变量的空间;
3、堆 : 就是平时所说的动态内存, malloc/new 大部分都来源于此。其中堆顶的位置可通过函数 brk 和 sbrk 进行动态调整。
4、文件映射区域: 如动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。
5、栈: 用于维护函数调用的上下文空间,一般为 8M ,可通过 ulimit –s 查看。
6、内核虚拟空间: 用户代码不可见的内存区域,由内核管理(页表就存放在内核虚拟空间)。
1.4 PCB
PCB本质是一个结构体(task_struct
),每个进程的PCB都存储在内核虚拟空间中中,PCB结构体中包含如下信息:
- 进程ID,类型是
pid_t
,本质是个unsigned int
- 进程的状态:初始态、就绪、运行、阻塞、停止
- 进程切换时需要保存和恢复的CPU寄存器
- 页表指针,指向页表,也表上存储了虚拟地址与物理地址的映射关系
- 当前进程的控制终端的信息
- 当前进程的工作目录
umask
掩码,保护文件创建或修改的默认权限- 文件描述符表,这其实是一个指针,指向了内核中的文件描述符表
- 和信号相关的信息
- 用户ID 和 组ID
- 会话 和 进程组
- 进程可以使用的资源上限
1.5 环境变量
用于指定操作系统运行环境的一些参数。环境变量有很多。
PATH
就是一个典型的环境变量,他用于存储可执行文件的路径。当我们在shell上输入一个命令时,shell就会在自己的PATH
环境变量中寻找该命令对应的文件在哪条路径中。进行如下实验:
输入命令①,shell在自己的PATH
环境变量中寻找哪条路径含有date
文件
输入命令②,shell直接去到/bin
路径运行date
文件
命令③则是查看shell的PATH
环境变量。(echo
用于输出shell的变量的值)
2、进程相关函数
2.1 fork
#include<sys/types.h>
#include<unistd.h>
pid_t fork(void);
创建一个子进程。父进程中返回子进程的进程ID,子进程中返回0,失败返回-1。
创建子进程后,父进程中打开的文件描述符在子进程中也是打开的,且文件描述符的引用计数+1,次啊外,父进程的用户根目录、当前工作目录等变量的引用计数都会+1。
2.2 进程共享
① 子进程对父进程信号的继承情况
② 调用fork
后父子进程的异同
父子相同处: 全局变量、数据段、代码段、栈、堆、环境变量、用户ID、宿主目录、进程工作目录
父子不同处: 进程ID、fork
的返回值、父进程ID、进程运行时间、定时器、未决信号集、
可以认为:子进程0-3G用户区的内容和父进程相同,内核区的内容(主要是PCB)有所不同。
虽然父子进程0-3G的内容一样,但子进程并不是将0-3G地址空间完全拷贝一份,而是遵循 读时共享写时复制 的原则。共享和复制都是指共享或复制物理内存,与虚拟内存无关。若fork
调用的后续只有对数据的读操作,那么子进程与父进程共享同一块物理内存,若存在写操作,那么子进程则会复制一份自己的数据。这样的设计,能够节省系统内存开销。
Linux操作系统底层借助MMU实现读时共享写时复制。
③ 父子进程共享的内容
- 文件描述符表
mmap
建立的映射区
注意:对于全局变量这种父子相同的内容,如果后面只有读操作,则父子共享该全局变量,若后面有写操作,则父子将不再共享。
2.3 getpid
和 getppid
#include<sys/types.h>
#include<unistd.h>
pid_t getpid(void); 获取 当前 进程的ID。
pid_t getppid(void); 获取 父 进程的ID。
2.4 getuid
和 geteuid
#include<sys/types.h>
#include<unistd.h>
pid_t getuid(void); 获取当前进程 实际 用户ID
pid_t geteuid(void); 获取当前进程 有效 用户ID
2.5 getgid
和 getegid
#include<sys/types.h>
#include<unistd.h>
pid_t getgid(void); 获取当前进程 实际 用户组ID
pid_t getegid(void); 获取当前进程 有效 用户组ID
3、exec
族函数
fork
创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec
函数以执行另一个程序。当进程调用一种exec
函数时,该进程的用户空间(0-3G的部分)代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec
并不创建新进程,所以调用exec
前后进程的ID并未改变。
一共有很多种exec
函数,但是它们的功能都是执行另一个程序,具体的方式有略微变化。
注意: exec
族函数只有失败才返回值-1并设置errno
。因为一旦这类函数调用成功,则后续的代码永远不会被执行,而是执行exec
加载的新程序并结束。原来程序的代码段整个被替换成了新程序的代码段。所以exec
族函数的调用成功返回值没有意义。原来进程中打开的文件会通过隐式回收关闭,不用担心这个问题。
exec
族函数不会关闭源程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC
的属性。
3.1 execlp
函数
函数名中的l
是 ‘list’ ,代表命令行参数列表 ; p
是PATH
,代表PATH
环境变量 。所以该函数的功能是加载一个进程,可以借助PATH
环境变量。
#include<unistd.h>
int execlp(const char* file, const char* arg, ...);
file
参数:
要执行的可执行程序的文件名
arg
参数:
可执行程序的argv[0]
参数,其实还是可执行程序的文件名。
...
可变参数:
命令行参数列表,以NULL
结尾。
execlp("ls", "ls", "-l", "-a", NULL); 等价于在 shell 中执行 "ls -l -a" 命令
execlp("ls", "djsdkjash", "-l", "-a", NULL); 等价于上一行调用
为什么第二个参数可以乱传呢?
第二个参数相当于argv[0]
参数,在ls
的内部实现中,该参数并没有被使用,所以可以乱传,所以第三个、第四个以及往后的参数都不允许乱传。
3.2 execl
函数
没有了PATH
环境变量,需要使用路径 + 程序名的方式加载程序。
#include<unistd.h>
int execl(const char* pathname, const char* arg, ...);
除了第一个参数的含义有改变外,其他参数含义与execlp
相同。
pathname
必须是含根目录的完整路径名 或 当前目录的相对路径。
execl("/bin/ls", "ls", "-l", "-a"); 与上面的 execlp 调用示例等价
这个函数使我们能够调用自己编写的可执行程序,只要把相对路径说清楚即可。
3.3 execle
函数
末尾的e
是 environment。该函数令加载的新程序使用调用者提供的环境变量表,不使用进程原有的环境变量表。即,设置新加载程序运行的环境变量表。
#include<unistd.h>
int execle(const char* pathname, const char* arg, ...);
3.4 execv
函数
末尾的v
是 vector 。使用命令行参数数组。
#include<unistd.h>
int execv(const char* pathname, char* const argv[]);
使用该函数前,首先要构建命令行参数数组,即,第二个参数argv
。如下面的例子所示:
char* argv[] = {"ls", "-l", "-a", NULL};
execv("/bin/ls", argv); 其实和我们在 shell 中敲命令差不多
3.5 execvp
函数
和execv
函数类似,只不过在使用时可以借助PATH
环境变量。
3.6 execve
函数
和execv
函数类似,只不过在使用时可以借助我们提供的环境变量表。
4、回收子进程
一个进程在终止时会关闭所有文件描述符,并释放在用户空间分配的内存,但他的PCB还保留着,内核在其中保存了一些信息:如果正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait
或 waitpid
获取这些信息,并彻底清除掉这个进程(及其PCB)。
在shell中,可以使用echo $?
命令查看上一个进程的退出状态,因为shell是他的父进程,当他终止时shell调用wait
或 waitpid
得到他的退出状态同时彻底清除掉这个进程。
4.1 孤儿进程
父进程先于子进程结束,则子进程称为孤儿进程。此时,子进程的父进程变成了 init
进程,由init
进程回收该子进程。
4.2 僵尸进程
子进程先于父进程终止,但是父进程没有回收子进程,此时,子进程的PCB残留于内核中,占有部分资源,变成僵尸进程。这样的僵尸进程越来越多会对系统运行造成危害。
kill
命令不能回收僵尸进程。 因为kill
命令是用来终止进程的,而僵尸进程已经终止了。
4.3 回收子进程
4.3.1 wait
函数
父进程调用 wait
函数能够阻塞的回收一个子进程,并获得其结束状态:
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
status
是一个传出参数,用于获取子进程退出状态,我们需要定义一个整型参数,并传其地址进去。
函数成功返回回收的进程ID,调用失败或没有可回收的子进程返回-1并设置errno
。
wait
会阻塞的等待子进程退出,只要子进程不退出,父进程也干不了别的。
如何回收僵尸进程?只能杀死他的父进程,从而迫使子进程的父进程变更为init
进程,init
进程发现他是个僵尸进程,会调用wait
函数回收它。
4.3.2 使用status
获取子进程退出状态
首先明确子进程终止的两种情况:
———正常终止 → 退出值
———异常终止 → 终止信号(linux所有程序的异常终止都是因为收到了某个信号)
我们的目的是获取子进程的退出值 或 终止信号。需要借助status
和宏函数进一步判断进程终止的具体原因。分为三种情况。
① 正常终止
WIFEXITED(status); 返回非0 ---→ 进程正常结束,要获取退出值继续调用下面的宏
WEXITSTATUS(status); 若上宏为真,调用此宏 ---→ 获取退出值(exit函数的参数,或最后return的值)
第一个宏只能判断是否正常结束,要获取具体的退出值需第一个宏为真后调用第二个宏。
① 异常终止
WIFSIGNALED(status); 为非0 ---→ 进程异常结束,要获取退出值继续调用下面的宏
WTERMSIG(status); 若上宏为真,调用此宏 ---→ 获取使进程终止的信号的编号
③ 子进程没有结束,而是暂停
WIFSTOPPED(status); 为非0 ---→进程暂停
WSTOPSIG(status); 若上宏为真,调用此宏 ---→ 获取使进程暂停的信号的编号
WIFCONTINUED(status); 为真 ---→ 进程暂停后以继续运行
4.3.1 waitpid
函数
waitpid
相较于wait
函数更加灵活,他能回收指定ID的子进程,而且可以工作在非阻塞模式:
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options);
pid
指定要回收的进程ID,其值有四种情况:
① >0: 要回收的子进程ID
② - 1: 回收任意一个子进程
③ =0: 回收和当前调用waitpid
一个组的任意一个子进程
④ < -1: 回收指定进程组内的任意一个子进程,如-10023
表示回收10023
进程组内的任意一个子进程。
status
参数的含义与wait
函数相同。
options
参数用于指定回收是阻塞还是非阻塞。若其值为0,则为阻塞,若其值为 WNOHANG
,则为非阻塞。
若第三个参数的值为WNOHANG
,且调用waitpid
时子进程还没结束,那么此次调用返回0。若子进程被回收,则返回子进程ID,调用失败或没有可回收的子进程返回-1。
5、进程间通信 IPC(Inter Process Communication)
5.1 进程间通信的4种方式
- 管道(使用最简单)
- 信号(开销最小,几乎没有开销)
- 共享映射区(可以使无血缘关系的进程间通信)
- 本地套接字(最稳定,但实现复杂)
5.2 进程间管道通信
在调用fork
创建子进程之前,创建一个管道,然后后面调用fork
后,父子进程就都有了管道的读端和写端,这样父子进程就能通过管道通信了。
管道通信的局限性
- 数据只能一端写入另一端读出
- 数据一旦被读走,管道中就没了,不能反复读取
- 数据只能在一个方向上流动
- 只能在有血缘关系的进程间使用管道
5.3 共享内存映射
5.3.1 mmap
函数
mmap
函数就是把磁盘中的文件映射到内存中的一片地址空间(这个工作是由MMU完成的),并返回指向该地址空间的指针。
#inlcude<sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
addr参数: 我们可以用该参数指定建立映射区的首地址,但是一般都传入NULL
,表示由Linux内核指定首地址。该参数类型void*
是个泛型指针,它可以隐式转换为任何类型的指针,因为可能还不知道映射区中存放什么类型的数据。
length参数: 指定映射区的大小,一般由被映射的文件大小决定。毕竟你是吧文件映射到这里。
prot参数: 设置用户对该映射区的访问权限(注意:这只是对内存的限制)
flags参数: 设置 映射区内数据被修改后产生的结果。(如:映射区数据修改后,映射的文件中的数据也被修改还是不被修改)
fd参数: 映射的文件
offset参数: 映射文件的偏移(必须是4Kb的整数倍),从开头开始映射,还是从4Kb的位置开始映射,还是从8Kb的位置开始映射…
函数调用成功返回映射区的首地址,失败返回MAP_FAILED
并设置errno
。
munmap
函数:
#include<sys/mman.h>
int munmap(void* addr, size_t length);
该函数用来关闭mmap
打开的映射区。addr
为映射区首地址的指针,length
为映射区的大小。成功返回0,失败返回-1。
mmap
函数的注意事项:
- ① 可以把当前程序新创建的文件映射到内存中,但是必须用类似
ftruncate
的函数指定文件大小,因为默认创建的文件大小为0,而映射区的大小不能为0,所以必须指定文件大小。 - ② 映射区的权限必须
<=
文件打开的权限,创建映射区的过程中隐含着一次对文件的读操作。 - ③ 文件描述符先关闭,对于映射区的读写没有任何影响。文件描述符是操作文件的一个句柄,而现在我们可以通过映射区间接读写文件,操作文件的方式发生了改变,所以关闭文件描述符没有任何影响。映射区一旦创建成功,文件描述符可以立即关闭。
5.3.2 mmap
父子进程通信
有血缘关系的父子进程
有血缘关系的父子进程通过mmap
建立的映射区通信时,要注意flags
参数应设置为MAP_SHARED
:
MAP_PRIVATE 父子进程各自独占映射区
MAP_SHARED 父子进程共享映射区
匿名映射
目前为止,每次创建映射区一定要依赖一个临时文件才能实现,我们需要对该临时文件进行open
、unlink
、close
等操作。如果我们创建映射区只是为了父子进程通信,其实根本不需要文件以及里面的内容,这些文件操作就显得很多余。Linux提供了创建匿名映射区的方法,无需依赖文件即可创建映射区。该操作主要通过flags
参数指定。
MAP_ANONYMOUS
该 flags
的值表示:这段映射区不是从文件映射而来的,其内容全部被初始化为0,这时, mmap
的最后两个参数将被忽略。而第二个参数length
(文件的大小),可以随意指定。
无血缘关系进程间通信
无血缘关系的进程间通信的关键是:每个进程中都使用同一个文件去创建映射区。并设置好读方和写方,这样就能实现进程通信了,当然喽,MAP_SHARED
是必须的。
文件名可以使用命令行参数argv[]
传入。