进程创建是一个非常复杂的过程,涉及到很多的技术细节,以及与内核中的很多子系统的交互等,要完整的讨论到各种情况不太可能,这里只以用户空间调用fork()创建进程为例来进行讨论。
fork()系统调用对应的内核实现为sys_fork(),sys_fork()是对do_fork()的简单封装,sys_fork()的任务是从处理器寄存器中提取由用户空间提供的信息,do_fork()负责进程的复制。fork()和clone()系统调用的入口点sys_vfork()和sys_clone()也是调用的do_fork()。
do_fork()函数中主要的处理流程如下所示(引自《深入Linux内核架构》):
我们关注的是用户进程的创建,所以这里只关注copy_process()和wake_up_new_task()这两个函数。
copy_process()函数有7个参数,其中我们需要关心的有clone_flags、stack_start和regs。cone_flags是一个标志集合,分为两部分:最低的字节指定了在子进程终止时发送给父进程的信号,其余的高位字节保存了各种真正的复制标志,如CLONE_FS、CLONE_THREAD等。在用户层调用fork()时不能指定标志,所以默认的CLONE_FLAGS的值为SIGCHLD。如果你想要修改默认的创建进程的方式,或者修改子进程退出时的信号,可以使用clone()系统调用(和fork不同,具体参见man
clone)。stack_start是父进程(也就是current)的用户栈的起始地址。regs是一个指向寄存器集合的指针,该参数使用的数据类型是特定于体系结构的struct
pt_regs。
现在我们来看copy_process()是创建子进程的。
1、标志检查
copy_process()首先会检查clone_flags中指定的标志是否冲突以及安全检查,创建用户进程时只有SIGCHLD,所以这个检查是肯定没有问题的。
2、dup_task_struct()
我们知道Linux内核中使用task_struct结构来表示进程,子进程的描述符结构是在du_task_struct()中创建的,其源码实现如下:
static struct task_struct *dup_task_struct(struct task_struct *orig)
{
struct task_struct *tsk;
struct thread_info *ti;
unsigned long *stackend;
int err;
prepare_to_copy(orig);
tsk = alloc_task_struct();
if (!tsk)
return NULL;
ti = alloc_thread_info(tsk);
if (!ti) {
free_task_struct(tsk);
return NULL;
}
err = arch_dup_task_struct(tsk, orig);
if (err)
goto out;
tsk->stack = ti;
......
setup_thread_stack(tsk, orig);
stackend = end_of_stack(tsk);
*stackend = STACK_END_MAGIC; /* for overflow detection */
......
return tsk;
......
}
|
prepare_to_copy()会调用unlazy_fpu(),它把FPU、MMX和SSE/SSE2寄存器的内容保存到父进程的thread_info结构实例中。如果父进程没有使用这些扩展寄存器的话,就不用保存。保存这些寄存器会用到xsave指令或fxsave指令,如果你对这些寄存器的保存方式及过程感兴趣的话,可以查看intel手册中关于xsave指令和fxsave指令的介绍。
task_struct结构的分配使用的是alloc_task_struct宏,该宏就是简单地调用kmem_cache_alloc()从task_struct_cachep缓存中分配。接着会使用alloc_thread_info宏分配thread_info结构。thread_info结构保存了特定于体系结构的汇编语言代码需要访问的那部分进程数据,包括执行域、可抢占标志和当前的CPU等信息。在分配thread_info结构的时,实际调用的是__get_free_pages(),64位下分配的是2个物理页(返回的是虚拟地址)。但是很显然thread_info结构是用不了2页的,剩余的内存其实是用作子进程的内核栈(不是stack_start参数指定的用户栈),可以通过end_of_stack()来计算栈顶位置。
在分配完必要的结构后,会调用arch_dup_task_struct()拷贝父进程的task_struct结构到子进程的task_struct结构,如果父进程使用FPU或其他的CPU扩展寄存器,则要将这些寄存器的信息(在prepare_to_copy()中保存的)拷贝到子进程的task_struct结构中。
setup_thread_stack()是将父进程的thread_info结构中的内容拷贝到子进程的thread_info结构中,然后还会设置内核栈栈顶的magic
number。
3、资源检查
我们知道Linux是多用户操作系统,每个用户可以使用的资源也是有限制的,包括可以创建的进程数。所以创建进程的过程是否继续进行取决于下面的检查:
if (atomic_read(&p->real_cred->user->processes) >=
p->signal->rlim[RLIMIT_NPROC].rlim_cur) {
if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) &&
p->real_cred->user != INIT_USER)
goto bad_fork_free;
}
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
retval = -EAGAIN;
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
|
第一个检查是系统对每个用户可以创建的进程数限制,默认是1024。这个限制可以通过ulimit()系统调用或ulimit命令修改。除了修改限制外,创建进程的用户如果是root用户或具有CAP_SYS_ADMIN或CAP_SYS_RESOURCE权限则不受这个限制。
第二个检查是当前的进程(线程属于轻量级进程)数nr_threads是否超过最大的进程数max_threads。max_threads是一个全局变量,在fork_init()中初始化,这个值是根据系统中的物理页面数计算出来的,如下所示:
max_threads = mempages / (8 * THREAD_SIZE
/ PAGE_SIZE);
64位系统下,THREAD_SIZE为(PAGE_SIZE <<
1),所以max_threads的值为所有物理页面数的1/16。如果你想调整的更大,可以通过修改/proc/sys/kernel/threads-max文件来实现。
另外还有一点要注意,第一个检查中使用的是子进程的p->real_cred->user结构来检查,但是在前面的代码中并没有发现有初始化real_cred成员的地方。我在看到这个地方的时候非常的疑惑,后来才找到在arch_dup_task_struct()中通过*dst
= *src;语句来拷贝父进程的所有内容。所以这里的比较,虽然使用的是子进程的描述符结构,但是其实还是使用的是父进程的数据。不过在copy_creds()中可能会给子进程分配新的cred结构。
4、执行域
由于子进程会继承父进程的执行域,所以需要对执行域所在的模块的引用计数加1,防止模块提前卸载(当然一般不会发生),如下所示:
if (!try_module_get(task_thread_info(p)->exec_domain->module))
goto bad_fork_cleanup_count;
|
执行域这个概念也是第一次注意到。Linux有一个特性就是能执行其他操作系统上编译的可执行文件。当然内核运行的平台和可执行文件包含的机器代码对应的平台必须是相同的计算机体系结构。对这些非Linux下编译的可执行文件有两种执行方式:
a.Emulated execution:执行的程序中使用的系统调用不是POSIX兼容的
b.Native execution: 执行的程序中使用的系统调用是POSIX兼容的。
微软的MS-DOS和Windows程序属于第一种,因为Linux不能识别它们中的API。相反POSIX兼容的程序在Linux上执行不会有太多的问题,因为他们的API和Linux是相似的。内核必须消除系统调用上的差别或如何给各种信号编号,这种类型的信息存放在类型为exec_domain的执行域描述符中。进程可以指定它的执行域,这是通过设置进程描述符task_struct的personality字段,以及把相应的exec_domain数据结构的地址存放到thread_info结构的exec_domain字段来实现的。personality()系统调用来改变进程的personality。
不过目前只有default_exec_domain一种执行域(类型为PER_LINUX)可用,也就是Linux的。在lookup_exec_domain()函数中可以看出,如果需要的执行域不是Linux的话,需要调用request_module()加载”personality-%ld“这样的module,其中%ld对应的personality的值,参见PER_LINUX_32BIT、PER_LINUX_FDPIC等。
5、子进程的初始化
在拷贝之前,会初始化子进程描述结构中的一系列成员,这些初始化比较简单,也不是我们关心的部分,所以大部分都略过,只关心一小部分。
进程的用户栈的起始地址存储在task_struct结构的stack_start成员中,在初始化的时候使用的是父进程的用户栈地址,所以在子进程创建时会和父进程使用相同的用户栈,如果有任何一方修改栈的话,会重新拷贝一份,这是基于COW技术,以避免无用的复制。
创建子进程为调度器类提供了调度进程的一个切入点,内核会调用sched_fork()函数,以便使调度器有机会对新进程进行设置
sched_fork()会初始化一些和调度相关的成员,并将进程设置为TASK_RUNNING状态。不过此时新的进程还没有放到CPU的执行队列中,所以新的进程不会被调度到。
6、资源拷贝
接下来是调用相关的函数来拷贝父进程的各个子系统的信息到子进程的相关成员中,包括文件系统信息、打开的文件、信号处理函数等。拷贝过程都比较类似,如果没有设置共享标志的话,则会给子进程分配新的资源,所以只以部分为例进行说明。
copy_semundo()是拷贝父进程的System V信号量。创建用户进程时,没有设置CLONE_SYSVSEM标志,因此只会简单地将tsk->sysvsem.undo_list置为NULL,其中tsk为子进程。这一步非常关键,必须执行,因为在arch_dup_task_struct()中将父进程的所有内容拷贝到子进程,所以如果不设置为NULL的话,tsk->sysvsem.undo_list仍会指向父进程的资源,而没有设置CLONE_SYSVSEM标志是不共享的,所以必须置为NULL。在后面拷贝其他子系统的资源过程中,如果不共享的话,也必须重新初始化子进程的相关成员,以避免误用资源。
copy_files()是拷贝父进程的打开文件描述符表。内核用files_struct结构来描述打开的文件描述符。如果没有设置CLONE_FILES标志的话,会调用dup_fd()函数给子进程创建新的打开文件描述符表,不过文件还是共享的,也就是说使用的是相同的file结构实例,只是增加了引用计数,这点非常重要。在进程中关闭文件时,会首先对文件描述符对应的file实例的引用计数减1,如果引用数为0时才真正释放文件。关闭文件描述符,只是说当前进程不再访问该文件了,但并不一定真正就关闭了文件。
copy_fs()是拷贝文件系统信息。如果没有设置CLONE_FS标志的话,会重新初始化子进程的文件系统信息。文件系统信息是使用fs_struct结构来描述,不过这个结构非常简单,只有创建新文件的权限(umask成员)和根路径、当前工作路径等信息。不过知道了当前的工作路径,就可以这个path结构来获取所属的文件系统信息,正如我们在用户层可以通过statvfs()系统调用指定一个文件来获取所属的文件系统信息。
在一系列的资源拷贝后,还会继续初始化cgroup、进程退出等相关成员的初始化。
子进程创建后,肯定要加入到CPU的执行队列中,这样才有可能被执行,这是调用wake_up_new_task()来实现的。这是调度器与进程创建的第二个逻辑交互时机,内核会调用调度器类的task_new函数(sched_class结构中),将新进程加入到相应类的就绪队列。
至此,创建用户进程的过程就完成了。
|