Linux内核进程创建、销毁源码剖析

226 阅读28分钟

进程描述

进程的虚拟地址空间分为用户虚拟地址空间和内核虚拟地址空间,所有进程共享内核虚拟地址空间,每个进程有独立的用户虚拟地址空间.
没有用户虚拟地址空间的进程称为内核线程,共享用户虚拟地址空间的进程称为用户线程.共享同一个用户虚拟地址空间的所有用户线程组成一个线程组.

结构体task_struct主要描述如下:

struct task_struct
{
    volatile long state;    // 进程的当前状态
    void* stack;            // 指向内核栈
    pid_t pid;              // 全局的进程号
    pid_t tgid;             // 全局的线程组标识符,也就是组长的标识符
    struct pid_link pids[PIDTYPE_MAX];    // 存储进程号、进程组标识符和会话标识符
    
    struct task_struct __rcu* real_parent; // 指向真实的父进程,不会改变
// 如果被另一个进程(通常是调试器)使用ptrace跟踪,那么这个是跟踪进程,否则和real_parent相同
    struct task_struct __rcu* parent;      
    
    char comm[TASK_COMM_LEN];              // 进程名称
// 调度策略和优先级    
    int prio, static_prio, normal_prio;
    unsigned int rt_priority, policy;
    
    cpumask_t cpus_allowed;    // 允许进程在哪些处理器上运行

// 指向内存描述符
// 如果是内核线程,mm是空指针;当内核线程运行时,active_mm指向从进程借用的内存描述符
    struct mm_struct* mm, *active_mm;
    
    struct fs_struct* fs;    // 文件系统信息,主要是进程的根目录和当前工作目录
    struct files_struct* files;    // 打开文件表
    struct nsproxy* nsproxy;       // 命名空间
   
// 信号处理
    struct signal_struct* signal;
    struct sighand_struct* sighand;
    sigset_t blocked, real_blcked;
    sigset_t saved_sigmask;
    struct sigpending pending;
    
    struct list_head children;    // 子进程链表
    struct list_head sibling;     // 兄弟进程链表
    struct task_struct* group_leader;      // 指向线程组的组长
    struct list_head tasks;    // 所有进程组成的链表,链表头是0号进程
    
// 信号量和共享内存
    struct sysv_sem sysvsem;
    struct sysv_shm sysvshm;
};

进程关系

父子进程

父子进程使用task_struct中的children指向所有的子进程
兄弟进程使用sibling构成兄弟进程链表 image.png

所有进程

所有的进程都是使用task_struct结构体里的tasks构成链表,存储所有的进程.
头结点是0号进程 image.png

创建进程(重要***)

在Linux内核中,新进程都是从一个已经存在的进程复制出来的.内核使用静态数据结构构造出0号内核线程.
linux-5.0.1/init/init_task.c文件中第57行

image.png 0号内核线程分叉生成1号内核线程和2号内核线程. 1号内核线程完成初始化以后装载用户程序,变成1号进程(kthreadd),其他进程都是1号进程或者是它的子孙进程分叉生成的. 其他内核线程都是kthreadd线程分叉生成的.

Linux提供了3个系统调用可以用来创建新的进程:

    1. fork: 子进程是父进程的一个副本,采用了写时复制(COW)技术
    1. vfork: 创建子进程后立即调用execve装载新程序,为了避免复制物理页,父进程会睡眠等待子进程装载结束,父进程才会去执行.
    1. clone: 可以精确的控制子进程和父进程共享哪些资源.提供了很多参数,可高度定制化. 主要用处是供pthread库用来创建线程.

fork系统调用:
下面代码定义在linux-5.0.1/kernel/fork.c

#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
	return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
#else
	/* can not support in nommu mode */
	return -EINVAL;
#endif
}
#endif

SYSCALL_DEFINE后面的数字表示系统调用的参数个数,SYSCALL_DEFINE0表示系统调用没有参数.
可以看到上面最终会调用_do_fork函数

下面是_do_fork函数的原型

long _do_fork(unsigned long clone_flags,	// 克隆标志,最低字节指定进程退出向父进程发出的信号,创建线程时,该参数最低字节为0,表示线程退出时不需要向父进程发送信号
	      unsigned long stack_start,	// 用户态栈的起始地址,创建线程时有意义
	      unsigned long stack_size,		// 用户态栈的大小,创建线程时有意义
	      int __user *parent_tidptr,	// 如果克隆标志指定为CLONE_PARENT_SETTID,调用线程需要把新线程的pid写到这个参数指定的位置. 创建线程时有意义
	      int __user *child_tidptr,		// 如果克隆标志指定了CLONE_CHILD_CLEARTID,那么线程退出时需要清除自己的进程标识符. 如果指定了CLONE_CHILD_SETTID,那么新线程第一次被调度需要把自己的进程标识符写到这个参数指定的位置. 创建线程时有意义
	      unsigned long tls)	// 参数tls只在创建线程时有意义,如果参数clone_flags指定了标志位CLONE_SETTLS,那么参数tls指定新线程的线程本地存储的地址

然后我们回到fork中是如何传递参数给_do_fork

// 参数1: 子进程退出发送SIGCHILD给父进程
// 参数2、参数3: 此处创建进程不需要指定用户态栈起始地址和大小
// 参数4、参数5、参数6: 都是创建进程所以不用关心这几个参数 
_do_fork(SIGCHILD, 0, 0, NULL, NULL, 0);   

接下来开始分析_do_fork函数,省略一些对了解原理不是很重要的代码

struct completion vfork;    // 用于vfork函数同步,父进程等待子进程结束再继续执行
struct pid* pid;            // 存储新进程的描述符
struct task_struct* p;      // 存储新进程的描述信息
int trace = 0;              // 用于ptrace调试追踪的标志
long nr;                    // 保存新进程的进程号

// 省略是否被ptrace追踪的代码

// 复制当前进程创建新的子进程(核心代码)
p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace, tls, NUMA_NO_NODE);   
if (IS_ERR(p))    // 如果创建新进程失败则退出
    return PTR_ERR(p);
   
// 
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);

if (clone_flags & CLONE_PARENT_SETTID)
    put_user(nr, parent_tidptr);// 将新进程的pid写入参数parent_tidptr指向的位置

if (clone_flags & CLONE_VFORK)    // 如果是vfork函数则会传入CLONE_VFORK标志
{
// 这段将子进程的vfork_done设置并初始化.主要作用用于父子进程同步,父进程等待子进程结束后再执行
    p->vfork_done = &vfork;   
    init_completion(&vfork);   // 初始化同步机制
    get_task_struct(p);        // 增加子进程引用计数,确保其在vfork结束前不会被销毁
}

wake_up_new_task(p);    // 唤醒新创建的子进程
if (clone_flags & CLONE_VFORK)
{
// 父进程会等待子进程执行结束
    wait_for_vfork_done(p, &vfork)
}

put_pid(pid);    
return nr;     // 父进程这个时候返回的是子进程的pid

大致流程图如下:
image.png
其中最主要、最核心的函数就是copy_process,这个函数拷贝进程内容并为新进程创建空间.把这个函数搞懂回头再来看do_fork后面的代码就清晰明了了

copy_process

这个函数代码有点长,防止看了会厌烦,还是拆分出来比较好

int retval;
struct task_struct* p;   // 存储新进程的描述信息
  
// 忽略检查标志是否设置正确
// 忽略处理信号的问题

// 为新进程的进程描述符分配内存,把当前进程的进程描述信息复制一份,为新进程分配内核栈
p = dup_task_struct(current, node);
if (!p)
    goto fork_out;

// 这一步检查新的进程号是否超出了限制,如果是非root用户且没有足够的权限,也就是不能创建子进程了
// 否则那就继续创建,不管限制
if (atomic_read(&p->real_cred->user->processes) >= 
    task_rlimit(p, RLIMIT_NPROC))
{
    if (p->real_cred->user != INIT_USER &&
        !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
        goto bad_fork_free;
}

上述定义了局部变量p,用来存储新创建进程的各种描述信息.这个是从父进程拷贝过来的.
调用dup_task_struct用来分配内存空间,并分配内核栈,用task_struct结构体的stack指向.
如果函数返回不为空,那么则在14行进行进程号是否超过限制的判断,如果超过限制而且还没有root权限那么只能退出了,无法创建新的进程.

接下来分析下dup_task_struct函数里面到底做了什么

struct task_struct* task;    // 存储新创建的进程一些描述信息
unsigned long* stack;        // 指向创建的内核栈
struct vm_struct* stack_vm_area __maybe_unused;    // 用于虚拟映射内核栈

// node参数是copy_process传递进来的,为NUMA_NO_NODE: 也就是让内核自动选择最合适的节点
tsk = alloc_task_struct_node(node);    // 为task_struct分配一个内存页
if (!tsk)
    return NULL;

// 分配内核栈内存
stack = alloc_thread_stack_node(tsk, node);
if (!stack)
    goto free_tsk;
    
// 复制进程信息, 参数orgi是当前的进程描述信息
err = arch_dup_task_struct(tsk, orig);

// 设置内核栈
tsk->stack = stack;
tak->stack_vm_area = stack_vm_area;

// 在内核栈低地址处设置thread_info结构体信息,其中指向当前进程task_struct
setup_thread_stack(tsk, orig);

这个函数主要做了4件事情:

  1. 为新进程分配内存空间,用于存储task_struct结构体信息
  2. 分配内核栈
  3. 复制进程信息到新进程的task_struct
  4. 设置task_struct的内核栈指针指向正确的内存位置
  5. 在内核栈低地址处设置thread_info结构体信息,其中指向当前进程task_struct,用于获取当前进程描述信息

说一下其中关于内核栈的内容,第3条和第5条所作的事情如下图所示:
image.png
也就是每个进程(线程)都有自己的内核栈.
这也就提出了问题,为什么要有内核栈呢?而不是所有的共享内核栈呢?直接使用用户态的栈空间不好吗?
回答:

q1: 用户栈和内核栈是分离的,可以防止内核代码意外覆盖进程的用户数据.其次,在内核栈上运行的代码不会被中断或其他进程打断,从而保证了内核代码的执行过程的稳定性.在进入内核态前,如果用户态将栈指针存放在一个恶意区域(比如内核地址空间等),那么内核就会轻易的覆盖不该写入的区域,造成系统崩溃
q2: 为每个进程分配一个内核栈是非常有必要的,一方面进行系统调用可能会阻塞在内核态(比如用户输入),此时进程在内核态的状态需要保留在内核态上,切换到其他进程(内核可抢占).如果其他进程没有自己的内核栈,栈上又会压入其他进程的数据,这样就会导致原进程状态无法恢复; 还有,这样可以使得进程从一个CPU迁移到另一个CPU变得十分便利,无需拷贝栈内容.
最后退出内核态时,当前进程的内核栈应当是全部清空的.

最后看一下setup_thread_stack怎么设置的内核栈中的thread_info

# define task_thread_info(task)	((struct thread_info *)(task)->stack)

#define setup_thread_stack(p, org)			\
	*task_thread_info(p) = *task_thread_info(org);	\
	task_thread_info(p)->ac_stime = 0;		\
	task_thread_info(p)->ac_utime = 0;		\ 
	task_thread_info(p)->task = (p); // 这个指向task_struct
#else
#define setup_thread_stack(p, org) \
	*task_thread_info(p) = *task_thread_info(org); \
	task_thread_info(p)->task = (p);
#endif

为什么要设置这个thread_info呢,主要是可以很方便快捷的获取当前进程的描述信息.咱们回到copy_process调用dup_task_struct函数时候,是如何传入参数的
image.png
这个current参数就是当前的进程描述信息,关于current宏的具体代码如下.具体底层细节应该是寄存器会指向内核栈的地址,然后就可以获取到了进程描述信息了.

#define get_current() (current_thread_info()->task)
#define current get_current()

现在已经创建了task_struct并且也设置了内核栈.让我们把视角回到copy_process函数中

p = dup_task_struct(current, node);
if (!p)
    goto fork_out;
    
// 检查是否超过了进程数量限制
if (atomic_read(&p->real_cred->user->processes) >= 
    task_rlimit(p, RLIMIT_NPROC))
{
    if (p->real_cred->user != INIT_USER &&
        !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
        goto bad_fork_free;
}

dup_task_struct函数执行成功后,判断进程数量是否超过最大限制了,如果还不是root用户没有足够的权限,那么就无法创建子进程在退出.

接下来忽略掉一些设置的代码,对原理理解无用的代码.

// 为新进程设置调度器相关的参数
retval = sched_fork(clone_flags, p);
if (retval)
    goto bad_fork_cleanup_policy;
    
// 往后就是复制文件系统、信号处理、内存管理、命名空间、io相关信息
retval = copy_xx(clone_flags, p);  // 后面都是这样的代码
if (retval)
    goto bad_fork_xx;
    
// 如果是CLONE_VM则共享内存空间,否则复制内存空间
retval = copy_mm(clone_flags, p);
if (retval)
    goto bad_fork_cleanup_signal;
    
retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
if (retval)
    goto bad_fork_cleanup_ip;

上面这几行代码,为新进程设置调度器相关参数,让新进程参加调度未来被执行. 然后后面就是大量的复制的函数,复制文件系统、io、信号处理的一些代码了.
第12行,用来进行是共享虚拟内存还是复制虚拟内存.copy_mm函数代码如下:

struct mm_struct* mm, *oldmm;
int retval;

tsk->mm = NULL;
tsk->active_mm = NULL;

oldmm = current->mm;
if (!oldmm)
    return 0;
    
if (clone_flags & CLONE_VM)
{
    mmget(oldmm);
    mm = oldmm;
    goto good_mm;
}

retval = -ENOMEM;
mm = dup_mm(tsk);
if (!mm)
    goto fail_nomem;
    
good_mm:
    tsk->mm = mm;
    tsk->active_mm = mm;
    return 0;
fail_nomem:
    return retval;
}

第7行首先获取父进程的虚拟内存.然后11行如果传入的参数是CLONE_VM,那么表示共享虚拟内存空间,将当前进程的成员mm指向父进程的mm
否则,分配一段虚拟内存空间,然后复制父进程的虚拟内存空间中的内容,第19行调用dup_mm函数.

上面dup_task_struct函数第16行copy_thread_tls,这个函数设置子进程的一些寄存器信息,以及子进程返回是0,都是在这个函数里完成的.

int copy_thread_tls(unsigned long clone_flags, unsigned long usp,
	unsigned long kthread_arg, struct task_struct *p, unsigned long tls)
{
    struct thread_info *ti = task_thread_info(p);
    struct pt_regs *childregs, *regs = current_pt_regs();
    unsigned long childksp;    // 新线程的堆栈指针地址
    
// 设置TSS(任务状态段)
    childksp = (unsigned long)task_stack_page(p) + THREAD_SIZE - 32;
    childregs = (struct pt_regs *) childksp - 1;
    childksp = (unsigned long) childregs;
    p->thread.cp0_status = read_c0_status() & ~(ST0_CU2|ST0_CU1);
    
// 如果创建的是内核线程
    if (unlikely(p->flags & PF_KTHREAD)) 
    {
        unsigned long status = p->thread.cp0_status;
        memset(childregs, 0, sizeof(struct pt_regs));
        ti->addr_limit = KERNEL_DS;
        p->thread.reg16 = usp; /* fn */
        p->thread.reg17 = kthread_arg;
        p->thread.reg29 = childksp;
        p->thread.reg31 = (unsigned long) ret_from_kernel_thread;
        status = (status & ~(ST0_KUP | ST0_IEP | ST0_IEC)) |
			 ((status & (ST0_KUC | ST0_IEC)) << 2);
        status |= ST0_EXL;
        childregs->cp0_status = status;
        return 0;
    }

// 用户线程或用户进程
    *childregs = *regs;
    childregs->regs[7] = 0; /* Clear error flag */
    childregs->regs[2] = 0; /* fork函数的返回值 */
    if (usp)
        childregs->regs[29] = usp;
    ti->addr_limit = USER_DS;
    
    p->thread.reg29 = (unsigned long) childregs;
    p->thread.reg31 = (unsigned long) ret_from_fork;
    
    childregs->cp0_status &= ~(ST0_CU2|ST0_CU1);

    clear_tsk_thread_flag(p, TIF_USEDFPU);
    clear_tsk_thread_flag(p, TIF_USEDMSA);
    clear_tsk_thread_flag(p, TIF_MSA_CTX_LIVE);
    
    atomic_set(&p->thread.bd_emu_frame, BD_EMUFRAME_NONE);

    if (clone_flags & CLONE_SETTLS)
        ti->tp_value = tls;
    return 0;
}

上面的内容就是复制当前进程的一些寄存器信息,并且将子进程在fork函数返回0.

接下来就是最后的收尾工作,设置新进程和原进程的关系了,


if (pid != &init_struct_pid)
{
    pid = alloc_pid(p->nsproxy->pid_ns_for_children);
    if (IS_ERR(pid))
    {
        retval = PTR_ERR(pid);
        goto bad_fork_cleanup_thread;
    }
}

p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD)
{
    p->exit_signal = -1;
    p->group_leader = current->group_leader;
    p->tgid = current->tgid;
}
else
{
    if (clone_flags & CLONE_PARENT)
        p->exit_signal = current->group_leader->exit_signal;
    else
        p->exit_signal = (clone_flags & CSIGNAL);
    p->group_leader = p;
    p->tgid = p->pid;
}

第1到第9行代码,为分进程分配进程号.从新进程所属的进程号命名空间一直到根,每层进程号命名空间为新进程分配一个进程号.
pid等于init_struct_pid的地址,这是什么意思?在内核初始化时,引导处理器为每个从处理器分叉生成一个空闲线程,所有处理器的空闲线程使用进程号0,全局变量init_struct_pid存放空闲线程的进程号

第12到16行,如果是创建线程,那么把新线程的成员exit_signal设置为-1,新线程退出时不需要发送信号给父进程;因为新线程和当前线程属于同一个线程组,所以成员group_leader指向同一个组长,成员tgid存放组长的进程号.

第18到26行,代表是创建进程.
第20到21行,如果传入的标志是CLONE_PARENT,表示新进程和当前进程是兄弟关系,那么新进程的成员exit_signal等于当前进程所属线程组的组长的成员exit_signal
第23行,表示新进程和当前进程是父子进程,那么新进程的成员exit_signal是调用者指定的信号.
第24到25行,新进程的所属线程组的组长是自己


cgroup_threadgroup_change_begin(current);
retval = cgroup_can_fork(p);
if (retval)
    goto bad_fork_free_pid;
write_lock_irq(&tasklist_lock);
if (clone_flags & (CLONE_PARNET | CLONE_THREAD))
{
    p->real_parent = current->real_parent;
    p->parent_exec_id = current->parent_exec_id;
}
else
{
    p->real_parent = current;
    p->parent_exec_id = current->self_exec_id;
}

第2行到4行,控制组的进程数控制器检查是否允许创建新进程:从当前进程所属的控制组一直到控制组层级的根,如果其中一个控制组的进程数量大于或等于限制,那么不允许使用fork和clone创建新进程.
控制组的进程数控制器检查是否允许创建新进程:从当前进程所属的控制组一直到控制组层级的根,如果其中一个控制组的进程数量大于或等于限制,那么不允许使用fork和clone创建新进程

第6行到15行,为新进程设置父进程
第8行到9行,如果传入了标志CLONE_PARENT(拥有相同父进程)或者CLONE_THREAD(创建线程),那么新进程和当前进程拥有相同的父进程.
第13到14行,否则当前的父进程是当前进程.


spin_lock(&current->sighand->siglock);
if (likely(p->pid))
{
    init_task_pid(p, PIDTYPE_PID, pid);
    if (thread_group_leader(p))
    {
        init_task_pid(p, PIDTYPE_PGID, task_pgrp(current));
        init_task_pid(p, PIDTYPE_SID, task_session(current));
        
        if (is_child_reaper(pid)) 
        {
            ns_of_pid(pid)->child_reaper = p;
            p->signal->flags |= SIGNAL_UNKILLABLE;
        }
        
        p->signal->shared_pending.signal = delayed.signal;
        p->signal->tty = tty_kref_get(current->signal->tty);
        p->signal->has_child_subreaper = p->real_parent->signal->has_child_subreaper ||
					p->real_parent->signal->is_child_subreaper;
        list_add_tail(&p->sibling, &p->real_parent->children);
        list_add_tail_rcu(&p->tasks, &init_task.tasks);
        attach_pid(p, PIDTYPE_PGID);
        attach_pid(p, PIDTYPE_SID);
        __this_cpu_inc(process_counts);
    }
    else
    {
        current->signal->nr_threads++;
        atomic_inc(&current->signal->live);
        atomic_inc(&current->signal->sigcnt);
        task_join_group_stop(p);
        list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
        list_add_tail_rcu(&p->thread_node, &p->signal->thread_head);
    }
    attach_pid(p, PIDTYPE_PID);
    nr_threads++;
}

第7行,因为新进程和当前进程属于同一个进程组,所以成员pids[PIDTYPE_PGID].pid指向同一个进程组的组长的进程号结构体
第8行,因为新进程和当前进程属于同一个会话,所以成员pids[PIDTYPE_SID].pid指向同一个会话的控制进程的进程号结构体
第10行到14行,如果新进程是1号进程,那么新进程是进程号命名空间中的孤儿进程领养者,忽略知名的信号,因为1号进程是不能杀死的.如果把1号进程杀死,谁来领养孤儿进程呢?
第20行,把新进程添加到父进程的子进程链表中
第21行,把新进程添加到进程链表中,链表节点是成员tasks,头节点是空闲线程的成员tasks(init_task.tasks)
第22行,把新进程添加到进程组的进程链表中
第23行,把新进程添加到会话的进程链表中

第29到35行,用于创建线程的逻辑
第29行,把线程组的线程计数值加1
第30行,把线程组的第2个线程计数值加1,这个计数值是原子变量
第31行,把信号结构体的引用计数加1
第33行,把线程加入线程组的线程链表中,链表节点是成员thread_group,头节点是组长的成员thread_group
第34行,把线程组加入线程组的第二条线程链表中,链表节点是成员thread_node,头节点是信号结构体的成员thread_head
第36行,把新进程添加到进程号结构体的进程链表中
第37行,线程计数值加1


total_forks++;
spin_unlock(&current->sighand->siglock);
write_unlock_irq(&tasklist_lock);
proc_fork_connector(p);
cgroup_post_fork(p);
cgroup_threadgroup_change_end(current);

第4行,通过进程时间连接器向用户空间通告进程事件PROC_EVENT_FORK.进程可以通过进程事件连接器监视进程事件: 创建协议号为NETLINK_CONNECTORnetlink套接字,然后绑定到多播组CN_IDX_PROC


整个copy_process函数到这里结束,让我们把视角转回到最初的_do_fork函数

p = copy_process(clone_flags, stack_start, stack_size,
			 child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
if (IS_ERR(p))	// 如果copy_process执行出错,则退出
    return PTR_ERR(p);

if (clone_flags & CLONE_VFORK)
{
    p->vfork_done = &vfork;
    init_completion(&vfork);
    get_task_struct(p);
}

wake_up_new_task(p);

if (clone_flags & CLONE_VFORK)
{
    if (!wait_fork_vfork_done(p, &vfork))
        ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

return nr;    // 返回新进程的pid

第6到11行和第15到19行,专门针对vfork函数所作的处理.
第6行到11行用于初始化同步机制,vforkfork不一样,它共享父进程的虚拟地址空间.而且父进程会被挂起,直到子进程执行exec或退出才会继续执行,所以需要用到这个结构用于同步.
第15到第19行,调用wait_fork_vfork_done函数等待vfork的完成,该函数会挂起,直到父进程使用exec或者退出.

static int wait_for_vfork_done(struct task_struct *child, struct completion *vfork)
{
    int killed;

    freezer_do_not_count();   // 冻结机制不计算在内
    killed = wait_for_completion_killable(vfork);  // 等待 vfork 完成
    freezer_count();         // 重新计入冻结机制

    if (killed) {            // 如果在等待时被信号中断
        task_lock(child);     // 锁定子进程的任务结构体,防止竞态条件
        child->vfork_done = NULL;  // 清除 vfork_done,避免父进程继续等待
        task_unlock(child);   // 解除锁定
    }

    put_task_struct(child);   // 减少子进程的引用计数
    return killed;            // 返回是否被信号中断
}

第6行,会使父进程等待vfork操作完成.当子进程通过vfork创建后执行exec或者退出,会通过complete函数通知父进程,父进程在此时继续执行
如果在等待期间,父进程收到了可杀死的信号(比如SIGKILL),函数将会被中断并返回非零值(killed设置为1)
第9到13行,如果条件成立表示收到了中断信号,那么直接设置vfork_done为NULL,表示不再等待vfork_done完成.


唤醒新进程函数wake_up_new_task

void wake_up_new_task(struct task_struct*  p)
{
    struct rq_flags rf;
    struct rq* rq;
    
    raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
    p->state = TASK_RUNNING;
    __set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
    rq = __task_rq_lock(p, &rf);
    update_rq_clock(rq);
    post_init_entity_util_avg(&p->se);
    
    activate_task(rq, p, ENQUEUE_NOCLOCK);
    p->on_rq = TASK_NO_RQ_QUEUED;
    
    check_preempt_curr(rq, p, WF_FORK);
    if (p->sched_class->task_woken)
    {
        rq_unpink_lock(rq, &rf);
        p->sched_class->task_woken(rq, p);
        rq_repin_lock(rq, &rf);
    }
    task_rq_unlock(rq, p, &rf);
}

第7行,将新进程的状态从TASK_NEW切换到TASK_RUNNING
第8行,在SMP系统上,创建新进程是执行负载均衡的绝佳时机,为新进程选择一个负载最轻的处理器.
第9行,锁住运行队列
第10行,更新云顶队列的时钟
第11行,根据公平运行队列的平均负载统计值,推算新进程的平均负载统计值
第13行,把新进程插入运行队列
第16行,检查新进程是否可以抢占当前进程
第20行,在SMP系统上,调度调度类的task_woken方法
第23行,释放运行队列的锁

装载程序

调度器调度新进程时,新进程从函数ret_from_fork开始执行,然后从系统调用fork返回用户空间,返回值是0.接着新进程使用系统调用execve装载程序
ret_from_fork所在文件linux-5.0.1/arch/x86/entry/entry_64.S

ENTRY(ret_from_fork)
	UNWIND_HINT_EMPTY
	movq	%rax, %rdi
	call	schedule_tail			/* rdi: 'prev' task parameter */

	testq	%rbx, %rbx			/* from kernel_thread? */
	jnz	1f				/* 内核线程并不常见 */

2:
	UNWIND_HINT_REGS
	movq	%rsp, %rdi
	call	syscall_return_slowpath	/* 返回并禁用 IRQ */
	TRACE_IRQS_ON			/* 用户模式被跟踪为 IRQS */
	jmp	swapgs_restore_regs_and_return_to_usermode

1:
	/* kernel thread */
	UNWIND_HINT_EMPTY
	movq	%r12, %rdi
	CALL_NOSPEC %rbx
	/*
	 * 成功完成 do_execve() 后,内核线程可以返回此处。退出到用户空间以完成 execve() 系统调用
	 */
	movq	$0, RAX(%rsp)
	jmp	2b
END(ret_from_fork)

linux内核提供了两个装载程序的系统调用:

int execve(const char* filename, char* const argv[], char* const envp[]);
int execveat(int dirfd, const char* pathname, char* const argv[], char* const envp[], int flags);

两个系统调用最终都会调用函数do_execveat_common,这个函数调用__do_execve_file

char *pathbuf = NULL;
struct linux_binprm *bprm;   // 二进制程序描述符结构体,用于存储有关要执行的程序的信息
struct files_struct *displaced;    // 存储被替换的文件描述符结构体
int retval;

// 复制当前进程的文件描述符表
retval = unshare_files(&displaced);

// 打开可执行文件
if (!file)
    file = do_open_execat(fd, filename, flags);
    
// 选择负载最轻的处理器,然后唤醒当前处理器上的迁移线程,当前进程睡眠等待迁移线程
// 把自己迁移到目标处理器
sched_exec();

// 创建新的内存描述符,分配临时的用户栈
retval = bprm_mm_init(bprm);

// 设置进程证书,读取文件的前面128字节到缓冲区
retval = prepare_binprm(bprm);   

// 把文件名称、环境字符串和参数字符串压到用户占中
retval = copy_xxx();

// 尝试注册过的每种二进制格式的处理程序,直到某个处理程序识别正在装载的程序位置
retval = exec_binprm(bprm);

第18行,临时用户栈的长度是一页,虚拟地址范围是[STACK_TOP_MAX−页长度,STACK_TOP_MAX),bprm->p指向在栈底保留一个字长后的位置 image.png 第23行,将所要的信息压入栈中

image.png

进程退出

进程退出分两种情况,主动退出和终止进程.
linux内核提供了两个使进程主动退出的系统调用

1. 使一个线程退出
void exit(int status);
2. 私有的系统调用使一个线程组的所有线程退出
void exit_group(int status);

glibc库封装了库函数exit、_exit和_Exit,来使一个进程退出,这个库函数调用系统调用exit_group.库函数exit_exit的区别是exit会执行由进程使用atexiton_exit注册的函数

注意: 我们在编写用户程序时调用的函数exit,是glibc库的函数exit,不是系统调用exit

当进程退出的时候,根据父纪南城是否关注子进程退出事件,处理存在如下差异:

  • 如果父进程关注子进程退出事件,那么进程退出时释放各种资源,只留下一个空的进程描述符,变成僵尸进程,发送SIGCHILD通知父进程,父进程在查询进程终止的原因以后回收子进程的进程描述符
  • 如果父进程不关注子进程退出事件,那么进程退出时释放各种资源,释放进程描述符,自动消失

进程默认关注子进程退出事件,如果不想关注,可以使用系统调用sigaction针对信号SIGCHILD设置标志SA_NOCLDWAIT,以指示子进程退出时不要变成僵尸进程,或者设置忽略信号SIGCHILD

Linux提供了2个系统调用来等待子进程的状态改变,状态包括:

  • 子进程终止
  • SIGSTOP信号,使子进程停止
  • SIGCONT使子进程继续执行

这三个系统调用如下:

pid_t waitpid(pid_t pid, int* wstatus, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t* infop, int options);

子进程退出以后需要父进程回收进程描述符,如果父进程先退出,子进程称为孤儿,谁来为子进程回收进程描述符呢?父进程退出时需要给子进程寻找一个领养者,按照下面的顺序选择领养:

  1. 如果属于一个线程组,且该线程组还有其他线程,那么选择任意一个线程
  2. 选择最亲近的充当替补领养者的祖先进程.可以使用prctl(PR_SET_CHILD_SUBREAPER)把自己设置为替补领养者
  3. 选择进程所属的进程号命名空间的1号进程

线程退出exit_group函数源码

SYSCALL_DEFINE1(exit_group, int, error_code)
{
    do_group_exit((error_code & 0xff) << 8);
    return 0;
}

可以看到核心代码在do_group_exit

void do_group_exit(int exit_code)
{
    // 获取当前进程的信号
    struct signal_struct *sig = current->signal;

    BUG_ON(exit_code & 0x80); /* 核心转储未到达此处 */

    // 如果线程组正在退出,那么从获取退出码
    if (signal_group_exit(sig))
        exit_code = sig->group_exit_code; 
    // 如果线程组未处于正在退出的状态,并且线程组至少有两个线程
    else if (!thread_group_empty(current)) 
    {
        struct sighand_struct *const sighand = current->sighand;

        spin_lock_irq(&sighand->siglock);// 关中断获取锁
        
        if (signal_group_exit(sig)) // 再次检查是否已经在处理组退出
            exit_code = sig->group_exit_code;
        else 
        {
            sig->group_exit_code = exit_code;
            sig->flags = SIGNAL_GROUP_EXIT;
            // 向线程组的其他线程发送杀死信号,然后唤醒线程,让线程处理杀死信号
            zap_other_threads(current);   
        }
        spin_unlock_irq(&sighand->siglock);
    }

    do_exit(exit_code);
}

上面函数的执行流程图如下:
image.png 函数do_exit的执行过程如下:

  1. 释放各种资源,把资源对应高德引用计数减1,如果计数为0则释放数据结构
  2. 调用函数exit_notify,先为称为孤儿进程的子进程选择领养者,然后把自己的死讯通知父进程
  3. 把进程状态设置为TASK_DEAD
  4. 最后一次调用函数__schedule以调度进程

最后就剩下个task_struct留在全局的进程描述符链表中
举个例子:
假设一个线程组有两个线程A和B,线程A调用exit_group使线程组退出,线程A的执行过程如下:

  1. 把退出码保存在信号结构体的成员group_exit_code中,传递给线程B
  2. 给线程组设置正在退出的标志
  3. 给线程B发送杀死信号,然后唤醒线程B,让线程B处理杀死信号
  4. 线程A调用函数do_exit销毁资源退出

线程B的退出执行流程如下:
image.png

查询子进程退出原因

系统调用waitpid、waitid和wait4把主要工作都委托给do_wait函数,执行流程如下图. image.png 遍历当前线程组的每个线程,针对每个线程遍历它的每个子进程,如果是僵尸进程,调用函数eligible_child来判断是不是符合等待条件的子进程,如果符合等待条件(也就是waitpid函数想要的等待信号),调用函数wait_task_zombie进程处理

函数wait_task_zombie的执行流程如下:

  1. 如果调用者没有传入WEXITED,那说明调用者不想等待退出的子进程,直接返回

  2. 如果调用者传入WNOWAIT,表明调用者想让子进程处于僵尸状态,以后可以再次查询子进程的状态信息,那么只读取进程的状态信息,从线程的成员exit_code读取退出码

  3. 如果调用者没有传入WNOWAIT,处理如下

  • 读取进程的状态信息,如果线程组处于正在退出的状态,从线程组的信号结构体的成员group_exit_code读取退出码;如果只是一个线程退出,那么从线程的成员exit_code读取退出码
  • 把状态切换到死亡,释放进程描述符