Never too late to learn.

0%

Linux/UNIX系统编程手册-进程

Linux/UNIX系统编程手册

[德] Michael Kerrisk

第6章 进程
第24章 进程的创建
第25章 进程的终止
第26章 监控子进程
第27章 程序的执行

进程

进程和程序(Processes and Programs)

进程是一个可执行程序的实例(A process is an instance of an executing program).

程序(program)是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程,所包含的信息有:

  • 二进制格式标识(Binary format identification): 每个程序文件都包含用于描述可执行文件格式的元信息(metainformation)。内核利用此信息来解释文件中的其他信息。大多数UNIX(包括Linux)采用Executable
    and Linking Format (ELF).
  • 机器语言指令(Machine-language instructions): 对程序算法进行编码
  • 程序入口地址(Program entry-point address):标识程序开始执行时起始指令位置
  • 数据(Data): 变量初始值和程序使用的字面常量(literal constant)
  • 符号表及重定位表(Symbol and relocation tables): 描述程序中函数和变量的位置及名称。
  • 共享库和动态链接信息(Shared-library and dynamic-linking information): 程序运行需要的共享库,以及加载共享库的动态链接器的路径名
  • 其他信息: 描述如何创建进程

可以用一个程序创建多个进程。

进程是由内核定义的抽象的实体,并为该实体分配用以执行程序的各项系统资源。

进程号和父进程号

进程号(PID),是用来唯一标识系统中某个进程的一个整数。对系统调用来说,进程号可以作为参数传入,kill()系统调用;也可以作为返回值,比如getpid()系统调用。

Linux内核限制进程号需要<=32767, 可以调整。

$ cat /proc/sys/kernel/pid_max
32768

每个进程都有一个创建自己的父进程,使用系统调用getppid()获取父进程的进程号。

使用pstree命令可以查看进程树。

进程内存布局(Memory Layout of a Process)

每个进程所分配的内存由很多部分组成,通常称为“段(segments)”,或者“区(section)”:

  • 文本段(text segment): 包含进程运行的机器语言指令。文本段具有只读属性,同时可共享,使多个进程使用同一份程序代码拷贝。
  • 初始化的数据段(initialized data segment): 包含显示初始化的全局变量和静态变量。
  • 未初始化数据段(uninitialized data segment):也被称为BSS(block started by symbol)。 包含未进行显式初始化的全局变量和静态变量。程序启动之前系统将本段内所有内存初始化为0。
  • 栈(stack): 动态变化的segment,由栈帧(stack frames)组成。系统会为每个当前调用的函数分配一个栈帧,其中存储函数的局部变量,实参和返回值。
  • 堆(heap): 在运行时动态分配内存的区域。堆顶端被称为program break。

size命令可显示文本段,初始化和未初始化数据段(bss)的大小

1
2
3
4
5
6
7
> $ man size
NAME
size - list section sizes and total size.
...
> $ size hello
text data bss dec hex filename
1230 548 4 1782 6f6 hello

虚拟内存管理(Virtual Memory Management)

进程的内存布局存在与虚拟内存(Virtual Memory)中。虚拟内存的规划之一就是将每个程序使用的内存切割成小型的、固定大小的“页”(page)单元。相应地,将RAM换成一系列与“页”大小相同的页帧。

为支持这一组织形式,内核为每个进程维护一张页表(page table),用于记录每页在进程虚拟地址空间的位置。

虚拟内存管理使进程的虚拟地址空间与RAM物理地址空间隔离开来。

栈和栈帧(the stack and stack frame)

函数的调用和返回使栈的增长和收缩呈线性。栈驻留在内存的高端比你高向下增长(朝堆的方向)。专用寄存器–栈指针(stack pointer),用于跟踪当前栈顶。每次调用函数和返回函数时,都是在栈上新增和移去栈帧。

栈帧(user stack), 一般指用户栈,区分与内核栈,包含一下信息:

  • 函数实参和局部变量
  • (函数)调用的链接信息:每个函数都会用到一些CPU寄存器,比如程序计数器。比如调用另一个函数时,保存当前寄存器状态,以便返回时恢复。

命令行参数(command-line argument),argc, argv

  • int argc:命令行参数的个数
  • char *argv[]: 指向命令行参数的指针数组,每一参数都是以空字符(‘\0’)结尾的字符串.

程序可以通过/proc/PID/cmdline文件访问任一进程的命令行参数,每个参数都以空(NULL)字节终止。

argv和environ(环境变量)数组,以及这些参数最初只想的字符串,都主流在进程栈上的一个单一、连续的内存区域。

进程的创建

创建新进程: fork()系统调用

fork()创建一个新进程(child),几近于对调用进程(parent)的翻版

1
2
3
4
#include <unistd.h>
pid_t fork(void);
In parent: returns process ID of child on success, or –1 on error;
in successfully created child: always returns 0

完成对其调用后将存在两个进程,每个进程都会从fork()的返回处继续执行,程序代码可通过fork()的返回值来区分父子进程。在父进程中,fork()将返回新创建子进程的进程ID,在子进程中则返回0。
子进程也可调用getpid(), getppid()分别获得自身进程以及父进程的ID。

执行fork()时候,子进程会获得父进程所有文件描述符的副本,也即父子进程共享打开的文件及其属性。

从概念上讲,fork()认作对父进程程序代码段,数据段,堆栈的拷贝,实际上,子进程一般会替换代码段,并重新初始化数据,堆栈,全拷贝就造成了浪费。因此UNIX采用两种技术来避免这种浪费:

  • 内核将每一进程的代码段标记为只读,父子进程共享该代码段。
  • 对于数据段,堆栈中各页,内核采用写时复制技术(copy-on-write)。即内核会捕捉进程中针对页的修改企图,并为将要修改的页创建拷贝。

fork()之后的竞争条件(race condition)

竞争表现在调用fork()后,无法确定父、子进程谁将率先访问CPU。Linux在版本升级中,多次调整默认优先的进程。
由于会产生所谓“竞争条件”的错误,不应对fork()之后执行父、子进程的特定顺序做任何假设。如若需要保证执行顺序,需要采用同步技术,包括信号量(semaphore)、文件锁(file lock)以及进程间经由管道(pipe)的消息发送。

进程的终止

_exit()和exit()

进程可能通过两种方式终止:

  • 异常(abnormal)终止:接受到终止信号(signal),可能产生核心转储(core dump)
  • 进程使用系统调用_exit()自主终止
    1
    2
    #include <unistd.h>
    void _exit(int status);
    _exit()的status参数定义了进程的终止状态,父进程可以调用wait()获取该状态。虽然定义为int类型,但仅有低8位可以被父进程使用。调用_exit()的程序总会成功终止,即使从不返回。

一般使用库函数exit()来终止进程,它会在调用_exit()前执行各种动作:

  • 调用退出处理程序(通过atexit()和on_exit()注册的函数)
  • 刷新stdio流缓冲区
  • 使用由status提供的值执行_exit()系统调用

监控子进程

父进程需要了解其某个子进程何时改变了状态,用于监控子进程有两种方式:

  • 系统调用wait()
  • 信号SIGCHLD

等待子进程

系统调用wait()

wait()等待进程的任一子进程终止,同时在参数status所指向的缓冲区中返回该子进程的终止状态

1
2
3
#include <sys/wait.h>
pid_t wait(int *status);
Returns process ID of terminated child, or –1 on error

wait()执行一下动作:

  • 如果调用之前还没有子进程终止,则一直阻塞,如果有,则立即返回
  • 如果status非空,那么关于子进程如何终止的信息则会通过status指向的整型变量返回
  • 内核将会为父进程下所有子进程的运行总量追加进程CPU时间以及资源使用数据
  • 将终止子进程的ID作为wait()的结果返回

系统调用waitpid()

wait()存在诸多限制,而waitpid()则意在突破这些限制

  • 无法指定某个特定的子进程,只能按循序等待下一个子进程终止
  • 没有进程退出,则wait()总是阻塞
  • wait()只能发现终止的子进程,而对终止原因及恢复执行情况无能为力
    1
    2
    3
    #include <sys/wait.h>
    pid_t waitpid(pid_t pid, int *status, int options);
    Returns process ID of child, 0 (see text), or –1 on error

等待状态值

wait()和waitpid()返回的status值,可用来区分一下子进程事件:

  • 子进程调用_exit()或exit()而终止,并指定一个整型值作为退出状态
  • 子进程收到未处理信号终止
  • 子进程因为信号而暂停,并以WUNTRACED标志调用waitpid()
  • 子进程因收到信号SIGCONT而恢复,并以WCONTINUED标志调用waitpid()

同时还有waitid(), wait3()和wait4()

孤儿进程和僵尸进程(Orphan and ZOmbie)

  • 孤儿进程:父进程先于子进程终止,此时init会接管该进程,对getppid()的调用将返回1
  • 僵尸进程,父进程在wait()之前,子进程就已终止,内核会将子进程转为僵尸进程,即释放资源,但是保留该进程在进程表里的一条记录,包含进程id,终止状态,资源使用数据等信息。

父进程应执行wait()方法,以确保系统中总是能够清理那些死去的子进程。

SIGCHLD信号

无论一个子进程何时终止,系统都会向其父进程发送SIGCHLD信号。
对该信号的默认处理时将其忽略,可以通过设置信号处理程序signal()或sigaction()来捕获,同时编写信号处理函数使用wait()来处理僵尸进程。

程序的执行

系统调用execve()

execve()可以将新程序加载到某一进程的内存空间,这一过程中,将丢弃旧有程序,而进程的栈、数据以及堆会被新程序的相应替换。

1
2
3
#include <unistd.h>
int execve(const char *pathname, char *const argv[], char *const envp[]);
Never returns on success; returns –1 on error

envp参数指定了新环境的环境列表。
构建于execve()之上,有很多exec()类函数,功能相投,提供的接口不同。

执行shell命令:system()

程序可通过调用system()函数来执行任意的shell命令。

1
2
3
#include <stdlib.h>
int system(const char *command);
See main text for a description of return value

函数system()创建一个子进程来运行shell,并以之执行命令command。
system()主要有点是简便:

  • 无需处理fork(), exec(), wait()和exit()的调用细节
  • 会代为处理错误和信号
    但是效率变低,使用system()运行命令需要创建至少两个进程。一个用于运行shell,另外一个或多个则用于shell所执行的命令(执行每个命令都会调用一次exec())
Coffee? ☕