什么是进程

进程是执行中的程序的抽象。程序是一个文件,这个程序中的指令被操作系统加载到内存,然后执行起来,如此就得到了一个进程。

进程的创建

fork

fork 用于创建进程,当某个进程调用 fork 后,一个新的进程就产生了,这个进程被成为子进程,创建该子进程的那个进程就是父进程。子进程和父进程共享代码段,数据段、栈和堆采用写时复制。调用 fork 之后在父进程和子进程中返回值不同,因此可以根据返回值来判断,当前是在子进程还是父进程中。

pid_t pid = fork();
switch(pid){
    case -1:
        // handle error
    case 0:
        // child
    default:
        // parent
}
  • 共享代码段
  • 数据段、堆和栈采用写时复制。

fork 调用后是先执行父进程还是子进程是不确定的。假如总是先执行父进程,但是有可能在执行开始前,父进程的时间片用完了。

因为很多子进程在创建后都会调用 exec 来执行一个程序,如果 fork 后先执行父进程,那么父进程的执行将导致子进程中共享的内存需要进行拷贝。而如果先执行子进程,那么 fork 后立刻执行 exec,这就不需要进行拷贝了。但从历史上看,linux 采用的策略发生过几次改变。综上,在需要依赖执行顺序的场景下,不能对系统的执行顺序做任何假设。

wait

一个进程终止后,会留下一些信息,比如退出状态。操作系统会保留这些信息,因为将来父进程可能需要获取这些信息。获取这些信息的方法是调用 wait 函数。在调用 wait 时有以下几种情况:

  1. 子进程尚未终止:此时 wait 将阻塞直到子进程终止
  2. 子进程已经终止:此时 wait 可以立刻返回,获取到子进程的退出状态

wait 返回一个已经终止的子进程的 pid,并用 status 传回子进程终止时的状态。当调用 wait 的时候,没有子进程终止,那么 wait 会一直阻塞直到某个子进程终止。调用 wait 时,如果存在已经终止的子进程,wait 会立刻返回。如果不存在任何尚未终止的子进程,wait 会出错,返回 -1,且设置 erron 为 ECHILD。

因此,可以使用下面的代码来等待所有子进程终止:

int status;
while((pid = wait(&status)) != -1)
    continue;
if(errno != ECHILD)
    err_exit("wait");

waitpidwait 的功能类似,但是比 wait 功能要强。waitpid 可以明确等待某个特定的子进程,还可以提供参数来设置非阻塞地进行 wait

pid_t waitpid(pid_t pid, int *stat_loc, int options);

孤儿进程和僵尸进程

如果父进程先于子进程终止,子进程成为孤儿进程,他们会被 init 进程接管。

子进程终止后,如果父进程尚未使用 wait 等函数来获取子进程的状态,那么操作系统会始终保留这些进程的状态,子进程占用的部分资源不会被释放,这些子进程成为僵尸进程。如果父进程不断地创建子进程,但是不回收,会因存在太多的僵尸进程导致无法再创建新的进程。

SIGCHID 信号

父进程要回收子进程,可以采用下列方法:

  1. 阻塞在 wait 调用上
  2. 使用非阻塞的 wait 调用,然后周期性轮询

这两种方法,主进程都难以做其他的事情。注意到子进程终止时会给父进程发送 SIGCHILD 信号,因此父进程可以监听此信号,然后在信号处理函数中回收子进程。

void child_handler(int signo){
    int saved_error = errno;
    // 非阻塞地调用,id 设置为 -1 表示可以回收任意可以回收的子进程
    while (waitpid(-1, nullptr, WNOHANG) > 0)
        continue;
    errno = saved_error;
}

因为在处理信号时,可能有其他子进程也终止了,并发来了信号,但是因为该信号正在被处理,所以该信号处理函数不会被再次调用(这涉及到信号是否排队)。因此需要在这里多次调用 waitpid,但是为了避免阻塞在这里,因此需要非阻塞地调用。

进程的终止

exit 是 C 运行时库中提供的函数,而 _exit 是系统调用。exit_exit 做的工作要多,具体如下:

void exit(int status){
    // 1. 调用退出处理函数(使用 atexit 和 on_exit 注册的函数),执行顺序与注册顺序相反
    // 2. 刷新 stdio 流缓冲区
    // 3. _exit(status)
}

运行一个可执行文件

系统中正在运行的任何一个进程都是通过 fork 得来的,但是系统中的各个进程又互不相同,这是因为很多进程在创建后,立刻就使用 exec 系列函数来执行一个新的程序,该程序的内容会覆盖掉原进程空间,然后一个全新的进程就诞生了。

execve 的功能是加载一个新程序,路径为 pathname,参数为 argv,环境变量为 envp。调用此函数,将丢弃现有程序的文本段,并重新创建执行栈、数据段和堆。

exec 存在一系列的函数,其功能大致相同,只是调用方式不同,它们的函数定义如下:

int execle (const char *__path, const char *__arg, ...)
int execlp (const char *__file, const char *__arg, ...)
int execvp (const char *__file, char *const __argv[])
int execv (const char *__path, char *const __argv[])
int execl (const char *__path, const char *__arg, ...)
int execve (const char *__path, char *const __argv[], char *const __envp[])
int fexecve (int __fd, char *const __argv[], char *const __envp[])

函数的第一个参数用来指定可执行的程序的地址。结尾为字母 p 的,接受可执行程序的文件名,操作系统会在 PATH 中去搜索该可执行文件,但如果文件名中包含 / 则将其视为路径,不再去 PATH 中搜索,而是直接访问该路径对应的文件。其他不以 p 结尾的函数要求传入可执行程序的文件路径,可以是绝对路径,也可以是相对路径。最后一个是传入可执行程序的 fd。

运行程序的时候,需要提供参数,在 C/C++ 中我们采用如下方式定义主函数(第三个参数可能不常见,但是确实是可以有的),使用 exec 执行一个程序的时候,也需要为其提供这些信息。

int main(int argc, char* argv[], char* env[]){

}

exec 系列函数除第一个参数外,都是用来指定这些信息的,只是形式不同。含有 l 字母的,表示程序的参数需要使用参数列表的形式传入,l 表示 list,下面是个例子,我调用了 ls 程序,来显示 /bin 目录下的所有文件。

execlp("ls", "ls", "/bin", nullptr);

有两点需要注意,argv 中第一个参数是程序的名称,因为参数数量不定,为标识参数的结束,最后需加一个空指针。

函数名中含有 v 的,参数需要使用字符串列表来传入,v 表示 vector。就像 main 函数的 argv 那样,注意 argv 中的最后一个元素是空指针。

如果不传递环境变量,程序在启动的时候就默认使用系统的环境变量,含有字母 e 的,可以指定环境变量。在程序中使用环境变量,可以用外部定义的 environ 变量,或者使用 main 函数的第三个参数来接收环境变量。

extern char **environ;

int main(){
    for(char p=environ; *p != nullptr; p++)
        printf("%s\n", *p);
    }
}

// 或者
int main(int argc, char* argv[], char* env[]){
    char **p = env;
    while(p != nullptr){
        printf("%s\n", *p);
        p++;
    }
}

system

system 用来执行一段命令,比如:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[]){
    system("ls /bin");
    printf("end");
}

system 创建了一个子进程,在子进程中执行命令,执行完成后,父进程接着执行。system 并没有去解释命令,而是把命令交给 sh 去执行:

execl("/bin/sh", "sh", "-c", command, (char *) NULL);

操作环境变量

可以操作当前进程中的环境变量,但是这些修改不会影响其他进程。

setenv("name","wangyu", 1);
printf("name: %s\n", getenv("name"));

char str[] = "email=wangyu_it@yeah.net";
putenv(str);
printf("email: %s\n", getenv("email"));

unsetenv("name");
unsetenv("email");

setjmp 和 longjmp

TODO

进程凭证

实际用户 ID 和实际组 ID

确定了进程所属的用户和组

有效用户 ID 和有效组 ID

进程组、会话、作业控制

一个会话就是一个终端,会话首进程通常是终端程序,如 bash。在终端中,用户键入命令会创建新的进程,每一个命令构成一个进程组。有的命令会创建多个进程,比如使用管道连接的多个进程,这些进程处于同一个进程组。 会话包含多个进程组,进程组包含多个进程。

进程组

在特定的进程组中父进程能够等待任意的子进程,信号能够被发送到进程组中的所有进程。子进程会继承其父进程的进程组 ID。

SIGHUP 信号

当终端关闭的时候,内核会发送 SIGHUP 信号给该会话中的控制进程。还会发送一个 SIGCONT 信号,保证之前停止的进程能够重新运行。控制进程往往是 shell 程序,shell 会向它创建的其他进程组发送 SIGHUP 信号,可能还有 SIGCONT 信号。

SIGHUP 信号的默认处理方式是终止进程。

nohup 的工作原理

nohub 的源代码可以在此处看到。它先做了一些重定向,比如把标注错误输出重定向到文件。最最关键的原理性代码就下面几行:

signal(SIGHUP, SIG_IGN);

char **cmd = argv + optind;
execvp (*cmd, cmd);

先调用 signal 忽略 SIGHUP 信号,然后执行用户想要执行的命令。如此一来,进程就替换成用户命令指定的程序了。在执行 exec 之后,代码段已经被替换了,用户自定义的信号处理函数也就无处找寻了,因此用户自定义信号也就都被重置为默认了。但是在父进程中设置为 SIG_IGNSIG_DFL 的信号,在子进程中依然维持原状,这就是为什么 execvp 后能够忽略 SIGHUP 信号的原因。

作业控制

# 在后台运行
$ sleep 100 &

# 查看当前作业
$ jobs
[1]+  Running   sleep 100 & # 方括号中为作业编号 

# 在前台运行 job 1,%1 表示 job 1
$ fg %1
sleep 100  # 会打印出命令

# 停止运行
$ Ctrl+Z
[1]+  Stopped   sleep 100  # job 1 已经停止


$ jobs
[1]+  Stopped   sleep 100  # 在后台处于停止状态

# 在后台运行 job 1
$ bg %1
[1]+ sleep 1000 &

# 在后台停止
$ kill -STOP %1  # %1 表示 job 1

下图总结了作业控制中常用的命令:

<width,600>

守护进程

守护进程具有如下特征:

  1. 生命周期很长,通常一个守护进程会在系统启动时被创建,一直运行到系统被关闭。
  2. 在后台运行,且没有控制终端。