linux-c之进程

内容纲要

进程控制块(PCB)

每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核
的进程控制块是 task_struct 结构体。

  • 进程id。
  • 进程的状态,有运行、挂起、停止、僵尸等状态。
  • 文件描述符表,包含很多指向 file 结构体的指针。
  • 当前工作目录(Current Working Directory)。
  • 用户id和组id。
  • 和信号相关的信息。
  • 进程切换时需要保存和恢复的一些CPU寄存器。
  • 描述虚拟地址空间的信息。
  • 描述控制终端的信息。
  • umask 掩码。
  • 控制终端、Session和进程组。
  • 进程可以使用的资源上限(Resource Limit)。

子进程的PCB是根据父进程复制而来的,只是进程id不一样
linux下的进程:
系统中同时运行着很多进程,这些进程都是从最初只有一个进程开始一个一个fork出来的。
在Shell下输入命令可以运行一个程序,是因为Shell进程在读取用户输入的命令之后会
调用 fork 复制出一个新的Shell进程,然后新的Shell进程调用 exec 执行新的程序。
比如:在Shell提示符下输入命令 ls ,首先 fork 创建子进程,这时子进程仍在执行 /bin/bash 程序,然
后子进程调用 exec 执行新的程序 /bin/ls

环境变量

libc 中定义的全局变量 environ 指向环境变量表, environ 没有包含在任何头文件中,所以在使用
时要用 extern 声明。例如:

#include <stdio.h>
int main(void)
{
    extern char **environ;
    for(int i=0; environ[i]!=NULL; i++)
        printf("%s\n", environ[i]);
    return 0;
}

用 environ 指针可以查看所有环境变量字符串,但是不够方便,如果给出 name 要在环境变量表中
查找它对应的 value ,可以用 getenv 函数。

#include <stdlib.h>
char *getenv(const char *name);

修改环境变量可以用以下函数

#include <stdlib.h>
int setenv(const char *name, const char *value, int rewrite);
void unsetenv(const char *name);

进程控制

fork函数

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

返回值:-1出错,大于0父进程,0子进程
fork 在子进程中返回0,子进程仍可以调用 getpid 函数得到
自己的进程id,也可以调用 getppid 函数得到父进程的id。在父进程中用 getpid 可以得到自己的
进程id,然而要想得到子进程的id,只有将 fork 的返回值记录下来,别无它法。
fork 函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回一次。
fork 的另一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程中相同编号
的文件描述符在内核中指向同一个 file 结构体,也就是说, file 结构体的引用计数要增加。
用 gdb 调试多进程的程序会遇到困难, gdb 只能跟踪一个进程(默认是跟踪父进程),而不能同
时跟踪多个进程,但可以设置 gdb 在 fork 之后跟踪父进程还是子进程。
set follow-fork-mode child 命令设置 gdb 在 fork 之后跟踪子进程

exec函数

用 fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程
往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间
代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所
以调用 exec 前后该进程的id并未改变。
其实有六种以 exec 开头的函数,统称 exec 函数:

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回,如果调用出错则返回-1,所以 exec 函数只有出错的返回值而没有成功的返回值
函数字母的含义:
不带字母p(path)的 exec 函数第一个参数必须是程序的相对路径或绝对路径,例如 "/bin/ls" 或 "./a.out" ,而不能是 "ls" 或 "a.out" 。对于带字母p的函数:如果参数中包含/,则将其视为路径名。
否则视为不带路径的程序名,在 PATH 环境变量的目录列表中搜索这个程序。
带有字母l(list)的 exec 函数要求将新程序的每个命令行参数都当作一个参数传给它,命令
行参数的个数是可变的,因此函数原型中有 ... , ... 中的最后一个可变参数应该是 NULL ,
起sentinel的作用。
对于带有字母v(vector)的函数,则应该先构造一个指向各参数的指针
数组,然后将该数组的首地址当作参数传给它,数组中的最后一个指针也应该是 NULL ,就像 main 函数的 argv 参数或者环境变量表一样。
对于以e(environment)结尾的 exec 函数,可以把一份新的环境变量表传给它,其他 exec 函数仍使用当前的环境变量表执行新程序。
一个完整的例子:

#include <unistd.h>
#include <stdlib.h>
int main(void)
{
    execlp("ps", "ps", "-o","pid,ppid,pgrp,session,tpgid,comm", NULL);
    perror("exec ps");
    exit(1);
}

注意在调用 execlp 时传了两个 "ps" 参数,第一个 "ps" 是程序名, execlp 函数要在 PATH 环境变量中找到这个程序并执行它,而第二个 "ps" 是第一个命令行参数, execlp 函数并不关心它的值,
只是简单地把它传给 ps 程序, ps 程序可以通过 main 函数的 argv[0] 取到这个参数。

wait和waitpid函数

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留
着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存
着导致该进程终止的信号是哪个。这个进程的父进程可以调用 wait 或 waitpid 获取这些信息,然
后彻底清除掉这个进程。我们知道一个进程的退出状态可以在Shell中用特殊变量 $? 查看,因
为Shell是它的父进程,当它终止时Shell调用 wait 或 waitpid 得到它的退出状态同时彻底清除掉
这个进程。
如果一个进程已经终止,但是它的父进程尚未调用 wait 或 waitpid 对它进行清理,这时的进程状
态称为僵尸(Zombie)进程。任何进程在刚终止时都是僵尸进程,正常情况下,僵尸进程都立
刻被父进程清理了.
如果一个父进程终止,而它的子进程还存在(这些子进程或者仍在运行,或者已经是僵尸进程
了),则这些子进程的父进程改为 init 进程。 init 是系统中的一个特殊进程,通常程序文件
是 /sbin/init ,进程id是1,在系统启动时负责启动各种系统服务,之后就负责清理子进程,只
要有子进程终止, init 就会调用 wait 函数清理它。
wait 和 waitpid 函数的原型是:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);

若调用成功则返回清理掉的子进程id,若调用出错则返回-1。父进程调用 wait 或 waitpid 时可能会:

  • 阻塞(如果它的所有子进程都还在运行)。
  • 带子进程的终止信息立即返回(如果一个子进程已终止,正等待父进程读取其终止信息)。
  • 出错立即返回(如果它没有任何子进程)。
    这两个函数的区别是:
  • 如果父进程的所有子进程都还在运行,调用 wait 将使父进程阻塞,而调用 waitpid 时如果在 options 参数中指定 WNOHANG 可以使父进程不阻塞而立即返回0。
  • wait 等待第一个终止的子进程,而 waitpid 可以通过 pid 参数指定等待哪一个子进程。
    可见,调用 wait 和 waitpid 不仅可以获得子进程的终止信息,还可以使父进程阻塞等待子进程终
    止,起到进程间同步的作用。如果参数 status 不是空指针,则子进程的终止信息通过这个参数
    传出,如果只是为了同步而不关心子进程的终止信息,可以将 status 参数指定为 NULL 。
    通过WIFEXITED、WEXITSTATUS宏可以取出status中的信息

进程间通信

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所
以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到
内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

管道(pipe)

管道是一种最基本的IPC机制,由 pipe 函数创建:

#include <unistd.h>
int pipe(int filedes[2]);

调用 pipe 函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端,然
后通过 filedes 参数传出给用户程序两个文件描述符, filedes[0] 指向管道的读
端, filedes[1] 指向管道的写端(很好记,就像0是标准输入1是标准输出一样)
所以管道在用户程序看起来就像一个打开的文件,通过 read(filedes[0]); 或者 write(filedes[1]); 向这个
文件读写数据其实是在读写内核缓冲区。 pipe 函数调用成功返回0,调用失败返回-1。

管道通信步骤:

  1. 父进程调用 pipe 开辟管道,得到两个文件描述符指向管道的两端。
  2. 父进程调用 fork 创建子进程,那么子进程也有两个文件描述符指向同一管道。
  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以往管道里写,子进程可以从管道
    里读,管道是用环形队列实现的,数据从写端流入从读端流出,这样就实现了进程间通信。

    管道的限制:

  4. 两个进程通过一个管道只能实现单向通信,如果想实现双向通信必须另开一个管道
  5. 管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道文件描述符。

    读写管道的4种特殊情况:

    假设都是阻塞I/O操作,没有设置 O_NONBLOCK 标志

  6. 写端都关闭了,依然读,管道中的数据读完后,再次read返回0
  7. 写端没关闭,依然读,管道中的数据读完后,再次 read 会阻塞
  8. 读端都关闭了,依然写,那么该进程会收到信号 SIGPIPE ,通常会导致进程异常终止
  9. 读端没关闭,依然写,管道中的数据写完后,再次 write 会阻塞
    管道的这四种特殊情况具有普遍意义,socket也具有管道的这些特性

FIFO

进程间通信必须通过内核提供的通道,而且必须有一种办法在进程中标识内核提供的某个通
道,pipe是用打开的文件描述符来标识的。内核提供一条通道不成问题,问题是如何标识这
条通道才能使各进程都可以访问它?文件系统中的路径名是全局的,各进程都可以访问,因此
可以用文件系统中的路径名来标识一个IPC通道。
FIFO和Unix Domain Socket这两种IPC机制都是利用文件系统中的特殊文件来标识的。
用 mkfifo 命令创建一个FIFO文件: