Linux系统调用 - 进程管理初探(中)

  本文对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>
/* Clone the calling process, creating an exact copy.
   Return -1 for errors, 0 to the new process,
   and the process ID of the new process to the old process.  */
extern __pid_t fork (void) __THROWNL;

头文件注释中已经写得很清楚了,fork()函数会将调用它的进程复制出一个完全一样的新进程

如果函数成功执行,它将在两个进程中分别返回返回值如下:

  • 父进程:返回fork出的子进程的pid
  • 子进程:返回0

如果函数执行失败,它会返回 -1.

这样的返回值可以帮助程序编写者分清哪个是父进程,哪个是新生成的子进程,从而对两个进程执行不同的动作。

另外,在资源共享方面,它也有自己的特点:

  • 复制进程地址空间。在早期的Linux版本中,fork函数将一个进程的所有内存资源(包括栈、堆、代码段、数据段等)完整复制一份,这对系统性能有极大的影响。后来,fork的地址空间管理采用了写时复制 (copy-on-write)技术,子进程仅复制父进程的页表,当读取时,它们从同一个物理页中读取;如果需要写入,则内存管理器会将这个内存页复制一份。在两个进程看来,它们的内存空间都是私有的,但是在操作系统的角度,这大大降低了fork的时间和空间开销。
  • 共享打开的文件fork时,父进程的文件描述符表被复制给了子进程,他们将共享打开的文件描述符。当然,所有对共享资源的使用都会受到锁的限制,以防止产生并发错误。

并发方面,父进程和子进程非阻塞同时运行,运行的次序先后由CPU调度决定。

vfork()
#include <unistd.h>
/* Clone the calling process, but without copying the whole address space.
   The calling process is suspended until the new process exits or is
   replaced by a call to `execve'.  Return -1 for errors, 0 to the new process,
   and the process ID of the new process to the old process.  */
extern __pid_t vfork (void) __THROW;

同样,我们可以在头文件中读出很多信息。从功能上,vfork很像fork,但是在具体实现细节上,它们也存在着许多差异。

vfork的返回值与fork相同,不再赘述。

资源共享方面:

  • vfork子进程完全共享父进程的地址空间。它不需要复制页表,子进程对内存资源(包括栈(局部变量)、数据段(全局变量))的修改在父进程中同样生效
  • 同样,子进程与父进程共享打开的文件描述符资源。

并发方面:

  • 由于父进程与子进程共享所有内存空间,为了避免竞争,父进程被阻塞,等待子进程运行完成。只有子进程退出(必须是exit(),否则会引发段错误 )
clone()
#ifdef __USE_GNU
#include <sched.h>
/* Clone current process.  */
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 相关的控制选项,这提供了精细化的子进程创建功能

    /*
     * cloning flags:
     */
    #define CSIGNAL		0x000000ff	/* signal mask to be sent at exit */
    #define CLONE_VM	0x00000100	/* set if VM shared between processes */
    #define CLONE_FS	0x00000200	/* set if fs info shared between processes */
    #define CLONE_FILES	0x00000400	/* set if open files shared between processes */
    #define CLONE_SIGHAND	0x00000800	/* set if signal handlers and blocked signals shared */
    #define CLONE_PTRACE	0x00002000	/* set if we want to let tracing continue on the child too */
    #define CLONE_VFORK	0x00004000	/* set if the parent wants the child to wake it up on mm_release */
    #define CLONE_PARENT	0x00008000	/* set if we want to have the same parent as the cloner */
    #define CLONE_THREAD	0x00010000	/* Same thread group? */
    #define CLONE_NEWNS	0x00020000	/* New mount namespace group */
    #define CLONE_SYSVSEM	0x00040000	/* share system V SEM_UNDO semantics */
    #define CLONE_SETTLS	0x00080000	/* create a new TLS for the child */
    #define CLONE_PARENT_SETTID	0x00100000	/* set the TID in the parent */
    #define CLONE_CHILD_CLEARTID	0x00200000	/* clear the TID in the child */
    #define CLONE_DETACHED		0x00400000	/* Unused, ignored */
    #define CLONE_UNTRACED		0x00800000	/* set if the tracing process can't force CLONE_PTRACE on this clone */
    #define CLONE_CHILD_SETTID	0x01000000	/* set the TID in the child */
    #define CLONE_NEWCGROUP		0x02000000	/* New cgroup namespace */
    #define CLONE_NEWUTS		0x04000000	/* New utsname namespace */
    #define CLONE_NEWIPC		0x08000000	/* New ipc namespace */
    #define CLONE_NEWUSER		0x10000000	/* New user namespace */
    #define CLONE_NEWPID		0x20000000	/* New pid namespace */
    #define CLONE_NEWNET		0x40000000	/* New network namespace */
    #define CLONE_IO		0x80000000	/* Clone io context */
    

    具体可以参考注释,下面仅介绍几个常用的选项:

    • 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>
/* Wait for a child to die.  When one does, put its status in *STAT_LOC
   and return its process ID.  For errors, return (pid_t) -1. */
extern __pid_t wait (int *__stat_loc);

wait函数用于等到一个子进程的状态变化(一般是运行结束)。这个状态变化可以是:

  • Terminated:进程运行结束,关闭
  • Stopped:进程被一个信号停止执行(不是结束),比如信号SIGSTOP/SIGTSTP/SIGTTIN
  • Resumed:进程被信号终止,如SIGKILL

任何一个子进程的变化都可以导致父进程被唤醒,具体见waitpid

如果一个子进程已经发生状态变化,则函数会立刻返回;否则将会等到子进程的状态变化,或是自身收到终止信号(SIGKILL等)

wait包含一个参数__stat_loc,当被唤醒时,子进程的返回状态信息会传回这个变量。

waitpid()
#include <wait.h>
/* Wait for a child matching PID to die.
   If PID is greater than 0, match any process whose process ID is PID.
   If PID is (pid_t) -1, match any process.
   If PID is (pid_t) 0, match any process with the
   same process group as the current process.
   If PID is less than -1, match any process whose
   process group is the absolute value of PID.
   If the WNOHANG bit is set in OPTIONS, and that child
   is not already dead, return (pid_t) 0.  If successful,
   return PID and store the dead child's status in STAT_LOC.
   Return (pid_t) -1 for errors.  If the WUNTRACED bit is
   set in OPTIONS, return status for stopped children; otherwise don't.

   This function is a cancellation point and therefore not marked with
   __THROW.  */
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

  forkvfork这两个函数在功能上极为相似,都是生成一个调用进程的副本。但它们也有非常多的区别。本节我们将联系运行机理等,从两函数的功能对比展开研究。

空间管理

  上一节已经说过,早期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_WAKEKILLTASK_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(&current->signal->wait_chldexit, &wo->child_wait);	// core
repeat:
    ......		// wait for matching task
}
wake_up 唤醒

当子进程运行结束,它会调用do_notify_parent函数通知父进程,

/*
 * Let a parent know about the death of a child.
 * For a stopped/continued status change, use do_notify_parent_cldstop instead.
 *
 * Returns true if our parent ignored us and so we've switched to
 * self-reaping.
 */
bool do_notify_parent(struct task_struct *tsk, int sig);

这个函数包含两个功能:

  • 发出一个SIGCHLD信号,将自身退出的相关信息发送给父进程
  • 执行__wake_up_parent函数,它将在等待队列中找出正在等待的父进程,并将其加入调度序列中,即唤醒父进程。

进程状态码

上面的几节中,我们已经看到过一些进程状态码。进程状态码被存放在PCB中,标志了当前进程的运行状态。下面是一个附录,列出了主要的进程状态,并对一些状态进行了解释:

/*
 * Task state bitmask. NOTE! These bits are also
 * encoded in fs/proc/array.c: get_task_state().
 *
 * We have two separate sets of flags: task->state
 * is about runnability, while task->exit_state are
 * about the task exiting. Confusing, but this way
 * modifying one set can't modify the other one by
 * mistake.
 */
/* Used in tsk->state: */
#define TASK_RUNNING			0x0000
#define TASK_INTERRUPTIBLE		0x0001
#define TASK_UNINTERRUPTIBLE		0x0002
#define __TASK_STOPPED			0x0004
#define __TASK_TRACED			0x0008
/* Used in tsk->exit_state: */
#define EXIT_DEAD			0x0010
#define EXIT_ZOMBIE			0x0020
#define EXIT_TRACE			(EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#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
/* Convenience macros for the sake of set_current_state: */
#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: 进程被SIGSTOPSIGTSTP等信号中断后进入这个状态,常见于ptrace进程跟踪,如调试器等。

下一篇中,我们将一起探索Linux信号相关的机制。

入睡眠序列,这些进程将等待条件满足或者信号到来时被唤醒。

  • TASK_UNINTERRUPTIBLE: 进程同样进入睡眠状态,但是不会被信号唤醒。当一个进程需要等待硬件的一个“原子操作”时,如果被其他的信号量唤醒,则会导致不可控的结果。进入这个状态的进程不会被任何信号中断,甚至SIGKILL也不行
  • TASK_KILLABLE: 是两个状态的结合(见上),进程不可被除了SIGKILL外的信号打断。
  • __TASK_STOPPED: 进程被SIGSTOPSIGTSTP等信号中断后进入这个状态,常见于ptrace进程跟踪,如调试器等。

下一篇中,我们将一起探索Linux信号相关的机制。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值