0%

Linux进程管理(二)进程的创建和终止

原图

概述

最新版本的POSIX标准中定义了进程创建和终止的操作系统层面的原语。进程创建包括fork()和execve()函数族,进程终止包括wait()、waitpid()、kill()以及exit()函数族。Linux在实现过程中为了提高效率,把POSIX标准的fork原语扩展成了vfork和clone两个原语。

我们最常见的一种场景是在shell界面中输入命令,然后等待命令返回,如图所示:

用户空间如何创建进程

应用程序在用户空间创建进程有两种场景:

  • 创建的子进程和父进程共用一个elf文件:这种情况适合于大多数的网络服务程序
  • 创建的子进程需要加载自己的elf文件:例如shell

应用程序可以通过fork系统调用创建进程,fork之后,子进程复制了父进程的绝大部分的资源(文件描述符、信号处理、当前工作目录等)。完全复制父进程的资源的开销非常大且没有什么意义,特别是对于场景2。不过,在引入COW(copy-on-write)技术后,fork的开销其实也不算特别大,大部分的copy都是通过share完成的,主要的开销集中在复制父进程的页表上。linux还提供了vfork函数,vfork和fork是类似的,除了下面两点:

  • 阻塞父进程
  • 不复制父进程的页表

之所以vfork要阻塞父进程是因为vfork后父子进程使用的是完全相同的memory descriptor, 也就是说使用的是完全相同的虚拟内存空间, 包括栈也相同。所以两个进程不能同时运行, 否则栈就乱掉了。除了fork和vfork,Linux内核还提供的clone的系统调用接口主要用于线程的创建。其实通过传递不同的参数,clone接口可以实现fork和vfork的功能。

创建进程

现代操作系统都采用写时复制(Copy On Write,cow)技术。写时复制技术就是父进程在创建子进程时不需要复制进程地址空间的内容给子进程,只需要复制父进程的进程地址空间的页表给子进程,这样父子进程就可以共享相同的物理内存。当父子进程中有一方需要修改某个物理页面的内容时,触发写保护的缺页异常,然后才把共享页面的内容复制出来,从而让父子进程拥有各自的副本,如图所示:

fork()函数

如果使用fork()函数来创建子进程,子进程和父进程将拥有各自独立的进程地址空间,但是共享物理内存资源,包括进程上下文、进程栈、内存信息、打开的文件描述符、进程优先级、资源限制等。在创建期间,子进程和父进程共享物理内存空间,当它们开始运行各自的程序时,它们的进程地址空间开始分道扬镳,这得益于写时复制技术的优势。子进程和父进程有如下一些区别。

  • 子进程和父进程的ID不一样
  • 子进程不会继承父进程的内存方面的锁,比如mlock()
  • 子进程不会继承父进程的一些定时器,比如setitimer()、alarm()、timer_create()
  • 子进程不会继承父进程的信号量,比如semop()

尽管使用了写时复制技术,但还是需要复制父进程的页表,在某些场景下会比较慢,所以有了后来的vfork原语和clone原语。

vfork()函数

vfork()函数通过系统调用进入Linux内核,然后通过kernel_clone()函数来实现。

1
2
3
4
5
6
7
8
9
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};

return kernel_clone(&args);
}

vfork()的实现比fork()多了两个标志位,分别是CLONE_VFORK和CLONE_VM。CLONE_VFORK表示父进程会被挂起,直至子进程释放虚拟内存资源。CLONE_VM表示父子进程运行在相同的进程地址空间中。vfork()的另一个优势是连父进程的页表项复制动作也被省去了。

clone()函数

clone()函数通常用来创建用户线程。clone()函数功能强大,可以传递众多参数,可以有选择地继承父进程的资源,比如可以和vfork()一样与父进程共享进程地址空间,从而创建线程;也可以不和父进程共享进程地空间,甚至可以创建兄弟关系进程。

1
2
3
4
5
6
7
8
/*glibc库的封装*/
#include <sched.h>
int clone(int (*fn) (voia*), voia* child_stack,
int flags, void *arg, ...);
/*原始的系统调用*/
long clone(unsigned long flags, void *child_stack,
void *ptid, voia* ctid,
struct pt_regs *regs);

以glibc封装的clone()函数为例,fn是子进程执行的函数指针;child_stack用于为子进程分配栈;flags用于设置clone标志位,表示需要从父进程继承哪些资源;arg是传递给子进程的参数。clone()函数通过系统调用进入Linux内核,然后通过kernel_clone()函数来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};

return kernel_clone(&args);
}

内核线程

内核线程(kermel thread)其实就是运行在内核地址空间中的进程,它和普通用户进程的区别在于内核线程没有独立的进程地址空间,也就是task_struct数据结构中的mm指针被设置为NULL,因而只能运行在内核地址空间中,和普通进程一样参与系统调度。所有的内核线程都共享内核地址空间。常见的内核线程有页面回收线程“kswapd”等。Linux内核提供了多个接口函数来创建内核线程。

1
2
3
4
5
6
7
8
9
10
11
#define kthread_create(threadfn, data, namefmt, arg...) \
kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)

#define kthread_run(threadfn, data, namefmt, ...) \
({ \
struct task_struct *__k \
= kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
if (!IS_ERR(__k)) \
wake_up_process(__k); \
__k; \
})

kthread_create()接口函数创建的内核线程被命名为namefmt。新建的内核线程将运行threadfn()函数。新建的内核线程处于不可运行状态,需要调用wake_up_process()函数来将其唤醒并添加到就绪队列中,要创建一个马上可以运行的内核线程,可以使用kthread_run()函数。内核线程最终还是通过kernel_clone()函数来实现。

在内核中,fork()、vfork()以及clone()这3个系统调用都通过调用同一个函数即kernel_clone()函数来实现,该函数定义在fork.c文件中,感兴趣的可以自行查看,这个函数最终调用调用copy_process函数。

fork、vfork和pthread_create区别

  • fork

  • vfork

  • pthread_create

  • do_fork实现

终止进程

进程的终止有两种方式:一种方式是主动终止,包括显式地执行exit()系统调用或者从某个程序的主函数返回;另一种方式是被动终止,在接收到终止的信号或异常时终止。

当一个进程终止时,Linux内核会释放它所占有的资源,并把这条消息告知父进程。一个进程的终止可能有两种情况。

  • 它有可能先于父进程终止,这时子进程会变成僵尸进程,直到父进程调用wait()才算最终消亡
  • 它也有可能在父进程之后终止,这时init进程将成为子进程新的父进程

僵尸进程和托孤进程

当一个进程通过exit()系统调用被终止之后,该进程将处于僵尸状态。在僵尸状态中,除了进程描述符依然保留之外,进程的所有资源都已经归还给内核。Linux内核这么做是为了让系统可以知道子进程的终止原因等信息,因此进程终止时所需要做的清理工作和释放进程描述符是分开的。当父进程通过wait()系统调用获取了已终止的子进程的信息之后,内核才会释放子进程的task_struct数据结构。

所谓托孤进程,是指如果父进程先于子进程消亡,那么子进程就变成孤儿进程,这时Linux内核会让它托孤给init进程(1号进程),于是init进程就成了子进程的父进程。

进程0和进程1

进程0是指Linux内核在初始化阶段从无到有创建的一个内核线程,它是所有进程的祖先,有好几个别名,比如进程0、idle进程或swapper进程。进程0的进程描述符是在init/init_task.c文件中静态初始化的。

初始化函数start_kernel()在初始化完内核所需要的所有数据结构之后会创建另一个内核线程,这个内核线程就是进程1或init进程。与进程0共享所有的数据结构。

1
2
3
4
5
6
7
8
9
10
noinline void __ref rest_init(void)
{
struct task_struct *tsk;
int pid;

rcu_scheduler_starting();

pid = kernel_thread(kernel_init, NULL, CLONE_FS);
...
}

进程1会执行kernel_init()函数,它会通过execve()系统调用装入可执行程序init(“sbin/init”,“/bin/init”或“bin/sh”),进程1变成一个用户进程,是内核启动的第一个用户级进程。init有许多很重要的任务,比如像启动getty(用于用户登录)、实现运行级别、以及处理孤立进程。进程1在从内核线程变成普通进程init之后,它的主要作用是根据/etc/inittab文件的内容启动所需要的任务,包括初始化系统配置、启动一个登录对话等。

当检测到来自终端的连接信号时,getty进程将通过函数do_execve()执行注册程序login,此时用户就可输入注册名和密码进入登录过程,如果成功,由login程序再通过函数execv()执行shell,该shell进程接收getty进程的pid,取代原来的getty进程。再由shell直接或间接地产生其他进程。

上述过程可描述为:0号进程->1号内核进程->1号用户进程(init进程)->getty进程->shell进程

参考文献

http://www.wowotech.net/process_management/Process-Creation-1.html
https://blog.csdn.net/qq_20817327/article/details/108289647
https://cloud.tencent.com/developer/article/1842307
《奔跑吧Linux内核》
《Linux内核设计与实现》