本文主要涉及进程创建和创建时写时复制以及线程、父死子托孤等
内容有点多,建议收藏、备份和转发
fork、vfork和clone
首先需要明确:
fork、vfork和clone会传入不同的参数,调用do_fork()。参数表示父子进程共享的资源是什么。
do_fork 参数
fork()会执行:
1、SIGCHLD // 共享信号
首先看两段代码:
int main(void){ fork(); printf("hello world\n"); fork(); printf("hello world\n"); while (1); return 0;}
上面代码会打印几次hello world?
答案是6次
原因是,每次fork都会创建一个新的进程。
进程a执行到第一次fork,创建出进程b,a和b分别打印一次hello world。
然后a和b再分别执行一次fork,又分别创建了a’ 和 b’ 这时候有4个进程,然后它们向下执行分别打印一次hello world。
int main(void){ pid_t pid; pid = fork(); if (pid == -1) { perror("fork() failed\n"); exit(1); } else if (pid == 0) { // 子进程符合条件,进入 printf("a\n"); } else { // 父进程符合条件,进入 printf("b\n"); } printf("c\n"); while (1); return 0;}
这段代码又会打印什么呢?
没错,a、b各一次,c两次。
fork()函数返回进程pid,父进程得到的是子进程的pid,子进程得到的pid为0。
上篇文章有提到,进程是一个资源的分配单位,在内核中用task_struct描述,那么fork出来一个新进程一定有自己的的task_struct和相关资源。
fork()后发生分裂
当父进程通过fork函数创建出子进程,linux认为父子进程存在一定的联系,所以fork函数内部会将子进程的task_struct结构中的资源指向父进程的资源。在创建出新进程的一瞬间,父子进程是拥有相同资源的两个不同task_struct,当然task_struct结构中部分字段值是独有的,比如进程的pid唯一标识。
当其中一个进程发生资源变动,比如子进程p2打开了新文件,这时候就发生了资源分裂,子进程就会创建新的*files 资源,将父进程的*files拷贝后再加上自己新打开的文件。
task_struct结构发生分裂时,其他资源都好处理,难就难在*mm内存如何分裂。
这时候展现linux著名的的写时拷贝(COW)技术了
写时拷贝
先看段代码:
int data = 1;int child_process(){ printf("child process %d, data %d ptr %p\n", getpid(), data, &data); data = 2; printf("child process %d, data %d ptr %p\n", getpid(), data, &data); _exit(0);}int main(void){ pid_t pid; pid = fork(); if (pid == -1) { perror("fork() failed\n"); exit(1); } else if (pid == 0) { // 子进程符合条件,进入 child_process(); } else { // 父进程符合条件,进入 sleep(1); // 等1秒 printf("master process %d, data=%d ptr %p\n", getpid(), data, &data); }}
上面代码会输出什么呢?
按上面说的,大家可能会得到结论:
master 打印1 data指针a
child 先打印1 data指针a,再打印 2 然后打印 新data指针b
➜ ~ ./a.outchild process 61875, data 1 ptr 0x10de75040child process 61875, data 2 ptr 0x10de75040master process 61873, data=1 ptr 0x10de75040
data的值符合预期,为什么指针没变呢?不科学啊
因为我们的CPU是支持MMU*的,所以此处打印的指针为虚拟指针。如果CPU不支持MMU,则打印的才是内存真实地址。
内存分裂的原理:
虚拟地址A通过MMU查表找到物理地址A,在开始阶段,进程A对内存的操作权限为可读可写。
当fork时,进程A fork 出进程B之后,linux将data全局变量 对应页表里这一页的权限改为只读。一旦页表里有一个地址所在这一页的权限被改为仅读,当cpu去操作写数据时,无论进程A还是B去写,CPU都会收到一个page fault(缺页中断)。
CPU收到page fault后会,调用linux内核在内存中的重新分配一个新的内存,将原物理地址A的数据拷贝到新内存中,然后修改子进程B的页表,将B进程的虚拟地址A指向新物理地址B。然后进程B就可以修改数据。此刻完成写时复制。
注意:根据计算机原理,当子进程出现page fault,执行data = 2 失败,cpu做了上述写时复制操作后,进程被唤醒,然后会再次执行data=2。短短一句赋值语句,计算机底层做了那么多操作,真是“这个需求很简单,怎么实现我不管。今晚上线”。
由上可知,Linux的写时复制(COW)严重依赖CPU的MMU模块,如果CPU不支持MMU,那么在这个CPU下是无法使用fork函数,只能使用vfork函数。而父进程调用vfork时会阻塞父进程,直到子进程执行exit或exec系统调用。
我们把上面代码的fork 改为 vfork 执行下:
➜ ~ ./a.outchild process 62334, data 1 ptr 0x10649c040child process 62334, data 2 ptr 0x10649c040master process 62333, data=2 ptr 0x10649c040
我们看到vfork 运行后,A和B内存是共享的。
vfork() 会执行:
1、CLONE_VM // 共享内存
2、CLONE_VFORK // 表明是vfork
3、SIGCHLD // 共享信号
由此我们可以想到,线程的定义:资源共享,可调度
pthread_create() -> clone()
clone() 会执行:
1、CLONE_VM
2、CLONE_FS
3、CLONE_FILES
4、CLONE_SIGCHLD
5、CLONE_THREAD
所以linux系统中,创建一个线程,其实就是将父进程的资源共享,然后创建一个新的task_struct结构,只要是一个task_struct结构就会被linux调度器调度。如果一个进程fork出来子进程,子进程会慢慢复制父进程的资源,这种情况创建的就是进程。如果子进程被创建出来,一直共享父进程的资源,这种叫线程。
所以linux中为什么线程又被称为轻量级进程的原因。
进程的经典定义是:资源的分配和调度的基本单位
线程的经典定义是:调度的单元
实际上,linux非常灵活,clone的时候可以指定任意部分资源共享。这种情况下,派生出来的既不是进程也不是线程,那么它是什么?这只能算是进程和线程的临界态 —— “人妖”。
这时候,我们应该可以对进程和线程有一个明确的认知了。
PID和TGID
当一个进程创建出N个线程,那么每个线程都是一个独立的task_struct,所以每个线程都有一个独立的pid。
但是基于POSIX标准要求,一个进程如果有多个线程,必须看起来像一个整体。
linux对这种情况,使用了一个障眼法,linux在内核里的task_struct结构中增加了一个tgid字段,子线程的tgid是父进程的pid。
主进程创建的线程,线程会将主进程的pid写入TGID
来个实验:
#include #include #include #include #include static pid_t gettid( void ){ return syscall(__NR_gettid);}static void *thread_fun(void *param){ // 线程内部 pid、真实pid 和 pthread_self printf("thread pid:%d, tid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self()); while(1); return NULL;}int main(void){ pthread_t tid1, tid2; int ret; // 打印线程中 pid 、 真实pid 和 pthread_self printf("thread pid:%d, tid:%d pthread_self:%lu\n", getpid(), gettid(),pthread_self()); ret = pthread_create(&tid1, NULL, thread_fun, NULL); if (ret == -1) { perror("cannot create new thread"); return -1; } ret = pthread_create(&tid2, NULL, thread_fun, NULL); // 创建线程 if (ret == -1) { perror("cannot create new thread"); return -1; } if (pthread_join(tid1, NULL) != 0) { // 等线程结束,因为线程内部最后执行死循环,所以不会走到这 perror("call pthread_join function fail"); return -1; } if (pthread_join(tid2, NULL) != 0) { perror("call pthread_join function fail"); return -1; } return 0;}
测试系统为4核处理器
[root@dev ~]$ cat /proc/cpuinfo | grep processorprocessor : 0processor : 1processor : 2processor : 3
[root@dev shell]$ ./a.outthread pid:15179, tid:15179 pthread_self:140227109390144thread pid:15179, tid:15180 pthread_self:140227101009664thread pid:15179, tid:15181 pthread_self:140227092616960top 命令 进程维度查看 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND15419 renfeng 20 0 22896 384 300 S 206.7 0.0 0:07.77 a.outtop -H 命令 线程维度查看 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND15420 renfeng 20 0 22896 384 300 R 99.9 0.0 0:23.79 a.out15421 renfeng 20 0 22896 384 300 R 99.9 0.0 0:23.79 a.out[root@dev ~]$ cd /proc/15419/task/[root@dev task]$ ll总用量 0dr-xr-xr-x 6 renfeng renfeng 0 6月 6 22:59 15419dr-xr-xr-x 6 renfeng renfeng 0 6月 6 22:59 15420dr-xr-xr-x 6 renfeng renfeng 0 6月 6 22:59 15421
我们可以看到,线程内和父进程的getpid()函数返回的都是相同的pid,真实的pid就不一样了。
两个线程因为死循环,两个线程的cpu利用率和起来将近200%。
我们可以进入到 /proc/父进程pid/task/ 目录查看所属所有线程,在/proc/文件夹下只能看到进程,线程得去对应的进程文件夹下查看。
所以,pid到底是多少,得看从哪个角度看pid。
如果在内核中看,pid分别是15419、15420、15421
如果在线程或者进程内看pid,则只能拿到父进程的pid。
父死子托孤的“社会化抚养”
linux中通常都是“白发人送黑发人”,父进程通过waitpid给子进程收尸,子进程才能从僵尸态完全释放“人间蒸发”。
linux中进程都是父节点派生出来的
我们后面再说0号进程和1号进程的“鸡生蛋,蛋生鸡”问题。
如果父进程先挂掉,那么父进程创建的子进程将变成孤儿进程,这时候linux就会将孤儿进程做托孤过程,有点像督工所说的“社会化抚养”。
一般会有两种情况:
1、找到子进程最近的SUBREAPER进程,然后将子进程挂在其下
2、如果找不到,则将子进程挂到linux的init进程(1号进程)
// 进程可以在代码中显性的系统调用 将自己设置为 SUBREAPER 进程if (prctl(PR_SET_CHILD_SUBREAPER, 1) < 0) { // linux内核3.4 加入的特性 perror("set subreaper failed\n");}
reaper是进程收割机,也可以理解为孤儿院,不过我理解为给孤儿进程收尸的火葬场。
再来个实验:
#include #include #include #include int main(void){ pid_t pid, wait_pid; int status; pid = fork(); if (pid == -1) { perror("Cannot create new process"); exit(1); } else if (pid == 0) { printf("child process id: %ld\n", (long)getpid()); pause(); _exit(0); } else { printf("parent process id: %ld\n", (long)getpid()); wait_pid = waitpid(pid, &status, WUNTRACED | WCONTINUED); if (wait_pid == -1) { perror("waitpid failed"); exit(1); } if (WIFSIGNALED(status)) { printf("child process is killed by signal %d\n", WTERMSIG(status)); } exit(0); }}
按刚才说的,如果先干掉父进程,那么子进程应该会找最近的“SUBREAPER 孤儿院进程”。
[root@dev shell]$ ./a.outparent process id: 18065child process id: 18066[root@dev ~]$ pstreesystemd─┬─AliYunDun───22*[{AliYunDun}] ├─sshd─┬─sshd───sshd───bash───a.out───a.out │ └─sshd───sshd───bash───pstree[root@dev ~]$ kill -9 18065 // 杀掉父进程[root@dev ~]$ pstreesystemd─┬─AliYunDun───22*[{AliYunDun}] ├─a.out
很遗憾,进程树中没有SUBREAPER进程,只能找根进程了。
深度睡眠和浅度睡眠
深度睡眠只能被资源唤醒
浅度睡眠除了被资源唤醒,还可以被信号唤醒
深度睡眠有时候不能避免,比如发生page fault的时候,linux内核将进程设置为深度睡眠,如果设置为浅度睡眠,进程可能会收到信号后继续出现page fault。
睡眠怎么实现的?
linux中进程睡眠借助等待队列结构实现,有点类似订阅和发布设计模式。
图红色箭头为错误方案
当p1~p4都在等待资源,如果一个资源就绪了,怎么通知进程呢?拿到资源后挨个给进程通知?太low!p1~p4进程需要等待资源,可以进程自己将自己挂在等待队列上,资源准备好了直接唤醒等待队列,p1~p4进程就会自己醒。
//https://github.com/torvalds/linux/blob/f359287765c04711ff54fbd11645271d8e5ff763/drivers/tty/n_hdlc.c#L489static ssize_t n_hdlc_tty_write(struct tty_struct *tty, struct file *file, const unsigned char *data, size_t count){ …… add_wait_queue(&tty->write_wait, &wait); // 加入等待队列 for (;;) { set_current_state(TASK_INTERRUPTIBLE); // 主动设置为睡眠态 tbuf = n_hdlc_buf_get(&n_hdlc->tx_free_buf_list); if (tbuf) break; if (tty_io_nonblock(tty, file)) { error = -EAGAIN; break; } schedule(); // 让出cpu if (signal_pending(current)) { // 醒来后检查是否是信号触发 error = -EINTR; break; } } __set_current_state(TASK_RUNNING); // 设置为运行态 remove_wait_queue(&tty->write_wait, &wait); // 移除等待队列 ……}
比如 一个键盘驱动程序,启动后等待键盘输入(资源),程序会创建一个等待队列,再将等待队列注册到linux内核,然后将自己设置为睡眠态,调用linux的schedule()函数,让出自己占用的cpu。当有键盘输入事件时(满足条件),linux内核执行__wake_up唤醒对应的等待队列。进程被唤醒后进入就绪态,从就绪态转换到执行态后,恢复进程执行的上下文,然后进程会先检查自己是否被信号唤醒,如果被信号唤醒则说明之前的睡眠态是浅睡眠。
ok,从这里我们可知,到底睡眠是深睡眠还是浅睡眠,是由linux内核决定的,进程只能自主将自己设置为睡眠态。
所以一个进程进入睡眠是进程将自己设置为睡眠,然后把cpu让出来;而暂停态是进程被动的让出cpu。
这块有点复杂,具体可以看文章后面提供的资料链接。
0号进程和1号进程:鸡生蛋还是蛋生鸡
我们都知道,衡量一种语言的成熟与否要看它是否可以自举。
gcc是怎么来的?gcc是由gcc编译而来(鸡生蛋还是蛋生鸡?)
我之前一直说,进程树的根结点是init进程(现在也叫systemd进程),那么systemd进程是怎么来?
[root@dev ~]$ ps aux | grep systemdroot 1 0.0 0.0 191264 4124 ? Ss 2018 96:58 /usr/lib/systemd/systemd --system --deserialize 23
我们可以看到 1进程的父进程ppid是0
[root@dev 1]$ cat /proc/1/statusName: systemdUmask: 0000State: S (sleeping)Tgid: 1Ngid: 0Pid: 1PPid: 0......
但是我们执行pstree命令却找不到0进程,因为0进程把1进程fork出来之后,0进程就退化成了idle进程。
idle进程的优先级最低,早之前linux内核版本是参与cpu进程调度的,当其他进程都睡眠后,idle进程开始执行,将cpu设置为低功耗省电模式。当有任何一个中断,都可以任何唤醒任何一个进程,当有任何一个进程被唤醒,被唤醒的进程都会idle进程优先级高,所以idle就会进入休眠。
现在的linux内核版本idle并不参与调度,而是在执行队列结构中含idle指针,指向idle进程,当调度器发现执行队列为空时,调用idle进程执行。
linux:你们尽管睡,你们都睡了,我idle再起来。
思考下,当所有进程都不需要执行的时候,要将cpu设置为低功耗省电模式,按照一般人思路设计,每个进程在让出cpu的时候都要检查自己是不是最后一个进程,如果是则进程将cpu设置为低功耗,再去让出cpu。这设计就太复杂了,将同样的逻辑耦合到了一起。相比而言,linux的这种设计是相当的优雅,简单即美好!
日常撸码中,能将复杂问题简单化实现的程序员,一定是最牛逼的程序员。
总结一些问题:
谁来通知pid0运行?
不需要通知,linux自己的调度算法直接进行调度。当所有进程都不运行了,pid0就会被调度拉起。
pid0是何时创建的?
系统是从BIOS加电自检,载入MBR的引导程序(LILO/GRUB),再载入linux内核后开始执行,一直到指定的shell开始执行告一段落,这时用户才可以操作linux。而在vmlinux的入口中,pid0的原始进程设置了运行环境,执行了start_kernal()的内核初始化工作,继而fork出pid=1的进程,pid=1的进程会继续完成剩余的初始化工作,而pid=0的进程则会调用cpu_idle() 退化成idle进程。
pid0也是有task_struct的,再次强调,只要是task_struct就会被cpu调度器调度。
写时拷贝是谁先触发?
父子进程谁先对内存做写操作,谁就会触发写时拷贝,谁先得到新的物理地址。
在/proc/下是看不到线程的,只有进入父进程下的task目录才能看到父进程下的线程。
ls -al /proc/父进程pid/task/
预告:下一篇 单核CPU时,Linux进程调度策略
MMU 内存管理单元:一种虚拟地址到物理地址的转换机制 https://baike.baidu.com/item/MMU
linux中的阻塞机制及等待队列:https://www.cnblogs.com/gdk-0078/p/5172941.html