2023-06-11
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

在最新的版本的POSIX标准中,定义了进程创建和终止的操作,进程创建包括fork()和execve(),进程终止包括wait(),waitpid(),kill()以及exit()。Linux系统为了提高效率,把POSIX标准的fork()扩展为vfork和clone。

前面一章我们学习了用GCC将一个最简单的程序(如hello world程序)编译成ELF文件,在shell提示符下输入该可执行文件并且按回车后,这个程序就开始执行了。起始这里shell会调用fork()来创建一个新进程,然后调用execve()来执行这个新程序。该函数负责读取可执行文件,将其装入子进程的地址空间并开始执行,这时候父子进程开始分道扬镳。

这一节,我们就来看一看,fork系统调用的实现,创建进程这个动作在内核里都做了什么事情。

1 _do_fork函数分析

在内核中,fork()、vfork()和clone()系统调用通过_do_fork()函数实现,_do_fork()函数实现在kernel/fork.c文件中

    long _do_fork(unsigned long clone_flags,
    	      unsigned long stack_start,
    	      unsigned long stack_size,
    	      int __user *parent_tidptr,
    	      int __user *child_tidptr,
    	      unsigned long tls)

_do_fork函数有6个参数,具体的含义如下:

参数 说明
clone_flags 创建进程的标志位集合,常见的标志位如下所示
stack_start 用户态栈的起始地址
stack_size 用户态栈的大小,通常设置为0
parent_tidptr和child_tidptr 指向用户空间中地址的两个指针,分别指向父、子进程的ID
tls 传递线程本地存储

常见的标志位,选取其中常用的几个

标志位 含义
CLONE_VM 父、子进程共享进程地址空间
CLONE_FS 父、子进程共享文件系统信息
CLONE_FILES 父、子进程共享打开的文件
CLONE_SIGHAND 父、子进程共享信号处理函数以及被阻塞的信号
CLONE_VFORK 在创建子进程时启用Linux内核的完成量机制,wait_for_completion会使父进程进入睡眠状态,直到子进程调用execve或exit释放内存
CLONE_IO 复制I/O上下文
CLONE_PTRACE 父进程被跟踪、子进程也会被跟踪

_do_fork()函数主要是调用copy_process函数来创建子进程的task_struct数据结构,以及从父进程复制必要的内容到子进程的task_struct数据结构中,完成子进程的创建,如下图所示

202306111304203131.png

第一步、检查子进程是否允许被跟踪

如果父进程正在被跟踪(即current->ptrace不为0时),检查debugger程序是否想跟踪子进程,并且子进程不是内核进程(CLONE_UNTRACED未设置)那么就设置CLONE_PTRACE标志,即子进程也被跟踪

    	if (!(clone_flags & CLONE_UNTRACED)) {
    		if (clone_flags & CLONE_VFORK)
    			trace = PTRACE_EVENT_VFORK;
    		else if ((clone_flags & CSIGNAL) != SIGCHLD)
    			trace = PTRACE_EVENT_CLONE;
    		else
    			trace = PTRACE_EVENT_FORK;
    
    		if (likely(!ptrace_event_enabled(current, trace)))
    			trace = 0;
    	}

第二步、复制进程描述符,返回的是新的进程描述符的地址

调用copy_process函数创建一个新的子进程,如果成功就返回子进程的task_struct

    	p = copy_process(clone_flags, stack_start, stack_size,
    			 child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
    	add_latent_entropy();

第三步、初始化完成量

对于vfork创建的子进程,首先要保证子进程先运行,子进程调用exec()或exit()之后,才可以调度,运行父进程,因此这里使用了一个vfork_done的完成量达到该目的。

    		struct completion vfork;
    		struct pid *pid;
    
    		trace_sched_process_fork(current, p);
    //1. 由子进程的task_struct数据结构来获取PID
    		pid = get_task_pid(p, PIDTYPE_PID);
    //2. pid_vnr获取虚拟的PID,即从当前命令空间内部看到的PID
    		nr = pid_vnr(pid);
    
    		if (clone_flags & CLONE_PARENT_SETTID)
    			put_user(nr, parent_tidptr);
    //3. init_completion初始化完成量
    		if (clone_flags & CLONE_VFORK) {
    			p->vfork_done = &vfork;
    			init_completion(&vfork);
    			get_task_struct(p);
    		}

第四步、唤醒新进程

wake_up_new_task函数用于唤醒新创建的进程,也就是把进程加入就绪队列里并接受调度、运行。

    	wake_up_new_task(p);//将子进程加入到调度器中,为其分配 CPU,准备执行

第五步、等待子进程完成

对于使用vfork(),wait_for_vfork_done函数等待子进程调用exec()或exit()

    		/* forking complete and child started to run, tell ptracer */
    		if (unlikely(trace))
    			ptrace_event_pid(trace, pid);
    
    		if (clone_flags & CLONE_VFORK) {
    			if (!wait_for_vfork_done(p, &vfork))
    				ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
    		}

第六步、返回子进程的ID

在父进程返回用户空间时,其返回子进程的ID,子进程返回用户空间时,其返回值为0。

do_fork函数执行后就存在两个进程,而且每个进程都会从 _do_fork函数的返回处执行。程序可以通过fork的返回值来区分父、子进程

  • 父进程,返回新创建的子进程的ID
  • 子进程,返回0

其处理流程如下图所示

202306111304213672.png

2 copy_process函数分析

copy_process函数使fork的核心函数,它会创建新进程的描述符,以及新进程执行所需要的其他数据结构,我们主要来看看这个具体做了些什么?

第一步、标志位检查

    // 1. CLONE_NEWS表明父子进程不共享mount的命名空间,每个进程可以拥有属于自己的mount空间
    	if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
    		return ERR_PTR(-EINVAL);
    // 2. CLONE_NEWUSER表示子进程要创建新的user命名空间,USER命令空间用于管理USER ID和Group ID的映射,起到隔离的作用
    	if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))
    		return ERR_PTR(-EINVAL);
    
    	/*
    	 * Thread groups must share signals as well, and detached threads
    	 * can only be started up within the thread group.
    	 */
    // 3. CLONE_THREAD表示父子进程在同一个线程组里,POSIX标准规定在一个进程的内部,多个线程共享一个PID,但是linux为每个线程和进程都分配了PID
    	if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
    		return ERR_PTR(-EINVAL);
    
    	/*
    	 * Shared signal handlers imply shared VM. By way of the above,
    	 * thread groups also imply shared VM. Blocking this case allows
    	 * for various simplifications in other code.
    	 */
    // 4. CLONE_SIGHAND表明父子进程共享相同的信号处理表,CLONE_VM表明父子进程共享内存空间
    	if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
    		return ERR_PTR(-EINVAL);
    
    	/*
    	 * Siblings of global init remain as zombies on exit since they are
    	 * not reaped by their parent (swapper). To solve this and to avoid
    	 * multi-rooted process trees, prevent global and container-inits
    	 * from creating siblings.
    	 */
    // 5. CLONE_PARENT表明新创建的进程是兄弟关系,而不是父子关系,他们拥有相同的父进程
    	if ((clone_flags & CLONE_PARENT) &&
    				current->signal->flags & SIGNAL_UNKILLABLE)
    		return ERR_PTR(-EINVAL);
    
    	/*
    	 * If the new process will be in a different pid or user namespace
    	 * do not allow it to share a thread group with the forking task.
    	 */
    // 6. CLONE_NEWPID表明创建一个新的PID命名空间
    	if (clone_flags & CLONE_THREAD) {
    		if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
    		    (task_active_pid_ns(current) !=
    				current->nsproxy->pid_ns_for_children))
    			return ERR_PTR(-EINVAL);
    	}

第二步、分配一个task_struct数据结构

dup_task_struct()为新进程分配一个task_struct数据结构,后续补充这个函数做了些什么?

    	retval = security_task_create(clone_flags);
    	if (retval)
    		goto fork_out;
    
    	retval = -ENOMEM;
    	p = dup_task_struct(current, node);
    	if (!p)
    		goto fork_out;

第三步、复制父进程

user数据结构中的processes成员记录了该用户的进程数,这里检查进程数是否超过了进程资源的限制RLIMIT_NPROC

    	ftrace_graph_init_task(p);
    
    	rt_mutex_init_task(p);
    
    #ifdef CONFIG_PROVE_LOCKING
    	DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
    	DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
    #endif
    	retval = -EAGAIN;
    // 1. 检查进程数是否超过限制,由操作系统定义
    	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;
    	}
    	current->flags &= ~PF_NPROC_EXCEEDED;
    //2. 复制父进程
    	retval = copy_creds(p, clone_flags);
    	if (retval < 0)
    		goto bad_fork_free;

第四步、初始化task_stcut

    //初始化子进程描述符中的list_head数据结构和自旋锁,并为与挂起信号、定时器及时间统计表相关的几个字段赋初值。
    	delayacct_tsk_init(p);	/* Must remain after dup_task_struct() */
    	p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER);
    	p->flags |= PF_FORKNOEXEC;
    	INIT_LIST_HEAD(&p->children);
    	INIT_LIST_HEAD(&p->sibling);
    	rcu_copy_process(p);
    	p->vfork_done = NULL;
    	spin_lock_init(&p->alloc_lock);
    
    	init_sigpending(&p->pending);
    
    	p->utime = p->stime = p->gtime = 0;
    	p->utimescaled = p->stimescaled = 0;
    	prev_cputime_init(&p->prev_cputime);

第五步、初始化进程调度相关的数据结构

sched_fork函数初始化与进程调度相关的数据结构,调度实体用sched_entity数据结构来抽象,每个进程或线程都是一个调度实体。

    	/* Perform scheduler related setup. Assign this task to a CPU. */
    	retval = sched_fork(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_policy;

第六步、初始化task_struct结构的其他数据结构

    	retval = perf_event_init_task(p);
    	if (retval)
    		goto bad_fork_cleanup_policy;
    	retval = audit_alloc(p);
    	if (retval)
    		goto bad_fork_cleanup_perf;
    	/* copy all the process information */
    	shm_init_task(p);
    	retval = copy_semundo(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_audit;
    	retval = copy_files(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_semundo;
    	retval = copy_fs(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_files;
    	retval = copy_sighand(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_fs;
    	retval = copy_signal(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_sighand;
    	retval = copy_mm(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_signal;
    	retval = copy_namespaces(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_mm;
    	retval = copy_io(clone_flags, p);
    	if (retval)
    		goto bad_fork_cleanup_namespaces;
    	retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
    	if (retval)
    		goto bad_fork_cleanup_io;
    
    	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;
    		}
    	}
  • copy_files 主要用于复制一个进程打开的文件信息。这些信息用一个结构 files_struct 来维护,每个打开的文件都有一个文件描述符。在 copy_files 函数里面调用 dup_fd,在这里面会创建一个新的 files_struct,然后将所有的文件描述符数组 fdtable 拷贝一份。
  • copy_fs 主要用于复制一个进程的目录信息。这些信息用一个结构 fs_struct 来维护。一个进程有自己的根目录和根文件系统 root,也有当前目录 pwd 和当前目录的文件系统,都在 fs_struct 里面维护。copy_fs 函数里面调用 copy_fs_struct,创建一个新的 fs_struct,并复制原来进程的 fs_struct。
  • copy_sighand 会分配一个新的 sighand_struct。这里最主要的是维护信号处理函数,在 copy_sighand 里面会调用 memcpy,将信号处理函数 sighand->action 从父进程复制到子进程。
  • init_sigpending 和 copy_signal 用于初始化,并且复制用于维护发给这个进程的信号的数据结构。copy_signal 函数会分配一个新的 signal_struct,并进行初始化。
  • 进程都自己的内存空间,用 mm_struct 结构来表示。copy_mm 函数中调用 dup_mm,分配一个新的 mm_struct 结构,调用 memcpy 复制这个结构。dup_mmap 用于复制内存空间中内存映射的部分。前面讲系统调用的时候,我们说过,mmap 可以分配大块的内存,其实 mmap 也可以将一个文件映射到内存中,方便可以像读写内存一样读写文件,这个在内存管理那节我们讲。
  • copy_namespace函数复制父进程的命名地址空间
  • copy_io函数复制父进程与I/O相关的内容
  • copy_thread_tls函数复制父进程的内核堆信息

第七步、分配ID

开始分配 pid,设置 tid,group_leader,并且建立进程之间的亲缘关系。

    	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;
    	}
  • pid_nr分配一个全局的PID,这个全局的PID是从init进程的命名空间的家督来看,是一个虚拟的PID
  • 设置group_leader和TGID

第八步、返回进程描述符

分配task_struct,并完成各项的初始化后,就返回子进程的描述符。

到此,copy_process函数的处理流程完毕,其处理流程如下图所示

202306111304223173.png

3. wake_up_new_task唤醒新进程流程

用copy_process来拷贝出一个新的进程pcb,然后调用wake_up_new_task将新的进程放入运行队列并唤醒该进程。同时新任务刚刚建立,有没有机会抢占别人,获得 CPU 呢?

    void wake_up_new_task(struct task_struct *p)
    {
    	struct rq_flags rf;
    	struct rq *rq;
    //1. 需要将进程的状态设置为 TASK_RUNNING
    	raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
    	p->state = TASK_RUNNING;
    #ifdef CONFIG_SMP
    	/*
    	 * Fork balancing, do it here and not earlier because:
    	 *  - cpus_allowed can change in the fork path
    	 *  - any previously selected cpu might disappear through hotplug
    	 *
    	 * Use __set_task_cpu() to avoid calling sched_class::migrate_task_rq,
    	 * as we're not fully set-up yet.
    	 */
    //2.这个函数会根据新创建的这个线程所属的调度类去执行不同的select_task_rq。
    	__set_task_cpu(p, select_task_rq(p, task_cpu(p), SD_BALANCE_FORK, 0));
    #endif
    	rq = __task_rq_lock(p, &rf);
    	post_init_entity_util_avg(&p->se);
    
    	activate_task(rq, p, 0);
    	p->on_rq = TASK_ON_RQ_QUEUED;
    	trace_sched_wakeup_new(p);
    	check_preempt_curr(rq, p, WF_FORK);
    #ifdef CONFIG_SMP
    	if (p->sched_class->task_woken) {
    		/*
    		 * Nothing relies on rq->lock after this, so its fine to
    		 * drop it.
    		 */
    		lockdep_unpin_lock(&rq->lock, rf.cookie);
    		p->sched_class->task_woken(rq, p);
    		lockdep_repin_lock(&rq->lock, rf.cookie);
    	}
    #endif
    	task_rq_unlock(rq, p, &rf);
    }

activate_task 函数中会调用 enqueue_task,就会涉及到调度相关的流程,该内容在调度中进行学习。

    static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
    {
    	update_rq_clock(rq);
    	if (!(flags & ENQUEUE_RESTORE))
    		sched_info_queued(rq, p);
    	p->sched_class->enqueue_task(rq, p, flags);
    }

子进程创建后,肯定要加入到CPU的执行队列中,这样才有可能被执行,这是调用wake_up_new_task()来实现的。这是调度器与进程创建的第二个逻辑交互时机,内核会调用调度器类的task_new函数(sched_class结构中),将新进程加入到相应类的就绪队列。
至此,创建用户进程的过程就完成了。其主要的要点如下:

  1. 每个进程需要有一个内核栈,不管是4K还是8KB,这个内核栈需要包含两部分,一个是task_struct数据结构,另外一个是内核栈
  2. 继承父进程的task_struct数据结构,然后进行调整
  3. 设置进程空间的栈
  4. 拷贝父进程的进程地址空间给子进程
  5. 将子进程唤醒,设置到就绪队列中,初始化调度相关的,然后等待调度器进行调度

4. 总结

fork, vfork和clone的系统调用的入口地址分别是sys_fork, sys_vfork和sys_clone, 而他们的定义是依赖于体系结构的, 而他们最终都调用了_do_fork,在_do_fork中通过copy_process复制进程的信息,调用wake_up_new_task将子进程加入调度器中,其主要的工作内容如下:

  • copy_process()函数会做fork的大部分事情,它主要完成讲父进程的运行环境复制到新的子进程,比如信号处理、文件描述符和进程的代码数据等,初始化进程控制块中的所有成员,其处理流程如下

    202306111304230254.png

  • wake_up_new_task()。计算此进程的优先级和其他调度参数,将新的进程加入到进程调度队列并设此进程为可被调度的,以后这个进程可以被进程调度模块调度执行。

阅读全文