前言
网上分析内核进程创建初始化的文章很多,例如:https://www.cnblogs.com/LittleHann/p/3853854.html,这位仁兄分析的非常详细和深入,非常值得初次看这段代码的人参考。
但是本文主要想解释一些常见但不太有文章详细解释的概念。比如一般都说进程有自己独立的地址空间,线程则共享;线程有自己的独立内核栈等等。像这些概念在代码上是怎么体现的呢?这就是本文的重点。
fork & vfork & clone在内核的实现
fork/vfork/clone是user创建进程/线程时候主要用到的系统调用。在内核中,这些接口最终都会调用到_do_fork来干活,只不过传入的参数/flags有所区别。这里先列出这些参数,后续分析中重点观察这些输入参数/flags是如何控制内核,让内核按照我们的意愿去创建相关数据结构的。
kernel/fork.c
1. fork:
_do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
2. vfork:
_do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0);
3. clone:
_do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
这里先放一些结论。
1.根据各方面资料可知vfork创建的进程和父进程是共享地址空间的(CLONE_VM的作用)。并且子进程会先于父进程执行(CLONE_VFORK的作用)。
2.用户通过调用clone()接口时设置不同flags来指定子进程/线程需要共享的资源。这一方法定制化程度比较高,但需要深刻理解各个flags的作用以及需求。
3.clone()的另一大用处就是用户空间通过pthread线程库的接口来调用。跟踪某个版本的glibc源码中的线程创建接口pthread_create()->create_thread()可知,创建thread时设置的clone_flags如下: (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0).这里的CLONE_VM | CLONE_FS | CLONE_FILES就是线程和父进程共享的资源。
clone flags如何影响内核
从前面的分析已经知道,无论创建线程还是进程,内核最终都是通过同一个函数接口_do_fork()来完成的。关键是不同的需求,传入的clone flags有差异。最终通过这个flags来影响内核的行为,从而创建出进程or线程。下面结合具体代码分析,重点关注CLONE_VM / CLONE_FILES等flags的影响。先说明,由于内核不区分进程or线程,均采用task_struct管理,所以下文中统一用new process来指代内核新创建task_struct的管理对象。它有可能表示的是进程也可能表示的是线程,需根据实际情况判断。
task_struct创建
_do_fork()->copy_process()->dup_task_struct()
341 tsk = alloc_task_struct_node(node);
342 if (!tsk)
343 return NULL;
344
345 ti = alloc_thread_info_node(tsk, node);
346 if (!ti)
347 goto free_tsk;
348
349 err = arch_dup_task_struct(tsk, orig);
350 if (err)
351 goto free_ti;
352
353 tsk->stack = ti;
dup_task_struct函数从名字就可以看出只要是dup一个task_struct。可以清楚看见,新创建的new process内核中都会分配一个task_struct以及一个thread_info。然后将父process的task_struct完整的复制给子process,接着再将新task_struct中的stack修改为自己的stack。
所以:无论创建的是进程还是线程,内核都会创建一个新的task_struct来描述它,并且它们之间的内核栈是独立的。
dup_task_struct后续代码就是做一些状态重置,需要把从父进程继承过来的比如调度状态等清除,具体不分析了。
CLONE_FILES
_do_fork()->copy_process()->copy_files()
1050 if (clone_flags & CLONE_FILES) {
1051 atomic_inc(&oldf->count);
1052 goto out;
1053 }
1054
1055 newf = dup_fd(oldf, &error);
如1050~1052所示,如果系统调用传入的参数中指定了CLONE_FILES时,内核会增加文件引用计数就结束了,内核并没有分配新的内存空间去管理这个打开的文件。也就是说线程或进程(设置了CLONE_FLAG的进程)会和父进程共享打开的文件,也就是说各自对文件的修改对方都可见。
如果未设置CLONE_FLAGS,则内核跳到1055行执行。内核通过dup_fd()会首先从kmem_cache中拿到一个新的files_struct结构体,然后将其初始化。这就是说进程会继承父进程打开的文件,且拥有的是一份拷贝,也就是说各自对文件的修改对方都不可见。
CLONE_VM
_do_fork()->copy_process()->copy_mm()
998 if (clone_flags & CLONE_VM) {
999 atomic_inc(&oldmm->mm_users);
1000 mm = oldmm;
1001 goto good_mm;
1002 }
1003
1004 retval = -ENOMEM;
1005 mm = dup_mm(tsk);
同CLONE_FILES一样,如果设置了CLONE_VM,表明我们需要共享mm_struct。此时内核仅增加父进程mm_struct引用的引用计数便结束了。如果没有设置这个flag,则内核需要将父进程的mm_struct复制一份。 由此可知,线程由于没有独立的mm_struct,所以说它没有独立的地址空间。
进程地址空间初始化
进程间共享内核空间的实现
大家都知道内核启动时会为自己初始化一个名为swapper_pg_dir的内核页表。这里面保存着低端物理内存的线性映射关系。进程拥有0~4GB的地址空间,而内核位于高地址,且内核是所有进程共享的。怎么理解这个共享?它是如何实现的呢?
根据前面的介绍,如果是线程或设置了CLONE_VM的进程,因为mm_struct是和parent共享的,所以毫无疑问内核空间也是共享。但如果是进程,那它会有自己独立的0~4GB空间,且进程之间相互隔离,如何做到内核共享呢?
我们从进程中管理地址空间的数据结构mm_struct的初始化说起,分析进程在创建过程中如何初始化内核空间部分。没错,依然是前面分析过的copy_mm()函数。
_do_fork()->copy_process()->copy_mm()->dup_mmap()->mm_init()
948 if (!mm_init(mm, tsk, mm->user_ns))
949 goto fail_nomem;
mm_init()在对一些成员做了初始化之后便调用了 mm_alloc_pgd()函数来初始化进程自己的一级页表了。
623 if (mm_alloc_pgd(mm))
624 goto fail_nopgd;
625
mm_alloc_pgd只是封装函数,真正干活的是pgd_alloc(), pgd_alloc()中和主题相关的代码如下
_do_fork()->copy_process()->copy_mm()->mm_init()->mm_alloc_pgd()->pgd_alloc()
40 new_pgd = __pgd_alloc();
41 if (!new_pgd)
42 goto no_pgd;
43
44 memset(new_pgd, 0, USER_PTRS_PER_PGD * sizeof(pgd_t));
45
46 /*
47 * Copy over the kernel and IO PGD entries
48 */
49 init_pgd = pgd_offset_k(0);
50 memcpy(new_pgd + USER_PTRS_PER_PGD, init_pgd + USER_PTRS_PER_PGD,
51 (PTRS_PER_PGD - USER_PTRS_PER_PGD) * sizeof(pgd_t));
最最关键的就是49和50两行代码。49行通过pgd_offset_k(0)能直接找到内核页表存放的物理起始地址(PAGE_OFFSET + TEXT_OFFSET - PG_DIR_SIZE)。50行则根据前面得到的内核页表起始地址信息,将内核页表完整的复制到进程自己的一级页表中,根据第三个参数也能知道,拷贝的就是内核页表的尺寸。所以每个拥有独立地址空间的进程,在初始化时都会创建独立的mm_struct。在mm_struct初始化时,会为进程单独分配一级页表,且分配完一级页表后马上会将内核页表拷贝到这个一级页表中。这就是每个进程最终都能共享内核的奥秘,因为每个进程自己的页表中都有一份一模一样的内核页表。
进程初始化用户空间的实现
前面介绍了进程内核空间的初始化。如果是32位的CPU,进程除了2G的内核空间,还有2G的用户空间需要初始化。
进程的用户空是直接从父进程复制过来的。Linux通过COW技术令进程只在写内存这个动作发生时才真正去分配物理内存。也就是说fork之后如果父子进程都只read而不write,它俩访问的其实都是同一片地址空间(包括虚拟地址和物理地址)。只有其中一个进程往里写之后,写的进程通过缺页异常才会重新分配物理地址给自己,进而把受影响的几项pte修改成新分配的物理地址。到时,父子进程的这部分地址空间才开始分道扬镳,不论read还是write都是不同的物理地址(虚拟地址倒是还有可能相同)互不影响。剩下没有发生写动作的地址,父子进程依然共享。
内核是如何做到这一点的呢?看dup_mm()吧。
_do_fork()->copy_process()->copy_mm()->dup_mm()->dup_mmap()
我们主要关注dup_mmap()中复制父进程各个vm的操作。
432 for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {
433 struct file *file;
434
435 if (mpnt->vm_flags & VM_DONTCOPY) {
436 vm_stat_account(mm, mpnt->vm_flags, mpnt->vm_file,
437 -vma_pages(mpnt));
438 continue;
439 }
... ...
503 retval = copy_page_range(mm, oldmm, mpnt);
... ...
510 }
dup_mmap()函数很长,直接看核心的for循环。这个for循环基本就是复制old进程的vm结构给new进程。先看第一个if判断,如果flags是VM_DONTCOPY,则子进程不会复制这部分空间。相应的,内核对相关vm数据管理结构在这里也要调整一下,然后开始下一次循环。 中间还有很多其它对VM flags的判断,这里现省略。直接看最重要的复制物理内存的copy_page_range()函数。
copy_page_range()->copy_pud_range()->copy_pmd_range()->copy_pte_range()
我们知道内核本身支持好几级的页表管理,有pgd/pud/pmd/pte,这里只分析两级页表的管理结构。这里虽然函数调用栈很深,但其实只需要关心最后一级pte的管理就可以了。所以直接看copy_pte_range()的实现。
copy_pte_range也有一个核心的循环函数。
952 do {
953 /*
954 * We are holding two locks at this point - either of them
955 * could generate latencies in another task on another CPU.
956 */
957 if (progress >= 32) {
958 progress = 0;
959 if (need_resched() ||
960 spin_needbreak(src_ptl) || spin_needbreak(dst_ptl))
961 break;
962 }
963 if (pte_none(*src_pte)) {
964 progress++;
965 continue;
966 }
967 entry.val = copy_one_pte(dst_mm, src_mm, dst_pte, src_pte,
968 vma, addr, rss);
969 if (entry.val)
970 break;
971 progress += 8;
972 } while (dst_pte++, src_pte++, addr += PAGE_SIZE, addr != end);
973
内核通过copy_onte_pte()函数将父进程的pte一项一项拷贝到子进程。
846 static inline unsigned long
847 copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
848 pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
849 unsigned long addr, int *rss)
850 {
… …
900 if (is_cow_mapping(vm_flags)) {
901 ptep_set_wrprotect(src_mm, addr, src_pte);
902 pte = pte_wrprotect(pte);
903 }
904 … …
这个函数还挺长的,其中第一个if函数是现判断当前这条pte是否在sawp中。如果是的话需要现做一些额外工作。现不分析这些复杂的情况,直接看第900行。可以看到,如果VM flags支持cow。在拷贝的过程中会首先将父进程的这条pte设为写保护模式,然后再将这条写保护的pte写到子进程的页表中!
到这里就发现,
1. Linux通过fork等创建的子进程,在继承父进程用户空间地址的过程中会同时在父子进程的页表中将这部分地址空间设置为写保护模式!所以不论父子进程,只要有一方向这部分空间执行写操作就会发生缺页异常。
2. 大家都知道子进程不会完全继承父进程的资源,比如阻塞信号,定时器等(通过man 2 fork 可以查看资源继承情况)。但事实上vm结构也并不会完全继承,比如说父进程对某些设了VM_DONTCOPY flags的VM结构等。
接下去分析子进程如果写了write protect部分的vm,内核如果走到page fault,然后内核如何处理的。