This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View 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 &quot;Development Tools&quot;
```
接下来我们要开始写程序了。在Windows上写的程序都会被保存成.h或者.c文件容易让人感觉这是某种有特殊格式的文件但其实这些文件只是普普通通的文本文件。因而在Linux上我们用Vim来创建并编辑一个文件就行了。
我们先来创建一个文件里面用一个函数封装通用的创建进程的逻辑名字叫process.c代码如下
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;unistd.h&gt;
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 &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;unistd.h&gt;
extern int create_process (char* program, char** arg_list);
int main ()
{
char* arg_list[] = {
&quot;ls&quot;,
&quot;-l&quot;,
&quot;/etc/yum.repos.d/&quot;,
NULL
};
create_process (&quot;ls&quot;, 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 TablePLT一个是.got.plt全局偏移量表Global Offset TableGOT
它们是怎么工作的使得程序运行的时候可以将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-&gt;do_execveat_common-&gt;exec_binprm-&gt;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 -&gt; ../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进程systemdPID 2的进程是内核线程kthreadd这两个我们在内核启动的时候都见过。其中用户态的不带中括号内核态的带中括号。
接下来进程号依次增大但是你会看所有带中括号的内核态的进程祖先都是2号进程。而用户态的进程祖先都是1号进程。tty那一列是问号的说明不是前台启动的一般都是后台的服务。
pts的父进程是sshdbash的父进程是ptsps -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="">

View 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 &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#define NUM_OF_TASKS 5
void *downloadfile(void *filename)
{
printf(&quot;I am downloading the file %s!\n&quot;, (char *)filename);
sleep(10);
long downloadtime = rand()%100;
printf(&quot;I finish downloading the file within %d minutes!\n&quot;, downloadtime);
pthread_exit((void *)downloadtime);
}
int main(int argc, char *argv[])
{
char files[NUM_OF_TASKS][20]={&quot;file1.avi&quot;,&quot;file2.rmvb&quot;,&quot;file3.mp4&quot;,&quot;file4.wmv&quot;,&quot;file5.flv&quot;};
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
int downloadtime;
pthread_attr_t thread_attr;
pthread_attr_init(&amp;thread_attr);
pthread_attr_setdetachstate(&amp;thread_attr,PTHREAD_CREATE_JOINABLE);
for(t=0;t&lt;NUM_OF_TASKS;t++){
printf(&quot;creating thread %d, please help me to download %s\n&quot;, t, files[t]);
rc = pthread_create(&amp;threads[t], &amp;thread_attr, downloadfile, (void *)files[t]);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
pthread_attr_destroy(&amp;thread_attr);
for(t=0;t&lt;NUM_OF_TASKS;t++){
pthread_join(threads[t],(void**)&amp;downloadtime);
printf(&quot;Thread %d downloads the file %s in %d minutes.\n&quot;,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查看默认情况下线程栈大小为81928MB。我们可以使用命令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 &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#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(&quot;Thread %u is transfering money!\n&quot;, (unsigned int)tid);
//第一次运行去掉下面这行
pthread_mutex_lock(&amp;g_money_lock);
sleep(rand()%10);
money_of_tom+=10;
sleep(rand()%10);
money_of_jerry-=10;
//第一次运行去掉下面这行
pthread_mutex_unlock(&amp;g_money_lock);
printf(&quot;Thread %u finish transfering money!\n&quot;, (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(&amp;g_money_lock, NULL);
for(t=0;t&lt;NUM_OF_TASKS;t++){
rc = pthread_create(&amp;threads[t], NULL, transfer, NULL);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
for(t=0;t&lt;100;t++){
//第一次运行去掉下面这行
pthread_mutex_lock(&amp;g_money_lock);
printf(&quot;money_of_tom + money_of_jerry = %d\n&quot;, money_of_tom + money_of_jerry);
//第一次运行去掉下面这行
pthread_mutex_unlock(&amp;g_money_lock);
}
//第一次运行去掉下面这行
pthread_mutex_destroy(&amp;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 &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#define NUM_OF_TASKS 3
#define MAX_TASK_QUEUE 11
char tasklist[MAX_TASK_QUEUE]=&quot;ABCDEFGHIJ&quot;;
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(&amp;g_task_lock);
while(tail == head){
if(quit){
pthread_mutex_unlock(&amp;g_task_lock);
pthread_exit((void *)0);
}
printf(&quot;No task now! Thread %u is waiting!\n&quot;, (unsigned int)tid);
pthread_cond_wait(&amp;g_task_cv, &amp;g_task_lock);
printf(&quot;Have task now! Thread %u is grabing the task !\n&quot;, (unsigned int)tid);
}
char task = tasklist[head++];
pthread_mutex_unlock(&amp;g_task_lock);
printf(&quot;Thread %u has a task %c now!\n&quot;, (unsigned int)tid, task);
sleep(5);
printf(&quot;Thread %u finish the task %c!\n&quot;, (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(&amp;g_task_lock, NULL);
pthread_cond_init(&amp;g_task_cv, NULL);
for(t=0;t&lt;NUM_OF_TASKS;t++){
rc = pthread_create(&amp;threads[t], NULL, coder, NULL);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
sleep(5);
for(t=1;t&lt;=4;t++){
pthread_mutex_lock(&amp;g_task_lock);
tail+=t;
printf(&quot;I am Boss, I assigned %d tasks, I notify all coders!\n&quot;, t);
pthread_cond_broadcast(&amp;g_task_cv);
pthread_mutex_unlock(&amp;g_task_lock);
sleep(20);
}
pthread_mutex_lock(&amp;g_task_lock);
quit = 1;
pthread_cond_broadcast(&amp;g_task_cv);
pthread_mutex_unlock(&amp;g_task_lock);
pthread_mutex_destroy(&amp;g_task_lock);
pthread_cond_destroy(&amp;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="">

View File

@@ -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 idtgid是thread group ID。
任何一个进程如果只有主线程那pid是自己tgid是自己group_leader指向的还是自己。
但是如果一个进程创建了其他线程那就会有所变化了。线程有自己的pidtgid就是进程的主线程的pidgroup_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, &gt;0 stopped */
int exit_state;
unsigned int flags;
```
state状态可以取的值定义在include/linux/sched.h头文件中。
```
/* Used in tsk-&gt;state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk-&gt;exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk-&gt;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="">

View File

@@ -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是parentbash是这个进程的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安装的。游戏这个程序文件的权限为rwxrr--。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="">

View File

@@ -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调用BA的栈里面包含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 &lt;&lt; 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 &lt;&lt; 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-&gt;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 &quot;struct pt_regs&quot;
* 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
* &quot;struct pt_regs&quot; 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()-&gt;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 &lt;asm/current.h&gt;
#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) = &amp;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(&quot;mov&quot;, 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_info64位主要靠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="">

View File

@@ -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;
```
优先级其实就是一个数值对于实时进程优先级的范围是099对于普通进程优先级的范围是100139。数值越小优先级越高。从这里可以看出所有的实时进程都比普通进程优先级要高。毕竟谁让人家加钱了呢。
### 实时调度策略
对于调度策略其中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-&gt;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-&gt;curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
......
delta_exec = now - curr-&gt;exec_start;
......
curr-&gt;exec_start = now;
......
curr-&gt;sum_exec_runtime += delta_exec;
......
curr-&gt;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-&gt;load.weight != NICE_0_LOAD))
/* delta_exec * weight / lw.weight */
delta = __calc_delta(delta, NICE_0_LOAD, &amp;se-&gt;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_entityDeadline调度实体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-&gt;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 = &amp;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_fairrt_sched_class的实现是pick_next_task_rt。
我们会发现这两个函数是操作不同的队列pick_next_task_rt操作的是rt_rqpick_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 = &amp;rq-&gt;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 = &amp;rq-&gt;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-&gt;pick_next_entity-&gt;__pick_first_entity。
```
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&amp;cfs_rq-&gt;tasks_timeline);
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
```
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。
## 总结时刻
好了这一节我们讲了调度相关的数据结构还是比较复杂的。一个CPU上有一个队列CFS的队列是一棵红黑树树的每一个节点都是一个sched_entity每个sched_entity都属于一个task_structtask_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="">

View File

@@ -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(&amp;root-&gt;subv_writers-&gt;wait, &amp;wait,
TASK_UNINTERRUPTIBLE);
writers = percpu_counter_sum(&amp;root-&gt;subv_writers-&gt;counter);
if (writers)
schedule();
finish_wait(&amp;root-&gt;subv_writers-&gt;wait, &amp;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(&amp;q-&gt;sk), &amp;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-&gt;curr;
......
```
首先在当前的CPU上我们取出任务队列rq。
task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr。为啥是prev因为一旦将来它被切换下来那它就成了前任了。
接下来代码如下:
```
next = pick_next_task(rq, prev, &amp;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-&gt;sched_class == &amp;idle_sched_class ||
prev-&gt;sched_class == &amp;fair_sched_class) &amp;&amp;
rq-&gt;nr_running == rq-&gt;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-&gt;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-&gt;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 = &amp;rq-&gt;cfs;
struct sched_entity *se;
struct task_struct *p;
int new_tasks;
```
对于CFS调度类取出相应的队列cfs_rq这就是我们上一节讲的那棵红黑树。
```
struct sched_entity *curr = cfs_rq-&gt;curr;
if (curr) {
if (curr-&gt;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 = &amp;prev-&gt;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-&gt;nr_switches++;
rq-&gt;curr = next;
++*switch_count;
......
rq = context_switch(rq, prev, next, &amp;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-&gt;mm;
oldmm = prev-&gt;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 = &amp;prev_p-&gt;thread;
struct thread_struct *next = &amp;next_p-&gt;thread;
......
int cpu = smp_processor_id();
struct tss_struct *tss = &amp;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希望在内存里面维护一个TSSTask State Segment任务状态段结构。这里面有所有的寄存器。
另外还有一个特殊的寄存器TRTask 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 = &amp;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是Anext是B。但是A执行完__switch_to_asm之后就被切换走了当C再次切换到A的时候运行到__switch_to_asm是从C的内核栈运行的。这个时候prev是Cnext是A但是__switch_to_asm里面切换成为了A当时的内核栈。
还记得当年的场景“prev是Anext是B”__switch_to_asm里面return prev的时候还没return的时候prev这个变量里面放的还是C因而它会把C放到返回结果中。但是一旦return就会弹出A当时的内核栈。这个时候prev变量就变成了Anext变量就变成了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="">
## 课堂练习
你知道应该用什么命令查看进程的运行时间和上下文切换次数吗?
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。

View File

@@ -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-&gt;curr;
......
curr-&gt;sched_class-&gt;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 = &amp;curr-&gt;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-&gt;nr_running &gt; 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-&gt;sum_exec_runtime - curr-&gt;prev_sum_exec_runtime;
if (delta_exec &gt; ideal_runtime) {
resched_curr(rq_of(cfs_rq));
return;
}
......
se = __pick_first_entity(cfs_rq);
delta = curr-&gt;vruntime - se-&gt;vruntime;
if (delta &lt; 0)
return;
if (delta &gt; 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-&gt;prev_sum_exec_runtime = se-&gt;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-&gt;state = TASK_RUNNING;
trace_sched_wakeup(p);
```
到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。
## 抢占的时机
真正的抢占还需要时机也就是需要那么一个时刻让正在运行中的进程有机会调用一下__schedule。
你可以想象不可能某个进程代码运行着突然要去调用__schedule代码里面不可能这么写所以一定要规划几个时机这个时机分为用户态和内核态。
### 用户态的抢占时机
对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。
前面讲系统调用的时候64位的系统调用的链路位do_syscall_64-&gt;syscall_return_slowpath-&gt;prepare_exit_to_usermode-&gt;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 &amp; _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-&gt;preempt_schedule_common-&gt;__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 &amp;&amp;
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="">

View 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 &amp; 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-&gt;cred = p-&gt;real_cred = get_cred(new)将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的cred。
</li>
接下来copy_process重新设置进程运行的统计量。
```
p-&gt;utime = p-&gt;stime = p-&gt;gtime = 0;
p-&gt;start_time = ktime_get_ns();
p-&gt;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-&gt;state = TASK_NEW
</li>
<li>
初始化优先级prio、normal_prio、static_prio
</li>
<li>
设置调度类如果是普通进程就设置为p-&gt;sched_class = &amp;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(&amp;p-&gt;pending);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
```
copy_sighand会分配一个新的sighand_struct。这里最主要的是维护信号处理函数在copy_sighand里面会调用memcpy将信号处理函数sighand-&gt;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设置tidgroup_leader并且建立进程之间的亲缘关系。
```
INIT_LIST_HEAD(&amp;p-&gt;children);
INIT_LIST_HEAD(&amp;p-&gt;sibling);
......
p-&gt;pid = pid_nr(pid);
if (clone_flags &amp; CLONE_THREAD) {
p-&gt;exit_signal = -1;
p-&gt;group_leader = current-&gt;group_leader;
p-&gt;tgid = current-&gt;tgid;
} else {
if (clone_flags &amp; CLONE_PARENT)
p-&gt;exit_signal = current-&gt;group_leader-&gt;exit_signal;
else
p-&gt;exit_signal = (clone_flags &amp; CSIGNAL);
p-&gt;group_leader = p;
p-&gt;tgid = p-&gt;pid;
}
......
if (clone_flags &amp; (CLONE_PARENT|CLONE_THREAD)) {
p-&gt;real_parent = current-&gt;real_parent;
p-&gt;parent_exec_id = current-&gt;parent_exec_id;
} else {
p-&gt;real_parent = current;
p-&gt;parent_exec_id = current-&gt;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-&gt;state = TASK_RUNNING;
......
activate_task(rq, p, ENQUEUE_NOCLOCK);
p-&gt;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-&gt;sched_class-&gt;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 = &amp;p-&gt;se;
......
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags);
......
cfs_rq-&gt;h_nr_running++;
......
}
```
在enqueue_task_fair中取出的队列就是cfs_rq然后调用enqueue_entity。
在enqueue_entity函数里面会调用update_curr更新运行的统计量然后调用__enqueue_entity将sched_entity加入到红黑树里面然后将se-&gt;on_rq = 1设置在队列上。
回到enqueue_task_fair后将这个队列上运行的进程数目加一。然后wake_up_new_task会调用check_preempt_curr看是否能够抢占当前进程。
在check_preempt_curr中会调用相应的调度类的rq-&gt;curr-&gt;sched_class-&gt;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-&gt;curr;
struct sched_entity *se = &amp;curr-&gt;se, *pse = &amp;p-&gt;se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
......
if (test_tsk_need_resched(curr))
return;
......
find_matching_se(&amp;se, &amp;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="">

View File

@@ -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 = &amp;default_attr;
}
```
接下来就像在内核里一样每一个进程或者线程都有一个task_struct结构在用户态也有一个用于维护线程的结构就是这个pthread结构。
```
struct pthread *pd = NULL;
```
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。
```
int err = ALLOCATE_STACK (iattr, &amp;pd);
```
ALLOCATE_STACK是一个宏我们找到它的定义之后发现它其实就是一个函数。只是这个函数有些复杂所以我这里把主要的代码列一下。
```
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &amp;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-&gt;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) &amp; PF_X) ? PROT_EXEC : 0));
/* Adjust the stack size for alignment. */
size &amp;= ~__static_tls_align_m1;
/* Make sure the size of the stack is enough for the guard and
eventually the thread descriptor. */
guardsize = (attr-&gt;guardsize + pagesize_m1) &amp; ~pagesize_m1;
size += guardsize;
pd = get_cached_stack (&amp;size, &amp;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) &amp; ~__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-&gt;stackblock = mem;
pd-&gt;stackblock_size = size;
pd-&gt;guardsize = guardsize;
pd-&gt;specific[0] = pd-&gt;specific_1stblock;
/* And add to the list of stacks in use. */
stack_list_add (&amp;pd-&gt;list, &amp;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-&gt;start_routine = start_routine;
pd-&gt;arg = arg;
pd-&gt;schedpolicy = self-&gt;schedpolicy;
pd-&gt;schedparam = self-&gt;schedparam;
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
atomic_increment (&amp;__nptl_nthreads);
retval = create_thread (pd, iattr, &amp;stopped_start, STACK_VARIABLES_ARGS, &amp;thread_ran);
```
start_routine就是咱们给线程的函数start_routinestart_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 (&amp;start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &amp;pd-&gt;tid, tp, &amp;pd-&gt;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-&gt;files;
if (clone_flags &amp; CLONE_FILES) {
atomic_inc(&amp;oldf-&gt;count);
goto out;
}
newf = dup_fd(oldf, &amp;error);
tsk-&gt;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-&gt;fs;
if (clone_flags &amp; CLONE_FS) {
fs-&gt;users++;
return 0;
}
tsk-&gt;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 &amp; CLONE_SIGHAND) {
atomic_inc(&amp;current-&gt;sighand-&gt;count);
return 0;
}
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
atomic_set(&amp;sig-&gt;count, 1);
memcpy(sig-&gt;action, current-&gt;sighand-&gt;action, sizeof(sig-&gt;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 &amp; CLONE_THREAD)
return 0;
sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
tsk-&gt;signal = sig;
init_sigpending(&amp;sig-&gt;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-&gt;mm;
if (clone_flags &amp; CLONE_VM) {
mmget(oldmm);
mm = oldmm;
goto good_mm;
}
mm = dup_mm(tsk);
good_mm:
tsk-&gt;mm = mm;
tsk-&gt;active_mm = mm;
return 0;
}
```
第二个就是**对于亲缘关系的影响**,毕竟我们要识别多个线程是不是属于一个进程。
```
p-&gt;pid = pid_nr(pid);
if (clone_flags &amp; CLONE_THREAD) {
p-&gt;exit_signal = -1;
p-&gt;group_leader = current-&gt;group_leader;
p-&gt;tgid = current-&gt;tgid;
} else {
if (clone_flags &amp; CLONE_PARENT)
p-&gt;exit_signal = current-&gt;group_leader-&gt;exit_signal;
else
p-&gt;exit_signal = (clone_flags &amp; CSIGNAL);
p-&gt;group_leader = p;
p-&gt;tgid = p-&gt;pid;
}
/* CLONE_PARENT re-uses the old parent */
if (clone_flags &amp; (CLONE_PARENT|CLONE_THREAD)) {
p-&gt;real_parent = current-&gt;real_parent;
p-&gt;parent_exec_id = current-&gt;parent_exec_id;
} else {
p-&gt;real_parent = current;
p-&gt;parent_exec_id = current-&gt;self_exec_id;
}
```
从上面的代码可以看出使用了CLONE_THREAD标识位之后使得亲缘关系有了一定的变化。
<li>
如果是新进程那这个进程的group_leader就是它自己tgid是它自己的pid这就完全重打锣鼓另开张了自己是线程组的头。如果是新线程group_leader是当前进程的group_leadertgid是当前进程的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(&amp;p-&gt;pending);
```
另外上面copy_signal的时候我们可以看到在创建进程的过程中会初始化signal_struct里面的struct sigpending shared_pending。但是在创建线程的过程中连signal_struct都共享了。也就是说整个进程里的所有线程共享一个shared_pending这也是一个信号列表是发给整个进程的哪个线程处理都一样。
```
init_sigpending(&amp;sig-&gt;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-&gt;start_routine (pd-&gt;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 (&amp;__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 (&amp;pd-&gt;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-&gt;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="">