和文件描述符相似,Unix 使用一个非负整数来代表进程,但是当一个进程销毁后,新创建的进程一般不会立即使用刚刚销毁的进程的 ID。一般而言,进程 ID 为零的进程是调度进程,一般也叫做交换进程。进程 ID 为 1 的是 init 进程是初始化进程,在系统启动时由内核创建。 init 进程绝不会终止 ,它是运行在超级用户特权下的用户进程(而交换进程是系统进程)。
init 进程是所有进程的祖先。
fork
使用 fork 可以创建一个子进程:
pid_t fork(void);
fork 调用一次,然后分别在父进程和子进程中返回一次,不同之处在于 父进程中 fork 的返回值为子进程的 PID ,子进程中 fork 的返回值为 0。
调用 fork 后子进程和父进程拥有相同的正文段,除非父进程或子进程对某个变量进行了修改,否则内核不会拷贝这些数据,也就是写时复制技术。
此外,fork 后的子进程和父进程拥有相同的:
相同的正文段
相同的 IO 重定向
相同的文件描述符
相同的权限和用户标示
相同的进程环境
相同的储存映像
但是,子进程不继承父进程的:
已获取的文件锁
未处理的闹钟
未处理的信号集
fork 失败的原因
fork 返回的失败原因只有内存不足,但是事实上造成此问题的原因并不是一定就是内存不足,真实原因有四种:
内存不足
numa 架构下,进程启动的时候绑定了node,导致只有一个 node 里的内存在起作用
numa 架构下,如果所有内存都插到一个槽,其它 node 就会没内存
进程数量超过限制
之所以返回的失败代码只有一种,原因是:
fork 的调用链为:fork → do_fork → copy_process,其中 copy_process 中的部分代码如下:
static struct task_struct *copy_process(...){
...
//注意这里!!!!!!
//申请整数形式的 pid 值
if (pid != &init_struct_pid) {
retval = -ENOMEM;
pid = alloc_pid(p->nsproxy->pid_ns);
if (!pid)
goto bad_fork_cleanup_io;
}
...
bad_fork_cleanup_io:
if (p->io_context)
exit_io_context(p);
...
fork_out:
return ERR_PTR(retval);
}
可以看到:无论 alloc_pid 返回的是何种类型的失败,其错误类型都写死的返回 -ENOMEM,而 ENOMEM 代表的是 Out of memory 的意思
而 alloc_pid 的代码节选如下:
//file:kernel/pid.c
struct pid *alloc_pid(struct pid_namespace *ns){
//第一种情况:申请 pid 内核对象失败
pid = kmem_cache_alloc(ns->pid_cachep, GFP_KERNEL);
if (!pid)
goto out;
//第二种情况:申请整数 pid 号失败
//调用到alloc_pidmap来分配一个空闲的pid
tmp = ns;
pid->level = ns->level;
for (i = ns->level; i >= 0; i--) {
nr = alloc_pidmap(tmp);
if (nr < 0)
goto out_free;
pid->numbers[i].nr = nr;
pid->numbers[i].ns = tmp;
tmp = tmp->parent;
}
...
out:
return pid;
out_free:
goto out;
}
另外 pid 在内核中并不是一个简单的整数类型,而是一个结构体,所以内存分配时会失败
第二中情况是申请进程号的时候如果失败,也会返回错误
通过这里我们还额外学习到了另外一个知识!一个进程并不只是申请一个进程号就够了。而是通过一个 for 循环去申请了多个。
假如说当前创建的进程是一个容器中的进程,那么它至少得申请两个 PID 号才行。一个 PID 是在容器命名空间中的进程号,一个是根命名空间(宿主机)中的进程号。
这也符合我们平时的经验。在容器中的每一个进程其实我们在宿主机中也都能看到。但是在容器中看到的进程号一般是和在宿主机上看到的是不一样的。
exec
如果父进程期望子进程执行其它程序,那么就需要在子进程中执行 exec 来替换当前的内存映像:
int execl(const char *pathname, const char *arg, ... /*, (char *) NULL */);
int execlp(const char *file, const char *arg, ... /*, (char *) NULL */);
int execle(const char *pathname, const char *arg, ... /*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
int fexecve(int fd, char *const argv[], char *const envp[]);
可以看到,存在两个函数族,这两个函数族的分别是 execl 和 execv。后面的 l 代表 list,v 代表 vector。execl 族函数要求向程序传递参数时以列表的形式传递,并且最后一个参数设置为空指针以指示结束。execv 族函数要求将参数放到一个字符数组中。
另外,以 e 结尾的函数使用 envp 初始化程序环境,而不是使用当前环境。
pathname 和 file 都代表了程序的路径,如果不是路径,那就在 PATH 中查找,如果查找到的不是二进制文件,就将其视为脚本语言。
当 exec 成功后,直接执行相关二进制文件的内容,否则返回 -1 并置 errno
exec 的常见错误为:
错误字符串 | 原因 |
Bad address | 参数列表最后一个不是空指针 |
|
进程退出
进程退出的原因不外乎两种:
进程自己退出
进程收到系统信号后退出
进程正常退出有以下几种方法:
在 main 函数中调用 return 0
在函数任意位置调用 exit
调用 _exit 或者 _Exit
进程的最后一个线程在启动函数中调用 return
进程的最后一个进程调用 pthread_exit
exit 和 _Exit 的不同之处是 exit 会在退出时调用使用 atexit 函数注册的清理函数,而 _Exit 则不进行处理就立即退出 |
进程异常退出的原因可能是:
进程读写了已经关闭的文件,因为 SIGPIPE 信号而退出
段错误
被其它进程 kill
进程缺少需要的动态库或符号而退出
waitpid
当子进程结束后,内核会向其父进程发送 SIGCHLD 信号,当父进程接受到 SIGCHLD 信号而调用 waitpid 函数时,函数调用会立即返回,否则父进程会被阻塞至子进程结束
下面是 waitpid 的函数签名:
pid_t waitpid(pid_t pid, int *wstatus, int options);
pid 的参数有四种含义:
值 含义 < -1
等待进程组 id 为 pid 绝对值的所有组内进程
-1
等待任意子进程
0
等待进程组 id 为 pid 的所有组内进程
> 0
等待进程 id 为 pid 的子进程
wstatus 是一个输出指针,用来获取子进程的状态。如果对子进程状态不关心,可以设为空指针。waitpid 的 options 可以根据需要将 waitpid 设置为非阻塞的。
wstatus 需要使用宏来查看子进程的状态,这些宏以 WIF 开头。
宏 何时为真 WIFEXITED(wstatus)
进程正常终止
WEXITSTATUS(wstatus)
进程异常终止
WIFSIGNALED(wstatus)
进程暂停
WTERMSIG(wstatus)
进程因信号终止
参数 options 为零或以下常量:
常量 含义 WNOHANG
非阻塞模式
WUNTRACED
子进程暂停时也返回。但结束时不理会
WCONTINUED
子进程恢复执行时也返回
守护进程
要创建一个守护进程,遵循以下步骤:
调用 umask(0) 将文件模式创建屏蔽字设置为 0。以防止父进程影响此进程的权限
调用 fork ,然后使父进程退出
调用 sedsid 创建一个新会话
将当前工作路径更改为根路径
关闭不需要的文件描述符
某些守护进程打开 /dev/null 来忽略标准输入/输出和错误的影响。stdout 可以根据需要屏蔽,因为 systemd 会收集来自 stdout 的日志
之所以进行这些步骤,根本原因是守护程序可能从终端运行,但是终端作为一个应用软件,其生存时间是很短的,但是守护程序却需要长时间在后台运行,因此需要执行上面的步骤来断开终端连接,将自己放到后台运行
Linux 对上述步骤进程了封装,形成了 daemon 函数,进程只需要调用 daemon 函数就能将自己变成守护进程。其函数签名为:
|
绑定父子进程的生命周期
使用 prctl 可以让子进程接收到父进程的相关信号。例如让父子进程同时退出
prctl(PR_SET_PDEATHSIG, SIGKILL);
僵死进程
如果一个进程已经终止,但是父进程却没有执行 waitpid 函数,则此进程被称为 僵死进程 ,僵死进程使用 ps 命令打印出来的状态为 Z。僵死进程的 PID 不会被回收,如果系统中存在大量的僵死进程,可能会导致系统没法创建新进程。
如果僵死进程的父进程结束了,那么僵死进程会被 init 进程收养,然后由 init 进程执行善后工作
要防止出现僵尸进程,有三种办法:
父进程调用 waitpid 回收子进程的资源
通过两次 fork 让 init 进程变成子进程的父进程
在代码中添加
signal(SIGCHLD, SIG_IGN)
其中,第二种代码的写法是:
if(fork() == 0){
if(fork() == 0){
// do something
}
exit(0);
}
第三种写法的原理如下:
当子进程的状态发生改变后产生 SIGCHLD 信号,这时父进程需要调用 wait 函数来确定发生了什么。
SIG_DFL 和 SIG_IGN 两个函数句柄可以分别用来处理 SIGCHLD 信号。
SIG_DFL 的默认方式是不理会这个信号,也不丢失子进程的状态。会产生僵死进程
SIG_IGN 的默认方式是丢弃子进程的状态,也不会产生僵尸进程
在杀死进程时,一个常用的方式是使用 kill -9 发送 SIGKILL 信号给进程。但是有两种情况下此方法不适用:
解决的办法也有两种:
|
linux线程函数 pthread_atfork 的深入理解
转自
在进行linux系统里开发时,经常会调用linux的系统函数fork来产生一个子进程,如果父子进程都没有用到pthread线程相关函数,则就不存在需要理解pthread_atfork的函数的必要。问题是有时候既要考虑多线程,又要考虑多进程,这个时候就要仔细理解pthread_atfork这个函数的作用了。
在父进程调用fork函数派生子进程的时候,如果父进程创建了pthread的互斥锁(pthread_mutex_t)对象,那么子进程将自动继承父进程中互斥锁对象,并且互斥锁的状态也会被子进程继承下来:如果父进程中已经加锁的互斥锁在子进程中也是被锁住的,如果在父进程中未加锁的互斥锁在子进程中也是未加锁的。在父进程调用fork之前所创建的pthread_mutex_t对象会在子进程中继续有效,而pthread_mutex_t对象通常是全局对象,会在父进程的任意线程中被操作(加锁或者解锁),这样就无法通过简单的方法让子进程明确知道被继承的 pthread_mutex_t对象到底有没有处于加锁状态。因此 pthread线程库就有了 pthread_atfork 这个函数,该函数的原型声明如下:
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
该函数通过3个不同阶段的回调函数来处理互斥锁状态。参数如下:
阶段 | 作用 |
prepare | 将在fork调用创建出子进程之前被执行,它可以给父进程中的互斥锁对象明明确确上锁。这个函数是在父进程的上下文中执行的,正常使用时,我们应该在此回调函数调用 pthread_mutex_lock 来给互斥锁明明确确加锁,这个时候如果父进程中的某个线程已经调用pthread_mutex_lock给互斥锁加上了锁,则在此回调中调用 pthread_mutex_lock 将迫使父进程中调用fork的线程处于阻塞状态,直到prepare能给互斥锁对象加锁为止。 |
parent | 是在fork调用创建出子进程之后,而fork返回之前执行,在父进程上下文中被执行。它的作用是释放所有在prepare函数中被明明确确锁住的互斥锁。 |
child | 是在fork返回之前,在子进程上下文中被执行。和parent处理函数一样,child函数也是用于释放所有在prepare函数中被明明确确锁住的互斥锁。 |
函数成功返回0, 错误返回错误码。
通过这个函数可以确保子进程继承pthread_mutex_t对象处在未加锁状态。该函数的正常的用法如下:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <errno.h>
#define ERROR(err, msg) do { errno = err; perror(msg); exit(-1); } while(0)
int count = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void prepare() {
int err;
printf("prepare: pthread_mutex_lock ...\n");
err = pthread_mutex_lock(&lock);
if (err != 0) ERROR(err, "prepare: pthread_mutex_lock failed");
printf("prepare: lock start...\n");
}
void parent() {
int err;
printf("parent: pthread_mutex_unlock ...\n");
err = pthread_mutex_unlock(&lock);
if (err != 0) ERROR(err, "parent: pthread_mutex_unlock");
}
void child() {
int err;
printf("child: pthread_mutex_unlock ...\n");
err = pthread_mutex_unlock(&lock);
if (err != 0) ERROR(err, "child: pthread_mutex_unlock");
}
void* thread_proc(void* arg) {
while(1) {
pthread_mutex_lock(&lock);
count++;
printf("parent thread: count:%d\n",count);
sleep(10);
pthread_mutex_unlock(&lock);
sleep(1);
}
return NULL;
}
int main(int argc,char * argv[])
{
int err;
pid_t pid;
pthread_t tid;
pthread_create(&tid, NULL, thread_proc, NULL);
err = pthread_atfork(prepare, parent, child);
if (err != 0) ERROR(err, "atfork");
sleep(1);
printf("parent is about to fork ...\n");
pid = fork();
if (pid < 0) ERROR(errno, "fork");
else if (pid == 0) {
int status;
printf("child running\n");
while(1) {
pthread_mutex_lock(&lock);
count ++;
printf("child: count:%d\n",count);
sleep(2);
pthread_mutex_unlock(&lock);
sleep(1);
}
exit(0);
}
pthread_join(tid, NULL);
return 0;
}
编译方法(低版本GCC) :
gcc -O2 -o atfork -lpthread atfork.c
如果GCC版本比较低用-lpthread来链接pthread库,高版本的gcc例如gcc 7.40通过-lpthread编译时会出现如下错误 :
undefined reference to `pthread_atfork'
将 -lpthread 改成 -pthread 即可。
高版本GCC编译链接:
gcc -O2 -o atfork -pthread atfork.c
运行 atfork 发现 main函数会在prepare回调中等待:prepare: pthread_mutex_lock,直到获得互斥锁以后再执行子进程创建。