Android 下 Linux 创建进程的姿势(上)
前言
最近在看 Android 底层源码的时候发现 fdsan 这个检测工具,全名为 file descriptor sanitizer,是用来检测 fd 的 use-after-close 和 double-close 错误,避免非必要的安全隐患。当然这个工具不是本文的重点,后续需要可以做个相关的介绍,但是在这个知识里面看到了其一些限制,发现在 vfork 的子进程里是无法正常工作的,原因是 vfork 得到的子进程虽然会拷贝父进程的 fd,但是使用的地址空间仍然属于父进程,这里面会涉及到 fdsan 的运行原理,我们暂且不表,但是这里出现了 vfork 这个操作进程的方式,于是三省吾身回顾扫盲了下,fork、vfork、clone、exec 等方式创建进程的区别,其中还也不免涉及到 COW 技术(老生常谈的技术了)。
Linux
Android 目前的内核还是基于宏内核 Linux 开发的,而 Linux 是 Unix 的 GNU 版本。众所周知进程是资源的单位,线程是调度的单位。也就是说每个进程都有自己独立的资源,独立的 task_struct,Linux 内核中没有独立的“线程”结构。这里面有历史的原因 Unix 里面其实只有进程,而线程是 POSIX 标准定义的,而 Linux 为了对齐(适配)通用标准,所以 Linux 的线程就是轻量级进程,换言之基本控制结构和 Linux 的进程是同样的(都是经过 struct task_struct 管理)。
Linux 的用户进程不能直接被创建出来,因为不存在这样的 API。它只能从某个进程中复制出来,再通过 exec 这样的 API 来切换到实际想要运行的程序文件。
Linux 复制的 API 有 fork,vfork,clone 都是系统调用,这三个函数分别调用了 sys_fork、sys_vfork、sys_clone,最终都调用了 do_fork 函数,差别在于参数的传递和一些基本的准备工作不同,主要用来 linux 创建新的子进程或线程。
进程
在 Unix 中 CPU 是以进程为分配单元进行资源分配和调度的,每一个进程都有一个非负整形表示的进程标识符,进程 ID 总是唯一的,但进程 ID 是可以重用的,当一个进程终止后其进程 ID 就可以被其他进程再次使用了,一个普通进程有且只有一个父进程,系统中有一些专用的进程。重点就是资源分配和调度单元。
0 号进程(进程 ID 为 0)是调度进程,又被称为交换进程(swapper),隶属内核的一部分,并不执行任何磁盘上的程序,统一称之为系统进程
1 号进程(进程 ID 为 1)又称为 init 进程,在系统启动时由内核通过相关的初始化脚本(*.rc 或 init.d 等文件)创建并启动,init 进程最终会成为所有孤儿进程的父进程。
2 号进程(进程 ID 为 2)是页守护进程,负责支持虚拟存储系统的分页操作。
进程的必备条件:
有一段程序供其执行,即使和进程共用;
有进程专用的系统堆栈空间;
有 task_struct 数据结构,也就是进程控制块。有了这个数据结构,进程才能成为内核调度的一个基本单位,从而接受内核的调度。但同时,该数据结构也记录着进程所占用的各项资源,内核为每个进程分配一个 task_struct 结构时,实际上是分配两个连续的物理页面(共 8192 字节)。
4.有独立的存储空间,即用户空间堆栈。
这四条都是必要条件,缺一不可,否则就只能称作是线程了。如果完全没有用户空间,就称作是“内核线程”,而共享用户空间就称作是“用户线程”,也统称线程。
通常父进程的很多属性会被子进程所继承包括(不限于):
实际用户 ID、实际组 ID、有效用户 ID、有效组 ID、附加组 ID
当前工作目录、根目录
信号动作
进程组 ID、会话 ID、控制终端
设置用户 ID 标志和设置组 ID 标志
文件模式创建屏蔽字
针对任一打开文件描述符的在执行时关闭标志(close-on-exec)
环境上下文和连接的共享存储段
存储映射
fork
fork()函数调用成功:返回两个值; 父进程:返回子进程的 PID;子进程:返回 0;失败:返回-1;fork 函数只被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新建子进程的进程 ID。同时,父进程 fork 后为子进程生成了一个 PCB。
fork 后子进程得到返回值 0 的原因是:一个进程只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID(进程 ID 0 总是由内核交换进程使用,所以 一个子进程的进程 ID 不可能为 0)。
子进程和父进程继续执行 fork 调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段。但很多情况下 fork 后紧跟着 exec 执行新的程序段,因此不需要完全复制,出于效率考虑,linux 中引入了 COW 技术,其需要依赖硬件中的 MMU (memoy ,management uni)。
COW
其核心思想是父进程和子进程共享页帧而不是复制页帧。因为只要页帧被共享,它们就不能被修改,即页帧被保护。因此无论父进程还是子进程何时试图写一个共享的页帧,就产生一个异常,这时内核就把这个页复制到一个新的页帧中并标记为可写,这样原来的页帧仍然是写保护的,即当其他进程试图写入时,内核检查写进程是否是这个页帧的唯一属主,如果是,就把这个页帧标记为对这个进程是可写的。
运行时分阶段看,在 fork 之后 exec 之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为 exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为 exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。
盗个图,特此感谢大佬辛勤结晶:
结果:
Android 中也存在很多 fork 的调用,比如 init 进程创建几个重要的进程,比如 Zygote,Zygote 孵化其他进程,system_server 等。
总结
本文旨在帮各位梳理 Linux 不同创建进程的方式,因一个小知识点为引子深入研究下各种方式的原理,怕文章篇幅过多,占用大家过多精力,于是拆成上下两篇文章,下篇敬请期待,欢迎关注。微信公众号首发,感谢各位老铁一键三连,欢迎留言交流。
版权声明: 本文为 InfoQ 作者【江湖修行】的原创文章。
原文链接:【http://xie.infoq.cn/article/401c3a3235375d6f2a79318d1】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论