前言
最近在看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
arduino
#include <unistd.h> pid_t fork(void);
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,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。
盗个图,特此感谢大佬辛勤结晶:
arduino
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
void main()
{
char str[6]="hello";
pid_t pid=fork();
if(pid==0)
{
str[0]='b';
printf("子进程中str=%s\n",str);
printf("子进程中str指向的首地址:%x\n",(unsigned int)str);
}
else
{
sleep(1);
printf("父进程中str=%s\n",str);
printf("父进程中str指向的首地址:%x\n",(unsigned int)str);
}
}
结果:
ini
子进程中str=bello
子进程中str指向的首地址:bfdbfc06
父进程中str=hello
父进程中str指向的首地址:bfdbfc06
Android中也存在很多fork的调用,比如init进程创建几个重要的进程,比如Zygote,Zygote孵化其他进程,system_server等。
总结
本文旨在帮各位梳理Linux不同创建进程的方式,因一个小知识点为引子深入研究下各种方式的原理,怕文章篇幅过多,占用大家过多精力,于是拆成上下两篇文章,下篇敬请期待,欢迎关注。 微信公众号首发,感谢各位老铁一键三连,欢迎留言交流。