创建子进程时的一些细节
一些复杂的项目里,主进程可能创建了多个线程执行一些 io 任务,比如计时,此时又要创建一个新的进程来完成一些 CPU 密集型的工作,关于子进程的行为,其实有一些隐秘的细节。
创建子进程的方式
第一个问题是创建子进程的方式。可以分为 fork 和 spawn 两种(我不知道这种叫法是否被广泛采用)。fork 是指子进程复制父进程的虚拟地址空间页表,spawn 可以理解为 fork + exec,在子进程中加载新的程序镜像。
子进程创建后的行为
像 python 和 C++ 中,存在动态初始化这个阶段,进程加载后 main 函数执行前,会先执行动态初始化的代码(C++ 是这样,Python 是解释器导入文件,或称模块后,模块中的顶层语句会先被执行)。
在这个基础上,子进程被创建后,在执行指定给他的入口点之前,这些“动态初始化”的代码会被执行吗?如果是通过 fork 的方式创建子进程,动态初始化是不会再次执行的,子进程会直接进入他的入口点,被动态初始化的那些变量随着进程内存的继承而继承。如果是 spawn 方式,对于 C++ 来说,比较清晰,子进程是一个全新的进程;对于 python 而言,子进程是一个全新的解释器,python 提供的一些对 spawn 的封装会让子进程先以 main 的身份先执行顶层代码,再进入指定的入口点。
再考虑线程相关的问题。如果父进程有几个和他共享内存空间的线程,那么子进程被创建后,这些线程会被复制一份吗?这里只考虑 fork 的情况。可以想见,由于子进程完整复制(COW)了父进程的内存空间,那么那些线程栈和可能的一些保存线程状态的变量也在子进程中存在,但是 linux 规定 fork 不会复制线程形成新的执行单元(只会复制调用 fork 的那个线程),意思是说这些线程的 TCP 不会在内核中复制一份,也不会创建新的调度单元加入内核的线程调度中。
最后一个有意思的行为和锁有关。如果父进程中有一个在用户态使用 CAS 维护的自旋锁(本质是用户态内存上的一个值,各线程用 CAS 原子的访问),当父进程 fork 时,如果恰好一个线程占用了这个锁(比如将锁置1)。那么子进程的内存空间中这个锁的值也是1,但是那个占用锁的线程又没被复制,此时万一子进程之后要获取这个锁,就会无意义的永远等待。实际应用中,虽然很少用在用户态使用 CAS 手工实现的原始自旋锁,但用到的 mutex futex 在这种情况下也可能出现 UB 的问题。