??本文对Linux系统调用的机制进行了大致分析,并以此为基础对Linux进程管理(多进程调用、进程同步等)进行初步探索。在研究进程fork() 函数的过程中,笔者产生了很多问题,也通过查阅资料、动手实验等方式对这些问题有了一定的研究和理解。本文将按照笔者对问题研究的历程入手,对Linux进程管理的部分基本过程和背后机理进行阐述。
在本文中,你将看到:
- 系统调用(system call)的流程
POSIX 基本进程管理函数——fork() / vfork() / clone() / wait() / waitpid() …
fork() 和 vfork() 的区别、发展vfork() 等待机制(Completion 进程同步机制)fork clone vfork 的内核实现机理wait() 函数等待机制(详解) - 进程状态码
本文是系列第二篇,关于系统调用请见:Linux系统调用 - 进程管理初探(上)
Linux 进程管理
进程管理函数
fork()
#include <unistd.h>
extern __pid_t fork (void) __THROWNL;
头文件注释中已经写得很清楚了,fork() 函数会将调用它的进程复制出一个完全一样的新进程。
如果函数成功执行,它将在两个进程中分别返回,返回值如下:
- 父进程:返回fork出的子进程的
pid - 子进程:返回0
如果函数执行失败,它会返回 -1.
这样的返回值可以帮助程序编写者分清哪个是父进程,哪个是新生成的子进程,从而对两个进程执行不同的动作。
另外,在资源共享方面,它也有自己的特点:
- 复制进程地址空间。在早期的Linux版本中,
fork 函数将一个进程的所有内存资源(包括栈、堆、代码段、数据段等)完整复制一份,这对系统性能有极大的影响。后来,fork的地址空间管理采用了写时复制 (copy-on-write)技术,子进程仅复制父进程的页表,当读取时,它们从同一个物理页中读取;如果需要写入,则内存管理器会将这个内存页复制一份。在两个进程看来,它们的内存空间都是私有的,但是在操作系统的角度,这大大降低了fork 的时间和空间开销。 - 共享打开的文件。
fork 时,父进程的文件描述符表被复制给了子进程,他们将共享打开的文件描述符。当然,所有对共享资源的使用都会受到锁的限制,以防止产生并发错误。
在并发方面,父进程和子进程非阻塞同时运行,运行的次序先后由CPU调度决定。
vfork()
#include <unistd.h>
extern __pid_t vfork (void) __THROW;
同样,我们可以在头文件中读出很多信息。从功能上,vfork 很像fork ,但是在具体实现细节上,它们也存在着许多差异。
vfork 的返回值与fork 相同,不再赘述。
在资源共享方面:
vfork 子进程完全共享父进程的地址空间。它不需要复制页表,子进程对内存资源(包括栈(局部变量)、数据段(全局变量))的修改,在父进程中同样生效。- 同样,子进程与父进程共享打开的文件描述符资源。
在并发方面:
- 由于父进程与子进程共享所有内存空间,为了避免竞争,父进程被阻塞,等待子进程运行完成。只有子进程退出(必须是
exit() ,否则会引发段错误 )
clone()
#ifdef __USE_GNU
#include <sched.h>
extern int clone (int (*__fn) (void *__arg), void *__child_stack,
int __flags, void *__arg, ...) __THROW;
clone() 函数作为glibc封装函数(wrapper function)的原型被定义,为创建子进程提供更精细化的选项。如果是C语言程序,需要定义C特性宏以开启此函数声明:
#define _GNU_SOURCE
#include <sched.h>
参数说明:
-
fn : 函数指针,类型见上,指定clone 后子进程要执行的函数,函数返回后,子进程会退出(使用exit 系统调用) -
__child_stack :子进程的栈,供子进程独立使用。在使用clone函数前,需要使用mmap 函数申请一块内存空间传入函数,如果该参数为空指针,函数会返回错误。 -
__arg :传递给子进程函数的指针。 -
__flags :clone 相关的控制选项,这提供了精细化的子进程创建功能:
#define CSIGNAL 0x000000ff
#define CLONE_VM 0x00000100
#define CLONE_FS 0x00000200
#define CLONE_FILES 0x00000400
#define CLONE_SIGHAND 0x00000800
#define CLONE_PTRACE 0x00002000
#define CLONE_VFORK 0x00004000
#define CLONE_PARENT 0x00008000
#define CLONE_THREAD 0x00010000
#define CLONE_NEWNS 0x00020000
#define CLONE_SYSVSEM 0x00040000
#define CLONE_SETTLS 0x00080000
#define CLONE_PARENT_SETTID 0x00100000
#define CLONE_CHILD_CLEARTID 0x00200000
#define CLONE_DETACHED 0x00400000
#define CLONE_UNTRACED 0x00800000
#define CLONE_CHILD_SETTID 0x01000000
#define CLONE_NEWCGROUP 0x02000000
#define CLONE_NEWUTS 0x04000000
#define CLONE_NEWIPC 0x08000000
#define CLONE_NEWUSER 0x10000000
#define CLONE_NEWPID 0x20000000
#define CLONE_NEWNET 0x40000000
#define CLONE_IO 0x80000000
具体可以参考注释,下面仅介绍几个常用的选项:
CLONE_VM : 两进程共享虚拟内存空间 (Virtual Memory);如果选中,则不启用写时复制,两进程完全共享内存空间;反之,复制页表,两进程私有内存空间。CLONE_FILES : 两进程共享相同的文件描述符表。CLONE_SIGHAND : 两进程共享信号处理例程表,即,它们对到来的信号使用同样处理程序。另外,如果一个程序调用sigaction 修改对某一个信号的处理例程,那么所有共享这个表的进程都会受到影响;反之使用复制,则不然CLONE_VFORK : 在子进程结束或者调用exec函数加载程序后唤醒父进程。此处的唤醒不同于发出信号SIGCHLD ,我们在后面将会讨论到;CLONE_PARENT : 生成的进程的父进程与原进程相同。两进程成为兄弟进程,而不是父子进程CLONE_THREAD : 为了支持POSIX线程标准而增加,两线程属于一个线程组(thread group),并且父进程为同一个(类似CLONE_PARENT ),同属于一个线程组的所有线程,getpid() 返回值相同
wait()
#include <wait.h>
extern __pid_t wait (int *__stat_loc);
wait 函数用于等到一个子进程的状态变化(一般是运行结束)。这个状态变化可以是:
Terminated :进程运行结束,关闭Stopped :进程被一个信号停止执行(不是结束),比如信号SIGSTOP /SIGTSTP /SIGTTIN 等Resumed :进程被信号终止,如SIGKILL 等
任何一个子进程的变化都可以导致父进程被唤醒,具体见waitpid 。
如果一个子进程已经发生状态变化,则函数会立刻返回;否则将会等到子进程的状态变化,或是自身收到终止信号(SIGKILL 等)
wait 包含一个参数__stat_loc ,当被唤醒时,子进程的返回状态信息会传回这个变量。
waitpid()
#include <wait.h>
extern __pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options);
waitpid 函数的功能与wait 相同,只是提供了更加精细的选项。事实上,wait 调用了waitpid 的一个特殊情况:waitpid(-1, __stat_loc, 0)
参数解释如下:
__PID : 指定等待的PID
- PID>0: 匹配PID指定的进程
- PID=-1: 匹配所有进程(子进程)
- PID=0: 匹配所有属于当前进程组的进程
- PID<-1: 匹配所有进程组为-PID的进程
__stat_loc : 同wait __options :
WNOHANG : 进程不挂起,直接采集信息并返回,如果没有子进程退出,则返回0。这个选项可以用来统计子进程信息,同时父进程不至于阻塞WUNTRACED : 如果一个子进程停止__TASK_STOPPED ,函数也返回
根据上面的条件就可看出,wait 函数只是waitpid 函数的一个特例。
fork & vfork
??fork 和vfork 这两个函数在功能上极为相似,都是生成一个调用进程的副本。但它们也有非常多的区别。本节我们将联系运行机理等,从两函数的功能对比展开研究。
空间管理
??上一节已经说过,早期fork 会直接复制整个调用进程的内存空间,即使很大概率被fork 出的新函数会直接执行exec 系统调用,执行一个新的程序,将fork 辛苦复制的所有内存空间全部刷新。事实上,Linux所有的进程都是由init (系统启动的第一个用户态进程,PID始终为1)进程通过fork 类系统调用创建的,命令行中程序的启动也是通过它完成,exec 函数族的使用频率非常高。如果对每一个进程都通过完全复制内存空间的方式创建,那将导致非常大的无用开销。 ??因此,内存管理单元 (Memory Management Unit, MMU) 引入的copy-on-write 写时复制技术被使用到fork 上。fork 仅复制进程的页表,并对页表属性做标记。在读取页数据时,两个页表映射到相同的物理页中,节约了空间。当其中一个进程需要进行写入,MMU才会将该页复制。在这些进程的视角下,它们的虚拟内存空间都是私有的,不会被别的进程修改。所以,fork 函数只能在支持MMU的架构上使用。 ??即使如此,fork 函数依然具有一定的开销,特别是在子进程立即执行exec 系统调用的情况下。这时候vfork 可以以相对较小的开销完成同样的功能。它将调用进程(Caller process)的内存空间直接共享给子进程,甚至不需要复制页表。这不仅节约了大量的时间开销,也减少了部分空间开销。另外,由于不需要写时复制,它允许在没有MMU的平台上运行。为了避免资源竞争,vfork会将父进程阻塞,直到子进程运行完成(或被信号终止),才会继续运行。
vfork等待机制
??在此期间,父进程状态被设置为TASK_KILLABLE ,这个状态是TASK_WAKEKILL 和TASK_UNINTERRUPTIBLE 的合并。进程不能被除终止信号外的任何信号打扰,将一直等到子进程运行完成。但是如果收到TASK_KILL ,它仍会被唤醒并结束,这是它有别于单独TASK_UNINTERRUPTIBLE 的地方。 ??进程初始化一个活动后,将会等待这个活动的结束。Linux通过Completion接口来完成这个操作,它是一种相对信号量更轻量的进程间同步机制。vfork 内部调用wait_for_task_killable 内核函数,将自己的状态更新,并等待子进程的结束。Completion等待的主例程如下:
do_wait_for_common(struct completion *x,
long (*action)(long), long timeout, int state)
{
if (!x->done) {
DECLARE_WAITQUEUE(wait, current);
__add_wait_queue_entry_tail_exclusive(&x->wait, &wait);
do {
if (signal_pending_state(state, current)) { // 检查任务状态
timeout = -ERESTARTSYS;
break;
}
__set_current_state(state);
spin_unlock_irq(&x->wait.lock);
timeout = action(timeout); // 执行计时器等待
spin_lock_irq(&x->wait.lock);
} while (!x->done && timeout);
__remove_wait_queue(&x->wait, &wait);
if (!x->done)
return timeout;
}
if (x->done != UINT_MAX)
x->done--;
return timeout ?: 1;
}
- 它通过定时检查目标活动状态实现等待,当目标活动被挂起,则退出等待循环并返回。
发展
实际上,用到vfork 的场景并不是很多,在fork 采用写时复制后,vfork 一般只在上面提到的那个场景中使用。POSIX 2008标准移除了vfork 函数,vfork +exec 函数组合的功能通过posix_spawn 替代。
wait函数机理
wait4 进入等待
wait 函数调用wait4 系统调用,对应内核函数为do_wait 。当检测到一个函数含有正在等待的目标活动,则将进程加入等待队列waitqueue 。
static long do_wait(struct wait_opts *wo)
{
struct task_struct *tsk;
int retval;
trace_sched_process_wait(wo->wo_pid);
init_waitqueue_func_entry(&wo->child_wait, child_wait_callback);
wo->child_wait.private = current;
add_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait);
repeat:
......
}
wake_up 唤醒
当子进程运行结束,它会调用do_notify_parent 函数通知父进程,
bool do_notify_parent(struct task_struct *tsk, int sig);
这个函数包含两个功能:
- 发出一个
SIGCHLD 信号,将自身退出的相关信息发送给父进程 - 执行
__wake_up_parent 函数,它将在等待队列中找出正在等待的父进程,并将其加入调度序列中,即唤醒父进程。
进程状态码
上面的几节中,我们已经看到过一些进程状态码。进程状态码被存放在PCB中,标志了当前进程的运行状态。下面是一个附录,列出了主要的进程状态,并对一些状态进行了解释:
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define __TASK_STOPPED 0x0004
#define __TASK_TRACED 0x0008
#define EXIT_DEAD 0x0010
#define EXIT_ZOMBIE 0x0020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
#define TASK_PARKED 0x0040
#define TASK_DEAD 0x0080
#define TASK_WAKEKILL 0x0100
#define TASK_WAKING 0x0200
#define TASK_NOLOAD 0x0400
#define TASK_NEW 0x0800
#define TASK_STATE_MAX 0x1000
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
#define TASK_STOPPED (TASK_WAKEKILL | __TASK_STOPPED)
#define TASK_TRACED (TASK_WAKEKILL | __TASK_TRACED)
TASK_RUNNING : 该状态是教科书中正在占用CPU的RUNNING运行状态,和进入等待队列的READY就绪状态。TASK_INTERRUPTIBLE : 进程进入睡眠状态,当进程需要等待一些条件满足,或等待低速设备的任务完成时,会被移出调度序列而放入睡眠序列,这些进程将等待条件满足或者信号到来时被唤醒。TASK_UNINTERRUPTIBLE : 进程同样进入睡眠状态,但是不会被信号唤醒。当一个进程需要等待硬件的一个“原子操作”时,如果被其他的信号量唤醒,则会导致不可控的结果。进入这个状态的进程不会被任何信号中断,甚至SIGKILL 也不行TASK_KILLABLE : 是两个状态的结合(见上),进程不可被除了SIGKILL 外的信号打断。__TASK_STOPPED : 进程被SIGSTOP 、SIGTSTP 等信号中断后进入这个状态,常见于ptrace 进程跟踪,如调试器等。
下一篇中,我们将一起探索Linux信号相关的机制。
入睡眠序列,这些进程将等待条件满足或者信号到来时被唤醒。
TASK_UNINTERRUPTIBLE : 进程同样进入睡眠状态,但是不会被信号唤醒。当一个进程需要等待硬件的一个“原子操作”时,如果被其他的信号量唤醒,则会导致不可控的结果。进入这个状态的进程不会被任何信号中断,甚至SIGKILL 也不行TASK_KILLABLE : 是两个状态的结合(见上),进程不可被除了SIGKILL 外的信号打断。__TASK_STOPPED : 进程被SIGSTOP 、SIGTSTP 等信号中断后进入这个状态,常见于ptrace 进程跟踪,如调试器等。
下一篇中,我们将一起探索Linux信号相关的机制。
|