mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
del
This commit is contained in:
356
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/10 | 进程:公司接这么多项目,如何管?.md
Normal file
356
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/10 | 进程:公司接这么多项目,如何管?.md
Normal file
@@ -0,0 +1,356 @@
|
||||
<audio id="audio" title="10 | 进程:公司接这么多项目,如何管?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/5f/18164999bd57a7863b51f95362c3355f.mp3"></audio>
|
||||
|
||||
有了系统调用,咱们公司就能开始批量接项目啦!对应到Linux操作系统,就是可以创建进程了。
|
||||
|
||||
在[命令行](https://time.geekbang.org/column/article/88761)那一节,我们讲了使用命令创建Linux进程的几种方式。现在学习了系统调用,你是不是想尝试一下,如何通过写代码使用系统调用创建一个进程呢?我们一起来看看。
|
||||
|
||||
## 写代码:用系统调用创建进程
|
||||
|
||||
在Linux上写程序和编译程序,也需要一系列的开发套件,就像Visual Studio一样。运行下面的命令,就可以在centOS 7操作系统上安装开发套件。在以后的章节里面,我们的实验都是基于centOS 7操作系统进行的。
|
||||
|
||||
```
|
||||
yum -y groupinstall "Development Tools"
|
||||
|
||||
```
|
||||
|
||||
接下来,我们要开始写程序了。在Windows上写的程序,都会被保存成.h或者.c文件,容易让人感觉这是某种有特殊格式的文件,但其实这些文件只是普普通通的文本文件。因而在Linux上,我们用Vim来创建并编辑一个文件就行了。
|
||||
|
||||
我们先来创建一个文件,里面用一个函数封装通用的创建进程的逻辑,名字叫process.c,代码如下:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
extern int create_process (char* program, char** arg_list);
|
||||
|
||||
|
||||
int create_process (char* program, char** arg_list)
|
||||
{
|
||||
pid_t child_pid;
|
||||
child_pid = fork ();
|
||||
if (child_pid != 0)
|
||||
return child_pid;
|
||||
else {
|
||||
execvp (program, arg_list);
|
||||
abort ();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面用到了咱们学过的fork系统调用,通过这里面的if-else,我们可以看到,根据fork的返回值不同,父进程和子进程就此分道扬镳了。在子进程里面,我们需要通过execvp运行一个新的程序。
|
||||
|
||||
接下来我们创建第二个文件,调用上面这个函数。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern int create_process (char* program, char** arg_list);
|
||||
|
||||
int main ()
|
||||
{
|
||||
char* arg_list[] = {
|
||||
"ls",
|
||||
"-l",
|
||||
"/etc/yum.repos.d/",
|
||||
NULL
|
||||
};
|
||||
create_process ("ls", arg_list);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们创建的子程序运行了一个最最简单的命令ls。学过命令行的那一节之后,这里你应该很熟悉了。
|
||||
|
||||
## 进行编译:程序的二进制格式
|
||||
|
||||
程序写完了,是不是很简单?你可能要问了,这是不是就是我们所谓的项目执行计划书了呢?当然不是了,这两个文件只是文本文件,CPU是不能执行文本文件里面的指令的,这些指令只有人能看懂,CPU能够执行的命令是二进制的,比如“0101”这种,所以这些指令还需要翻译一下,这个翻译的过程就是**编译**(Compile)。编译好的二进制文件才是项目执行计划书。
|
||||
|
||||
现在咱们是正规的公司了,接项目要有章法,项目执行计划书也要有统一的格式,这样才能保证无论项目交到哪个项目组手里,都能以固定的流程执行。按照里面的指令来,项目也能达到预期的效果。
|
||||
|
||||
在Linux下面,二进制的程序也要有严格的格式,这个格式我们称为**ELF**(Executeable and Linkable Format,可执行与可链接格式)。这个格式可以根据编译的结果不同,分为不同的格式。
|
||||
|
||||
接下来我们看一下,如何从文本文件编译成二进制格式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/de/85320245cd80ce61e69c8391958240de.jpeg" alt="">
|
||||
|
||||
在上面两段代码中,上面include的部分是头文件,而我们写的这个.c结尾的是源文件。
|
||||
|
||||
接下来我们编译这两个程序。
|
||||
|
||||
```
|
||||
gcc -c -fPIC process.c
|
||||
gcc -c -fPIC createprocess.c
|
||||
|
||||
```
|
||||
|
||||
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o文件,这就是ELF的第一种类型,**可重定位文件**(Relocatable File)。
|
||||
|
||||
这个文件的格式是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/d6/e9c2b4c67f8784a8eec7392628ce6cd6.jpg" alt="">
|
||||
|
||||
ELF文件的头是用于描述整个文件的。这个文件格式在内核中有定义,分别为struct elf32_hdr和struct elf64_hdr。
|
||||
|
||||
接下来我们来看一个一个的section,我们也叫**节**。这里面的名字有点晦涩,不过你可以猜一下它们是干什么的。
|
||||
|
||||
这个编译好的二进制文件里面,应该是代码,还有一些全局变量、静态变量等等。没错,我们依次来看。
|
||||
|
||||
<li>
|
||||
.text:放编译好的二进制可执行代码
|
||||
</li>
|
||||
<li>
|
||||
.data:已经初始化好的全局变量
|
||||
</li>
|
||||
<li>
|
||||
.rodata:只读数据,例如字符串常量、const的变量
|
||||
</li>
|
||||
<li>
|
||||
.bss:未初始化全局变量,运行时会置0
|
||||
</li>
|
||||
<li>
|
||||
.symtab:符号表,记录的则是函数和变量
|
||||
</li>
|
||||
<li>
|
||||
.strtab:字符串表、字符串常量和变量名
|
||||
</li>
|
||||
|
||||
为啥这里只有全局变量呢?其实前面我们讲函数栈的时候说过,局部变量是放在栈里面的,是程序运行过程中随时分配空间,随时释放的,现在我们讨论的是二进制文件,还没启动呢,所以只需要讨论在哪里保存全局变量。
|
||||
|
||||
这些节的元数据信息也需要有一个地方保存,就是最后的节头部表(Section Header Table)。在这个表里面,每一个section都有一项,在代码里面也有定义struct elf32_shdr和struct elf64_shdr。在ELF的头里面,有描述这个文件的节头部表的位置,有多少个表项等等信息。
|
||||
|
||||
我们刚才说了可重定位,为啥叫**可重定位**呢?我们可以想象一下,这个编译好的代码和变量,将来加载到内存里面的时候,都是要加载到一定位置的。比如说,调用一个函数,其实就是跳到这个函数所在的代码位置执行;再比如修改一个全局变量,也是要到变量的位置那里去修改。但是现在这个时候,还是.o文件,不是一个可以直接运行的程序,这里面只是部分代码片段。
|
||||
|
||||
例如这里的create_process函数,将来被谁调用,在哪里调用都不清楚,就更别提确定位置了。所以,.o里面的位置是不确定的,但是必须是可重新定位的,因为它将来是要做函数库的嘛,就是一块砖,哪里需要哪里搬,搬到哪里就重新定位这些代码、变量的位置。
|
||||
|
||||
有的section,例如.rel.text, .rel.data就与重定位有关。例如这里的createprocess.o,里面调用了create_process函数,但是这个函数在另外一个.o里面,因而createprocess.o里面根本不可能知道被调用函数的位置,所以只好在rel.text里面标注,这个函数是需要重定位的。
|
||||
|
||||
要想让create_process这个函数作为库文件被重用,不能以.o的形式存在,而是要形成库文件,最简单的类型是静态链接库.a文件(Archives),仅仅将一系列对象文件(.o)归档为一个文件,使用命令ar创建。
|
||||
|
||||
```
|
||||
ar cr libstaticprocess.a process.o
|
||||
|
||||
```
|
||||
|
||||
虽然这里libstaticprocess.a里面只有一个.o,但是实际情况可以有多个.o。当有程序要使用这个静态连接库的时候,会将.o文件提取出来,链接到程序中。
|
||||
|
||||
```
|
||||
gcc -o staticcreateprocess createprocess.o -L. -lstaticprocess
|
||||
|
||||
```
|
||||
|
||||
在这个命令里,-L表示在当前目录下找.a文件,-lstaticprocess会自动补全文件名,比如加前缀lib,后缀.a,变成libstaticprocess.a,找到这个.a文件后,将里面的process.o取出来,和createprocess.o做一个链接,形成二进制执行文件staticcreateprocess。
|
||||
|
||||
这个链接的过程,重定位就起作用了,原来createprocess.o里面调用了create_process函数,但是不能确定位置,现在将process.o合并了进来,就知道位置了。
|
||||
|
||||
形成的二进制文件叫**可执行文件**,是ELF的第二种格式,格式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/60/1d8de36a58a98a53352b40efa81e9660.jpg" alt="">
|
||||
|
||||
这个格式和.o文件大致相似,还是分成一个个的section,并且被节头表描述。只不过这些section是多个.o文件合并过的。但是这个时候,这个文件已经是马上就可以加载到内存里面执行的文件了,因而这些section被分成了需要加载到内存里面的代码段、数据段和不需要加载到内存里面的部分,将小的section合成了大的段segment,并且在最前面加一个段头表(Segment Header Table)。在代码里面的定义为struct elf32_phdr和struct elf64_phdr,这里面除了有对于段的描述之外,最重要的是p_vaddr,这个是这个段加载到内存的虚拟地址。
|
||||
|
||||
在ELF头里面,有一项e_entry,也是个虚拟地址,是这个程序运行的入口。
|
||||
|
||||
当程序运行起来之后,就是下面这个样子:
|
||||
|
||||
```
|
||||
# ./staticcreateprocess
|
||||
# total 40
|
||||
-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
静态链接库一旦链接进去,代码和变量的section都合并了,因而程序运行的时候,就不依赖于这个库是否存在。但是这样有一个缺点,就是相同的代码段,如果被多个程序使用的话,在内存里面就有多份,而且一旦静态链接库更新了,如果二进制执行文件不重新编译,也不随着更新。
|
||||
|
||||
因而就出现了另一种,**动态链接库**(Shared Libraries),不仅仅是一组对象文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享。
|
||||
|
||||
```
|
||||
gcc -shared -fPIC -o libdynamicprocess.so process.o
|
||||
|
||||
```
|
||||
|
||||
当一个动态链接库被链接到一个程序文件中的时候,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的名称。
|
||||
|
||||
```
|
||||
gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess
|
||||
|
||||
```
|
||||
|
||||
当运行这个程序的时候,首先寻找动态链接库,然后加载它。默认情况下,系统在/lib和/usr/lib文件夹下寻找动态链接库。如果找不到就会报错,我们可以设定LD_LIBRARY_PATH环境变量,程序运行时会在此环境变量指定的文件夹下寻找动态链接库。
|
||||
|
||||
```
|
||||
# export LD_LIBRARY_PATH=.
|
||||
# ./dynamiccreateprocess
|
||||
# total 40
|
||||
-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
动态链接库,就是ELF的第三种类型,**共享对象文件**(Shared Object)。
|
||||
|
||||
基于动态链接库创建出来的二进制文件格式还是ELF,但是稍有不同。
|
||||
|
||||
首先,多了一个.interp的Segment,这里面是ld-linux.so,这是动态链接器,也就是说,运行时的链接动作都是它做的。
|
||||
|
||||
另外,ELF文件中还多了两个section,一个是.plt,过程链接表(Procedure Linkage Table,PLT),一个是.got.plt,全局偏移量表(Global Offset Table,GOT)。
|
||||
|
||||
它们是怎么工作的,使得程序运行的时候,可以将so文件动态链接到进程空间的呢?
|
||||
|
||||
dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数。由于是运行时才去找,编译的时候,压根不知道这个函数在哪里,所以就在PLT里面建立一项PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用create_process函数,而是调用PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的create_process函数。
|
||||
|
||||
去哪里找代理代码呢?这就用到了GOT,这里面也会为create_process函数创建一项GOT[y]。这一项是运行时create_process函数在内存中真正的地址。
|
||||
|
||||
如果这个地址在dynamiccreateprocess调用PLT[x]里面的代理代码,代理代码调用GOT表中对应项GOT[y],调用的就是加载到内存中的libdynamicprocess.so里面的create_process函数了。
|
||||
|
||||
但是GOT怎么知道的呢?对于create_process函数,GOT一开始就会创建一项GOT[y],但是这里面没有真正的地址,因为它也不知道,但是它有办法,它又回调PLT,告诉它,你里面的代理代码来找我要create_process函数的真实地址,我不知道,你想想办法吧。
|
||||
|
||||
PLT这个时候会转而调用PLT[0],也即第一项,PLT[0]转而调用GOT[2],这里面是ld-linux.so的入口函数,这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面。下次,PLT[x]的代理函数就能够直接调用了。
|
||||
|
||||
这个过程有点绕,但是是不是也很巧妙?
|
||||
|
||||
## 运行程序为进程
|
||||
|
||||
知道了ELF这个格式,这个时候它还是个程序,那怎么把这个文件加载到内存里面呢?
|
||||
|
||||
在内核中,有这样一个数据结构,用来定义加载二进制文件的方法。
|
||||
|
||||
```
|
||||
struct linux_binfmt {
|
||||
struct list_head lh;
|
||||
struct module *module;
|
||||
int (*load_binary)(struct linux_binprm *);
|
||||
int (*load_shlib)(struct file *);
|
||||
int (*core_dump)(struct coredump_params *cprm);
|
||||
unsigned long min_coredump; /* minimal dump size */
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
对于ELF文件格式,有对应的实现。
|
||||
|
||||
```
|
||||
static struct linux_binfmt elf_format = {
|
||||
.module = THIS_MODULE,
|
||||
.load_binary = load_elf_binary,
|
||||
.load_shlib = load_elf_library,
|
||||
.core_dump = elf_core_dump,
|
||||
.min_coredump = ELF_EXEC_PAGESIZE,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
load_elf_binary是不是你很熟悉?没错,我们加载内核镜像的时候,用的也是这种格式。
|
||||
|
||||
还记得当时是谁调用的load_elf_binary函数吗?具体是这样的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。
|
||||
|
||||
那do_execve又是被谁调用的呢?我们看下面的代码。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(execve,
|
||||
const char __user *, filename,
|
||||
const char __user *const __user *, argv,
|
||||
const char __user *const __user *, envp)
|
||||
{
|
||||
return do_execve(getname(filename), argv, envp);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
学过了系统调用一节,你会发现,原理是exec这个系统调用最终调用的load_elf_binary。
|
||||
|
||||
exec比较特殊,它是一组函数:
|
||||
|
||||
<li>
|
||||
包含p的函数(execvp, execlp)会在PATH路径下面寻找程序;
|
||||
</li>
|
||||
<li>
|
||||
不包含p的函数需要输入程序的全路径;
|
||||
</li>
|
||||
<li>
|
||||
包含v的函数(execv, execvp, execve)以数组的形式接收参数;
|
||||
</li>
|
||||
<li>
|
||||
包含l的函数(execl, execlp, execle)以列表的形式接收参数;
|
||||
</li>
|
||||
<li>
|
||||
包含e的函数(execve, execle)以数组的形式接收环境变量。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/f6/465b740b86ccc6ad3f8e38de25336bf6.jpg" alt="">
|
||||
|
||||
在上面process.c的代码中,我们创建ls进程,也是通过exec。
|
||||
|
||||
## 进程树
|
||||
|
||||
既然所有的进程都是从父进程fork过来的,那总归有一个祖宗进程,这就是咱们系统启动的init进程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/16/4de740c10670a92bbaa58348e66b7b16.jpeg" alt="">
|
||||
|
||||
在解析Linux的启动过程的时候,1号进程是/sbin/init。如果在centOS 7里面,我们ls一下,可以看到,这个进程是被软链接到systemd的。
|
||||
|
||||
```
|
||||
/sbin/init -> ../lib/systemd/systemd
|
||||
|
||||
```
|
||||
|
||||
系统启动之后,init进程会启动很多的daemon进程,为系统运行提供服务,然后就是启动getty,让用户登录,登录后运行shell,用户启动的进程都是通过shell运行的,从而形成了一棵进程树。
|
||||
|
||||
我们可以通过ps -ef命令查看当前系统启动的进程,我们会发现有三类进程。
|
||||
|
||||
```
|
||||
[root@deployer ~]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 2018 ? 00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
|
||||
root 2 0 0 2018 ? 00:00:00 [kthreadd]
|
||||
root 3 2 0 2018 ? 00:00:00 [ksoftirqd/0]
|
||||
root 5 2 0 2018 ? 00:00:00 [kworker/0:0H]
|
||||
root 9 2 0 2018 ? 00:00:40 [rcu_sched]
|
||||
......
|
||||
root 337 2 0 2018 ? 00:00:01 [kworker/3:1H]
|
||||
root 380 1 0 2018 ? 00:00:00 /usr/lib/systemd/systemd-udevd
|
||||
root 415 1 0 2018 ? 00:00:01 /sbin/auditd
|
||||
root 498 1 0 2018 ? 00:00:03 /usr/lib/systemd/systemd-logind
|
||||
......
|
||||
root 852 1 0 2018 ? 00:06:25 /usr/sbin/rsyslogd -n
|
||||
root 2580 1 0 2018 ? 00:00:00 /usr/sbin/sshd -D
|
||||
root 29058 2 0 Jan03 ? 00:00:01 [kworker/1:2]
|
||||
root 29672 2 0 Jan04 ? 00:00:09 [kworker/2:1]
|
||||
root 30467 1 0 Jan06 ? 00:00:00 /usr/sbin/crond -n
|
||||
root 31574 2 0 Jan08 ? 00:00:01 [kworker/u128:2]
|
||||
......
|
||||
root 32792 2580 0 Jan10 ? 00:00:00 sshd: root@pts/0
|
||||
root 32794 32792 0 Jan10 pts/0 00:00:00 -bash
|
||||
root 32901 32794 0 00:01 pts/0 00:00:00 ps -ef
|
||||
|
||||
```
|
||||
|
||||
你会发现,PID 1的进程就是我们的init进程systemd,PID 2的进程是内核线程kthreadd,这两个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
|
||||
|
||||
接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是2号进程。而用户态的进程,祖先都是1号进程。tty那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
|
||||
|
||||
pts的父进程是sshd,bash的父进程是pts,ps -ef这个命令的父进程是bash。这样整个链条都比较清晰了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了一个进程从代码到二进制到运行时的一个过程,我们用一个图总结一下。
|
||||
|
||||
我们首先通过图右边的文件编译过程,生成so文件和可执行文件,放在硬盘上。下图左边的用户态的进程A执行fork,创建进程B,在进程B的处理逻辑中,执行exec系列系统调用。这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程B的内存中执行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/db/a9/dbd8785da6c3ce3fe1abb7bb5934b7a9.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
对于ELF,有几个工具能帮你看这些文件的格式。readelf工具用于分析ELF的信息,objdump工具用来显示二进制文件的信息,hexdump工具用来查看文件的十六进制编码,nm 工具用来显示关于指定文件中符号的信息。你可以尝试用这几个工具,来解析这一节生成的.o, .so 和可执行文件。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
555
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/11 | 线程:如何让复杂的项目并行执行?.md
Normal file
555
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/11 | 线程:如何让复杂的项目并行执行?.md
Normal file
@@ -0,0 +1,555 @@
|
||||
<audio id="audio" title="11 | 线程:如何让复杂的项目并行执行?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/c3/969787bd61560a605ca14868dc1eeec3.mp3"></audio>
|
||||
|
||||
上一节我们讲了如何创建进程,这一节我们来看如何创建线程。
|
||||
|
||||
## 为什么要有线程?
|
||||
|
||||
其实,对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据项目执行计划书,一行一行执行下去。进程要比线程管的宽多了,除了执行指令之外,内存、文件系统等等都要它来管。
|
||||
|
||||
所以,**进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务**。默认情况下,你可以建一个大的任务,就是完成某某功能,然后交给一个人让它从头做到尾,这就是主线程。但是有时候,你发现任务是可以拆解的,如果相关性没有非常大前后关联关系,就可以并行执行。
|
||||
|
||||
例如,你接到了一个开发任务,要开发200个页面,最后组成一个网站。这时候你就可以拆分成20个任务,每个任务10个页面,并行开发。都开发完了,再做一次整合,这肯定比依次开发200个页面快多了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/9e/485ce8195d241c2a6930803286302e9e.jpg" alt="">
|
||||
|
||||
那我们能不能成立多个项目组实现并行开发呢?当然可以了,只不过这样做有两个比较麻烦的地方。
|
||||
|
||||
第一个麻烦是,立项。涉及的部门比较多,总是劳师动众。你本来想的是,只要能并行执行任务就可以,不需要把会议室都搞成独立的。另一个麻烦是,项目组是独立的,会议室是独立的,很多事情就不受你控制了,例如一旦有了两个项目组,就会有沟通问题。
|
||||
|
||||
所以,使用进程实现并行执行的问题也有两个。第一,创建进程占用资源太多;第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。
|
||||
|
||||
除了希望任务能够并行执行,有的时候,你作为项目管理人员,肯定要管控风险,因此还会预留一部分人作为应急小分队,来处理紧急的事情。
|
||||
|
||||
例如,主线程正在一行一行执行二进制命令,突然收到一个通知,要做一点小事情,应该停下主线程来做么?太耽误事情了,应该创建一个单独的线程,单独处理这些事件。
|
||||
|
||||
另外,咱们希望自己的公司越来越有竞争力。要想实现远大的目标,我们不能把所有人力都用在接项目上,应该预留一些人力来做技术积累,比如开发一些各个项目都能用到的共享库、框架等等。
|
||||
|
||||
在Linux中,有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如你输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。
|
||||
|
||||
## 如何创建线程?
|
||||
|
||||
看来多线程还是有很多好处的。接下来我们来看一下,如何使用线程来干一件大事。
|
||||
|
||||
假如说,现在我们有N个非常大的视频需要下载,一个个下载需要的时间太长了。按照刚才的思路,我们可以拆分成N个任务,分给N个线程各自去下载。
|
||||
|
||||
我们知道,进程的执行是需要项目执行计划书的,那线程是一个项目小组,这个小组也应该有自己的项目执行计划书,也就是一个函数。我们将要执行的子任务放在这个函数里面,比如上面的下载任务。
|
||||
|
||||
这个函数参数是void类型的指针,用于接收任何类型的参数。我们就可以将要下载的文件的文件名通过这个指针传给它。
|
||||
|
||||
为了方便,我将代码整段都贴在这里,这样你把下面的代码放在一个文件里面就能成功编译。
|
||||
|
||||
当然,这里我们不是真的下载这个文件,而仅仅打印日志,并生成一个一百以内的随机数,作为下载时间返回。这样,每个子任务干活的同时在喊:“我正在下载,终于下载完了,用了多少时间。”
|
||||
|
||||
```
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 5
|
||||
|
||||
void *downloadfile(void *filename)
|
||||
{
|
||||
printf("I am downloading the file %s!\n", (char *)filename);
|
||||
sleep(10);
|
||||
long downloadtime = rand()%100;
|
||||
printf("I finish downloading the file within %d minutes!\n", downloadtime);
|
||||
pthread_exit((void *)downloadtime);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
char files[NUM_OF_TASKS][20]={"file1.avi","file2.rmvb","file3.mp4","file4.wmv","file5.flv"};
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
int downloadtime;
|
||||
|
||||
pthread_attr_t thread_attr;
|
||||
pthread_attr_init(&thread_attr);
|
||||
pthread_attr_setdetachstate(&thread_attr,PTHREAD_CREATE_JOINABLE);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
printf("creating thread %d, please help me to download %s\n", t, files[t]);
|
||||
rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
pthread_attr_destroy(&thread_attr);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
pthread_join(threads[t],(void**)&downloadtime);
|
||||
printf("Thread %d downloads the file %s in %d minutes.\n",t,files[t],downloadtime);
|
||||
}
|
||||
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一个运行中的线程可以调用pthread_exit退出线程。这个函数可以传入一个参数转换为(void *)类型。这是线程退出的返回值。
|
||||
|
||||
接下来,我们来看主线程。在这里面,我列了五个文件名。接下来声明了一个数组,里面有五个pthread_t类型的线程对象。
|
||||
|
||||
接下来,声明一个线程属性pthread_attr_t。我们通过pthread_attr_init初始化这个属性,并且设置属性PTHREAD_CREATE_JOINABLE。这表示将来主线程程等待这个线程的结束,并获取退出时的状态。
|
||||
|
||||
接下来是一个循环。对于每一个文件和每一个线程,可以调用pthread_create创建线程。一共有四个参数,第一个参数是线程对象,第二个参数是线程的属性,第三个参数是线程运行函数,第四个参数是线程运行函数的参数。主线程就是通过第四个参数,将自己的任务派给子线程。
|
||||
|
||||
任务分配完毕,每个线程下载一个文件,接下来主线程要做的事情就是等待这些子任务完成。当一个线程退出的时候,就会发送信号给其他所有同进程的线程。有一个线程使用pthread_join获取这个线程退出的返回值。线程的返回值通过pthread_join传给主线程,这样子线程就将自己下载文件所耗费的时间,告诉给主线程。
|
||||
|
||||
好了,程序写完了,开始编译。多线程程序要依赖于libpthread.so。
|
||||
|
||||
```
|
||||
gcc download.c -lpthread
|
||||
|
||||
```
|
||||
|
||||
编译好了,执行一下,就能得到下面的结果。
|
||||
|
||||
```
|
||||
# ./a.out
|
||||
creating thread 0, please help me to download file1.avi
|
||||
creating thread 1, please help me to download file2.rmvb
|
||||
I am downloading the file file1.avi!
|
||||
creating thread 2, please help me to download file3.mp4
|
||||
I am downloading the file file2.rmvb!
|
||||
creating thread 3, please help me to download file4.wmv
|
||||
I am downloading the file file3.mp4!
|
||||
creating thread 4, please help me to download file5.flv
|
||||
I am downloading the file file4.wmv!
|
||||
I am downloading the file file5.flv!
|
||||
I finish downloading the file within 83 minutes!
|
||||
I finish downloading the file within 77 minutes!
|
||||
I finish downloading the file within 86 minutes!
|
||||
I finish downloading the file within 15 minutes!
|
||||
I finish downloading the file within 93 minutes!
|
||||
Thread 0 downloads the file file1.avi in 83 minutes.
|
||||
Thread 1 downloads the file file2.rmvb in 86 minutes.
|
||||
Thread 2 downloads the file file3.mp4 in 77 minutes.
|
||||
Thread 3 downloads the file file4.wmv in 93 minutes.
|
||||
Thread 4 downloads the file file5.flv in 15 minutes.
|
||||
|
||||
```
|
||||
|
||||
这里我们画一张图总结一下,一个普通线程的创建和运行过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/bd/e38c28b0972581d009ef16f1ebdee2bd.jpg" alt="">
|
||||
|
||||
## 线程的数据
|
||||
|
||||
线程可以将项目并行起来,加快进度,但是也带来的负面影响,过程并行起来了,那数据呢?
|
||||
|
||||
我们把线程访问的数据细分成三类。下面我们一一来看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/3f/e7b06dcf431f388170ab0a79677ee43f.jpg" alt="">
|
||||
|
||||
第一类是**线程栈上的本地数据**,比如函数执行过程中的局部变量。前面我们说过,函数的调用会使用栈的模型,这在线程里面是一样的。只不过每个线程都有自己的栈空间。
|
||||
|
||||
栈的大小可以通过命令ulimit -a查看,默认情况下线程栈大小为8192(8MB)。我们可以使用命令ulimit -s修改。
|
||||
|
||||
对于线程栈,可以通过下面这个函数pthread_attr_t,修改线程栈的大小。
|
||||
|
||||
```
|
||||
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
|
||||
|
||||
```
|
||||
|
||||
主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。
|
||||
|
||||
第二类数据就是**在整个进程里共享的全局数据**。例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。如果同一个全局变量,两个线程一起修改,那肯定会有问题,有可能把数据改的面目全非。这就需要有一种机制来保护他们,比如你先用我再用。这一节的最后,我们专门来谈这个问题。
|
||||
|
||||
那线程能不能像进程一样,也有自己的私有数据呢?如果想声明一个线程级别,而非进程级别的全局变量,有没有什么办法呢?虽然咱们都是一个大组,分成小组,也应该有点隐私。
|
||||
|
||||
这就是第三类数据,**线程私有数据**(Thread Specific Data),可以通过以下函数创建:
|
||||
|
||||
```
|
||||
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
|
||||
|
||||
```
|
||||
|
||||
可以看到,创建一个key,伴随着一个析构函数。
|
||||
|
||||
key一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往key中填入不同的值,这就相当于提供了一个同名而不同值的全局变量。
|
||||
|
||||
我们可以通过下面的函数设置key对应的value。
|
||||
|
||||
```
|
||||
int pthread_setspecific(pthread_key_t key, const void *value)
|
||||
|
||||
```
|
||||
|
||||
我们还可以通过下面的函数获取key对应的value。
|
||||
|
||||
```
|
||||
void *pthread_getspecific(pthread_key_t key)
|
||||
|
||||
```
|
||||
|
||||
而等到线程退出的时候,就会调用析构函数释放value。
|
||||
|
||||
## 数据的保护
|
||||
|
||||
接下来,我们来看共享的数据保护问题。
|
||||
|
||||
我们先来看一种方式,**Mutex**,全称Mutual Exclusion,中文叫**互斥**。顾名思义,有你没我,有我没你。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。
|
||||
|
||||
我这里构建了一个“转账”的场景。相关的代码我放到这里,你可以看看。
|
||||
|
||||
```
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 5
|
||||
|
||||
int money_of_tom = 100;
|
||||
int money_of_jerry = 100;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_t g_money_lock;
|
||||
|
||||
void *transfer(void *notused)
|
||||
{
|
||||
pthread_t tid = pthread_self();
|
||||
printf("Thread %u is transfering money!\n", (unsigned int)tid);
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_lock(&g_money_lock);
|
||||
sleep(rand()%10);
|
||||
money_of_tom+=10;
|
||||
sleep(rand()%10);
|
||||
money_of_jerry-=10;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_unlock(&g_money_lock);
|
||||
printf("Thread %u finish transfering money!\n", (unsigned int)tid);
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_init(&g_money_lock, NULL);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
rc = pthread_create(&threads[t], NULL, transfer, NULL);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
for(t=0;t<100;t++){
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_lock(&g_money_lock);
|
||||
printf("money_of_tom + money_of_jerry = %d\n", money_of_tom + money_of_jerry);
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_unlock(&g_money_lock);
|
||||
}
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_destroy(&g_money_lock);
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里说,有两个员工Tom和Jerry,公司食堂的饭卡里面各自有100元,并行启动5个线程,都是Jerry转10元给Tom,主线程不断打印Tom和Jerry的资金之和。按说,这样的话,总和应该永远是200元。
|
||||
|
||||
在上面的程序中,我们先去掉mutex相关的行,就像注释里面写的那样。在没有锁的保护下,在Tom的账户里面加上10元,在Jerry的账户里面减去10元,这不是一个原子操作。
|
||||
|
||||
我们来编译一下。
|
||||
|
||||
```
|
||||
gcc mutex.c -lpthread
|
||||
|
||||
```
|
||||
|
||||
然后运行一下,就看到了下面这样的结果。
|
||||
|
||||
```
|
||||
[root@deployer createthread]# ./a.out
|
||||
Thread 508479232 is transfering money!
|
||||
Thread 491693824 is transfering money!
|
||||
Thread 500086528 is transfering money!
|
||||
Thread 483301120 is transfering money!
|
||||
Thread 516871936 is transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 220
|
||||
money_of_tom + money_of_jerry = 220
|
||||
money_of_tom + money_of_jerry = 230
|
||||
money_of_tom + money_of_jerry = 240
|
||||
Thread 483301120 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 240
|
||||
Thread 508479232 finish transfering money!
|
||||
Thread 500086528 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 220
|
||||
Thread 516871936 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 210
|
||||
money_of_tom + money_of_jerry = 210
|
||||
Thread 491693824 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
|
||||
```
|
||||
|
||||
可以看到,中间有很多状态不正确,比如两个人的账户之和出现了超过200的情况,也就是Tom转入了,Jerry还没转出。
|
||||
|
||||
接下来我们在上面的代码里面,加上mutex,然后编译、运行,就得到了下面的结果。
|
||||
|
||||
```
|
||||
[root@deployer createthread]# ./a.out
|
||||
Thread 568162048 is transfering money!
|
||||
Thread 576554752 is transfering money!
|
||||
Thread 551376640 is transfering money!
|
||||
Thread 542983936 is transfering money!
|
||||
Thread 559769344 is transfering money!
|
||||
Thread 568162048 finish transfering money!
|
||||
Thread 576554752 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
Thread 542983936 finish transfering money!
|
||||
Thread 559769344 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
Thread 551376640 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
|
||||
```
|
||||
|
||||
这个结果就正常了。两个账号之和永远是200。这下你看到锁的作用了吧?
|
||||
|
||||
使用Mutex,首先要使用pthread_mutex_init函数初始化这个mutex,初始化后,就可以用它来保护共享变量了。
|
||||
|
||||
pthread_mutex_lock() 就是去抢那把锁的函数,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,就被阻塞在那里等待。
|
||||
|
||||
如果不想被阻塞,可以使用pthread_mutex_trylock去抢那把锁,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,不会被阻塞,而是返回一个错误码。
|
||||
|
||||
当共享数据访问结束了,别忘了使用pthread_mutex_unlock释放锁,让给其他人使用,最终调用pthread_mutex_destroy销毁掉这把锁。
|
||||
|
||||
这里我画个图,总结一下Mutex的使用流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/be/0ccf37aafa2b287363399e130b2726be.jpg" alt="">
|
||||
|
||||
在使用Mutex的时候,有个问题是如果使用pthread_mutex_lock(),那就需要一直在那里等着。如果是pthread_mutex_trylock(),就可以不用等着,去干点儿别的,但是我怎么知道什么时候回来再试一下,是不是轮到我了呢?能不能在轮到我的时候,通知我一下呢?
|
||||
|
||||
这其实就是条件变量,也就是说如果没事儿,就让大家歇着,有事儿了就去通知,别让人家没事儿就来问问,浪费大家的时间。
|
||||
|
||||
但是当它接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以**条件变量和互斥锁是配合使用的**。
|
||||
|
||||
我这里还是用一个场景给你解释。
|
||||
|
||||
你这个老板,招聘了三个员工,但是你不是有了活才去招聘员工,而是先把员工招来,没有活的时候员工需要在那里等着,一旦有了活,你要去通知他们,他们要去抢活干(为啥要抢活?因为有绩效呀!),干完了再等待,你再有活,再通知他们。
|
||||
|
||||
具体的样例代码我也放在这里。你可以直接编译运行。
|
||||
|
||||
```
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 3
|
||||
#define MAX_TASK_QUEUE 11
|
||||
|
||||
char tasklist[MAX_TASK_QUEUE]="ABCDEFGHIJ";
|
||||
int head = 0;
|
||||
int tail = 0;
|
||||
|
||||
int quit = 0;
|
||||
|
||||
pthread_mutex_t g_task_lock;
|
||||
pthread_cond_t g_task_cv;
|
||||
|
||||
void *coder(void *notused)
|
||||
{
|
||||
pthread_t tid = pthread_self();
|
||||
|
||||
while(!quit){
|
||||
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
while(tail == head){
|
||||
if(quit){
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);
|
||||
pthread_cond_wait(&g_task_cv, &g_task_lock);
|
||||
printf("Have task now! Thread %u is grabing the task !\n", (unsigned int)tid);
|
||||
}
|
||||
char task = tasklist[head++];
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);
|
||||
sleep(5);
|
||||
printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);
|
||||
}
|
||||
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
|
||||
pthread_mutex_init(&g_task_lock, NULL);
|
||||
pthread_cond_init(&g_task_cv, NULL);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
rc = pthread_create(&threads[t], NULL, coder, NULL);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
|
||||
for(t=1;t<=4;t++){
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
tail+=t;
|
||||
printf("I am Boss, I assigned %d tasks, I notify all coders!\n", t);
|
||||
pthread_cond_broadcast(&g_task_cv);
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
sleep(20);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
quit = 1;
|
||||
pthread_cond_broadcast(&g_task_cv);
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
|
||||
pthread_mutex_destroy(&g_task_lock);
|
||||
pthread_cond_destroy(&g_task_cv);
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们创建了10个任务,每个任务一个字符,放在一个数组里面,另外有两个变量head和tail,表示当前分配的工作从哪里开始,到哪里结束。如果head等于tail,则当前的工作分配完毕;如果tail加N,就是新分配了N个工作。
|
||||
|
||||
接下来声明的pthread_mutex_t g_task_lock和pthread_cond_t g_task_cv,是用于通知和抢任务的,工作模式如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/f7/1d4e17fdb1860f7ca7f23bbe682d93f7.jpeg" alt="">
|
||||
|
||||
图中左边的就是员工的工作模式,对于每一个员工coder,先要获取锁pthread_mutex_lock,这样才能保证一个任务只分配给一个员工。
|
||||
|
||||
然后,我们要判断有没有任务,也就是说,head和tail是否相等。如果不相等的话,就是有任务,则取出head位置代表的任务task,然后将head加一,这样整个任务就给了这个员工,下个员工来抢活的时候,也需要获取锁,获取之后抢到的就是下一个任务了。当这个员工抢到任务后,pthread_mutex_unlock解锁,让其他员工可以进来抢任务。抢到任务后就开始干活了,这里没有真正开始干活,而是sleep,也就是摸鱼了5秒。
|
||||
|
||||
如果发现head和tail相当,也就是没有任务,则需要调用pthread_cond_wait进行等待,这个函数会把锁也作为变量传进去。这是因为等待的过程中需要解锁,要不然,你不干活,等待睡大觉,还把门给锁了,别人也干不了活,而且老板也没办法获取锁来分配任务。
|
||||
|
||||
一开始三个员工都是在等待的状态,因为初始化的时候,head和tail相等都为零。
|
||||
|
||||
现在我们把目光聚焦到老板这里,也就是主线程上。它初始化了条件变量和锁,然后创建三个线程,也就是我们说的招聘了三个员工。
|
||||
|
||||
接下来要开始分配任务了,总共10个任务。老板分四批分配,第一批一个任务三个人抢,第二批两个任务,第三批三个任务,正好每人抢到一个,第四批四个任务,可能有一个员工抢到两个任务。这样三个员工,四批工作,经典的场景差不多都覆盖到了。
|
||||
|
||||
老板分配工作的时候,也是要先获取锁pthread_mutex_lock,然后通过tail加一来分配任务,这个时候head和tail已经不一样了,但是这个时候三个员工还在pthread_cond_wait那里睡着呢,接下来老板要调用pthread_cond_broadcast通知所有的员工,“来活了,醒醒,起来干活”。
|
||||
|
||||
这个时候三个员工醒来后,先抢锁,生怕老板只分配了一个任务,让别人抢去。当然抢锁这个动作是pthread_cond_wait在收到通知的时候,自动做的,不需要我们另外写代码。
|
||||
|
||||
抢到锁的员工就通过while再次判断head和tail是否相同。这次因为有了任务,不相同了,所以就抢到了任务。而没有抢到任务的员工,由于抢锁失败,只好等待抢到任务的员工释放锁,抢到任务的员工在tasklist里面拿到任务后,将head加一,然后就释放锁。这个时候,另外两个员工才能从pthread_cond_wait中返回,然后也会再次通过while判断head和tail是否相同。不过已经晚了,任务都让人家抢走了,head和tail又一样了,所以只好再次进入pthread_cond_wait,接着等任务。
|
||||
|
||||
这里,我们只解析了第一批一个任务的工作的过程。如果运行上面的程序,可以得到下面的结果。我将整个过程在里面写了注释,你看起来就比较容易理解了。
|
||||
|
||||
```
|
||||
[root@deployer createthread]# ./a.out
|
||||
//招聘三个员工,一开始没有任务,大家睡大觉
|
||||
No task now! Thread 3491833600 is waiting!
|
||||
No task now! Thread 3483440896 is waiting!
|
||||
No task now! Thread 3475048192 is waiting!
|
||||
//老板开始分配任务了,第一批任务就一个,告诉三个员工醒来抢任务
|
||||
I am Boss, I assigned 1 tasks, I notify all coders!
|
||||
//员工一先发现有任务了,开始抢任务
|
||||
Have task now! Thread 3491833600 is grabing the task !
|
||||
//员工一抢到了任务A,开始干活
|
||||
Thread 3491833600 has a task A now!
|
||||
//员工二也发现有任务了,开始抢任务,不好意思,就一个任务,让人家抢走了,接着等吧
|
||||
Have task now! Thread 3483440896 is grabing the task !
|
||||
No task now! Thread 3483440896 is waiting!
|
||||
//员工三也发现有任务了,开始抢任务,你比员工二还慢,接着等吧
|
||||
Have task now! Thread 3475048192 is grabing the task !
|
||||
No task now! Thread 3475048192 is waiting!
|
||||
//员工一把任务做完了,又没有任务了,接着等待
|
||||
Thread 3491833600 finish the task A !
|
||||
No task now! Thread 3491833600 is waiting!
|
||||
//老板又有新任务了,这次是两个任务,叫醒他们
|
||||
I am Boss, I assigned 2 tasks, I notify all coders!
|
||||
//这次员工二比较积极,先开始抢,并且抢到了任务B
|
||||
Have task now! Thread 3483440896 is grabing the task !
|
||||
Thread 3483440896 has a task B now!
|
||||
//这次员工三也聪明了,赶紧抢,要不然没有年终奖了,终于抢到了任务C
|
||||
Have task now! Thread 3475048192 is grabing the task !
|
||||
Thread 3475048192 has a task C now!
|
||||
//员工一上次抢到了,这次抢的慢了,没有抢到,是不是飘了
|
||||
Have task now! Thread 3491833600 is grabing the task !
|
||||
No task now! Thread 3491833600 is waiting!
|
||||
//员工二做完了任务B,没有任务了,接着等待
|
||||
Thread 3483440896 finish the task B !
|
||||
No task now! Thread 3483440896 is waiting!
|
||||
//员工三做完了任务C,没有任务了,接着等待
|
||||
Thread 3475048192 finish the task C !
|
||||
No task now! Thread 3475048192 is waiting!
|
||||
//又来任务了,这次是三个任务,人人有份
|
||||
I am Boss, I assigned 3 tasks, I notify all coders!
|
||||
//员工一抢到了任务D,员工二抢到了任务E,员工三抢到了任务F
|
||||
Have task now! Thread 3491833600 is grabing the task !
|
||||
Thread 3491833600 has a task D now!
|
||||
Have task now! Thread 3483440896 is grabing the task !
|
||||
Thread 3483440896 has a task E now!
|
||||
Have task now! Thread 3475048192 is grabing the task !
|
||||
Thread 3475048192 has a task F now!
|
||||
//三个员工都完成了,然后都又开始等待
|
||||
Thread 3491833600 finish the task D !
|
||||
Thread 3483440896 finish the task E !
|
||||
Thread 3475048192 finish the task F !
|
||||
No task now! Thread 3491833600 is waiting!
|
||||
No task now! Thread 3483440896 is waiting!
|
||||
No task now! Thread 3475048192 is waiting!
|
||||
//公司活越来越多了,来了四个任务,赶紧干呀
|
||||
I am Boss, I assigned 4 tasks, I notify all coders!
|
||||
//员工一抢到了任务G,员工二抢到了任务H,员工三抢到了任务I
|
||||
Have task now! Thread 3491833600 is grabing the task !
|
||||
Thread 3491833600 has a task G now!
|
||||
Have task now! Thread 3483440896 is grabing the task !
|
||||
Thread 3483440896 has a task H now!
|
||||
Have task now! Thread 3475048192 is grabing the task !
|
||||
Thread 3475048192 has a task I now!
|
||||
//员工一和员工三先做完了,发现还有一个任务开始抢
|
||||
Thread 3491833600 finish the task G !
|
||||
Thread 3475048192 finish the task I !
|
||||
//员工三没抢到,接着等
|
||||
No task now! Thread 3475048192 is waiting!
|
||||
//员工一抢到了任务J,多做了一个任务
|
||||
Thread 3491833600 has a task J now!
|
||||
//员工二这才把任务H做完,黄花菜都凉了,接着等待吧
|
||||
Thread 3483440896 finish the task H !
|
||||
No task now! Thread 3483440896 is waiting!
|
||||
//员工一做完了任务J,接着等待
|
||||
Thread 3491833600 finish the task J !
|
||||
No task now! Thread 3491833600 is waiting!
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲了如何创建线程,线程都有哪些数据,如何对线程数据进行保护。
|
||||
|
||||
写多线程的程序是有套路的,我这里用一张图进行总结。你需要记住的是,创建线程的套路、mutex使用的套路、条件变量使用的套路。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/58/02a774d7c0f83bb69fec4662622d6d58.png" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节讲了多线程编程的套路,但是我没有对于每一个函数进行详细的讲解,相关的还有很多其他的函数可以调用,这需要你自己去学习。这里我给你推荐一本书$Programming with POSIX<br>
|
||||
Threads$,你可以系统地学习一下。另外,上面的代码,建议你一定要上手编译运行一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,219 @@
|
||||
<audio id="audio" title="12 | 进程数据结构(上):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/44/732f131303e8a319d25f1338def3db44.mp3"></audio>
|
||||
|
||||
前面两节,我们讲了如何使用系统调用,创建进程和线程。你是不是觉得进程和线程管理,还挺复杂的呢?如此复杂的体系,在内核里面应该如何管理呢?
|
||||
|
||||
有的进程只有一个线程,有的进程有多个线程,它们都需要由内核分配CPU来干活。可是CPU总共就这么几个,应该怎么管理,怎么调度呢?你是老板,这个事儿得你来操心。
|
||||
|
||||
首先,我们得明确,公司的项目售前售后人员,接来了这么多的项目,这是个好事儿。这些项目都通过办事大厅立了项的,有的需要整个项目组一起开发,有的是一个项目组分成多个小组并行开发。无论哪种模式,到你这个老板这里,都需要有一个项目管理体系,进行统一排期、统一管理和统一协调。这样,你才能对公司的业务了如指掌。
|
||||
|
||||
那具体应该怎么做呢?还记得咱们平时开发的时候,用的项目管理软件Jira吧?它的办法对我们来讲,就很有参考意义。
|
||||
|
||||
我们这么来看,其实,无论是一个大的项目组一起完成一个大的功能(单体应用模式),还是把一个大的功能拆成小的功能并行开发(微服务模式),这些都是开发组根据客户的需求来定的,项目经理没办法决定,但是从项目经理的角度来看,这些都是任务,需要同样关注进度、协调资源等等。
|
||||
|
||||
同样在Linux里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构**task_struct**进行管理。这个结构非常复杂,但你也不用怕,我们慢慢来解析。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/2d/75c4d28a9d2daa4acc1107832be84e2d.jpeg" alt="">
|
||||
|
||||
接下来,我们沿着建立项目管理体系的思路,设想一下,**Linux的任务管理都应该干些啥?**
|
||||
|
||||
首先,所有执行的项目应该有个项目列表吧,所以Linux内核也应该先弄一个**链表**,将所有的task_struct串起来。
|
||||
|
||||
```
|
||||
struct list_head tasks;
|
||||
|
||||
```
|
||||
|
||||
接下来,我们来看每一个任务都应该包含哪些字段。
|
||||
|
||||
## 任务ID
|
||||
|
||||
每一个任务都应该有一个ID,作为这个任务的唯一标识。到时候排期啊、下发任务啊等等,都按ID来,就不会产生歧义。
|
||||
|
||||
task_struct里面涉及任务ID的,有下面几个:
|
||||
|
||||
```
|
||||
pid_t pid;
|
||||
pid_t tgid;
|
||||
struct task_struct *group_leader;
|
||||
|
||||
```
|
||||
|
||||
你可能觉得奇怪,既然是ID,有一个就足以做唯一标识了,这个怎么看起来这么麻烦?这是因为,上面的进程和线程到了内核这里,统一变成了任务,这就带来两个问题。
|
||||
|
||||
第一个问题是,**任务展示**。
|
||||
|
||||
啥是任务展示呢?这么说吧,你作为老板,想了解的肯定是,公司都接了哪些项目,每个项目多少营收。什么项目执行是不是分了小组,每个小组是啥情况,这些细节,项目经理没必要全都展示给你看。
|
||||
|
||||
前面我们学习命令行的时候,知道ps命令可以展示出所有的进程。但是如果你是这个命令的实现者,到了内核,按照上面的任务列表把这些命令都显示出来,把所有的线程全都平摊开来显示给用户。用户肯定觉得既复杂又困惑。复杂在于,列表这么长;困惑在于,里面出现了很多并不是自己创建的线程。
|
||||
|
||||
第二个问题是,**给任务下发指令**。
|
||||
|
||||
如果客户突然给项目组提个新的需求,比如说,有的客户觉得项目已经完成,可以终止;再比如说,有的客户觉得项目做到一半没必要再进行下去了,可以中止,这时候应该给谁发指令?当然应该给整个项目组,而不是某个小组。我们不能让客户看到,不同的小组口径不一致。这就好比说,中止项目的指令到达一个小组,这个小组很开心就去休息了,同一个项目组的其他小组还干的热火朝天的。
|
||||
|
||||
Linux也一样,前面我们学习命令行的时候,知道可以通过kill来给进程发信号,通知进程退出。如果发给了其中一个线程,我们就不能只退出这个线程,而是应该退出整个进程。当然,有时候,我们希望只给某个线程发信号。
|
||||
|
||||
所以在内核中,它们虽然都是任务,但是应该加以区分。其中,pid是process id,tgid是thread group ID。
|
||||
|
||||
任何一个进程,如果只有主线程,那pid是自己,tgid是自己,group_leader指向的还是自己。
|
||||
|
||||
但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的pid,tgid就是进程的主线程的pid,group_leader指向的就是进程的主线程。
|
||||
|
||||
好了,有了tgid,我们就知道tast_struct代表的是一个进程还是代表一个线程了。
|
||||
|
||||
## 信号处理
|
||||
|
||||
这里既然提到了下发指令的问题,我就顺便提一下task_struct里面关于信号处理的字段。
|
||||
|
||||
```
|
||||
/* Signal handlers: */
|
||||
struct signal_struct *signal;
|
||||
struct sighand_struct *sighand;
|
||||
sigset_t blocked;
|
||||
sigset_t real_blocked;
|
||||
sigset_t saved_sigmask;
|
||||
struct sigpending pending;
|
||||
unsigned long sas_ss_sp;
|
||||
size_t sas_ss_size;
|
||||
unsigned int sas_ss_flags;
|
||||
|
||||
```
|
||||
|
||||
这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。
|
||||
|
||||
信号处理函数默认使用用户态的函数栈,当然也可以开辟新的栈专门用于信号处理,这就是sas_ss_xxx这三个变量的作用。
|
||||
|
||||
上面我说了下发信号的时候,需要区分进程和线程。从这里我们其实也能看出一些端倪。
|
||||
|
||||
task_struct里面有一个struct sigpending pending。如果我们进入struct signal_struct *signal去看的话,还有一个struct sigpending shared_pending。它们一个是本任务的,一个是线程组共享的。
|
||||
|
||||
关于信号,你暂时了解到这里就够用了,后面我们会有单独的章节进行解读。
|
||||
|
||||
## 任务状态
|
||||
|
||||
作为一个项目经理,另外一个需要关注的是项目当前的状态。例如,在Jira里面,任务的运行就可以分成下面的状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/21/e0019fcd11ff1ba33a3389e285b6a121.jpg" alt="">
|
||||
|
||||
在task_struct里面,涉及任务状态的是下面这几个变量:
|
||||
|
||||
```
|
||||
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
|
||||
int exit_state;
|
||||
unsigned int flags;
|
||||
|
||||
```
|
||||
|
||||
state(状态)可以取的值定义在include/linux/sched.h头文件中。
|
||||
|
||||
```
|
||||
/* Used in tsk->state: */
|
||||
#define TASK_RUNNING 0
|
||||
#define TASK_INTERRUPTIBLE 1
|
||||
#define TASK_UNINTERRUPTIBLE 2
|
||||
#define __TASK_STOPPED 4
|
||||
#define __TASK_TRACED 8
|
||||
/* Used in tsk->exit_state: */
|
||||
#define EXIT_DEAD 16
|
||||
#define EXIT_ZOMBIE 32
|
||||
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
|
||||
/* Used in tsk->state again: */
|
||||
#define TASK_DEAD 64
|
||||
#define TASK_WAKEKILL 128
|
||||
#define TASK_WAKING 256
|
||||
#define TASK_PARKED 512
|
||||
#define TASK_NOLOAD 1024
|
||||
#define TASK_NEW 2048
|
||||
#define TASK_STATE_MAX 4096
|
||||
|
||||
```
|
||||
|
||||
从定义的数值很容易看出来,state是通过bitset的方式设置的,也就是说,当前是什么状态,哪一位就置一。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/88/e2fa348c67ce41ef730048ff9ca4c988.jpeg" alt="">
|
||||
|
||||
TASK_RUNNING并不是说进程正在运行,而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。
|
||||
|
||||
在运行中的进程,一旦要进行一些I/O操作,需要等待I/O完毕,这个时候会释放CPU,进入睡眠状态。
|
||||
|
||||
在Linux中,有两种睡眠状态。
|
||||
|
||||
一种是**TASK_INTERRUPTIBLE**,**可中断的睡眠状态**。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等待I/O完成,但是这个时候一个信号来的时候,进程还是要被唤醒。只不过唤醒后,不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个I/O操作完成,直接退出;或者收到某些信息,继续等待。
|
||||
|
||||
另一种睡眠是**TASK_UNINTERRUPTIBLE**,**不可中断的睡眠状态**。这是一种深度睡眠状态,不可被信号唤醒,只能死等I/O操作完成。一旦I/O操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。你可能会说,我kill它呢?别忘了,kill本身也是一个信号,既然这个状态不可被信号唤醒,kill信号也被忽略了。除非重启电脑,没有其他办法。
|
||||
|
||||
因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成TASK_UNINTERRUPTIBLE。
|
||||
|
||||
于是,我们就有了一种新的进程睡眠状态,**TASK_KILLABLE,可以终止的新睡眠状态**。进程处于这种状态中,它的运行原理类似TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。
|
||||
|
||||
从定义可以看出,TASK_WAKEKILL用于在接收到致命信号时唤醒进程,而TASK_KILLABLE相当于这两位都设置了。
|
||||
|
||||
```
|
||||
#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
|
||||
|
||||
```
|
||||
|
||||
TASK_STOPPED是在进程接收到SIGSTOP、SIGTTIN、SIGTSTP或者SIGTTOU信号之后进入该状态。
|
||||
|
||||
TASK_TRACED表示进程被debugger等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。
|
||||
|
||||
一旦一个进程要结束,先进入的是EXIT_ZOMBIE状态,但是这个时候它的父进程还没有使用wait()等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
|
||||
|
||||
EXIT_DEAD是进程的最终状态。
|
||||
|
||||
EXIT_ZOMBIE和EXIT_DEAD也可以用于exit_state。
|
||||
|
||||
上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,我们称为**标志**。放在flags字段中,这些字段都被定义成为**宏**,以PF开头。我这里举几个例子。
|
||||
|
||||
```
|
||||
#define PF_EXITING 0x00000004
|
||||
#define PF_VCPU 0x00000010
|
||||
#define PF_FORKNOEXEC 0x00000040
|
||||
|
||||
```
|
||||
|
||||
**PF_EXITING**表示正在退出。当有这个flag的时候,在函数find_alive_thread中,找活着的线程,遇到有这个flag的,就直接跳过。
|
||||
|
||||
**PF_VCPU**表示进程运行在虚拟CPU上。在函数account_system_time中,统计进程的系统运行时间,如果有这个flag,就调用account_guest_time,按照客户机的时间进行统计。
|
||||
|
||||
**PF_FORKNOEXEC**表示fork完了,还没有exec。在_do_fork函数里面调用copy_process,这个时候把flag设置为PF_FORKNOEXEC。当exec中调用了load_elf_binary的时候,又把这个flag去掉。
|
||||
|
||||
## 进程调度
|
||||
|
||||
进程的状态切换往往涉及调度,下面这些字段都是用于调度的。为了让你理解task_struct进程管理的全貌,我先在这里列一下,咱们后面会有单独的章节讲解,这里你只要大概看一下里面的注释就好了。
|
||||
|
||||
```
|
||||
//是否在运行队列上
|
||||
int on_rq;
|
||||
//优先级
|
||||
int prio;
|
||||
int static_prio;
|
||||
int normal_prio;
|
||||
unsigned int rt_priority;
|
||||
//调度器类
|
||||
const struct sched_class *sched_class;
|
||||
//调度实体
|
||||
struct sched_entity se;
|
||||
struct sched_rt_entity rt;
|
||||
struct sched_dl_entity dl;
|
||||
//调度策略
|
||||
unsigned int policy;
|
||||
//可以使用哪些CPU
|
||||
int nr_cpus_allowed;
|
||||
cpumask_t cpus_allowed;
|
||||
struct sched_info sched_info;
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲述了进程管理复杂的数据结构,我还是画一个图总结一下。这个图是进程管理task_struct的结构图。其中红色的部分是今天讲的部分,你可以对着这张图说出它们的含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/e8/016ae7fb63f8b3fd0ca072cb9964e3e8.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节我们讲了任务的状态,你可以试着在代码里面搜索一下这些状态改变的地方是哪个函数,是什么时机,从而进一步理解任务的概念。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,190 @@
|
||||
<audio id="audio" title="13 | 进程数据结构(中):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/8e/3a23420976c838a0f7f0d1a8e2c7828e.mp3"></audio>
|
||||
|
||||
上一节我们讲了,task_struct这个结构非常长。由此我们可以看出,Linux内核的任务管理是非常复杂的。上一节,我们只是讲了一部分,今天我们接着来解析剩下的部分。
|
||||
|
||||
## 运行统计信息
|
||||
|
||||
作为项目经理,你肯定需要了解项目的运行情况。例如,有的员工很长时间都在做一个任务,这个时候你就需要特别关注一下;再如,有的员工的琐碎任务太多,这会大大影响他的工作效率。
|
||||
|
||||
那如何才能知道这些员工的工作情况呢?在进程的运行过程中,会有一些统计量,具体你可以看下面的列表。这里面有进程在用户态和内核态消耗的时间、上下文切换的次数等等。
|
||||
|
||||
```
|
||||
u64 utime;//用户态消耗的CPU时间
|
||||
u64 stime;//内核态消耗的CPU时间
|
||||
unsigned long nvcsw;//自愿(voluntary)上下文切换计数
|
||||
unsigned long nivcsw;//非自愿(involuntary)上下文切换计数
|
||||
u64 start_time;//进程启动时间,不包含睡眠时间
|
||||
u64 real_start_time;//进程启动时间,包含睡眠时间
|
||||
|
||||
```
|
||||
|
||||
## 进程亲缘关系
|
||||
|
||||
从我们之前讲的创建进程的过程,可以看出,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
|
||||
|
||||
```
|
||||
struct task_struct __rcu *real_parent; /* real parent process */
|
||||
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
|
||||
struct list_head children; /* list of my children */
|
||||
struct list_head sibling; /* linkage in my parent's children list */
|
||||
|
||||
```
|
||||
|
||||
<li>
|
||||
parent指向其父进程。当它终止时,必须向它的父进程发送信号。
|
||||
</li>
|
||||
<li>
|
||||
children表示链表的头部。链表中的所有元素都是它的子进程。
|
||||
</li>
|
||||
<li>
|
||||
sibling用于把当前进程插入到兄弟链表中。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/04/92711107d8dcdf2c19e8fe4ee3965304.jpeg" alt="">
|
||||
|
||||
通常情况下,real_parent和parent是一样的,但是也会有另外的情况存在。例如,bash创建一个进程,那进程的parent和real_parent就都是bash。如果在bash上使用GDB来debug一个进程,这个时候GDB是parent,bash是这个进程的real_parent。
|
||||
|
||||
## 进程权限
|
||||
|
||||
了解了运行统计信息,接下来,我们需要关注一下项目组权限的控制。什么是项目组权限控制呢?这么说吧,我这个项目组能否访问某个文件,能否访问其他的项目组,以及我这个项目组能否被其他项目组访问等等,这都是项目组权限的控制范畴。
|
||||
|
||||
在Linux里面,对于进程权限的定义如下:
|
||||
|
||||
```
|
||||
/* Objective and real subjective task credentials (COW): */
|
||||
const struct cred __rcu *real_cred;
|
||||
/* Effective (overridable) subjective task credentials (COW): */
|
||||
const struct cred __rcu *cred;
|
||||
|
||||
```
|
||||
|
||||
这个结构的注释里,有两个名词比较拗口,Objective和Subjective。事实上,所谓的权限,就是我能操纵谁,谁能操纵我。
|
||||
|
||||
“谁能操作我”,很显然,这个时候我就是被操作的对象,就是Objective,那个想操作我的就是Subjective。“我能操作谁”,这个时候我就是Subjective,那个要被我操作的就是Objectvie。
|
||||
|
||||
“操作”,就是一个对象对另一个对象进行某些动作。当动作要实施的时候,就要审核权限,当两边的权限匹配上了,就可以实施操作。其中,real_cred就是说明谁能操作我这个进程,而cred就是说明我这个进程能够操作谁。
|
||||
|
||||
这里cred的定义如下:
|
||||
|
||||
```
|
||||
struct cred {
|
||||
......
|
||||
kuid_t uid; /* real UID of the task */
|
||||
kgid_t gid; /* real GID of the task */
|
||||
kuid_t suid; /* saved UID of the task */
|
||||
kgid_t sgid; /* saved GID of the task */
|
||||
kuid_t euid; /* effective UID of the task */
|
||||
kgid_t egid; /* effective GID of the task */
|
||||
kuid_t fsuid; /* UID for VFS ops */
|
||||
kgid_t fsgid; /* GID for VFS ops */
|
||||
......
|
||||
kernel_cap_t cap_inheritable; /* caps our children can inherit */
|
||||
kernel_cap_t cap_permitted; /* caps we're permitted */
|
||||
kernel_cap_t cap_effective; /* caps we can actually use */
|
||||
kernel_cap_t cap_bset; /* capability bounding set */
|
||||
kernel_cap_t cap_ambient; /* Ambient capability set */
|
||||
......
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
从这里的定义可以看出,大部分是关于**用户和用户所属的用户组信息**。
|
||||
|
||||
第一个是uid和gid,注释是real user/group id。一般情况下,谁启动的进程,就是谁的ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
|
||||
|
||||
第二个是euid和egid,注释是effective user/group id。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
|
||||
|
||||
第三个是fsuid和fsgid,也就是filesystem user/group id。这个是对文件操作会审核的权限。
|
||||
|
||||
一般说来,fsuid、euid,和uid是一样的,fsgid、egid,和gid也是一样的。因为谁启动的进程,就应该审核启动的用户到底有没有这个权限。
|
||||
|
||||
但是也有特殊的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/f7/c4688c36afd90f933727483c56500ff7.jpeg" alt="">
|
||||
|
||||
例如,用户A想玩一个游戏,这个游戏的程序是用户B安装的。游戏这个程序文件的权限为rwxr–r--。A是没有权限运行这个程序的,所以用户B要给用户A权限才行。用户B说没问题,都是朋友嘛,于是用户B就给这个程序设定了所有的用户都能执行的权限rwxr-xr-x,说兄弟你玩吧。
|
||||
|
||||
于是,用户A就获得了运行这个游戏的权限。当游戏运行起来之后,游戏进程的uid、euid、fsuid都是用户A。看起来没有问题,玩得很开心。
|
||||
|
||||
用户A好不容易通过一关,想保留通关数据的时候,发现坏了,这个游戏的玩家数据是保存在另一个文件里面的。这个文件权限rw-------,只给用户B开了写入权限,而游戏进程的euid和fsuid都是用户A,当然写不进去了。完了,这一局白玩儿了。
|
||||
|
||||
那怎么解决这个问题呢?我们可以通过chmod u+s program命令,给这个游戏程序设置set-user-ID的标识位,把游戏的权限变成rwsr-xr-x。这个时候,用户A再启动这个游戏的时候,创建的进程uid当然还是用户A,但是euid和fsuid就不是用户A了,因为看到了set-user-id标识,就改为文件的所有者的ID,也就是说,euid和fsuid都改成用户B了,这样就能够将通关结果保存下来。
|
||||
|
||||
在Linux里面,一个进程可以随时通过setuid设置用户ID,所以,游戏程序的用户B的ID还会保存在一个地方,这就是suid和sgid,也就是saved uid和save gid。这样就可以很方便地使用setuid,通过设置uid或者suid来改变权限。
|
||||
|
||||
除了以用户和用户组控制权限,Linux还有另一个机制就是**capabilities**。
|
||||
|
||||
原来控制进程的权限,要么是高权限的root用户,要么是一般权限的普通用户,这时候的问题是,root用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个root的权限。这个太不安全了。
|
||||
|
||||
于是,我们引入新的机制capabilities,用位图表示权限,在capability.h可以找到定义的权限。我这里列举几个。
|
||||
|
||||
```
|
||||
#define CAP_CHOWN 0
|
||||
#define CAP_KILL 5
|
||||
#define CAP_NET_BIND_SERVICE 10
|
||||
#define CAP_NET_RAW 13
|
||||
#define CAP_SYS_MODULE 16
|
||||
#define CAP_SYS_RAWIO 17
|
||||
#define CAP_SYS_BOOT 22
|
||||
#define CAP_SYS_TIME 25
|
||||
#define CAP_AUDIT_READ 37
|
||||
#define CAP_LAST_CAP CAP_AUDIT_READ
|
||||
|
||||
```
|
||||
|
||||
对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;没有的时候,就不能做,这样粒度要小很多。
|
||||
|
||||
cap_permitted表示进程能够使用的权限。但是真正起作用的是cap_effective。cap_permitted中可以包含cap_effective中没有的权限。一个进程可以在必要的时候,放弃自己的某些权限,这样更加安全。假设自己因为代码漏洞被攻破了,但是如果啥也干不了,就没办法进一步突破。
|
||||
|
||||
cap_inheritable表示当可执行文件的扩展属性设置了inheritable位时,调用exec执行该程序会继承调用者的inheritable集合,并将其加入到permitted集合。但在非root用户下执行exec时,通常不会保留inheritable集合,但是往往又是非root用户,才想保留权限,所以非常鸡肋。
|
||||
|
||||
cap_bset,也就是capability bounding set,是系统中所有进程允许保留的权限。如果这个集合中不存在某个权限,那么系统中的所有进程都没有这个权限。即使以超级用户权限执行的进程,也是一样的。
|
||||
|
||||
这样有很多好处。例如,系统启动以后,将加载内核模块的权限去掉,那所有进程都不能加载内核模块。这样,即便这台机器被攻破,也做不了太多有害的事情。
|
||||
|
||||
cap_ambient是比较新加入内核的,就是为了解决cap_inheritable鸡肋的状况,也就是,非root用户进程使用exec执行一个程序的时候,如何保留权限的问题。当执行exec的时候,cap_ambient会被添加到cap_permitted中,同时设置到cap_effective中。
|
||||
|
||||
## 内存管理
|
||||
|
||||
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。这个我们在内存管理那一节详细讲述。这里你先有个印象。
|
||||
|
||||
```
|
||||
struct mm_struct *mm;
|
||||
struct mm_struct *active_mm;
|
||||
|
||||
```
|
||||
|
||||
## 文件与文件系统
|
||||
|
||||
每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。这个我们放到文件系统那一节详细讲述。
|
||||
|
||||
```
|
||||
/* Filesystem information: */
|
||||
struct fs_struct *fs;
|
||||
/* Open file information: */
|
||||
struct files_struct *files;
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们终于把进程管理复杂的数据结构基本讲完了,请你重点记住以下两点:
|
||||
|
||||
<li>
|
||||
进程亲缘关系维护的数据结构,是一种很有参考价值的实现方式,在内核中会多个地方出现类似的结构;
|
||||
</li>
|
||||
<li>
|
||||
进程权限中setuid的原理,这一点比较难理解,但是很重要,面试经常会考。
|
||||
</li>
|
||||
|
||||
你可以对着下面这张图,看看自己是否真的理解了,进程树是如何组织的,以及如何控制进程的权限的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/bc/1c91956b52574b62a4418a7c6993d8bc.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
通过这一节的学习,你会发现,一个进程的运行竟然要保存这么多信息,这些信息都可以通过命令行取出来,所以今天的练习题就是,对于一个正在运行的进程,通过命令行找到上述进程运行的所有信息。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,346 @@
|
||||
<audio id="audio" title="14 | 进程数据结构(下):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/7b/1af93ca1d507yy4eebf050eb27b7577b.mp3"></audio>
|
||||
|
||||
上两节,我们解读了task_struct的大部分的成员变量。这样一个任务执行的方方面面,都可以很好地管理起来,但是其中有一个问题我们没有谈。在程序执行过程中,一旦调用到系统调用,就需要进入内核继续执行。那如何将用户态的执行和内核态的执行串起来呢?
|
||||
|
||||
这就需要以下两个重要的成员变量:
|
||||
|
||||
```
|
||||
struct thread_info thread_info;
|
||||
void *stack;
|
||||
|
||||
```
|
||||
|
||||
## 用户态函数栈
|
||||
|
||||
在用户态中,程序的执行往往是一个函数调用另一个函数。函数调用都是通过栈来进行的。我们前面大致讲过函数栈的原理,今天我们仔细分析一下。
|
||||
|
||||
函数调用其实也很简单。如果你去看汇编语言的代码,其实就是指令跳转,从代码的一个地方跳到另外一个地方。这里比较棘手的问题是,参数和返回地址应该怎么传递过去呢?
|
||||
|
||||
我们看函数的调用过程,A调用B、调用C、调用D,然后返回C、返回B、返回A,这是一个后进先出的过程。有没有觉得这个过程很熟悉?没错,咱们数据结构里学的栈,也是后进先出的,所以用栈保存这些最合适。
|
||||
|
||||
在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/2e/aec865abccf0308155f4138cc905972e.jpg" alt="">
|
||||
|
||||
我们先来看32位操作系统的情况。在CPU里,**ESP**(Extended Stack Pointer)是栈顶指针寄存器,入栈操作Push和出栈操作Pop指令,会自动调整ESP的值。另外有一个寄存器**EBP**(Extended Base Pointer),是栈基地址指针寄存器,指向当前栈帧的最底部。
|
||||
|
||||
例如,A调用B,A的栈里面包含A函数的局部变量,然后是调用B的时候要传给它的参数,然后返回A的地址,这个地址也应该入栈,这就形成了A的栈帧。接下来就是B的栈帧部分了,先保存的是A栈帧的栈底位置,也就是EBP。因为在B函数里面获取A传进来的参数,就是通过这个指针获取的,接下来保存的是B的局部变量等等。
|
||||
|
||||
当B返回的时候,返回值会保存在EAX寄存器中,从栈中弹出返回地址,将指令跳转回去,参数也从栈中弹出,然后继续执行A。
|
||||
|
||||
对于64位操作系统,模式多少有些不一样。因为64位操作系统的寄存器数目比较多。rax用于保存函数调用的返回结果。栈顶指针寄存器变成了rsp,指向栈顶位置。堆栈的Pop和Push操作会自动调整rsp,栈基指针寄存器变成了rbp,指向当前栈帧的起始位置。
|
||||
|
||||
改变比较多的是参数传递。rdi、rsi、rdx、rcx、r8、r9这6个寄存器,用于传递存储函数调用时的6个参数。如果超过6的时候,还是需要放到栈里面。
|
||||
|
||||
然而,前6个参数有时候需要进行寻址,但是如果在寄存器里面,是没有地址的,因而还是会放到栈里面,只不过放到栈里面的操作是被调用函数做的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/c0/770b0036a8b2695463cd95869f5adec0.jpg" alt="">
|
||||
|
||||
以上的栈操作,都是在进程的内存空间里面进行的。
|
||||
|
||||
## 内核态函数栈
|
||||
|
||||
接下来,我们通过系统调用,从进程的内存空间到内核中了。内核中也有各种各样的函数调用来调用去的,也需要这样一个机制,这该怎么办呢?
|
||||
|
||||
这时候,上面的成员变量stack,也就是内核栈,就派上了用场。
|
||||
|
||||
Linux给每个task都分配了内核栈。在32位系统上arch/x86/include/asm/page_32_types.h,是这样定义的:一个PAGE_SIZE是4K,左移一位就是乘以2,也就是8K。
|
||||
|
||||
```
|
||||
#define THREAD_SIZE_ORDER 1
|
||||
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
|
||||
|
||||
```
|
||||
|
||||
内核栈在64位系统上arch/x86/include/asm/page_64_types.h,是这样定义的:在PAGE_SIZE的基础上左移两位,也即16K,并且要求起始地址必须是8192的整数倍。
|
||||
|
||||
```
|
||||
#ifdef CONFIG_KASAN
|
||||
#define KASAN_STACK_ORDER 1
|
||||
#else
|
||||
#define KASAN_STACK_ORDER 0
|
||||
#endif
|
||||
|
||||
|
||||
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
|
||||
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
|
||||
|
||||
```
|
||||
|
||||
内核栈是一个非常特殊的结构,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/2d/31d15bcd2a053235b5590977d12ffa2d.jpeg" alt="">
|
||||
|
||||
这段空间的最低位置,是一个thread_info结构。这个结构是对task_struct结构的补充。因为task_struct结构庞大但是通用,不同的体系结构就需要保存不同的东西,所以往往与体系结构有关的,都放在thread_info里面。
|
||||
|
||||
在内核代码里面有这样一个union,将thread_info和stack放在一起,在include/linux/sched.h文件中就有。
|
||||
|
||||
```
|
||||
union thread_union {
|
||||
#ifndef CONFIG_THREAD_INFO_IN_TASK
|
||||
struct thread_info thread_info;
|
||||
#endif
|
||||
unsigned long stack[THREAD_SIZE/sizeof(long)];
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这个union就是这样定义的,开头是thread_info,后面是stack。
|
||||
|
||||
在内核栈的最高地址端,存放的是另一个结构pt_regs,定义如下。其中,32位和64位的定义不一样。
|
||||
|
||||
```
|
||||
#ifdef __i386__
|
||||
struct pt_regs {
|
||||
unsigned long bx;
|
||||
unsigned long cx;
|
||||
unsigned long dx;
|
||||
unsigned long si;
|
||||
unsigned long di;
|
||||
unsigned long bp;
|
||||
unsigned long ax;
|
||||
unsigned long ds;
|
||||
unsigned long es;
|
||||
unsigned long fs;
|
||||
unsigned long gs;
|
||||
unsigned long orig_ax;
|
||||
unsigned long ip;
|
||||
unsigned long cs;
|
||||
unsigned long flags;
|
||||
unsigned long sp;
|
||||
unsigned long ss;
|
||||
};
|
||||
#else
|
||||
struct pt_regs {
|
||||
unsigned long r15;
|
||||
unsigned long r14;
|
||||
unsigned long r13;
|
||||
unsigned long r12;
|
||||
unsigned long bp;
|
||||
unsigned long bx;
|
||||
unsigned long r11;
|
||||
unsigned long r10;
|
||||
unsigned long r9;
|
||||
unsigned long r8;
|
||||
unsigned long ax;
|
||||
unsigned long cx;
|
||||
unsigned long dx;
|
||||
unsigned long si;
|
||||
unsigned long di;
|
||||
unsigned long orig_ax;
|
||||
unsigned long ip;
|
||||
unsigned long cs;
|
||||
unsigned long flags;
|
||||
unsigned long sp;
|
||||
unsigned long ss;
|
||||
/* top of stack page */
|
||||
};
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
看到这个是不是很熟悉?咱们在讲系统调用的时候,已经多次见过这个结构。当系统调用从用户态到内核态的时候,首先要做的第一件事情,就是将用户态运行过程中的CPU上下文保存起来,其实主要就是保存在这个结构的寄存器变量里。这样当从内核系统调用返回的时候,才能让进程在刚才的地方接着运行下去。
|
||||
|
||||
如果我们对比系统调用那一节的内容,你会发现系统调用的时候,压栈的值的顺序和struct pt_regs中寄存器定义的顺序是一样的。
|
||||
|
||||
在内核中,CPU的寄存器ESP或者RSP,已经指向内核栈的栈顶,在内核态里的调用都有和用户态相似的过程。
|
||||
|
||||
## 通过task_struct找内核栈
|
||||
|
||||
如果有一个task_struct的stack指针在手,你可以通过下面的函数找到这个线程内核栈:
|
||||
|
||||
```
|
||||
static inline void *task_stack_page(const struct task_struct *task)
|
||||
{
|
||||
return task->stack;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从task_struct如何得到相应的pt_regs呢?我们可以通过下面的函数:
|
||||
|
||||
```
|
||||
/*
|
||||
* TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
|
||||
* This is necessary to guarantee that the entire "struct pt_regs"
|
||||
* is accessible even if the CPU haven't stored the SS/ESP registers
|
||||
* on the stack (interrupt gate does not save these registers
|
||||
* when switching to the same priv ring).
|
||||
* Therefore beware: accessing the ss/esp fields of the
|
||||
* "struct pt_regs" is possible, but they may contain the
|
||||
* completely wrong values.
|
||||
*/
|
||||
#define task_pt_regs(task) \
|
||||
({ \
|
||||
unsigned long __ptr = (unsigned long)task_stack_page(task); \
|
||||
__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \
|
||||
((struct pt_regs *)__ptr) - 1; \
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
你会发现,这是先从task_struct找到内核栈的开始位置。然后这个位置加上THREAD_SIZE就到了最后的位置,然后转换为struct pt_regs,再减一,就相当于减少了一个pt_regs的位置,就到了这个结构的首地址。
|
||||
|
||||
这里面有一个TOP_OF_KERNEL_STACK_PADDING,这个的定义如下:
|
||||
|
||||
```
|
||||
#ifdef CONFIG_X86_32
|
||||
# ifdef CONFIG_VM86
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 16
|
||||
# else
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 8
|
||||
# endif
|
||||
#else
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 0
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
也就是说,32位机器上是8,其他是0。这是为什么呢?因为压栈pt_regs有两种情况。我们知道,CPU用ring来区分权限,从而Linux可以区分内核态和用户态。
|
||||
|
||||
因此,第一种情况,我们拿涉及从用户态到内核态的变化的系统调用来说。因为涉及权限的改变,会压栈保存SS、ESP寄存器的,这两个寄存器共占用8个byte。
|
||||
|
||||
另一种情况是,不涉及权限的变化,就不会压栈这8个byte。这样就会使得两种情况不兼容。如果没有压栈还访问,就会报错,所以还不如预留在这里,保证安全。在64位上,修改了这个问题,变成了定长的。
|
||||
|
||||
好了,现在如果你task_struct在手,就能够轻松得到内核栈和内核寄存器。
|
||||
|
||||
## 通过内核栈找task_struct
|
||||
|
||||
那如果一个当前在某个CPU上执行的进程,想知道自己的task_struct在哪里,又该怎么办呢?
|
||||
|
||||
这个艰巨的任务要交给thread_info这个结构。
|
||||
|
||||
```
|
||||
struct thread_info {
|
||||
struct task_struct *task; /* main task structure */
|
||||
__u32 flags; /* low level flags */
|
||||
__u32 status; /* thread synchronous flags */
|
||||
__u32 cpu; /* current CPU */
|
||||
mm_segment_t addr_limit;
|
||||
unsigned int sig_on_uaccess_error:1;
|
||||
unsigned int uaccess_err:1; /* uaccess failed */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这里面有个成员变量task指向task_struct,所以我们常用current_thread_info()->task来获取task_struct。
|
||||
|
||||
```
|
||||
static inline struct thread_info *current_thread_info(void)
|
||||
{
|
||||
return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而thread_info的位置就是内核栈的最高位置,减去THREAD_SIZE,就到了thread_info的起始地址。
|
||||
|
||||
但是现在变成这样了,只剩下一个flags。
|
||||
|
||||
```
|
||||
struct thread_info {
|
||||
unsigned long flags; /* low level flags */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
那这时候怎么获取当前运行中的task_struct呢?current_thread_info有了新的实现方式。
|
||||
|
||||
在include/linux/thread_info.h中定义了current_thread_info。
|
||||
|
||||
```
|
||||
#include <asm/current.h>
|
||||
#define current_thread_info() ((struct thread_info *)current)
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
那current又是什么呢?在arch/x86/include/asm/current.h中定义了。
|
||||
|
||||
```
|
||||
struct task_struct;
|
||||
|
||||
|
||||
DECLARE_PER_CPU(struct task_struct *, current_task);
|
||||
|
||||
|
||||
static __always_inline struct task_struct *get_current(void)
|
||||
{
|
||||
return this_cpu_read_stable(current_task);
|
||||
}
|
||||
|
||||
|
||||
#define current get_current
|
||||
|
||||
```
|
||||
|
||||
到这里,你会发现,新的机制里面,每个CPU运行的task_struct不通过thread_info获取了,而是直接放在Per CPU 变量里面了。
|
||||
|
||||
多核情况下,CPU是同时运行的,但是它们共同使用其他的硬件资源的时候,我们需要解决多个CPU之间的同步问题。
|
||||
|
||||
Per CPU变量是内核中一种重要的同步机制。顾名思义,Per CPU变量就是为每个CPU构造一个变量的副本,这样多个CPU各自操作自己的副本,互不干涉。比如,当前进程的变量current_task就被声明为Per CPU变量。
|
||||
|
||||
要使用Per CPU变量,首先要声明这个变量,在arch/x86/include/asm/current.h中有:
|
||||
|
||||
```
|
||||
DECLARE_PER_CPU(struct task_struct *, current_task);
|
||||
|
||||
```
|
||||
|
||||
然后是定义这个变量,在arch/x86/kernel/cpu/common.c中有:
|
||||
|
||||
```
|
||||
DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;
|
||||
|
||||
```
|
||||
|
||||
也就是说,系统刚刚初始化的时候,current_task都指向init_task。
|
||||
|
||||
当某个CPU上的进程进行切换的时候,current_task被修改为将要切换到的目标进程。例如,进程切换函数__switch_to就会改变current_task。
|
||||
|
||||
```
|
||||
__visible __notrace_funcgraph struct task_struct *
|
||||
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
|
||||
{
|
||||
......
|
||||
this_cpu_write(current_task, next_p);
|
||||
......
|
||||
return prev_p;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当要获取当前的运行中的task_struct的时候,就需要调用this_cpu_read_stable进行读取。
|
||||
|
||||
```
|
||||
#define this_cpu_read_stable(var) percpu_stable_op("mov", var)
|
||||
|
||||
```
|
||||
|
||||
好了,现在如果你是一个进程,正在某个CPU上运行,就能够轻松得到task_struct了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节虽然只介绍了内核栈,但是内容更加重要。如果说task_struct的其他成员变量都是和进程管理有关的,内核栈是和进程运行有关系的。
|
||||
|
||||
我这里画了一张图总结一下32位和64位的工作模式,左边是32位的,右边是64位的。
|
||||
|
||||
<li>
|
||||
在用户态,应用程序进行了至少一次函数调用。32位和64的传递参数的方式稍有不同,32位的就是用函数栈,64位的前6个参数用寄存器,其他的用函数栈。
|
||||
</li>
|
||||
<li>
|
||||
在内核态,32位和64位都使用内核栈,格式也稍有不同,主要集中在pt_regs结构上。
|
||||
</li>
|
||||
<li>
|
||||
在内核态,32位和64位的内核栈和task_struct的关联关系不同。32位主要靠thread_info,64位主要靠Per-CPU变量。
|
||||
</li>
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/5c/82ba663aad4f6bd946d48424196e515c.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节讲函数调用的时候,我们讲了函数栈的工作模式。请你写一个程序,然后编译为汇编语言,打开看一下,函数栈是如何起作用的。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,441 @@
|
||||
<audio id="audio" title="15 | 调度(上):如何制定项目管理流程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/f8/14216d5747af1f835f328943490637f8.mp3"></audio>
|
||||
|
||||
前几节,我们介绍了task_struct数据结构。它就像项目管理系统一样,可以帮项目经理维护项目运行过程中的各类信息,但这并不意味着项目管理工作就完事大吉了。task_struct仅仅能够解决“**看到**”的问题,咱们还要解决如何制定流程,进行项目调度的问题,也就是“**做到**”的问题。
|
||||
|
||||
公司的人员总是有限的。无论接了多少项目,公司不可能短时间增加很多人手。有的项目比较紧急,应该先进行排期;有的项目可以缓缓,但是也不能让客户等太久。所以这个过程非常复杂,需要平衡。
|
||||
|
||||
对于操作系统来讲,它面对的CPU的数量是有限的,干活儿都是它们,但是进程数目远远超过CPU的数目,因而就需要进行进程的调度,有效地分配CPU的时间,既要保证进程的最快响应,也要保证进程之间的公平。这也是一个非常复杂的、需要平衡的事情。
|
||||
|
||||
## 调度策略与调度类
|
||||
|
||||
在Linux里面,进程大概可以分成两种。
|
||||
|
||||
一种称为**实时进程**,也就是需要尽快执行返回结果的那种。这就好比我们是一家公司,接到的客户项目需求就会有很多种。有些客户的项目需求比较急,比如一定要在一两个月内完成的这种,客户会加急加钱,那这种客户的优先级就会比较高。
|
||||
|
||||
另一种是**普通进程**,大部分的进程其实都是这种。这就好比,大部分客户的项目都是普通的需求,可以按照正常流程完成,优先级就没实时进程这么高,但是人家肯定也有确定的交付日期。
|
||||
|
||||
那很显然,对于这两种进程,我们的调度策略肯定是不同的。
|
||||
|
||||
在task_struct中,有一个成员变量,我们叫**调度策略**。
|
||||
|
||||
```
|
||||
unsigned int policy;
|
||||
|
||||
```
|
||||
|
||||
它有以下几个定义:
|
||||
|
||||
```
|
||||
#define SCHED_NORMAL 0
|
||||
#define SCHED_FIFO 1
|
||||
#define SCHED_RR 2
|
||||
#define SCHED_BATCH 3
|
||||
#define SCHED_IDLE 5
|
||||
#define SCHED_DEADLINE 6
|
||||
|
||||
```
|
||||
|
||||
配合调度策略的,还有我们刚才说的**优先级**,也在task_struct中。
|
||||
|
||||
```
|
||||
int prio, static_prio, normal_prio;
|
||||
unsigned int rt_priority;
|
||||
|
||||
```
|
||||
|
||||
优先级其实就是一个数值,对于实时进程,优先级的范围是0~99;对于普通进程,优先级的范围是100~139。数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。毕竟,谁让人家加钱了呢。
|
||||
|
||||
### 实时调度策略
|
||||
|
||||
对于调度策略,其中SCHED_FIFO、SCHED_RR、SCHED_DEADLINE是实时进程的调度策略。
|
||||
|
||||
虽然大家都是加钱加急的项目,但是也不能乱来,还是需要有个办事流程才行。
|
||||
|
||||
例如,**SCHED_FIFO**就是交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
|
||||
|
||||
另外一种策略是,交了相同钱的,轮换着来,这就是**SCHED_RR轮流调度算法**,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
|
||||
|
||||
还有一种新的策略是**SCHED_DEADLINE**,是按照任务的deadline进行调度的。当产生一个调度点的时候,DL调度器总是选择其deadline距离当前时间点最近的那个任务,并调度它执行。
|
||||
|
||||
### 普通调度策略
|
||||
|
||||
对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。
|
||||
|
||||
既然大家的项目都没有那么紧急,就应该按照普通的项目流程,公平地分配人员。
|
||||
|
||||
SCHED_NORMAL是普通的进程,就相当于咱们公司接的普通项目。
|
||||
|
||||
SCHED_BATCH是后台进程,几乎不需要和前端进行交互。这有点像公司在接项目同时,开发一些可以复用的模块,作为公司的技术积累,从而使得在之后接新项目的时候,能够减少工作量。这类项目可以默默执行,不要影响需要交互的进程,可以降低它的优先级。
|
||||
|
||||
SCHED_IDLE是特别空闲的时候才跑的进程,相当于咱们学习训练类的项目,比如咱们公司很长时间没有接到外在项目了,可以弄几个这样的项目练练手。
|
||||
|
||||
上面无论是policy还是priority,都设置了一个变量,变量仅仅表示了应该这样这样干,但事情总要有人去干,谁呢?在task_struct里面,还有这样的成员变量:
|
||||
|
||||
```
|
||||
const struct sched_class *sched_class;
|
||||
|
||||
```
|
||||
|
||||
调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。
|
||||
|
||||
sched_class有几种实现:
|
||||
|
||||
<li>
|
||||
stop_sched_class优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
|
||||
</li>
|
||||
<li>
|
||||
dl_sched_class就对应上面的deadline调度策略;
|
||||
</li>
|
||||
<li>
|
||||
rt_sched_class就对应RR算法或者FIFO算法的调度策略,具体调度策略由进程的task_struct->policy指定;
|
||||
</li>
|
||||
<li>
|
||||
fair_sched_class就是普通进程的调度策略;
|
||||
</li>
|
||||
<li>
|
||||
idle_sched_class就是空闲进程的调度策略。
|
||||
</li>
|
||||
|
||||
这里实时进程的调度策略RR和FIFO相对简单一些,而且由于咱们平时常遇到的都是普通进程,在这里,咱们就重点分析普通进程的调度问题。普通进程使用的调度策略是fair_sched_class,顾名思义,对于普通进程来讲,公平是最重要的。
|
||||
|
||||
## 完全公平调度算法
|
||||
|
||||
在Linux里面,实现了一个基于CFS的调度算法。CFS全称Completely Fair Scheduling,叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢?我们来看看。
|
||||
|
||||
首先,你需要记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断。就像咱们的表滴答一下,这个我们叫Tick。CFS会为每一个进程安排一个虚拟运行时间vruntime。如果一个进程在运行,随着时间的增长,也就是一个个tick的到来,进程的vruntime将不断增大。没有得到执行的进程vruntime不变。
|
||||
|
||||
显然,那些vruntime少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。
|
||||
|
||||
这有点像让你把一筐球平均分到N个口袋里面,你看着哪个少,就多放一些;哪个多了,就先不放。这样经过多轮,虽然不能保证球完全一样多,但是也差不多公平。
|
||||
|
||||
你可能会说,不还有优先级呢?如何给优先级高的进程多分时间呢?
|
||||
|
||||
这个简单,就相当于N个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。
|
||||
|
||||
在更新进程运行的统计量的时候,我们其实就可以看出这个逻辑。
|
||||
|
||||
```
|
||||
/*
|
||||
* Update the current task's runtime statistics.
|
||||
*/
|
||||
static void update_curr(struct cfs_rq *cfs_rq)
|
||||
{
|
||||
struct sched_entity *curr = cfs_rq->curr;
|
||||
u64 now = rq_clock_task(rq_of(cfs_rq));
|
||||
u64 delta_exec;
|
||||
......
|
||||
delta_exec = now - curr->exec_start;
|
||||
......
|
||||
curr->exec_start = now;
|
||||
......
|
||||
curr->sum_exec_runtime += delta_exec;
|
||||
......
|
||||
curr->vruntime += calc_delta_fair(delta_exec, curr);
|
||||
update_min_vruntime(cfs_rq);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* delta /= w
|
||||
*/
|
||||
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
|
||||
{
|
||||
if (unlikely(se->load.weight != NICE_0_LOAD))
|
||||
/* delta_exec * weight / lw.weight */
|
||||
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
|
||||
return delta;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里得到当前的时间,以及这次的时间片开始的时间,两者相减就是这次运行的时间delta_exec ,但是得到的这个时间其实是实际运行的时间,需要做一定的转化才作为虚拟运行时间vruntime。转化方法如下:
|
||||
|
||||
这就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的vruntime来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重(权重)200斤的胖子吃两个馒头,和给一个体重100斤的瘦子吃一个馒头,然后说,你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍,但是还是公平的。
|
||||
|
||||
## 调度队列与调度实体
|
||||
|
||||
看来CFS需要一个数据结构来对vruntime进行排序,找出最小的那个。这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速地调整排序,要知道vruntime可是经常在变的,变了再插入这个数据结构,就需要重新排序。
|
||||
|
||||
能够平衡查询和更新速度的是树,在这里使用的是红黑树。
|
||||
|
||||
红黑树的的节点是应该包括vruntime的,称为调度实体。
|
||||
|
||||
在task_struct中有这样的成员变量:
|
||||
|
||||
struct sched_entity se;<br>
|
||||
struct sched_rt_entity rt;<br>
|
||||
struct sched_dl_entity dl;
|
||||
|
||||
这里有实时调度实体sched_rt_entity,Deadline调度实体sched_dl_entity,以及完全公平算法调度实体sched_entity。
|
||||
|
||||
看来不光CFS调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。
|
||||
|
||||
而进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是个普通进程,则通过sched_entity,将自己挂在这棵红黑树上。
|
||||
|
||||
对于普通进程的调度实体定义如下,这里面包含了vruntime和权重load_weight,以及对于运行时间的统计。
|
||||
|
||||
```
|
||||
struct sched_entity {
|
||||
struct load_weight load;
|
||||
struct rb_node run_node;
|
||||
struct list_head group_node;
|
||||
unsigned int on_rq;
|
||||
u64 exec_start;
|
||||
u64 sum_exec_runtime;
|
||||
u64 vruntime;
|
||||
u64 prev_sum_exec_runtime;
|
||||
u64 nr_migrations;
|
||||
struct sched_statistics statistics;
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
下图是一个红黑树的例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c2/93/c2b86e79f19d811ce10774688fc0c093.jpeg" alt="">
|
||||
|
||||
所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime最小的在树的左侧,vruntime最多的在树的右侧。 CFS调度策略会选择红黑树最左边的叶子节点作为下一个将获得CPU的任务。
|
||||
|
||||
这棵红黑树放在哪里呢?就像每个软件工程师写代码的时候,会将任务排成队列,做完一个做下一个。
|
||||
|
||||
CPU也是这样的,每个CPU都有自己的 struct rq 结构,其用于描述在此CPU上所运行的所有进程,其包括一个实时进程队列rt_rq和一个CFS运行队列cfs_rq,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去CFS运行队列找是否有进程需要运行。
|
||||
|
||||
```
|
||||
struct rq {
|
||||
/* runqueue lock: */
|
||||
raw_spinlock_t lock;
|
||||
unsigned int nr_running;
|
||||
unsigned long cpu_load[CPU_LOAD_IDX_MAX];
|
||||
......
|
||||
struct load_weight load;
|
||||
unsigned long nr_load_updates;
|
||||
u64 nr_switches;
|
||||
|
||||
|
||||
struct cfs_rq cfs;
|
||||
struct rt_rq rt;
|
||||
struct dl_rq dl;
|
||||
......
|
||||
struct task_struct *curr, *idle, *stop;
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
对于普通进程公平队列cfs_rq,定义如下:
|
||||
|
||||
```
|
||||
/* CFS-related fields in a runqueue */
|
||||
struct cfs_rq {
|
||||
struct load_weight load;
|
||||
unsigned int nr_running, h_nr_running;
|
||||
|
||||
|
||||
u64 exec_clock;
|
||||
u64 min_vruntime;
|
||||
#ifndef CONFIG_64BIT
|
||||
u64 min_vruntime_copy;
|
||||
#endif
|
||||
struct rb_root tasks_timeline;
|
||||
struct rb_node *rb_leftmost;
|
||||
|
||||
|
||||
struct sched_entity *curr, *next, *last, *skip;
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这里面rb_root指向的就是红黑树的根节点,这个红黑树在CPU看起来就是一个队列,不断地取下一个应该运行的进程。rb_leftmost指向的是最左面的节点。
|
||||
|
||||
到这里终于凑够数据结构了,上面这些数据结构的关系如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/fd/ac043a08627b40b85e624477d937f3fd.jpeg" alt="">
|
||||
|
||||
## 调度类是如何工作的?
|
||||
|
||||
凑够了数据结构,接下来我们来看调度类是如何工作的。
|
||||
|
||||
调度类的定义如下:
|
||||
|
||||
```
|
||||
struct sched_class {
|
||||
const struct sched_class *next;
|
||||
|
||||
|
||||
void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);
|
||||
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);
|
||||
void (*yield_task) (struct rq *rq);
|
||||
bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);
|
||||
|
||||
|
||||
void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);
|
||||
|
||||
|
||||
struct task_struct * (*pick_next_task) (struct rq *rq,
|
||||
struct task_struct *prev,
|
||||
struct rq_flags *rf);
|
||||
void (*put_prev_task) (struct rq *rq, struct task_struct *p);
|
||||
|
||||
|
||||
void (*set_curr_task) (struct rq *rq);
|
||||
void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
|
||||
void (*task_fork) (struct task_struct *p);
|
||||
void (*task_dead) (struct task_struct *p);
|
||||
|
||||
|
||||
void (*switched_from) (struct rq *this_rq, struct task_struct *task);
|
||||
void (*switched_to) (struct rq *this_rq, struct task_struct *task);
|
||||
void (*prio_changed) (struct rq *this_rq, struct task_struct *task, int oldprio);
|
||||
unsigned int (*get_rr_interval) (struct rq *rq,
|
||||
struct task_struct *task);
|
||||
void (*update_curr) (struct rq *rq)
|
||||
|
||||
```
|
||||
|
||||
这个结构定义了很多种方法,用于在队列上操作任务。这里请大家注意第一个成员变量,是一个指针,指向下一个调度类。
|
||||
|
||||
上面我们讲了,调度类分为下面这几种:
|
||||
|
||||
```
|
||||
extern const struct sched_class stop_sched_class;
|
||||
extern const struct sched_class dl_sched_class;
|
||||
extern const struct sched_class rt_sched_class;
|
||||
extern const struct sched_class fair_sched_class;
|
||||
extern const struct sched_class idle_sched_class;
|
||||
|
||||
```
|
||||
|
||||
它们其实是放在一个链表上的。这里我们以调度最常见的操作,**取下一个任务**为例,来解析一下。可以看到,这里面有一个for_each_class循环,沿着上面的顺序,依次调用每个调度类的方法。
|
||||
|
||||
```
|
||||
/*
|
||||
* Pick up the highest-prio task:
|
||||
*/
|
||||
static inline struct task_struct *
|
||||
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
const struct sched_class *class;
|
||||
struct task_struct *p;
|
||||
......
|
||||
for_each_class(class) {
|
||||
p = class->pick_next_task(rq, prev, rf);
|
||||
if (p) {
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就说明,调度的时候是从优先级最高的调度类到优先级低的调度类,依次执行。而对于每种调度类,有自己的实现,例如,CFS就有fair_sched_class。
|
||||
|
||||
```
|
||||
const struct sched_class fair_sched_class = {
|
||||
.next = &idle_sched_class,
|
||||
.enqueue_task = enqueue_task_fair,
|
||||
.dequeue_task = dequeue_task_fair,
|
||||
.yield_task = yield_task_fair,
|
||||
.yield_to_task = yield_to_task_fair,
|
||||
.check_preempt_curr = check_preempt_wakeup,
|
||||
.pick_next_task = pick_next_task_fair,
|
||||
.put_prev_task = put_prev_task_fair,
|
||||
.set_curr_task = set_curr_task_fair,
|
||||
.task_tick = task_tick_fair,
|
||||
.task_fork = task_fork_fair,
|
||||
.prio_changed = prio_changed_fair,
|
||||
.switched_from = switched_from_fair,
|
||||
.switched_to = switched_to_fair,
|
||||
.get_rr_interval = get_rr_interval_fair,
|
||||
.update_curr = update_curr_fair,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
对于同样的pick_next_task选取下一个要运行的任务这个动作,不同的调度类有自己的实现。fair_sched_class的实现是pick_next_task_fair,rt_sched_class的实现是pick_next_task_rt。
|
||||
|
||||
我们会发现这两个函数是操作不同的队列,pick_next_task_rt操作的是rt_rq,pick_next_task_fair操作的是cfs_rq。
|
||||
|
||||
```
|
||||
static struct task_struct *
|
||||
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct task_struct *p;
|
||||
struct rt_rq *rt_rq = &rq->rt;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
static struct task_struct *
|
||||
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct cfs_rq *cfs_rq = &rq->cfs;
|
||||
struct sched_entity *se;
|
||||
struct task_struct *p;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样整个运行的场景就串起来了,在每个CPU上都有一个队列rq,这个队列里面包含多个子队列,例如rt_rq和cfs_rq,不同的队列有不同的实现方式,cfs_rq就是用红黑树实现的。
|
||||
|
||||
当有一天,某个CPU需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然rt_sched_class先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
|
||||
|
||||
下面我们仔细看一下sched_class定义的与调度有关的函数。
|
||||
|
||||
<li>
|
||||
enqueue_task向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
|
||||
</li>
|
||||
<li>
|
||||
dequeue_task 将一个进程从就绪队列中删除;
|
||||
</li>
|
||||
<li>
|
||||
pick_next_task 选择接下来要运行的进程;
|
||||
</li>
|
||||
<li>
|
||||
put_prev_task 用另一个进程代替当前运行的进程;
|
||||
</li>
|
||||
<li>
|
||||
set_curr_task 用于修改调度策略;
|
||||
</li>
|
||||
<li>
|
||||
task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。
|
||||
</li>
|
||||
|
||||
在这里面,我们重点看fair_sched_class对于pick_next_task的实现pick_next_task_fair,获取下一个进程。调用路径如下:pick_next_task_fair->pick_next_entity->__pick_first_entity。
|
||||
|
||||
```
|
||||
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
|
||||
{
|
||||
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
|
||||
|
||||
|
||||
if (!left)
|
||||
return NULL;
|
||||
|
||||
|
||||
return rb_entry(left, struct sched_entity, run_node);
|
||||
|
||||
```
|
||||
|
||||
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,这一节我们讲了调度相关的数据结构,还是比较复杂的。一个CPU上有一个队列,CFS的队列是一棵红黑树,树的每一个节点都是一个sched_entity,每个sched_entity都属于一个task_struct,task_struct里面有指针指向这个进程属于哪个调度类。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/af/10381dbafe0f78d80beb87560a9506af.jpeg" alt="">
|
||||
|
||||
在调度的时候,依次调用调度类的函数,从CPU的队列中取出下一个进程。上面图中的调度器、上下文切换这一节我们没有讲,下一节我们讲讲基于这些数据结构,如何实现调度。
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这里讲了进程调度的策略和算法,你知道如何通过API设置进程和线程的调度策略吗?你可以写个程序尝试一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,432 @@
|
||||
<audio id="audio" title="16 | 调度(中):主动调度是如何发生的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/53/9c66465f00cbc22674ca5ac1220ef853.mp3"></audio>
|
||||
|
||||
上一节,我们为调度准备了这么多的数据结构,这一节我们来看调度是如何发生的。
|
||||
|
||||
所谓进程调度,其实就是一个人在做A项目,在某个时刻,换成做B项目去了。发生这种情况,主要有两种方式。
|
||||
|
||||
**方式一**:A项目做着做着,发现里面有一条指令sleep,也就是要休息一下,或者在等待某个I/O事件。那没办法了,就要主动让出CPU,然后可以开始做B项目。
|
||||
|
||||
**方式二**:A项目做着做着,旷日持久,实在受不了了。项目经理介入了,说这个项目A先停停,B项目也要做一下,要不然B项目该投诉了。
|
||||
|
||||
## 主动调度
|
||||
|
||||
我们这一节先来看方式一,主动调度。
|
||||
|
||||
这里我找了几个代码片段。**第一个片段是Btrfs,等待一个写入**。[B](https://zh.wikipedia.org/wiki/Btrfs)[trfs](https://zh.wikipedia.org/wiki/Btrfs)(B-Tree)是一种文件系统,感兴趣你可以自己去了解一下。
|
||||
|
||||
这个片段可以看作写入块设备的一个典型场景。写入需要一段时间,这段时间用不上CPU,还不如主动让给其他进程。
|
||||
|
||||
```
|
||||
static void btrfs_wait_for_no_snapshoting_writes(struct btrfs_root *root)
|
||||
{
|
||||
......
|
||||
do {
|
||||
prepare_to_wait(&root->subv_writers->wait, &wait,
|
||||
TASK_UNINTERRUPTIBLE);
|
||||
writers = percpu_counter_sum(&root->subv_writers->counter);
|
||||
if (writers)
|
||||
schedule();
|
||||
finish_wait(&root->subv_writers->wait, &wait);
|
||||
} while (writers);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外一个例子是,**从Tap网络设备等待一个读取**。Tap网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把CPU让给其他进程。
|
||||
|
||||
```
|
||||
static ssize_t tap_do_read(struct tap_queue *q,
|
||||
struct iov_iter *to,
|
||||
int noblock, struct sk_buff *skb)
|
||||
{
|
||||
......
|
||||
while (1) {
|
||||
if (!noblock)
|
||||
prepare_to_wait(sk_sleep(&q->sk), &wait,
|
||||
TASK_INTERRUPTIBLE);
|
||||
......
|
||||
/* Nothing to read, let's sleep */
|
||||
schedule();
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是CPU和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出CPU,就像上面两段代码一样,选择调用schedule()函数。
|
||||
|
||||
接下来,我们就来看**schedule函数的调用过程**。
|
||||
|
||||
```
|
||||
asmlinkage __visible void __sched schedule(void)
|
||||
{
|
||||
struct task_struct *tsk = current;
|
||||
|
||||
|
||||
sched_submit_work(tsk);
|
||||
do {
|
||||
preempt_disable();
|
||||
__schedule(false);
|
||||
sched_preempt_enable_no_resched();
|
||||
} while (need_resched());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的主要逻辑是在__schedule函数中实现的。这个函数比较复杂,我们分几个部分来讲解。
|
||||
|
||||
```
|
||||
static void __sched notrace __schedule(bool preempt)
|
||||
{
|
||||
struct task_struct *prev, *next;
|
||||
unsigned long *switch_count;
|
||||
struct rq_flags rf;
|
||||
struct rq *rq;
|
||||
int cpu;
|
||||
|
||||
|
||||
cpu = smp_processor_id();
|
||||
rq = cpu_rq(cpu);
|
||||
prev = rq->curr;
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
首先,在当前的CPU上,我们取出任务队列rq。
|
||||
|
||||
task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr。为啥是prev?因为一旦将来它被切换下来,那它就成了前任了。
|
||||
|
||||
接下来代码如下:
|
||||
|
||||
```
|
||||
next = pick_next_task(rq, prev, &rf);
|
||||
clear_tsk_need_resched(prev);
|
||||
clear_preempt_need_resched();
|
||||
|
||||
```
|
||||
|
||||
第二步,获取下一个任务,task_struct *next指向下一个任务,这就是**继任**。
|
||||
|
||||
pick_next_task的实现如下:
|
||||
|
||||
```
|
||||
static inline struct task_struct *
|
||||
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
const struct sched_class *class;
|
||||
struct task_struct *p;
|
||||
/*
|
||||
* Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
|
||||
*/
|
||||
if (likely((prev->sched_class == &idle_sched_class ||
|
||||
prev->sched_class == &fair_sched_class) &&
|
||||
rq->nr_running == rq->cfs.h_nr_running)) {
|
||||
p = fair_sched_class.pick_next_task(rq, prev, rf);
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
/* Assumes fair_sched_class->next == idle_sched_class */
|
||||
if (unlikely(!p))
|
||||
p = idle_sched_class.pick_next_task(rq, prev, rf);
|
||||
return p;
|
||||
}
|
||||
again:
|
||||
for_each_class(class) {
|
||||
p = class->pick_next_task(rq, prev, rf);
|
||||
if (p) {
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们来看again这里,就是咱们上一节讲的依次调用调度类。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的就是fair_sched_class.pick_next_task。
|
||||
|
||||
根据上一节对于fair_sched_class的定义,它调用的是pick_next_task_fair,代码如下:
|
||||
|
||||
```
|
||||
static struct task_struct *
|
||||
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct cfs_rq *cfs_rq = &rq->cfs;
|
||||
struct sched_entity *se;
|
||||
struct task_struct *p;
|
||||
int new_tasks;
|
||||
|
||||
```
|
||||
|
||||
对于CFS调度类,取出相应的队列cfs_rq,这就是我们上一节讲的那棵红黑树。
|
||||
|
||||
```
|
||||
struct sched_entity *curr = cfs_rq->curr;
|
||||
if (curr) {
|
||||
if (curr->on_rq)
|
||||
update_curr(cfs_rq);
|
||||
else
|
||||
curr = NULL;
|
||||
......
|
||||
}
|
||||
se = pick_next_entity(cfs_rq, curr);
|
||||
|
||||
```
|
||||
|
||||
取出当前正在运行的任务curr,如果依然是可运行的状态,也即处于进程就绪状态,则调用update_curr更新vruntime。update_curr咱们上一节就见过了,它会根据实际运行时间算出vruntime来。
|
||||
|
||||
接着,pick_next_entity从红黑树里面,取最左边的一个节点。这个函数的实现我们上一节也讲过了。
|
||||
|
||||
```
|
||||
p = task_of(se);
|
||||
|
||||
|
||||
if (prev != p) {
|
||||
struct sched_entity *pse = &prev->se;
|
||||
......
|
||||
put_prev_entity(cfs_rq, pse);
|
||||
set_next_entity(cfs_rq, se);
|
||||
}
|
||||
|
||||
|
||||
return p
|
||||
|
||||
```
|
||||
|
||||
task_of得到下一个调度实体对应的task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的vruntime更新过了,put_prev_entity放回红黑树,会找到相应的位置,然后set_next_entity将继任者设为当前任务。
|
||||
|
||||
第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行。
|
||||
|
||||
```
|
||||
if (likely(prev != next)) {
|
||||
rq->nr_switches++;
|
||||
rq->curr = next;
|
||||
++*switch_count;
|
||||
......
|
||||
rq = context_switch(rq, prev, next, &rf);
|
||||
|
||||
```
|
||||
|
||||
## 进程上下文切换
|
||||
|
||||
上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和CPU上下文。
|
||||
|
||||
我们先来看context_switch的实现。
|
||||
|
||||
```
|
||||
/*
|
||||
* context_switch - switch to the new MM and the new thread's register state.
|
||||
*/
|
||||
static __always_inline struct rq *
|
||||
context_switch(struct rq *rq, struct task_struct *prev,
|
||||
struct task_struct *next, struct rq_flags *rf)
|
||||
{
|
||||
struct mm_struct *mm, *oldmm;
|
||||
......
|
||||
mm = next->mm;
|
||||
oldmm = prev->active_mm;
|
||||
......
|
||||
switch_mm_irqs_off(oldmm, mm, next);
|
||||
......
|
||||
/* Here we just switch the register state and the stack. */
|
||||
switch_to(prev, next, prev);
|
||||
barrier();
|
||||
return finish_task_switch(prev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里首先是内存空间的切换,里面涉及内存管理的内容比较多。内存管理后面我们会有专门的章节来讲,这里你先知道有这么一回事就行了。
|
||||
|
||||
接下来,我们看switch_to。它就是寄存器和栈的切换,它调用到了__switch_to_asm。这是一段汇编代码,主要用于栈的切换。
|
||||
|
||||
对于32位操作系统来讲,切换的是栈顶指针esp。
|
||||
|
||||
```
|
||||
/*
|
||||
* %eax: prev task
|
||||
* %edx: next task
|
||||
*/
|
||||
ENTRY(__switch_to_asm)
|
||||
......
|
||||
/* switch stack */
|
||||
movl %esp, TASK_threadsp(%eax)
|
||||
movl TASK_threadsp(%edx), %esp
|
||||
......
|
||||
jmp __switch_to
|
||||
END(__switch_to_asm)
|
||||
|
||||
```
|
||||
|
||||
对于64位操作系统来讲,切换的是栈顶指针rsp。
|
||||
|
||||
```
|
||||
/*
|
||||
* %rdi: prev task
|
||||
* %rsi: next task
|
||||
*/
|
||||
ENTRY(__switch_to_asm)
|
||||
......
|
||||
/* switch stack */
|
||||
movq %rsp, TASK_threadsp(%rdi)
|
||||
movq TASK_threadsp(%rsi), %rsp
|
||||
......
|
||||
jmp __switch_to
|
||||
END(__switch_to_asm)
|
||||
|
||||
```
|
||||
|
||||
最终,都返回了__switch_to这个函数。这个函数对于32位和64位操作系统虽然有不同的实现,但里面做的事情是差不多的。所以我这里仅仅列出64位操作系统做的事情。
|
||||
|
||||
```
|
||||
__visible __notrace_funcgraph struct task_struct *
|
||||
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
|
||||
{
|
||||
struct thread_struct *prev = &prev_p->thread;
|
||||
struct thread_struct *next = &next_p->thread;
|
||||
......
|
||||
int cpu = smp_processor_id();
|
||||
struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
|
||||
......
|
||||
load_TLS(next, cpu);
|
||||
......
|
||||
this_cpu_write(current_task, next_p);
|
||||
|
||||
|
||||
/* Reload esp0 and ss1. This changes current_thread_info(). */
|
||||
load_sp0(tss, next);
|
||||
......
|
||||
return prev_p;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面有一个Per CPU的结构体tss。这是个什么呢?
|
||||
|
||||
在x86体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86希望在内存里面维护一个TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。
|
||||
|
||||
另外,还有一个特殊的寄存器TR(Task Register,任务寄存器),指向某个进程的TSS。更改TR的值,将会触发硬件保存CPU所有寄存器的值到当前进程的TSS中,然后从新进程的TSS中读出所有寄存器值,加载到CPU对应的寄存器中。
|
||||
|
||||
下图就是32位的TSS结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/64/dfa9762cfec16822ec74d53350db4664.png" alt="">
|
||||
|
||||
但是这样有个缺点。我们做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个TSS,就需要全量保存,全量切换,动作太大了。
|
||||
|
||||
于是,Linux操作系统想了一个办法。还记得在系统初始化的时候,会调用cpu_init吗?这里面会给每一个CPU关联一个TSS,然后将TR指向这个TSS,然后在操作系统的运行过程中,TR就不切换了,永远指向这个TSS。TSS用数据结构tss_struct表示,在x86_hw_tss中可以看到和上图相应的结构。
|
||||
|
||||
```
|
||||
void cpu_init(void)
|
||||
{
|
||||
int cpu = smp_processor_id();
|
||||
struct task_struct *curr = current;
|
||||
struct tss_struct *t = &per_cpu(cpu_tss, cpu);
|
||||
......
|
||||
load_sp0(t, thread);
|
||||
set_tss_desc(cpu, t);
|
||||
load_TR_desc();
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
struct tss_struct {
|
||||
/*
|
||||
* The hardware state:
|
||||
*/
|
||||
struct x86_hw_tss x86_tss;
|
||||
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Linux中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。
|
||||
|
||||
于是,在task_struct里面,还有一个我们原来没有注意的成员变量thread。这里面保留了要切换进程的时候需要修改的寄存器。
|
||||
|
||||
```
|
||||
/* CPU-specific state of this task: */
|
||||
struct thread_struct thread;
|
||||
|
||||
```
|
||||
|
||||
所谓的进程切换,就是将某个进程的thread_struct里面的寄存器的值,写入到CPU的TR指向的tss_struct,对于CPU来讲,这就算是完成了切换。
|
||||
|
||||
例如__switch_to中的load_sp0,就是将下一个进程的thread_struct的sp0的值加载到tss_struct里面去。
|
||||
|
||||
## 指令指针的保存与恢复
|
||||
|
||||
你是不是觉得,这样真的就完成切换了吗?是的,不信我们来**盘点**一下。
|
||||
|
||||
从进程A切换到进程B,用户栈要不要切换呢?当然要,其实早就已经切换了,就在切换内存空间的时候。每个进程的用户栈都是独立的,都在内存空间里面。
|
||||
|
||||
那内核栈呢?已经在__switch_to里面切换了,也就是将current_task指向当前的task_struct。里面的void *stack指针,指向的就是当前的内核栈。
|
||||
|
||||
内核栈的栈顶指针呢?在__switch_to_asm里面已经切换了栈顶指针,并且将栈顶指针在__switch_to加载到了TSS里面。
|
||||
|
||||
用户栈的栈顶指针呢?如果当前在内核里面的话,它当然是在内核栈顶部的pt_regs结构里面呀。当从内核返回用户态运行的时候,pt_regs里面有所有当时在用户态的时候运行的上下文信息,就可以开始运行了。
|
||||
|
||||
唯一让人不容易理解的是指令指针寄存器,它应该指向下一条指令的,那它是如何切换的呢?这里有点绕,请你仔细看。
|
||||
|
||||
这里我先明确一点,进程的调度都最终会调用到__schedule函数。为了方便你记住,我姑且给它起个名字,就叫“**进程调度第一定律**”。后面我们会多次用到这个定律,你一定要记住。
|
||||
|
||||
我们用最前面的例子仔细分析这个过程。本来一个进程A在用户态是要写一个文件的,写文件的操作用户态没办法完成,就要通过系统调用到达内核态。在这个切换的过程中,用户态的指令指针寄存器是保存在pt_regs里面的,到了内核态,就开始沿着写文件的逻辑一步一步执行,结果发现需要等待,于是就调用__schedule函数。
|
||||
|
||||
这个时候,进程A在内核态的指令指针是指向__schedule了。这里请记住,A进程的内核栈会保存这个__schedule的调用,而且知道这是从btrfs_wait_for_no_snapshoting_writes这个函数里面进去的。
|
||||
|
||||
__schedule里面经过上面的层层调用,到达了context_switch的最后三行指令(其中barrier语句是一个编译器指令,用于保证switch_to和finish_task_switch的执行顺序,不会因为编译阶段优化而改变,这里咱们可以忽略它)。
|
||||
|
||||
```
|
||||
switch_to(prev, next, prev);
|
||||
barrier();
|
||||
return finish_task_switch(prev);
|
||||
|
||||
```
|
||||
|
||||
当进程A在内核里面执行switch_to的时候,内核态的指令指针也是指向这一行的。但是在switch_to里面,将寄存器和栈都切换到成了进程B的,唯一没有变的就是指令指针寄存器。当switch_to返回的时候,指令指针寄存器指向了下一条语句finish_task_switch。
|
||||
|
||||
但这个时候的finish_task_switch已经不是进程A的finish_task_switch了,而是进程B的finish_task_switch了。
|
||||
|
||||
这样合理吗?你怎么知道进程B当时被切换下去的时候,执行到哪里了?恢复B进程执行的时候一定在这里呢?这时候就要用到咱的“进程调度第一定律”了。
|
||||
|
||||
当年B进程被别人切换走的时候,也是调用__schedule,也是调用到switch_to,被切换成为C进程的,所以,B进程当年的下一个指令也是finish_task_switch,这就说明指令指针指到这里是没有错的。
|
||||
|
||||
接下来,我们要从finish_task_switch完毕后,返回__schedule的调用了。返回到哪里呢?按照函数返回的原理,当然是从内核栈里面去找,是返回到btrfs_wait_for_no_snapshoting_writes吗?当然不是了,因为btrfs_wait_for_no_snapshoting_writes是在A进程的内核栈里面的,它早就被切换走了,应该从B进程的内核栈里面找。
|
||||
|
||||
假设,B就是最前面例子里面调用tap_do_read读网卡的进程。它当年调用__schedule的时候,是从tap_do_read这个函数调用进去的。
|
||||
|
||||
当然,B进程的内核栈里面放的是tap_do_read。于是,从__schedule返回之后,当然是接着tap_do_read运行,然后在内核运行完毕后,返回用户态。这个时候,B进程内核栈的pt_regs也保存了用户态的指令指针寄存器,就接着在用户态的下一条指令开始运行就可以了。
|
||||
|
||||
假设,我们只有一个CPU,从B切换到C,从C又切换到A。在C切换到A的时候,还是按照“进程调度第一定律”,C进程还是会调用__schedule到达switch_to,在里面切换成为A的内核栈,然后运行finish_task_switch。
|
||||
|
||||
这个时候运行的finish_task_switch,才是A进程的finish_task_switch。运行完毕从__schedule返回的时候,从内核栈上才知道,当年是从btrfs_wait_for_no_snapshoting_writes调用进去的,因而应该返回btrfs_wait_for_no_snapshoting_writes继续执行,最后内核执行完毕返回用户态,同样恢复pt_regs,恢复用户态的指令指针寄存器,从用户态接着运行。
|
||||
|
||||
到这里你是不是有点理解为什么switch_to有三个参数呢?为啥有两个prev呢?其实我们从定义就可以看到。
|
||||
|
||||
```
|
||||
#define switch_to(prev, next, last) \
|
||||
do { \
|
||||
prepare_switch_to(prev, next); \
|
||||
\
|
||||
((last) = __switch_to_asm((prev), (next))); \
|
||||
} while (0)
|
||||
|
||||
```
|
||||
|
||||
在上面的例子中,A切换到B的时候,运行到__switch_to_asm这一行的时候,是在A的内核栈上运行的,prev是A,next是B。但是,A执行完__switch_to_asm之后就被切换走了,当C再次切换到A的时候,运行到__switch_to_asm,是从C的内核栈运行的。这个时候,prev是C,next是A,但是__switch_to_asm里面切换成为了A当时的内核栈。
|
||||
|
||||
还记得当年的场景“prev是A,next是B”,__switch_to_asm里面return prev的时候,还没return的时候,prev这个变量里面放的还是C,因而它会把C放到返回结果中。但是,一旦return,就会弹出A当时的内核栈。这个时候,prev变量就变成了A,next变量就变成了B。这就还原了当年的场景,好在返回值里面的last还是C。
|
||||
|
||||
通过三个变量switch_to(prev = A, next=B, last=C),A进程就明白了,我当时被切换走的时候,是切换成B,这次切换回来,是从C回来的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲主动调度的过程,也即一个运行中的进程主动调用__schedule让出CPU。在__schedule里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/64/9f4433e82c78ed5cd4399b4b116a9064.png" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
你知道应该用什么命令查看进程的运行时间和上下文切换次数吗?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="17 | 调度(下):抢占式调度是如何发生的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/87/e9701600d287e4fb9aea3b6c1d342b87.mp3"></audio>
|
||||
|
||||
上一节,我们讲了主动调度,就是进程运行到一半,因为等待I/O等操作而主动让出CPU,然后就进入了我们的“进程调度第一定律”。所有进程的调用最终都会走__schedule函数。那这个定律在这一节还是要继续起作用。
|
||||
|
||||
## 抢占式调度
|
||||
|
||||
上一节我们讲的主动调度是第一种方式,第二种方式,就是抢占式调度。什么情况下会发生抢占呢?
|
||||
|
||||
最常见的现象就是**一个进程执行时间太长了,是时候切换到另一个进程了**。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,这是个很好的方式,可以查看是否是需要抢占的时间点。
|
||||
|
||||
时钟中断处理函数会调用scheduler_tick(),它的代码如下:
|
||||
|
||||
```
|
||||
void scheduler_tick(void)
|
||||
{
|
||||
int cpu = smp_processor_id();
|
||||
struct rq *rq = cpu_rq(cpu);
|
||||
struct task_struct *curr = rq->curr;
|
||||
......
|
||||
curr->sched_class->task_tick(rq, curr, 0);
|
||||
cpu_load_update_active(rq);
|
||||
calc_global_load_tick(rq);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个函数先取出当前CPU的运行队列,然后得到这个队列上当前正在运行中的进程的task_struct,然后调用这个task_struct的调度类的task_tick函数,顾名思义这个函数就是来处理时钟事件的。
|
||||
|
||||
如果当前运行的进程是普通进程,调度类为fair_sched_class,调用的处理时钟的函数为task_tick_fair。我们来看一下它的实现。
|
||||
|
||||
```
|
||||
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
|
||||
{
|
||||
struct cfs_rq *cfs_rq;
|
||||
struct sched_entity *se = &curr->se;
|
||||
|
||||
|
||||
for_each_sched_entity(se) {
|
||||
cfs_rq = cfs_rq_of(se);
|
||||
entity_tick(cfs_rq, se, queued);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
根据当前进程的task_struct,找到对应的调度实体sched_entity和cfs_rq队列,调用entity_tick。
|
||||
|
||||
```
|
||||
static void
|
||||
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
|
||||
{
|
||||
update_curr(cfs_rq);
|
||||
update_load_avg(curr, UPDATE_TG);
|
||||
update_cfs_shares(curr);
|
||||
.....
|
||||
if (cfs_rq->nr_running > 1)
|
||||
check_preempt_tick(cfs_rq, curr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在entity_tick里面,我们又见到了熟悉的update_curr。它会更新当前进程的vruntime,然后调用check_preempt_tick。顾名思义就是,检查是否是时候被抢占了。
|
||||
|
||||
```
|
||||
static void
|
||||
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
|
||||
{
|
||||
unsigned long ideal_runtime, delta_exec;
|
||||
struct sched_entity *se;
|
||||
s64 delta;
|
||||
|
||||
|
||||
ideal_runtime = sched_slice(cfs_rq, curr);
|
||||
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
|
||||
if (delta_exec > ideal_runtime) {
|
||||
resched_curr(rq_of(cfs_rq));
|
||||
return;
|
||||
}
|
||||
......
|
||||
se = __pick_first_entity(cfs_rq);
|
||||
delta = curr->vruntime - se->vruntime;
|
||||
if (delta < 0)
|
||||
return;
|
||||
if (delta > ideal_runtime)
|
||||
resched_curr(rq_of(cfs_rq));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
check_preempt_tick先是调用sched_slice函数计算出的ideal_runtime。ideal_runtime是一个调度周期中,该进程运行的实际时间。
|
||||
|
||||
sum_exec_runtime指进程总共执行的实际时间,prev_sum_exec_runtime指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的se->prev_sum_exec_runtime = se->sum_exec_runtime,所以sum_exec_runtime-prev_sum_exec_runtime就是这次调度占用实际时间。如果这个时间大于ideal_runtime,则应该被抢占了。
|
||||
|
||||
除了这个条件之外,还会通过__pick_first_entity取出红黑树中最小的进程。如果当前进程的vruntime大于红黑树中最小的进程的vruntime,且差值大于ideal_runtime,也应该被抢占了。
|
||||
|
||||
当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。为什么呢?因为进程调度第一定律呀,一定要等待正在运行的进程调用__schedule才行啊,所以这里只能先标记一下。
|
||||
|
||||
标记一个进程应该被抢占,都是调用resched_curr,它会调用set_tsk_need_resched,标记进程应该被抢占,但是此时此刻,并不真的抢占,而是打上一个标签TIF_NEED_RESCHED。
|
||||
|
||||
```
|
||||
static inline void set_tsk_need_resched(struct task_struct *tsk)
|
||||
{
|
||||
set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外一个可能抢占的场景是**当一个进程被唤醒的时候**。
|
||||
|
||||
我们前面说过,当一个进程在等待一个I/O的时候,会主动放弃CPU。但是当I/O到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于CPU上的当前进程,就会触发抢占。try_to_wake_up()调用ttwu_queue将这个唤醒的任务添加到队列当中。ttwu_queue再调用ttwu_do_activate激活这个任务。ttwu_do_activate调用ttwu_do_wakeup。这里面调用了check_preempt_curr检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走当前进程,而是将当前进程标记为应该被抢占。
|
||||
|
||||
```
|
||||
static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
|
||||
struct rq_flags *rf)
|
||||
{
|
||||
check_preempt_curr(rq, p, wake_flags);
|
||||
p->state = TASK_RUNNING;
|
||||
trace_sched_wakeup(p);
|
||||
|
||||
```
|
||||
|
||||
到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。
|
||||
|
||||
## 抢占的时机
|
||||
|
||||
真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下__schedule。
|
||||
|
||||
你可以想象,不可能某个进程代码运行着,突然要去调用__schedule,代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。
|
||||
|
||||
### 用户态的抢占时机
|
||||
|
||||
对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。
|
||||
|
||||
前面讲系统调用的时候,64位的系统调用的链路位do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop,当时我们还没关注exit_to_usermode_loop这个函数,现在我们来看一下。
|
||||
|
||||
```
|
||||
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
|
||||
{
|
||||
while (true) {
|
||||
/* We have work to do. */
|
||||
local_irq_enable();
|
||||
|
||||
|
||||
if (cached_flags & _TIF_NEED_RESCHED)
|
||||
schedule();
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在我们看到在exit_to_usermode_loop函数中,上面打的标记起了作用,如果被打了_TIF_NEED_RESCHED,调用schedule进行调度,调用的过程和上一节解析的一样,会选择一个进程让出CPU,做上下文切换。
|
||||
|
||||
对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。
|
||||
|
||||
在arch/x86/entry/entry_64.S中有中断的处理过程。又是一段汇编语言代码,你重点领会它的意思就行,不要纠结每一行都看懂。
|
||||
|
||||
```
|
||||
common_interrupt:
|
||||
ASM_CLAC
|
||||
addq $-0x80, (%rsp)
|
||||
interrupt do_IRQ
|
||||
ret_from_intr:
|
||||
popq %rsp
|
||||
testb $3, CS(%rsp)
|
||||
jz retint_kernel
|
||||
/* Interrupt came from user space */
|
||||
GLOBAL(retint_user)
|
||||
mov %rsp,%rdi
|
||||
call prepare_exit_to_usermode
|
||||
TRACE_IRQS_IRETQ
|
||||
SWAPGS
|
||||
jmp restore_regs_and_iret
|
||||
/* Returning to kernel space */
|
||||
retint_kernel:
|
||||
#ifdef CONFIG_PREEMPT
|
||||
bt $9, EFLAGS(%rsp)
|
||||
jnc 1f
|
||||
0: cmpl $0, PER_CPU_VAR(__preempt_count)
|
||||
jnz 1f
|
||||
call preempt_schedule_irq
|
||||
jmp 0b
|
||||
|
||||
```
|
||||
|
||||
中断处理调用的是do_IRQ函数,中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态。这个通过注释也能看出来。
|
||||
|
||||
咱们先来看返回用户态这一部分,先不管返回内核态的那部分代码,retint_user会调用prepare_exit_to_usermode,最终调用exit_to_usermode_loop,和上面的逻辑一样,发现有标记则调用schedule()。
|
||||
|
||||
### 内核态的抢占时机
|
||||
|
||||
用户态的抢占时机讲完了,接下来我们看内核态的抢占时机。
|
||||
|
||||
对内核态的执行中,被抢占的时机一般发生在preempt_enable()中。
|
||||
|
||||
在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用preempt_disable()关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。
|
||||
|
||||
就像下面代码中展示的一样,preempt_enable()会调用preempt_count_dec_and_test(),判断preempt_count和TIF_NEED_RESCHED是否可以被抢占。如果可以,就调用preempt_schedule->preempt_schedule_common->__schedule进行调度。还是满足进程调度第一定律的。
|
||||
|
||||
```
|
||||
#define preempt_enable() \
|
||||
do { \
|
||||
if (unlikely(preempt_count_dec_and_test())) \
|
||||
__preempt_schedule(); \
|
||||
} while (0)
|
||||
|
||||
|
||||
#define preempt_count_dec_and_test() \
|
||||
({ preempt_count_sub(1); should_resched(0); })
|
||||
|
||||
|
||||
static __always_inline bool should_resched(int preempt_offset)
|
||||
{
|
||||
return unlikely(preempt_count() == preempt_offset &&
|
||||
tif_need_resched());
|
||||
}
|
||||
|
||||
|
||||
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)
|
||||
|
||||
|
||||
static void __sched notrace preempt_schedule_common(void)
|
||||
{
|
||||
do {
|
||||
......
|
||||
__schedule(true);
|
||||
......
|
||||
} while (need_resched())
|
||||
|
||||
```
|
||||
|
||||
在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态。这个时候也是一个执行抢占的时机,现在我们再来上面中断返回的代码中返回内核的那部分代码,调用的是preempt_schedule_irq。
|
||||
|
||||
```
|
||||
asmlinkage __visible void __sched preempt_schedule_irq(void)
|
||||
{
|
||||
......
|
||||
do {
|
||||
preempt_disable();
|
||||
local_irq_enable();
|
||||
__schedule(true);
|
||||
local_irq_disable();
|
||||
sched_preempt_enable_no_resched();
|
||||
} while (need_resched());
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
preempt_schedule_irq调用__schedule进行调度。还是满足进程调度第一定律的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,抢占式调度就讲到这里了。我这里画了一张脑图,将整个进程的调度体系都放在里面。
|
||||
|
||||
这个脑图里面第一条就是总结了进程调度第一定律的核心函数__schedule的执行过程,这是上一节讲的,因为要切换的东西比较多,需要你详细了解每一部分是如何切换的。
|
||||
|
||||
第二条总结了标记为可抢占的场景,第三条是所有的抢占发生的时机,这里是真正验证了进程调度第一定律的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/7f/93588d71abd7f007397979f0ba7def7f.png" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
通过对于内核中进程调度的分析,我们知道,时间对于调度是很重要的,你知道Linux内核是如何管理和度量时间的吗?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
320
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/18 | 进程的创建:如何发起一个新项目?.md
Normal file
320
极客时间专栏/geek/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/18 | 进程的创建:如何发起一个新项目?.md
Normal file
@@ -0,0 +1,320 @@
|
||||
<audio id="audio" title="18 | 进程的创建:如何发起一个新项目?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/92/1d98f9d47fc8deb4c2fe30774ddcaf92.mp3"></audio>
|
||||
|
||||
前面我们学习了如何使用fork创建进程,也学习了进程管理和调度的相关数据结构。这一节,我们就来看一看,创建进程这个动作在内核里都做了什么事情。
|
||||
|
||||
fork是一个系统调用,根据咱们讲过的系统调用的流程,流程的最后会在sys_call_table中找到相应的系统调用sys_fork。
|
||||
|
||||
sys_fork是如何定义的呢?根据SYSCALL_DEFINE0这个宏的定义,下面这段代码就定义了sys_fork。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE0(fork)
|
||||
{
|
||||
......
|
||||
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
sys_fork会调用_do_fork。
|
||||
|
||||
```
|
||||
long _do_fork(unsigned long clone_flags,
|
||||
unsigned long stack_start,
|
||||
unsigned long stack_size,
|
||||
int __user *parent_tidptr,
|
||||
int __user *child_tidptr,
|
||||
unsigned long tls)
|
||||
{
|
||||
struct task_struct *p;
|
||||
int trace = 0;
|
||||
long nr;
|
||||
|
||||
|
||||
......
|
||||
p = copy_process(clone_flags, stack_start, stack_size,
|
||||
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
|
||||
......
|
||||
if (!IS_ERR(p)) {
|
||||
struct pid *pid;
|
||||
pid = get_task_pid(p, PIDTYPE_PID);
|
||||
nr = pid_vnr(pid);
|
||||
|
||||
|
||||
if (clone_flags & CLONE_PARENT_SETTID)
|
||||
put_user(nr, parent_tidptr);
|
||||
|
||||
|
||||
......
|
||||
wake_up_new_task(p);
|
||||
......
|
||||
put_pid(pid);
|
||||
}
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
## fork的第一件大事:复制结构
|
||||
|
||||
_do_fork里面做的第一件大事就是copy_process,咱们前面讲过这个思想。如果所有数据结构都从头创建一份太麻烦了,还不如使用惯用“伎俩”,Ctrl C + Ctrl V。
|
||||
|
||||
这里我们再把task_struct的结构图拿出来,对比着看如何一个个复制。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/1d/fda98b6c68605babb2036bf91782311d.png" alt="">
|
||||
|
||||
```
|
||||
static __latent_entropy struct task_struct *copy_process(
|
||||
unsigned long clone_flags,
|
||||
unsigned long stack_start,
|
||||
unsigned long stack_size,
|
||||
int __user *child_tidptr,
|
||||
struct pid *pid,
|
||||
int trace,
|
||||
unsigned long tls,
|
||||
int node)
|
||||
{
|
||||
int retval;
|
||||
struct task_struct *p;
|
||||
......
|
||||
p = dup_task_struct(current, node);
|
||||
|
||||
```
|
||||
|
||||
dup_task_struct主要做了下面几件事情:
|
||||
|
||||
<li>
|
||||
调用alloc_task_struct_node分配一个task_struct结构;
|
||||
</li>
|
||||
<li>
|
||||
调用alloc_thread_stack_node来创建内核栈,这里面调用__vmalloc_node_range分配一个连续的THREAD_SIZE的内存空间,赋值给task_struct的void *stack成员变量;
|
||||
</li>
|
||||
<li>
|
||||
调用arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),将task_struct进行复制,其实就是调用memcpy;
|
||||
</li>
|
||||
<li>
|
||||
调用setup_thread_stack设置thread_info。
|
||||
</li>
|
||||
|
||||
到这里,整个task_struct复制了一份,而且内核栈也创建好了。
|
||||
|
||||
我们再接着看copy_process。
|
||||
|
||||
```
|
||||
retval = copy_creds(p, clone_flags);
|
||||
|
||||
```
|
||||
|
||||
轮到权限相关了,copy_creds主要做了下面几件事情:
|
||||
|
||||
<li>
|
||||
调用prepare_creds,准备一个新的struct cred *new。如何准备呢?其实还是从内存中分配一个新的struct cred结构,然后调用memcpy复制一份父进程的cred;
|
||||
</li>
|
||||
<li>
|
||||
接着p->cred = p->real_cred = get_cred(new),将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的cred。
|
||||
</li>
|
||||
|
||||
接下来,copy_process重新设置进程运行的统计量。
|
||||
|
||||
```
|
||||
p->utime = p->stime = p->gtime = 0;
|
||||
p->start_time = ktime_get_ns();
|
||||
p->real_start_time = ktime_get_boot_ns();
|
||||
|
||||
```
|
||||
|
||||
接下来,copy_process开始设置调度相关的变量。
|
||||
|
||||
```
|
||||
retval = sched_fork(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
sched_fork主要做了下面几件事情:
|
||||
|
||||
<li>
|
||||
调用__sched_fork,在这里面将on_rq设为0,初始化sched_entity,将里面的exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime都设为0。你还记得吗,这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了,就靠它们几个;
|
||||
</li>
|
||||
<li>
|
||||
设置进程的状态p->state = TASK_NEW;
|
||||
</li>
|
||||
<li>
|
||||
初始化优先级prio、normal_prio、static_prio;
|
||||
</li>
|
||||
<li>
|
||||
设置调度类,如果是普通进程,就设置为p->sched_class = &fair_sched_class;
|
||||
</li>
|
||||
<li>
|
||||
调用调度类的task_fork函数,对于CFS来讲,就是调用task_fork_fair。在这个函数里,先调用update_curr,对于当前的进程进行统计量更新,然后把子进程和父进程的vruntime设成一样,最后调用place_entity,初始化sched_entity。这里有一个变量sysctl_sched_child_runs_first,可以设置父进程和子进程谁先运行。如果设置了子进程先运行,即便两个进程的vruntime一样,也要把子进程的sched_entity放在前面,然后调用resched_curr,标记当前运行的进程TIF_NEED_RESCHED,也就是说,把父进程设置为应该被调度,这样下次调度的时候,父进程会被子进程抢占。
|
||||
</li>
|
||||
|
||||
接下来,copy_process开始初始化与文件和文件系统相关的变量。
|
||||
|
||||
```
|
||||
retval = copy_files(clone_flags, p);
|
||||
retval = copy_fs(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
copy_files主要用于复制一个进程打开的文件信息。这些信息用一个结构files_struct来维护,每个打开的文件都有一个文件描述符。在copy_files函数里面调用dup_fd,在这里面会创建一个新的files_struct,然后将所有的文件描述符数组fdtable拷贝一份。
|
||||
|
||||
copy_fs主要用于复制一个进程的目录信息。这些信息用一个结构fs_struct来维护。一个进程有自己的根目录和根文件系统root,也有当前目录pwd和当前目录的文件系统,都在fs_struct里面维护。copy_fs函数里面调用copy_fs_struct,创建一个新的fs_struct,并复制原来进程的fs_struct。
|
||||
|
||||
接下来,copy_process开始初始化与信号相关的变量。
|
||||
|
||||
```
|
||||
init_sigpending(&p->pending);
|
||||
retval = copy_sighand(clone_flags, p);
|
||||
retval = copy_signal(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
copy_sighand会分配一个新的sighand_struct。这里最主要的是维护信号处理函数,在copy_sighand里面会调用memcpy,将信号处理函数sighand->action从父进程复制到子进程。
|
||||
|
||||
init_sigpending和copy_signal用于初始化,并且复制用于维护发给这个进程的信号的数据结构。copy_signal函数会分配一个新的signal_struct,并进行初始化。
|
||||
|
||||
接下来,copy_process开始复制进程内存空间。
|
||||
|
||||
```
|
||||
retval = copy_mm(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
进程都有自己的内存空间,用mm_struct结构来表示。copy_mm函数中调用dup_mm,分配一个新的mm_struct结构,调用memcpy复制这个结构。dup_mmap用于复制内存空间中内存映射的部分。前面讲系统调用的时候,我们说过,mmap可以分配大块的内存,其实mmap也可以将一个文件映射到内存中,方便可以像读写内存一样读写文件,这个在内存管理那节我们讲。
|
||||
|
||||
接下来,copy_process开始分配pid,设置tid,group_leader,并且建立进程之间的亲缘关系。
|
||||
|
||||
```
|
||||
INIT_LIST_HEAD(&p->children);
|
||||
INIT_LIST_HEAD(&p->sibling);
|
||||
......
|
||||
p->pid = pid_nr(pid);
|
||||
if (clone_flags & CLONE_THREAD) {
|
||||
p->exit_signal = -1;
|
||||
p->group_leader = current->group_leader;
|
||||
p->tgid = current->tgid;
|
||||
} else {
|
||||
if (clone_flags & CLONE_PARENT)
|
||||
p->exit_signal = current->group_leader->exit_signal;
|
||||
else
|
||||
p->exit_signal = (clone_flags & CSIGNAL);
|
||||
p->group_leader = p;
|
||||
p->tgid = p->pid;
|
||||
}
|
||||
......
|
||||
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
|
||||
p->real_parent = current->real_parent;
|
||||
p->parent_exec_id = current->parent_exec_id;
|
||||
} else {
|
||||
p->real_parent = current;
|
||||
p->parent_exec_id = current->self_exec_id;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好了,copy_process要结束了,上面图中的组件也初始化的差不多了。
|
||||
|
||||
## fork的第二件大事:唤醒新进程
|
||||
|
||||
_do_fork做的第二件大事是wake_up_new_task。新任务刚刚建立,有没有机会抢占别人,获得CPU呢?
|
||||
|
||||
```
|
||||
void wake_up_new_task(struct task_struct *p)
|
||||
{
|
||||
struct rq_flags rf;
|
||||
struct rq *rq;
|
||||
......
|
||||
p->state = TASK_RUNNING;
|
||||
......
|
||||
activate_task(rq, p, ENQUEUE_NOCLOCK);
|
||||
p->on_rq = TASK_ON_RQ_QUEUED;
|
||||
trace_sched_wakeup_new(p);
|
||||
check_preempt_curr(rq, p, WF_FORK);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们需要将进程的状态设置为TASK_RUNNING。
|
||||
|
||||
activate_task函数中会调用enqueue_task。
|
||||
|
||||
```
|
||||
static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
|
||||
{
|
||||
.....
|
||||
p->sched_class->enqueue_task(rq, p, flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果是CFS的调度类,则执行相应的enqueue_task_fair。
|
||||
|
||||
```
|
||||
static void
|
||||
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
|
||||
{
|
||||
struct cfs_rq *cfs_rq;
|
||||
struct sched_entity *se = &p->se;
|
||||
......
|
||||
cfs_rq = cfs_rq_of(se);
|
||||
enqueue_entity(cfs_rq, se, flags);
|
||||
......
|
||||
cfs_rq->h_nr_running++;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在enqueue_task_fair中取出的队列就是cfs_rq,然后调用enqueue_entity。
|
||||
|
||||
在enqueue_entity函数里面,会调用update_curr,更新运行的统计量,然后调用__enqueue_entity,将sched_entity加入到红黑树里面,然后将se->on_rq = 1设置在队列上。
|
||||
|
||||
回到enqueue_task_fair后,将这个队列上运行的进程数目加一。然后,wake_up_new_task会调用check_preempt_curr,看是否能够抢占当前进程。
|
||||
|
||||
在check_preempt_curr中,会调用相应的调度类的rq->curr->sched_class->check_preempt_curr(rq, p, flags)。对于CFS调度类来讲,调用的是check_preempt_wakeup。
|
||||
|
||||
```
|
||||
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
|
||||
{
|
||||
struct task_struct *curr = rq->curr;
|
||||
struct sched_entity *se = &curr->se, *pse = &p->se;
|
||||
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
|
||||
......
|
||||
if (test_tsk_need_resched(curr))
|
||||
return;
|
||||
......
|
||||
find_matching_se(&se, &pse);
|
||||
update_curr(cfs_rq_of(se));
|
||||
if (wakeup_preempt_entity(se, pse) == 1) {
|
||||
goto preempt;
|
||||
}
|
||||
return;
|
||||
preempt:
|
||||
resched_curr(rq);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在check_preempt_wakeup函数中,前面调用task_fork_fair的时候,设置sysctl_sched_child_runs_first了,已经将当前父进程的TIF_NEED_RESCHED设置了,则直接返回。
|
||||
|
||||
否则,check_preempt_wakeup还是会调用update_curr更新一次统计量,然后wakeup_preempt_entity将父进程和子进程PK一次,看是不是要抢占,如果要则调用resched_curr标记父进程为TIF_NEED_RESCHED。
|
||||
|
||||
如果新创建的进程应该抢占父进程,在什么时间抢占呢?别忘了fork是一个系统调用,从系统调用返回的时候,是抢占的一个好时机,如果父进程判断自己已经被设置为TIF_NEED_RESCHED,就让子进程先跑,抢占自己。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,fork系统调用的过程咱们就解析完了。它包含两个重要的事件,一个是将task_struct结构复制一份并且初始化,另一个是试图唤醒新创建的子进程。
|
||||
|
||||
这个过程我画了一张图,你可以对照着这张图回顾进程创建的过程。
|
||||
|
||||
这个图的上半部分是复制task_struct结构,你可以对照着右面的task_struct结构图,看这里面的成员是如何一部分一部分地被复制的。图的下半部分是唤醒新创建的子进程,如果条件满足,就会将当前进程设置应该被调度的标识位,就等着当前进程执行__schedule了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/58/9d9c5779436da40cabf8e8599eb85558.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
你可以试着设置sysctl_sched_child_runs_first参数,然后使用系统调用写程序创建进程,看看执行结果。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,489 @@
|
||||
<audio id="audio" title="19 | 线程的创建:如何执行一个新子项目?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/36/a3a57522202a3443cd08a6e86eb9b736.mp3"></audio>
|
||||
|
||||
上一节,我们了解了进程创建的整个过程,今天我们来看线程创建的过程。
|
||||
|
||||
我们前面已经写过多线程编程的程序了,你应该都知道创建一个线程调用的是pthread_create,可你知道它背后的机制吗?
|
||||
|
||||
## 用户态创建线程
|
||||
|
||||
你可能会问,咱们之前不是讲过了吗?无论是进程还是线程,在内核里面都是任务,管起来不是都一样吗?但是问题来了,如果两个完全一样,那为什么咱们前两节写的程序差别那么大?如果不一样,那怎么在内核里面加以区分呢?
|
||||
|
||||
其实,线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。pthread_create不是一个系统调用,是Glibc库的一个函数,所以我们还要去Glibc里面去找线索。
|
||||
|
||||
果然,我们在nptl/pthread_create.c里面找到了这个函数。这里的参数我们应该比较熟悉了。
|
||||
|
||||
```
|
||||
int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg)
|
||||
{
|
||||
......
|
||||
}
|
||||
versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);
|
||||
|
||||
```
|
||||
|
||||
下面我们依次来看这个函数做了些啥。
|
||||
|
||||
首先处理的是线程的属性参数。例如前面写程序的时候,我们设置的线程栈大小。如果没有传入线程属性,就取默认值。
|
||||
|
||||
```
|
||||
const struct pthread_attr *iattr = (struct pthread_attr *) attr;
|
||||
struct pthread_attr default_attr;
|
||||
if (iattr == NULL)
|
||||
{
|
||||
......
|
||||
iattr = &default_attr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,就像在内核里一样,每一个进程或者线程都有一个task_struct结构,在用户态也有一个用于维护线程的结构,就是这个pthread结构。
|
||||
|
||||
```
|
||||
struct pthread *pd = NULL;
|
||||
|
||||
```
|
||||
|
||||
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。
|
||||
|
||||
```
|
||||
int err = ALLOCATE_STACK (iattr, &pd);
|
||||
|
||||
```
|
||||
|
||||
ALLOCATE_STACK是一个宏,我们找到它的定义之后,发现它其实就是一个函数。只是,这个函数有些复杂,所以我这里把主要的代码列一下。
|
||||
|
||||
```
|
||||
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)
|
||||
|
||||
|
||||
static int
|
||||
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
|
||||
ALLOCATE_STACK_PARMS)
|
||||
{
|
||||
struct pthread *pd;
|
||||
size_t size;
|
||||
size_t pagesize_m1 = __getpagesize () - 1;
|
||||
......
|
||||
size = attr->stacksize;
|
||||
......
|
||||
/* Allocate some anonymous memory. If possible use the cache. */
|
||||
size_t guardsize;
|
||||
void *mem;
|
||||
const int prot = (PROT_READ | PROT_WRITE
|
||||
| ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
|
||||
/* Adjust the stack size for alignment. */
|
||||
size &= ~__static_tls_align_m1;
|
||||
/* Make sure the size of the stack is enough for the guard and
|
||||
eventually the thread descriptor. */
|
||||
guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
|
||||
size += guardsize;
|
||||
pd = get_cached_stack (&size, &mem);
|
||||
if (pd == NULL)
|
||||
{
|
||||
/* If a guard page is required, avoid committing memory by first
|
||||
allocate with PROT_NONE and then reserve with required permission
|
||||
excluding the guard page. */
|
||||
mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
|
||||
/* Place the thread descriptor at the end of the stack. */
|
||||
#if TLS_TCB_AT_TP
|
||||
pd = (struct pthread *) ((char *) mem + size) - 1;
|
||||
#elif TLS_DTV_AT_TP
|
||||
pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
|
||||
#endif
|
||||
/* Now mprotect the required region excluding the guard area. */
|
||||
char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
|
||||
setup_stack_prot (mem, size, guard, guardsize, prot);
|
||||
pd->stackblock = mem;
|
||||
pd->stackblock_size = size;
|
||||
pd->guardsize = guardsize;
|
||||
pd->specific[0] = pd->specific_1stblock;
|
||||
/* And add to the list of stacks in use. */
|
||||
stack_list_add (&pd->list, &stack_used);
|
||||
}
|
||||
|
||||
*pdp = pd;
|
||||
void *stacktop;
|
||||
# if TLS_TCB_AT_TP
|
||||
/* The stack begins before the TCB and the static TLS block. */
|
||||
stacktop = ((char *) (pd + 1) - __static_tls_size);
|
||||
# elif TLS_DTV_AT_TP
|
||||
stacktop = (char *) (pd - 1);
|
||||
# endif
|
||||
*stack = stacktop;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们来看一下,allocate_stack主要做了以下这些事情:
|
||||
|
||||
<li>
|
||||
如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来;
|
||||
</li>
|
||||
<li>
|
||||
为了防止栈的访问越界,在栈的末尾会有一块空间guardsize,一旦访问到这里就错误了;
|
||||
</li>
|
||||
<li>
|
||||
其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程,我们不可能不断地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack就是根据计算出来的size大小,看一看已经有的缓存中,有没有已经能够满足条件的;
|
||||
</li>
|
||||
<li>
|
||||
如果缓存里面没有,就需要调用__mmap创建一块新的,系统调用那一节我们讲过,如果要在堆里面malloc一块内存,比较大的话,用__mmap;
|
||||
</li>
|
||||
<li>
|
||||
线程栈也是自顶向下生长的,还记得每个线程要有一个pthread结构,这个结构也是放在栈的空间里面的。在栈底的位置,其实是地址最高位;
|
||||
</li>
|
||||
<li>
|
||||
计算出guard内存的位置,调用setup_stack_prot设置这块内存的是受保护的;
|
||||
</li>
|
||||
<li>
|
||||
接下来,开始填充pthread这个结构里面的成员变量stackblock、stackblock_size、guardsize、specific。这里的specific是用于存放Thread Specific Data的,也即属于线程的全局变量;
|
||||
</li>
|
||||
<li>
|
||||
将这个线程栈放到stack_used链表中,其实管理线程栈总共有两个链表,一个是stack_used,也就是这个栈正被使用;另一个是stack_cache,就是上面说的,一旦线程结束,先缓存起来,不释放,等有其他的线程创建的时候,给其他的线程用。
|
||||
</li>
|
||||
|
||||
搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
|
||||
|
||||
## 内核态创建任务
|
||||
|
||||
接下来,我们接着pthread_create看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题。
|
||||
|
||||
```
|
||||
pd->start_routine = start_routine;
|
||||
pd->arg = arg;
|
||||
pd->schedpolicy = self->schedpolicy;
|
||||
pd->schedparam = self->schedparam;
|
||||
/* Pass the descriptor to the caller. */
|
||||
*newthread = (pthread_t) pd;
|
||||
atomic_increment (&__nptl_nthreads);
|
||||
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);
|
||||
|
||||
```
|
||||
|
||||
start_routine就是咱们给线程的函数,start_routine,start_routine的参数arg,以及调度策略都要赋值给pthread。
|
||||
|
||||
接下来__nptl_nthreads加一,说明又多了一个线程。
|
||||
|
||||
真正创建线程的是调用create_thread函数,这个函数定义如下:
|
||||
|
||||
```
|
||||
static int
|
||||
create_thread (struct pthread *pd, const struct pthread_attr *attr,
|
||||
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
|
||||
{
|
||||
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
|
||||
ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
|
||||
/* It's started now, so if we fail below, we'll have to cancel it
|
||||
and let it clean itself up. */
|
||||
*thread_ran = true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面有很长的clone_flags,这些咱们原来一直没注意,不过接下来的过程,我们要特别的关注一下这些标志位。
|
||||
|
||||
然后就是ARCH_CLONE,其实调用的是__clone。看到这里,你应该就有感觉了,马上就要到系统调用了。
|
||||
|
||||
```
|
||||
# define ARCH_CLONE __clone
|
||||
|
||||
|
||||
/* The userland implementation is:
|
||||
int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
|
||||
the kernel entry is:
|
||||
int clone (long flags, void *child_stack).
|
||||
|
||||
|
||||
The parameters are passed in register and on the stack from userland:
|
||||
rdi: fn
|
||||
rsi: child_stack
|
||||
rdx: flags
|
||||
rcx: arg
|
||||
r8d: TID field in parent
|
||||
r9d: thread pointer
|
||||
%esp+8: TID field in child
|
||||
|
||||
|
||||
The kernel expects:
|
||||
rax: system call number
|
||||
rdi: flags
|
||||
rsi: child_stack
|
||||
rdx: TID field in parent
|
||||
r10: TID field in child
|
||||
r8: thread pointer */
|
||||
|
||||
.text
|
||||
ENTRY (__clone)
|
||||
movq $-EINVAL,%rax
|
||||
......
|
||||
/* Insert the argument onto the new stack. */
|
||||
subq $16,%rsi
|
||||
movq %rcx,8(%rsi)
|
||||
|
||||
|
||||
/* Save the function pointer. It will be popped off in the
|
||||
child in the ebx frobbing below. */
|
||||
movq %rdi,0(%rsi)
|
||||
|
||||
|
||||
/* Do the system call. */
|
||||
movq %rdx, %rdi
|
||||
movq %r8, %rdx
|
||||
movq %r9, %r8
|
||||
mov 8(%rsp), %R10_LP
|
||||
movl $SYS_ify(clone),%eax
|
||||
......
|
||||
syscall
|
||||
......
|
||||
PSEUDO_END (__clone)
|
||||
|
||||
```
|
||||
|
||||
如果对于汇编不太熟悉也没关系,你可以重点看上面的注释。
|
||||
|
||||
我们能看到最后调用了syscall,这一点clone和我们原来熟悉的其他系统调用几乎是一致的。但是,也有少许不一样的地方。
|
||||
|
||||
如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用clone的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
|
||||
|
||||
但是对于线程来说,这些都要变。因为我们希望当clone这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。
|
||||
|
||||
所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。
|
||||
|
||||
接下来我们就要进入内核了。内核里面对于clone系统调用的定义是这样的:
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
|
||||
int __user *, parent_tidptr,
|
||||
int __user *, child_tidptr,
|
||||
unsigned long, tls)
|
||||
{
|
||||
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里,发现了熟悉的面孔_do_fork,是不是轻松了一些?上一节我们已经沿着它的逻辑过了一遍了。这里我们重点关注几个区别。
|
||||
|
||||
第一个是上面**复杂的标志位设定**,我们来看都影响了什么。
|
||||
|
||||
对于copy_files,原来是调用dup_fd复制一个files_struct的,现在因为CLONE_FILES标识位变成将原来的files_struct引用计数加一。
|
||||
|
||||
```
|
||||
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct files_struct *oldf, *newf;
|
||||
oldf = current->files;
|
||||
if (clone_flags & CLONE_FILES) {
|
||||
atomic_inc(&oldf->count);
|
||||
goto out;
|
||||
}
|
||||
newf = dup_fd(oldf, &error);
|
||||
tsk->files = newf;
|
||||
out:
|
||||
return error;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_fs,原来是调用copy_fs_struct复制一个fs_struct,现在因为CLONE_FS标识位变成将原来的fs_struct的用户数加一。
|
||||
|
||||
```
|
||||
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct fs_struct *fs = current->fs;
|
||||
if (clone_flags & CLONE_FS) {
|
||||
fs->users++;
|
||||
return 0;
|
||||
}
|
||||
tsk->fs = copy_fs_struct(fs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_sighand,原来是创建一个新的sighand_struct,现在因为CLONE_SIGHAND标识位变成将原来的sighand_struct引用计数加一。
|
||||
|
||||
```
|
||||
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct sighand_struct *sig;
|
||||
|
||||
|
||||
if (clone_flags & CLONE_SIGHAND) {
|
||||
atomic_inc(&current->sighand->count);
|
||||
return 0;
|
||||
}
|
||||
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
|
||||
atomic_set(&sig->count, 1);
|
||||
memcpy(sig->action, current->sighand->action, sizeof(sig->action));
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_signal,原来是创建一个新的signal_struct,现在因为CLONE_THREAD直接返回了。
|
||||
|
||||
```
|
||||
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct signal_struct *sig;
|
||||
if (clone_flags & CLONE_THREAD)
|
||||
return 0;
|
||||
sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
|
||||
tsk->signal = sig;
|
||||
init_sigpending(&sig->shared_pending);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_mm,原来是调用dup_mm复制一个mm_struct,现在因为CLONE_VM标识位而直接指向了原来的mm_struct。
|
||||
|
||||
```
|
||||
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct mm_struct *mm, *oldmm;
|
||||
oldmm = current->mm;
|
||||
if (clone_flags & CLONE_VM) {
|
||||
mmget(oldmm);
|
||||
mm = oldmm;
|
||||
goto good_mm;
|
||||
}
|
||||
mm = dup_mm(tsk);
|
||||
good_mm:
|
||||
tsk->mm = mm;
|
||||
tsk->active_mm = mm;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个就是**对于亲缘关系的影响**,毕竟我们要识别多个线程是不是属于一个进程。
|
||||
|
||||
```
|
||||
p->pid = pid_nr(pid);
|
||||
if (clone_flags & CLONE_THREAD) {
|
||||
p->exit_signal = -1;
|
||||
p->group_leader = current->group_leader;
|
||||
p->tgid = current->tgid;
|
||||
} else {
|
||||
if (clone_flags & CLONE_PARENT)
|
||||
p->exit_signal = current->group_leader->exit_signal;
|
||||
else
|
||||
p->exit_signal = (clone_flags & CSIGNAL);
|
||||
p->group_leader = p;
|
||||
p->tgid = p->pid;
|
||||
}
|
||||
/* CLONE_PARENT re-uses the old parent */
|
||||
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
|
||||
p->real_parent = current->real_parent;
|
||||
p->parent_exec_id = current->parent_exec_id;
|
||||
} else {
|
||||
p->real_parent = current;
|
||||
p->parent_exec_id = current->self_exec_id;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的代码可以看出,使用了CLONE_THREAD标识位之后,使得亲缘关系有了一定的变化。
|
||||
|
||||
<li>
|
||||
如果是新进程,那这个进程的group_leader就是它自己,tgid是它自己的pid,这就完全重打锣鼓另开张了,自己是线程组的头。如果是新线程,group_leader是当前进程的,group_leader,tgid是当前进程的tgid,也就是当前进程的pid,这个时候还是拜原来进程为老大。
|
||||
</li>
|
||||
<li>
|
||||
如果是新进程,新进程的real_parent是当前的进程,在进程树里面又见一辈人;如果是新线程,线程的real_parent是当前的进程的real_parent,其实是平辈的。
|
||||
</li>
|
||||
|
||||
第三,**对于信号的处理**,如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。例如,kill一个进程,则所有线程都要被干掉。如果一个信号是发给一个线程的pthread_kill,则应该只有线程能够收到。
|
||||
|
||||
在copy_process的主流程里面,无论是创建进程还是线程,都会初始化struct sigpending pending,也就是每个task_struct,都会有这样一个成员变量。这就是一个信号列表。如果这个task_struct是一个线程,这里面的信号就是发给这个线程的;如果这个task_struct是一个进程,这里面的信号是发给主线程的。
|
||||
|
||||
```
|
||||
init_sigpending(&p->pending);
|
||||
|
||||
```
|
||||
|
||||
另外,上面copy_signal的时候,我们可以看到,在创建进程的过程中,会初始化signal_struct里面的struct sigpending shared_pending。但是,在创建线程的过程中,连signal_struct都共享了。也就是说,整个进程里的所有线程共享一个shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。
|
||||
|
||||
```
|
||||
init_sigpending(&sig->shared_pending);
|
||||
|
||||
```
|
||||
|
||||
至此,clone在内核的调用完毕,要返回系统调用,回到用户态。
|
||||
|
||||
## 用户态执行线程
|
||||
|
||||
根据__clone的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的start_thread,这是所有线程在用户态的统一入口。
|
||||
|
||||
```
|
||||
#define START_THREAD_DEFN \
|
||||
static int __attribute__ ((noreturn)) start_thread (void *arg)
|
||||
|
||||
|
||||
START_THREAD_DEFN
|
||||
{
|
||||
struct pthread *pd = START_THREAD_SELF;
|
||||
/* Run the code the user provided. */
|
||||
THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
|
||||
/* Call destructors for the thread_local TLS variables. */
|
||||
/* Run the destructor for the thread-local data. */
|
||||
__nptl_deallocate_tsd ();
|
||||
if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
|
||||
/* This was the last thread. */
|
||||
exit (0);
|
||||
__free_tcb (pd);
|
||||
__exit_thread ();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在start_thread入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。例如,线程本地数据thread_local variables,线程数目也减一。如果这是最后一个线程了,就直接退出进程,另外__free_tcb用于释放pthread。
|
||||
|
||||
```
|
||||
void
|
||||
internal_function
|
||||
__free_tcb (struct pthread *pd)
|
||||
{
|
||||
......
|
||||
__deallocate_stack (pd);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
internal_function
|
||||
__deallocate_stack (struct pthread *pd)
|
||||
{
|
||||
/* Remove the thread from the list of threads with user defined
|
||||
stacks. */
|
||||
stack_list_del (&pd->list);
|
||||
/* Not much to do. Just free the mmap()ed memory. Note that we do
|
||||
not reset the 'used' flag in the 'tid' field. This is done by
|
||||
the kernel. If no thread has been created yet this field is
|
||||
still zero. */
|
||||
if (__glibc_likely (! pd->user_stack))
|
||||
(void) queue_stack (pd);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__free_tcb会调用__deallocate_stack来释放整个线程栈,这个线程栈要从当前使用线程栈的列表stack_used中拿下来,放到缓存的线程栈列表stack_cache中。
|
||||
|
||||
好了,整个线程的生命周期到这里就结束了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
线程的调用过程解析完毕了,我画了一个图总结一下。这个图对比了创建进程和创建线程在用户态和内核态的不同。
|
||||
|
||||
创建进程的话,调用的系统调用是fork,在copy_process函数里面,会将五大结构files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用clone,在copy_process函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/4b/14635b1613d04df9f217c3508ae8524b.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
你知道如果查看一个进程的线程以及线程栈的使用情况吗?请找一下相关的命令和API,尝试一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
Reference in New Issue
Block a user