一、程序从编译(编译汇编、链接、装载到内存)到运行为进程
1. 在Linux上写程序和编译程序,也需要一系列的开发套件,运行下面的命令,就可以在centOS 7操作系统上安装开发套件:
yum -y groupinstall "Development Tools"
接下来就可以开始写程序了。在Windows上写的程序,都会被保存成.h或者.c文件,容易让人感觉这是某种有特殊格式的文件,但其实这些文件暂时还只是普普通通的文本文件。因而在Linux上用Vim来创建并编辑一个文件就行了。先来创建一个文件,里面用一个函数封装通用的创建进程的逻辑,名字叫process.c,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
extern int create_process (char* program, char** arg_list);
int create_process (char* program, char** arg_list)
{
pid_t child_pid;
child_pid = fork ();
if (child_pid != 0)
return child_pid;
else {
execvp (program, arg_list);
abort ();
}
}
这里面用到了fork系统调用,通过这里面的if-else,可以看到根据fork的返回值不同,父进程和子进程就此分道扬镳了。在子进程里面,需要通过execvp运行一个新的程序。接下来创建第二个文件createprocess.c,调用上面这个函数:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
extern int create_process (char* program, char** arg_list);
int main ()
{
char* arg_list[] = {
"ls",
"-l",
"/etc/yum.repos.d/",
NULL
};
create_process ("ls", arg_list);
return 0;
}
在这里,创建的子程序运行了一个最简单的命令ls。
2. 上面这两个文件只是文本文件,CPU是不能执行文本文件里面的指令的,这些指令只有人能看懂,CPU能够执行的命令是二进制的,所以这些指令还需要翻译一下,这个翻译的过程就是编译(Compile)。编译成的二进制格式称为ELF(Executeable and Linkable Format,可执行与可链接格式),这个格式可以根据编译的结果不同,分为不同的格式。从文本文件编译成二进制格式的流程如下所示:
在上面两段代码中,上面include的部分是头文件,而写的.c结尾的是源文件。接下来编译这两个程序:
gcc -c -fPIC process.c
gcc -c -fPIC createprocess.c
在编译的时候,系统会先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o文件,这就是ELF的第一种类型,叫可重定位文件(Relocatable File)。这个文件的格式如下,由一ELF文件头和多个section组成:
(1)ELF文件头:用于描述整个文件。这个文件格式在内核中有定义,分别为struct elf32_hdr和struct elf64_hdr。
(2).text:放编译好的二进制可执行代码。
(3).data:已经初始化好的全局变量。
(4).rodata:只读数据,例如字符串常量、const的变量。
(5).bss:未初始化全局变量,运行时会置0。
(6).symtab:符号表,记录的则是函数和变量。
(7).strtab:字符串表、字符串常量和变量名。
为啥这里只有全局变量呢?因为局部变量是放在栈里的,是程序运行过程中随时分配空间,随时释放的,这里二进制文件还没开始启动,所以只需要讨论在哪里保存全局变量。这些节(section)的元数据信息也需要有一个地方保存,就是最后的节头部表(Section Header Table)。在这个表里面,每一个section都有一项,在代码里的定义在struct elf32_shdr和struct elf64_shdr。在ELF的头里面,描述了这个文件的节头部表的位置,有多少个表项等等信息。
为啥叫可重定位呢?这个编译好的代码和变量,将来加载到内存里面的时候,都是要加载到一定位置的。比如说调用一个函数,其实就是跳到这个函数所在的代码位置执行;再比如修改一个全局变量,也是要到变量的位置那里去修改。但是现在这个时候还是.o文件,不是一个可以直接运行的程序,这里面只是部分代码片段。例如上面的create_process函数,将来被谁调用,在哪里调用都不清楚,就更别提确定位置了。所以,.o里面的位置是不确定的,但是必须是可重新定位的,因为它将来是要做函数库的,搬到哪里用就重新定位这些代码、变量的位置。
有的section,例如.rel.text, .rel.data就与重定位有关。例如这里的createprocess.o,里面调用了create_process函数,但是这个函数在另外一个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的第二种格式,格式如下:
这个格式和.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),不仅仅是一组.o文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享,在内存中只占一份副本。创建动态链接库的命令如下:
gcc -shared -fPIC -o libdynamicprocess.so process.o
当一个动态链接库被链接到一个程序文件中时,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的名称。将代码和动态链接库进行连接的命令如下:
gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess
当运行这个程序的时候,首先寻找动态链接库,然后加载它。默认情况下,系统在/lib和/usr/lib文件夹下寻找动态链接库,如果找不到就会报错。可以设定LD_LIBRARY_PATH环境变量,程序运行时会在此环境变量指定的文件夹下寻找动态链接库,如下所示:
# export LD_LIBRARY_PATH=.
# ./dynamiccreateprocess
# total 40
-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo
......
动态链接库就是ELF的第三种类型,叫共享对象文件(Shared Object)。基于动态连接库创建出来的二进制文件格式还是ELF,但是稍有不同。首先多了一个.interp的Segment,这里面是ld-linux.so,这是动态链接器,也就是说运行时的链接动作都是它做的。另外,ELF文件中还多了两个section,一个是.plt,叫过程链接表(Procedure Linkage Table,PLT),一个是.got.plt,叫全局偏移量表(Global Offset Table,GOT)。
3. PLT和GOT是怎么工作的,使得程序运行时,可以将so文件动态链接到进程空间呢?上面dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数。由于是运行时才去找,编译的时候并不知道这个函数在哪里,所以就在PLT里面建立一项PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面不直接调用create_process函数,而是调用PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的create_process函数。
去哪里找代理代码呢?这就用到了GOT表,这里面也会为create_process函数创建一项 GOT[y]。这一项是运行时create_process函数在内存中真正的地址。如果这个地址存在,dynamiccreateprocess调用PLT[x]里面的代理代码,代理代码调用GOT表中对应项GOT[y],调用的就是加载到内存中的libdynamicprocess.so里面的create_process函数了。
但是GOT一开始也不知道,对于create_process函数,GOT一开始就会创建一项GOT[y],但是这里面没有真正的地址,因为它也还不知道代理代码的位置,它又回调 PLT,让PLT先自己想办法。PLT这个时候会转而调用PLT[0],也即第一项,PLT[0]再调用GOT[2],这里面是ld-linux.so的入口函数,这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面。下次,PLT[x]的代理函数就能够直接调用GOT[y]了。
4. 知道了ELF这个格式,这个时候它还是个程序,那怎么把这个文件加载到内存里面呢?在内核中有这样一个数据结构,用来定义加载二进制文件的方法:
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
} __randomize_layout;
对于ELF文件格式,有对应的实现。
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
这里看到了之前熟悉的load_elf_binary,加载内核镜像的时候,用的也是这种格式。调用load_elf_binary函数的调用链当初是这样的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。那do_execve又是被谁调用的呢?看下面的代码:
SYSCALL_DEFINE3(execve,
const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}
原理是exec这个系统调用最终调用的load_elf_binary。exec比较特殊,它是一组函数:
(1)包含p的函数(execvp, execlp)会在PATH路径下面寻找程序;
(2)不包含p的函数需要输入程序的全路径;包含v的函数(execv, execvp, execve)以数组的形式接收参数;
(3)包含l的函数(execl, execlp, execle)以列表的形式接收参数;
(4)包含e的函数(execve, execle)以数组的形式接收环境变量。如下图所示:
在上面process.c的代码中,创建ls进程也是通过exec。
5. 既然所有的进程都是从父进程fork过来的,那总归有一个祖宗进程,这就是系统启动的init进程。如下图所示:
在解析Linux启动过程时,1号进程是/sbin/init。如果在centOS 7里面,ls一下可以看到,这个进程是被软链接到systemd的:
/sbin/init -> ../lib/systemd/systemd
系统启动之后,init进程会启动很多的daemon进程,为系统运行提供服务,然后就是启动getty,让用户登录,登录后运行shell,用户启动的进程都是通过shell运行的,从而形成了一棵进程树。可以通过ps -ef命令查看当前系统启动的进程,会发现有三类进程:
[root@deployer ~]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 2020 ? 00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
root 2 0 0 2020 ? 00:00:00 [kthreadd]
root 3 2 0 2020 ? 00:00:00 [ksoftirqd/0]
root 5 2 0 2020 ? 00:00:00 [kworker/0:0H]
root 9 2 0 2020 ? 00:00:40 [rcu_sched]
......
root 337 2 0 2020 ? 00:00:01 [kworker/3:1H]
root 380 1 0 2020 ? 00:00:00 /usr/lib/systemd/systemd-udevd
root 415 1 0 2020 ? 00:00:01 /sbin/auditd
root 498 1 0 2020 ? 00:00:03 /usr/lib/systemd/systemd-logind
......
root 852 1 0 2020 ? 00:06:25 /usr/sbin/rsyslogd -n
root 2580 1 0 2020 ? 00:00:00 /usr/sbin/sshd -D
root 29058 2 0 Jan03 ? 00:00:01 [kworker/1:2]
root 29672 2 0 Jan04 ? 00:00:09 [kworker/2:1]
root 30467 1 0 Jan06 ? 00:00:00 /usr/sbin/crond -n
root 31574 2 0 Jan08 ? 00:00:01 [kworker/u128:2]
......
root 32792 2580 0 Jan10 ? 00:00:00 sshd: root@pts/0
root 32794 32792 0 Jan10 pts/0 00:00:00 -bash
root 32901 32794 0 00:01 pts/0 00:00:00 ps -ef
其中PID为1的进程就是init进程systemd,PID为2的进程是内核线程kthreadd,这两个在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。接下来进程号依次增大,但是会看到所有带中括号的内核态的进程,祖先(PPID)都是2号进程。而用户态的进程,祖先都是1号进程。tty那一列是问号的,说明不是前台启动的,一般都是后台的服务。pts的父进程是sshd,bash的父进程是pts,ps -ef这个命令的父进程是bash。
6. 一个进程从代码到二进制到运行时的过程如下所示:
首先通过图右边的文件编译过程,生成.so文件和可执行文件放在硬盘上。上图左边的用户态进程A执行fork,创建进程B,在进程B的处理逻辑中执行exec系列系统调用。这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程B的内存中执行。
二、线程
7. 对于任何一个进程来讲,即便没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据代码一行一行执行下去。进程要比线程管的宽得多,除了执行指令之外,内存、文件系统等等都要它来管。所以,进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务。可以建一个大的任务完成某功能,然后交给一个人让它从头做到尾,这就是主线程。但是有时候发现任务是可以拆解的,如果没有相关性非常大的前后关联关系,就可以并行执行。
当然,使用进程实现并行执行的问题也有两个。第一,创建进程占用资源太多;第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。除了希望任务能够并行执行,系统肯定还要预留一点资源做其他突发的任务,比如有时主线程正在一行一行执行二进制命令,突然收到一个通知要做一点小事情,肯定不能停下主线程来做,这样太耽误事情了,应该创建一个单独的线程,单独处理这些事件。
在Linux中,有时候希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。
8. 假如现在有N个非常大的视频需要下载,一个个下载需要的时间太长了。按照多线程的思路,可以拆分给N个线程各自去下载。线程的执行需要一个函数,将要执行的子任务放在这个函数里面,比如下载任务。这个函数参数是void类型的指针,用于接收任何类型的参数,就可以将要下载的文件的文件名通过这个指针传给它。当然,这里的代码不是真的下载文件,而仅仅打印日志并生成一个一百以内的随机数,作为下载时间返回。如下所示:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUM_OF_TASKS 5
void *downloadfile(void *filename)
{
printf("I am downloading the file %s!\n", (char *)filename);
sleep(10);
long downloadtime = rand()%100;
printf("I finish downloading the file within %d minutes!\n", downloadtime);
pthread_exit((void *)downloadtime);
}
int main(int argc, char *argv[])
{
char files[NUM_OF_TASKS][20]={"file1.avi","file2.rmvb","file3.mp4","file4.wmv","file5.flv"};
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
int downloadtime;
pthread_attr_t thread_attr;
pthread_attr_init(&thread_attr);
pthread_attr_setdetachstate(&thread_attr,PTHREAD_CREATE_JOINABLE);
for(t=0;t<NUM_OF_TASKS;t++){
printf("creating thread %d, please help me to download %s\n", t, files[t]);
rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);
if (rc){
printf("ERROR; return code from pthread_create() is %d\n", rc);
exit(-1);
}
}
pthread_attr_destroy(&thread_attr);
for(t=0;t<NUM_OF_TASKS;t++){
pthread_join(threads[t],(void**)&downloadtime);
printf("Thread %d downloads the file %s in %d minutes.\n",t,files[t],downloadtime);
}
pthread_exit(NULL);
}
一个运行中的线程可以调用pthread_exit退出线程。这个函数可以传入一个参数转换为 (void *) 类型。这是线程退出的返回值。接下来来看主线程,在这里面列了五个文件名,接下来声明了一个数组,里面有五个pthread_t类型的线程对象。接下来声明一个线程属性pthread_attr_t。通过pthread_attr_init初始化这个属性,并且设置属性PTHREAD_CREATE_JOINABLE。这表示将来主线程程等待这个线程的结束,并获取退出时的状态。
接下来是一个循环。对于每一个文件和每一个线程,可以调用pthread_create创建线程。一共有四个参数,第一个参数是线程对象,第二个参数是线程的属性,第三个参数是线程运行函数,第四个参数是线程运行函数的参数。主线程就是通过第四个参数,将自己的任务派给子线程。任务分配完毕,每个线程下载一个文件,接下来主线程要做的事情就是等待这些子任务完成。当一个线程退出的时候,就会发送信号给其他所有同进程的线程。一个线程使用pthread_join获取这个线程退出的返回值。线程的返回值通过pthread_join传给主线程,这样子线程就将自己下载文件所耗费的时间,告诉给主线程。
程序写完了可以开始编译。多线程程序要依赖于libpthread.so,编译命令如下所示:
gcc download.c -lpthread
执行后得到以下结果:
# ./a.out
creating thread 0, please help me to download file1.avi
creating thread 1, please help me to download file2.rmvb
I am downloading the file file1.avi!
creating thread 2, please help me to download file3.mp4
I am downloading the file file2.rmvb!
creating thread 3, please help me to download file4.wmv
I am downloading the file file3.mp4!
creating thread 4, please help me to download file5.flv
I am downloading the file file4.wmv!
I am downloading the file file5.flv!
I finish downloading the file within 83 minutes!
I finish downloading the file within 77 minutes!
I finish downloading the file within 86 minutes!
I finish downloading the file within 15 minutes!
I finish downloading the file within 93 minutes!
Thread 0 downloads the file file1.avi in 83 minutes.
Thread 1 downloads the file file2.rmvb in 86 minutes.
Thread 2 downloads the file file3.mp4 in 77 minutes.
Thread 3 downloads the file file4.wmv in 93 minutes.
Thread 4 downloads the file file5.flv in 15 minutes.
因此,一个普通线程的创建和运行过程如下所示:
9. 线程可以将项目并行起来加快进度,但是也会带来负面影响,过程是并行起来了,那数据呢?线程访问的数据可以细分成三类:
(1)线程栈上的本地数据,比如函数执行过程中的局部变量。函数的调用会使用栈的模型,这在线程里面是一样的,只不过每个线程都有自己的栈空间。栈的大小可以通过命令ulimit -a查看,默认情况下线程栈大小为8192(8MB),可以使用命令ulimit -s修改。对于线程栈可以通过下面这个函数pthread_attr_t,修改线程栈的大小:
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。
(2)在整个进程里共享的全局数据。例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。如果同一个全局变量,两个线程一起修改,有可能把数据改的面目全非。这就需要有一种机制来保护他们,比如你先用我再用。
(3)线程私有数据(Thread Specific Data)。比如想声明一个线程级别,而非进程级别的全局变量。线程私有数据可以通过如下命令创建:
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
可以看到,创建一个key伴随着一个析构函数。key一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往key中填入不同的值,这相当于提供了一个同名而不同值的全局变量。可以通过下面的函数设置 key对应的value:
void *pthread_getspecific(pthread_key_t key)
还可以通过下面的函数获取key对应的value:
void *pthread_getspecific(pthread_key_t key)
等到线程退出的时候,就会调用析构函数释放value。
10. 关于共享的数据保护问题,有一种方式叫Mutex(Mutual Exclusion,互斥)。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。例如下面“转账”的例子:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUM_OF_TASKS 5
int money_of_tom = 100;
int money_of_jerry = 100;
//第一次运行去掉下面这行
pthread_mutex_t g_money_lock;
void *transfer(void *notused)
{
pthread_t tid = pthread_self();
printf("Thread %u is transfering money!\n", (unsigned int)tid);
//第一次运行去掉下面这行
pthread_mutex_lock(&g_money_lock);
sleep(rand()%10);
money_of_tom+=10;
sleep(rand()%10);
money_of_jerry-=10;
//第一次运行去掉下面这行
pthread_mutex_unlock(&g_money_lock);
printf("Thread %u finish transfering money!\n", (unsigned int)tid);
pthread_exit((void *)0);
}
int main(int argc, char *argv[])
{
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
//第一次运行去掉下面这行
pthread_mutex_init(&g_money_lock, NULL);
for(t=0;t<NUM_OF_TASKS;t++){
rc = pthread_create(&threads[t], NULL, transfer, NULL);
if (rc){
printf("ERROR; return code from pthread_create() is %d\n", rc);
exit(-1);
}
}
for(t=0;t<100;t++){
//第一次运行去掉下面这行
pthread_mutex_lock(&g_money_lock);
printf("money_of_tom + money_of_jerry = %d\n", money_of_tom + money_of_jerry);
//第一次运行去掉下面这行
pthread_mutex_unlock(&g_money_lock);
}
//第一次运行去掉下面这行
pthread_mutex_destroy(&g_money_lock);
pthread_exit(NULL);
}
这里说有两个员工Tom和Jerry,公司食堂的饭卡里面各自有100元,并行启动5个线程,都是Jerry转10元给Tom,主线程不断打印Tom和Jerry的资金之和。按说这样的话总和应该永远是200元。在上面的程序中,先去掉mutex相关的行。在没有锁的保护下,在Tom的账户里面加上10元,在Jerry的账户里面减去10元,这不是一个原子操作。编译运行后的结果如下所示:
[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的使用流程如下:
11. 在使用Mutex时,如果使用pthread_mutex_lock(),那就需要一直在那里等着。如果是pthread_mutex_trylock(),就可以不用等着去干点儿别的,那能不能在轮到我的时候,通知我一下呢?这其实就是条件变量,也就是说如果没事就让大家歇着,有事了就去通知。但是当某个线程接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以条件变量和互斥锁是配合使用的。
例如下面这个例子,老板招聘了三个员工,但是不是有了活才去招聘员工,而是先把员工招来,没有活的时候员工需要在那里等着,一旦有了活要去通知他们,他们要去抢活干(绩效),干完了再等待,再有活就再通知他们。代码如下所示:
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define NUM_OF_TASKS 3
#define MAX_TASK_QUEUE 11
char tasklist[MAX_TASK_QUEUE]="ABCDEFGHIJ";
int head = 0;
int tail = 0;
int quit = 0;
pthread_mutex_t g_task_lock;
pthread_cond_t g_task_cv;
void *coder(void *notused)
{
pthread_t tid = pthread_self();
while(!quit){
pthread_mutex_lock(&g_task_lock);
while(tail == head){
if(quit){
pthread_mutex_unlock(&g_task_lock);
pthread_exit((void *)0);
}
printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);
pthread_cond_wait(&g_task_cv, &g_task_lock);
printf("Have task now! Thread %u is grabing the task !\n", (unsigned int)tid);
}
char task = tasklist[head++];
pthread_mutex_unlock(&g_task_lock);
printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);
sleep(5);
printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);
}
pthread_exit((void *)0);
}
int main(int argc, char *argv[])
{
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
pthread_mutex_init(&g_task_lock, NULL);
pthread_cond_init(&g_task_cv, NULL);
for(t=0;t<NUM_OF_TASKS;t++){
rc = pthread_create(&threads[t], NULL, coder, NULL);
if (rc){
printf("ERROR; return code from pthread_create() is %d\n", rc);
exit(-1);
}
}
sleep(5);
for(t=1;t<=4;t++){
pthread_mutex_lock(&g_task_lock);
tail+=t;
printf("I am Boss, I assigned %d tasks, I notify all coders!\n", t);
pthread_cond_broadcast(&g_task_cv);
pthread_mutex_unlock(&g_task_lock);
sleep(20);
}
pthread_mutex_lock(&g_task_lock);
quit = 1;
pthread_cond_broadcast(&g_task_cv);
pthread_mutex_unlock(&g_task_lock);
pthread_mutex_destroy(&g_task_lock);
pthread_cond_destroy(&g_task_cv);
pthread_exit(NULL);
}
首先创建了10个任务,每个任务一个字符,放在一个数组里面,另外有两个变量head和tail,表示当前分配的工作从哪里开始,到哪里结束。如果head等于tail,则当前的工作分配完毕;如果tail加N,就是新分配了N个工作。接下来声明的pthread_mutex_t g_task_lock和pthread_cond_t g_task_cv是用于通知和抢任务的,工作模式如下图所示:
然后,要判断有没有任务,也就是说head和tail是否相等。如果不相等的话就是有任务,则取出head位置代表的任务task,然后将head加一,这样整个任务就给了这个员工,下个员工来抢活的时候,也需要获取锁,获取之后抢到的就是下一个任务了。当这个员工抢到任务后,pthread_mutex_unlock解锁,让其他员工可以进来抢任务。抢到任务后就开始干活了,这里是sleep也就是摸鱼了5秒。
如果发现head和tail相当,也就是没有任务,则需要调用pthread_cond_wait进行等待,这个函数会把锁也作为变量传进去,这是因为等待的过程中需要解锁,不然就像一个人不干活在等待,还把门锁了别人也干不了活,而且老板(主线程main)也没办法获取锁来分配任务。
一开始三个员工都是在等待的状态,因为初始化的时候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是否相同,不过已经晚了,第一批的1个任务已经被抢走了,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!
12. 写多线程的程序是有套路的,需要记住的是创建线程的套路、mutex使用的套路、条件变量使用的套路,如下所示:
三、进程数据结构
13. 在Linux里,无论是进程还是线程,到了内核里面统一都叫任务(Task),由一个统一的结构task_struct进行管理。如下所示:
Linux的任务管理都应该干些啥?首先所有执行的项目应该有个项目列表,所以Linux内核也应该弄一个链表,将所有的task_struct串起来,如下所示:
struct list_head tasks;
每一个任务都应该有一个ID,作为这个任务的唯一标识。task_struct里面涉及任务ID 的,有下面几个:
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;
既然是ID,有一个就足以做唯一标识了,这个怎么看起来这么麻烦?这是因为上面的进程和线程到了内核这里,统一变成了任务,这就带来两个问题:
(1)任务展示。ps命令可以展示出所有的进程。但是如果到了内核,按照上面的任务列表把这些命令都显示出来,把所有的线程全都平摊开来显示给用户。用户肯定觉得既复杂又困惑。复杂在于列表这么长;困惑在于里面出现了很多并不是自己创建的线程。
(2)给任务下发指令。kill用来给进程发信号,通知进程退出。如果发给了其中一个线程,就不能只退出这个线程,而是应该退出整个进程。当然,有时候也希望只给某个线程发信号。
所以在内核中,进程和线程虽然都是任务,但是应该加以区分。其中,pid是process id,tgid是thread group ID。任何一个进程,如果只有主线程,那pid是自己,tgid是自己,group_leader指向的还是自己。但是如果一个进程创建了其他线程,那就会有所变化了。线程有自己的pid,tgid就是进程的主线程的pid,group_leader指向的就是进程的主线程。有了tgid,就知道tast_struct代表的是一个进程还是代表一个线程了。
14. 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。它们一个是本任务的,一个是线程组共享的。
15. 在task_struct里面,涉及任务状态的是下面这几个变量:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
int exit_state;
unsigned int flags;
state(状态)可以取的值定义在 include/linux/sched.h 头文件中。如下所示:
/* Used in tsk->state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk->exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
#define TASK_PARKED 512
#define TASK_NOLOAD 1024
#define TASK_NEW 2048
#define TASK_STATE_MAX 4096
从定义的数值可以看出,flags是通过二进制比特位的方式设置的,也就是说当前是什么状态,哪一位就置一。进程运行的状态图如下所示:
TASK_RUNNING并不是说进程正在运行,而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得CPU时间片的时候,就是在运行中;如果没有获得时间片,就说明被其他进程抢占了,就等待再次分配时间片。
在运行中的进程,进行一些I/O操作时需要等待磁盘I/O完毕,这个时候会释放占用的CPU,进入睡眠状态。在Linux中有以下几种睡眠状态:
(1)TASK_INTERRUPTIBLE,可中断的睡眠状态。这是一种浅睡眠的状态,也就是说虽然在睡眠等待I/O完成,但是这个时候一个信号来了,进程还是要被唤醒。只不过唤醒后不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个I/O操作完成,直接退出,也可也收到某些信息,继续等待。
(2)TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被信号唤醒,只能死等I/O操作完成。一旦I/O操作因为特殊原因不能完成,这个时候谁也叫不醒这个进程了。Kill命令本身也是一个信号,既然这个状态不可被信号唤醒,kill信号也被忽略了。除非重启电脑没有其他办法。因此这其实是一个比较危险的事情,除非程序员极其有把握,不然不要设置成 TASK_UNINTERRUPTIBLE。
(3)TASK_KILLABLE,可以终止的新睡眠状态。它的运行原理类似TASK_UNINTERRUPTIBLE,只不过可以响应kill信号。从定义可以看出,TASK_WAKEKILL用于在接收到kill信号时唤醒进程,而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。
16. 上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,称为标志,放在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去掉。
17. 进程的状态切换往往涉及调度,下面这些字段都是用于调度的:
//是否在运行队列上
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;
在进程的运行过程中,会有一些统计量,下面列表里有进程在用户态和内核态消耗的时间、上下文切换的次数等等:
u64 utime;//用户态消耗的CPU时间
u64 stime;//内核态消耗的CPU时间
unsigned long nvcsw;//自愿(voluntary)上下文切换计数
unsigned long nivcsw;//非自愿(involuntary)上下文切换计数
u64 start_time;//进程启动时间,不包含睡眠时间
u64 real_start_time;//进程启动时间,包含睡眠时间
从创建进程的过程(fork)可以看出,任何一个进程都有父进程(0、1、2号进程除外)。所以整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系,如下所示:
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 */
其中parent指向其父进程,当它终止时必须向它的父进程发送信号。children表示链表的头部,链表中的所有元素都是它的子进程。sibling用于把当前进程插入到兄弟链表中。这里其实也解释了为什么task_struct要用链表结构,是为了维护多个task之间的关系。一个task节点的parent指针指向其父进程task,children指针指向子进程所有task的头部,然后又靠sibling指针来维护统一级兄弟task。
通常情况下real_parent和parent是一样的,但是也会有另外的情况存在,例如bash创建一个进程,那进程的parent和real_parent就都是bash。如果在bash上使用GDB来debug一个进程,这个时候GDB是parent,bash是这个进程的real_parent。
17. 在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。当操作别的进程时,本进程就是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也是一样的。因为谁启动的进程,就应该审核启动的用户到底有没有这个权限。
但是也有特殊的情况,如下面的例子:
例如用户A想玩一个游戏,这个游戏的程序是用户B安装的。游戏这个程序文件的权限为rwxr--r--。A是没有权限运行这个程序的,于是用户B就给这个程序设定了所有的用户都能执行的权限rwxr-xr-x。于是用户A就获得了运行这个游戏的权限。当游戏运行起来之后,游戏进程的uid、euid、fsuid都是用户A。看起来没有问题,用户A好不容易通关,想保存通关数据的时候,发现这个游戏的玩家数据是保存在另一个文件里面的,这个文件权限是rw-------,只给用户B开了写入权限,而游戏进程的euid和fsuid都是用户 A,当然写不进去了。
这时可以通过chmod u+s命令,给这个游戏程序设置set-user-ID的标识位,把游戏的权限变成rwsr-xr-x。这时用户A再启动这个游戏的时候,创建的进程uid当然还是用户A,但是euid和fsuid就不是用户A了,因为看到了set-user-id标识,就改为文件的所有者的ID,即改成用户B了,这样就能够将通关结果保存下来。在 Linux 里面,一个进程可以随时通过setuid设置用户ID,所以游戏程序的用户B的ID还会保存在另一个地方,这就是suid和sgid,即saved uid和save gid。这样就可以很方便地使用setuid,通过设置uid或者suid来改变权限。
18. 除了以用户和用户组控制权限,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中。
19. 进程列表的数据结构组成如下所示:
在程序执行过程中,一旦调用到系统调用,就需要进入内核继续执行。如何将用户态的执行和内核态的执行串起来?这就需要以下两个重要的成员变量:
struct thread_info thread_info;
void *stack;
在用户态中,程序的执行往往是一个函数调用另一个函数,函数调用都是通过栈来进行的。如果去看汇编语言的代码,其实就是指令跳转,从代码的一个地方跳到另外一个地方。在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的,如下所示:
先来看32位操作系统的情况。在CPU里,ESP(Extended Stack Pointer)是栈顶指针寄存器,入栈操作Push和出栈操作Pop指令会自动调整ESP的值。另外有一个寄存器 EBP(Extended Base Pointer),是栈基地址指针寄存器,指向当前栈帧的最底部。
例如A调用B,A的栈帧里面包含A函数的局部变量,然后是调用B的时候要传给它的参数,然后是返回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个参数有时候需要进行寻址,但是如果在寄存器里面,是没有地址的,因而还是会放到栈里面,只不过放到栈里的操作是被调用函数做的。64位系统的栈结构如下:
20. 以上的栈操作,都是在进程的内存空间里面进行的。接下来通过系统调用,从进程的内存空间到内核中了。内核中也有各种各样的函数调用的,也需要这样一个机制,这时候上面的成员变量stack,也就是内核栈,就派上了用场。Linux给每个task都分配了内核栈。在32位系统上arch/x86/include/asm/page_32_types.h是这样定义的:一个PAGE_SIZE是4K,THREAD_SIZE左移一位就是乘以2,也就是8K,如下所示:
#define THREAD_SIZE_ORDER 1
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
内核栈在64位系统上的arch/x86/include/asm/page_64_types.h,略有不同:在PAGE_SIZE的基础上左移两位(乘以4)即大小是16K,并且要求起始地址必须是8192的整数倍。如下所示:
#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
内核栈是一个非常特殊的结构,如下图所示:
这段空间的最低位置,是一个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,已经指向内核栈的栈顶,在内核态里的调用都有和用户态相似的过程。
21. 如果知道一个task_struct的stack指针,可以通过下面的函数找到这个线程的内核栈:
static inline void *task_stack_page(const struct task_struct *task)
{
return task->stack;
}
从task_struct如何得到相应的pt_regs呢?可以通过下面的函数:
/*
* TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
* This is necessary to guarantee that the entire "struct pt_regs"
* is accessible even if the CPU haven't stored the SS/ESP registers
* on the stack (interrupt gate does not save these registers
* when switching to the same priv ring).
* Therefore beware: accessing the ss/esp fields of the
* "struct pt_regs" is possible, but they may contain the
* completely wrong values.
*/
#define task_pt_regs(task) \
({ \
unsigned long __ptr = (unsigned long)task_stack_page(task); \
__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \
((struct pt_regs *)__ptr) - 1; \
})
可以看到,这是先从task_struct找到内核栈的开始位置。然后这个位置加上THREAD_SIZE就到了最后的位置,然后转换为struct pt_regs,再减一,就相当于减少了一个 pt_regs 的位置,就到了这个结构的首地址。这里面有一个 TOP_OF_KERNEL_STACK_PADDING,这个的定义如下:
#ifdef CONFIG_X86_32
# ifdef CONFIG_VM86
# define TOP_OF_KERNEL_STACK_PADDING 16
# else
# define TOP_OF_KERNEL_STACK_PADDING 8
# endif
#else
# define TOP_OF_KERNEL_STACK_PADDING 0
#endif
也就是说,32位机器上是8,其他是0。这是为什么呢?因为压栈pt_regs有两种情况,CPU用ring来区分权限,从而Linux可以区分内核态和用户态。因此第一种情况,拿涉及从用户态到内核态的变化的系统调用来说,因为涉及权限的改变,会压栈保存SS、ESP寄存器,这两个寄存器共占用8个byte。另一种情况是不涉及权限的变化,就不会压栈这8个byte。这样就会使得两种情况不兼容。如果没有压栈还访问,就会报错,所以还不如预留在这里保证安全。在64位系统上改进了这个问题,变成了定长的。
22. 如果知道task_struct的值,就能够轻松得到内核栈和内核寄存器。那如果一个当前在某个CPU上执行的进程,想知道自己的task_struct在哪里,又该怎么办呢?可以交给thread_info这个结构,如下所示:
struct thread_info {
struct task_struct *task; /* main task structure */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
mm_segment_t addr_limit;
unsigned int sig_on_uaccess_error:1;
unsigned int uaccess_err:1; /* uaccess failed */
};
这里面有个成员变量task指向task_struct,所以常用current_thread_info()->task来获取task_struct,如下所示:
static inline struct thread_info *current_thread_info(void)
{
return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
}
而thread_info的位置就是内核栈的最高位置减去THREAD_SIZE,就到了thread_info的起始地址。而thread_info数据结构里只有一个flags:
struct thread_info {
unsigned long flags; /* low level flags */
};
这时候怎么获取当前运行中的task_struct呢?current_thread_info有了新的实现方式,在include/linux/thread_info.h中定义了current_thread_info。如下所示:
#include <asm/current.h>
#define current_thread_info() ((struct thread_info *)current)
#endif
current又是什么呢?在arch/x86/include/asm/current.h中定义了,如下所示:
struct task_struct;
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
return this_cpu_read_stable(current_task);
}
#define current get_current
这里的get_current就是current,会发现新的机制里面,每个CPU运行的task_struct不通过thread_info获取了,而是直接放在Per CPU变量里面了。多核情况下CPU是同时运行的,但是它们共同使用其他硬件资源时,需要解决多个CPU之间的同步问题。Per CPU变量是内核中一种重要的同步机制,顾名思义就是为每个CPU core构造一个变量的副本,这样多个CPU core各自操作自己的副本互不干涉。比如,当前进程的变量current_task就被声明为Per CPU变量。要使用Per CPU变量,首先要声明这个变量,在arch/x86/include/asm/current.h中:
DECLARE_PER_CPU(struct task_struct *, current_task);
然后是定义这个变量,在arch/x86/kernel/cpu/common.c中有:
DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;
也就是说,系统刚刚初始化时,current_task都指向init_task。当某个CPU上的进程进行切换的时候,current_task被修改为将要切换到的目标进程。例如,进程切换函数__switch_to就会改变current_task,如下所示:
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
......
this_cpu_write(current_task, next_p);
......
return prev_p;
}
其中this_cpu_write函数获取到了下一个要执行的进程,当要获取当前的运行中的task_struct的时候,就需要调用this_cpu_read_stable进行读取,如下所示:
#define this_cpu_read_stable(var) percpu_stable_op("mov", var)
23. 如果说task_struct的其他成员变量都是和进程管理有关的,内核栈是和进程运行有关系的。总结一下32位和64位系统的函数栈工作模式如下,左边是32位的,右边是64位的:
(1)在用户态,应用程序进行了至少一次函数调用。32位和64位的传递参数方式稍有不同,32位的是用函数栈,64位的前6个参数用寄存器,其他的用函数栈。
(2)在内核态,32位和64位都使用内核栈,格式也稍有不同,主要集中在pt_regs结构上。
(3)在内核态,32位和64位的内核栈和task_struct的关联关系不同,32位主要靠thread_info,64位主要靠Per-CPU变量。
四、进程的调度
24. task_struct数据结构就像项目管理系统一样,可以帮项目经理维护项目运行过程中的各类信息,但task_struct仅能够解决“看到”的问题,还要解决如何制定流程,进行进程调度的问题。对于操作系统来讲,CPU的数量是有限的,但是进程数目远远超过CPU的数目,因而需要进行进程的调度,有效地分配CPU的时间,既要保证进程的最快响应,也要保证进程之间的公平。在Linux里面,进程大概可以分成两种:
(1)实时进程,也就是需要尽快执行返回结果的那种,优先级较高。
(2)普通进程,大部分的进程其实都是这种,优先级没实时进程这么高。
很显然,对于这两种进程,调度策略肯定是不同的。在task_struct中,有一个成员变量叫调度策略,如下所示:
unsigned int policy;
它有以下几个定义:
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
配合调度策略的,还有刚才说的优先级,也在task_struct中。如下所示:
int prio, static_prio, normal_prio;
unsigned int rt_priority;
优先级其实就是一个数值,对于实时进程,优先级的范围是0~99;对于普通进程,优先级的范围是100~139。数值越小,优先级越高。
25. 对于实时进程的调度策略,有以下几种:
(1)SCHED_FIFO,相同优先级的进程先来先服务,高优先级的进程可以抢占低优先级的进程。
(2)SCHED_RR轮流调度算法,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,高优先级的任务也是可以抢占低优先级的任务。
(3)SCHED_DEADLINE,按照任务的deadline进行调度。当产生一个调度点的时候,DL调度器总是选择其deadline距离当前时间点最近的那个任务,并调度它执行。
对于普通进程的调度策略,大家都不紧急优先级不高,有以下几种:
(1)SCHED_NORMAL,普通的进程。
(2)SCHED_BATCH,后台进程,几乎不需要和前端进行交互。这有点像公司在接项目同时,开发一些可以复用的模块,作为公司的技术积累,从而使得在之后接新项目的时候,能够减少工作量。这类项目可以默默执行,不要影响需要交互的进程,可以降低他的优先级。
(3)SCHED_IDLE,特别空闲的时候才跑的进程。
上面无论是policy还是priority,都设置了一个变量,变量仅仅表示了应该这样干,但事情总要有人去干,是谁呢?在task_struct里面,还有这样的成员变量:
const struct sched_class *sched_class;
调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。sched_class有几种实现:
(1)stop_sched_class,优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
(2)dl_sched_class,对应上面的deadline调度策略;
(3)rt_sched_class,对应RR算法或者FIFO算法的调度策略,具体调度策略由进程的task_struct->policy指定;
(4)fair_sched_class,普通进程的调度策略;
(5)idle_sched_class,空闲进程的调度策略。
26. 由于平常遇到的都是普通进程,在这里就重点分析普通进程的调度问题。普通进程使用的调度策略是fair_sched_class,顾名思义,对于普通进程来讲公平是最重要的。在Linux里实现了一个基于CFS(Completely Fair Scheduling,完全公平调度)的调度算法。
首先,需要记录下进程的运行时间。CPU会提供一个时钟,过一段时间就触发一个时钟中断,就像表滴答一下,这个叫Tick。CFS会为每一个进程安排一个虚拟运行时间vruntime。如果一个进程在运行,随着时间的增长,也就是一个个tick的到来,进程的vruntime将不断增大。没有得到执行的进程vruntime不变。显然,那些vruntime少的,原来受到了不公平的对待,需要给它补上,所以会优先运行这样的进程。
那如何给优先级高的进程多分时间呢?这就相当于N个口袋,优先级高的袋子大,优先级低的袋子小。这样球就不能按照个数分配了,要按照比例来,大口袋的放了一半和小口袋放了一半,里面的球数目虽然差很多,也认为是公平的。在更新进程运行的统计量的时候,其实可以看出这个逻辑,如下所示:
/*
* Update the current task's runtime statistics.
*/
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
......
delta_exec = now - curr->exec_start;
......
curr->exec_start = now;
......
curr->sum_exec_runtime += delta_exec;
......
curr->vruntime += calc_delta_fair(delta_exec, curr);
update_min_vruntime(cfs_rq);
......
}
/*
* delta /= w
*/
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
/* delta_exec * weight / lw.weight */
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
return delta;
}
update_curr()这个函数很重要,后面还会很多次看见它。在这里面得到当前的时间,以及这次的时间片开始的时间,两者相减就是这次运行的时间 delta_exec ,但是得到的这个时间其实是实际运行的时间,需要做一定的转化才作为虚拟运行时间 vruntime。转化方法如下:
虚拟运行时间vruntime += 实际运行时间delta_exec * NICE_0_LOAD / 权重
也就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的vruntime来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200斤的胖子吃两个馒头,和给一个体重100斤的瘦子吃一个馒头,然后说两个吃的是一样多。
27. 看来CFS需要一个数据结构来对vruntime进行排序,找出最小的那个。这个能够排序的数据结构不但需要查询的时候,能够快速找到最小的,更新的时候也需要能够快速地调整排序,因为vruntime经常在变,变了再插入这个数据结构就需要重新排序。能够平衡查询和更新速度的是树,在这里使用的是红黑树。红黑树的的节点是应该包括vruntime的,称为调度实体。
在task_struct中有这样的成员变量:
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
这里有实时调度实体sched_rt_entity,Deadline调度实体sched_dl_entity,以及完全公平算法调度实体sched_entity。看来不光CFS调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。而进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是个普通进程,则通过sched_entity,将自己挂在公平调度算法的这棵红黑树上。
对于普通进程的调度实体定义如下,这里面包含了vruntime和权重load_weight,以及对于运行时间的统计:
struct sched_entity {
struct load_weight load;
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
u64 nr_migrations;
struct sched_statistics statistics;
......
};
下图是一个红黑树的例子:
所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中,vruntime最小的在树的左侧,vruntime最多的在树的右侧。CFS调度策略会选择红黑树最左边的叶子节点作为下一个将获得CPU的任务。这棵红黑树放在那里呢?每个CPU都有自己的struct rq结构,用于描述在此CPU上所运行的所有进程,其包括一个实时进程队列rt_rq和一个CFS运行队列cfs_rq,在调度时调度器首先会去实时进程队列,找是否有实时进程需要运行,如果没有才会去CFS运行队列找是否有进行需要运行。rq(run queue)的定义如下所示:
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指向的是最左面的节点。到这里终于凑够数据结构了,上面这些数据结构的关系如下图:
28. 凑够了数据结构,接下来看调度类是如何工作的。调度类的定义如下:
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循环,沿着上面的顺序,依次调用每个调度类(class)的方法,如下所示:
/*
* Pick up the highest-prio task:
*/
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
......
for_each_class(class) {
p = class->pick_next_task(rq, prev, rf);
if (p) {
if (unlikely(p == RETRY_TASK))
goto again;
return p;
}
}
}
这就说明,调度的时候是从优先级最高的调度类到优先级低的调度类,依次执行。而对于每种调度类,有自己的实现,例如CFS就有fair_sched_class,如下所示:
const struct sched_class fair_sched_class = {
.next = &idle_sched_class,
.enqueue_task = enqueue_task_fair,
.dequeue_task = dequeue_task_fair,
.yield_task = yield_task_fair,
.yield_to_task = yield_to_task_fair,
.check_preempt_curr = check_preempt_wakeup,
.pick_next_task = pick_next_task_fair,
.put_prev_task = put_prev_task_fair,
.set_curr_task = set_curr_task_fair,
.task_tick = task_tick_fair,
.task_fork = task_fork_fair,
.prio_changed = prio_changed_fair,
.switched_from = switched_from_fair,
.switched_to = switched_to_fair,
.get_rr_interval = get_rr_interval_fair,
.update_curr = update_curr_fair,
};
从上面几个等号来看,对于同样的pick_next_task,即选取下一个要运行的任务这个动作,不同的调度类有自己的实现。fair_sched_class的实现是pick_next_task_fair,rt_sched_class的实现是pick_next_task_rt。可以发现这两个函数是操作不同的队列,pick_next_task_rt操作的是rt_rq,pick_next_task_fair操作的是cfs_rq。其中pick_next_task_rt的逻辑如下所示:
static struct task_struct *
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct task_struct *p;
struct rt_rq *rt_rq = &rq->rt;
......
}
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;
......
}
这样整个运行的场景就串起来了,在每个CPU上都有一个队列rq,这个队列里面包含多个子队列,例如rt_rq和cfs_rq,不同的队列有不同的实现方式,cfs_rq就是用红黑树实现的。当某个CPU需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然rt_sched_class先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
29. 下面仔细看一下sched_class定义的与调度有关的函数:
(1)enqueue_task向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
(2)dequeue_task将一个进程从就就绪队列中删除;
(3)pick_next_task选择接下来要运行的进程;
(4)put_prev_task用另一个进程代替当前运行的进程;
(5)set_curr_task用于修改调度策略;
(6)task_tick,每次周期性时钟到的时候,这个函数被调用,可能触发调度。
在这里面重点看fair_sched_class对于pick_next_task的实现pick_next_task_fair,获取下一个进程。调用路径如下:pick_next_task_fair->pick_next_entity->__pick_first_entity,如下所示:
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。因此,调度相关的数据结构还是比较复杂的。一个CPU上有一个队列,CFS的队列是一棵红黑树,树的每一个节点都是一个sched_entity,每个sched_entity都属于一个task_struct,task_struct里面有指针指向这个进程属于哪个调度类。在调度的时候,依次调用调度类的函数,从CPU的队列中取出下一个进程。各结构的关系如下所示:
30. 所谓进程调度,其实就像一个人在做A项目,在某个时刻换成做B项目去了。发生这种情况,主要有两种方式。
(1)A项目做着做着,发现里面有一条指令sleep要休息一下,或者在等待某个I/O事件,那就要主动让出CPU,然后可以开始做B项目。
(2)A项目做着做着,旷日持久,项目经理介入了说这个项目A先停停,B项目也要做一下。
先来看第一种主动调度的方式。例如Btrfs等待一个写入,写入需要一段时间,这段时间用不上CPU,还不如调用schedule()主动让给其他进程。还有一个例子是从Tap网络设备等待一个读取。Tap网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把CPU让给其他进程,这里也调用了schedule(),如下所示:
static ssize_t tap_do_read(struct tap_queue *q,
struct iov_iter *to,
int noblock, struct sk_buff *skb)
{
......
while (1) {
if (!noblock)
prepare_to_wait(sk_sleep(&q->sk), &wait,
TASK_INTERRUPTIBLE);
......
/* Nothing to read, let's sleep */
schedule();
}
......
}
计算主要是CPU和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出CPU, schedule() 函数的调用逻辑如下:
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
sched_submit_work(tsk);
do {
preempt_disable();
__schedule(false);
sched_preempt_enable_no_resched();
} while (need_resched());
}
这段代码的主要逻辑是在__schedule函数中实现的。这个函数比较复杂,分几个部分来看,先看第一部分:
static void __sched notrace __schedule(bool preempt)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
......
首先在当前的CPU上,取出任务队列rq。task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr,因为一旦将来它被切换下来,那它就成了前任了。接下来代码如下:
next = pick_next_task(rq, prev, &rf);
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
第二步,获取下一个任务,task_struct *next指向下一个任务,这就是继任。pick_next_task的实现如下:
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
/*
* Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
*/
if (likely((prev->sched_class == &idle_sched_class ||
prev->sched_class == &fair_sched_class) &&
rq->nr_running == rq->cfs.h_nr_running)) {
p = fair_sched_class.pick_next_task(rq, prev, rf);
if (unlikely(p == RETRY_TASK))
goto again;
/* Assumes fair_sched_class->next == idle_sched_class */
if (unlikely(!p))
p = idle_sched_class.pick_next_task(rq, prev, rf);
return p;
}
again:
for_each_class(class) {
p = class->pick_next_task(rq, prev, rf);
if (p) {
if (unlikely(p == RETRY_TASK))
goto again;
return p;
}
}
}
直接来看again这里,就是之前讲的for_each_class会依次调用调度类,会调用每一个调度类的pick_next_task。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下不用每一个调度类都过一遍,即直接调用针对普通进程的公平调度器类,就是 fair_sched_class.pick_next_task。根据之前对于fair_sched_class的定义,它调用的是pick_next_task_fair,代码如下:
static struct task_struct *
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;
int new_tasks;
对于CFS调度类,取出相应的队列cfs_rq,这就是之前讲过的那棵红黑树。如下所示:
struct sched_entity *curr = cfs_rq->curr;
if (curr) {
if (curr->on_rq)
update_curr(cfs_rq);
else
curr = NULL;
......
}
se = pick_next_entity(cfs_rq, curr);
从红黑树上取出当前正在运行的任务curr,如果依然是可运行的状态,也即处于进程就绪状态,则调用update_curr 更新vruntime。update_curr之前就见过了,它会根据实际运行时间算出vruntime来。接着pick_next_entity从红黑树里面取最左边的一个节点。这个函数的实现之前也讲过。再看下一部分代码:
p = task_of(se);
if (prev != p) {
struct sched_entity *pse = &prev->se;
......
put_prev_entity(cfs_rq, pse);
set_next_entity(cfs_rq, se);
}
return p
task_of得到下一个调度实体对应的task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的vruntime更新过了,put_prev_entity放回红黑树,会找到相应的位置,然后set_next_entity将继任者设为当前任务。
31. 当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行,调用context_switch,如下所示:
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
++*switch_count;
......
rq = context_switch(rq, prev, next, &rf);
上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和CPU上下文。先来看context_switch的实现,如下所示:
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
struct mm_struct *mm, *oldmm;
......
mm = next->mm;
oldmm = prev->active_mm;
......
switch_mm_irqs_off(oldmm, mm, next);
......
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
这里首先是内存空间的切换,调用的是switch_mm_irqs_off。接下来重点看switch_to,它就是寄存器和栈的切换,它调用到了__switch_to_asm。这是一段汇编代码,主要用于栈的切换。对于32位操作系统来讲,切换的是栈顶指针esp。该汇编代码如下:
/*
* %eax: prev task
* %edx: next task
*/
ENTRY(__switch_to_asm)
......
/* switch stack */
movl %esp, TASK_threadsp(%eax)
movl TASK_threadsp(%edx), %esp
......
jmp __switch_to
END(__switch_to_asm)
对于64位操作系统来讲,切换的是栈顶指针rsp,如下所示:
/*
* %rdi: prev task
* %rsi: next task
*/
ENTRY(__switch_to_asm)
......
/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp
......
jmp __switch_to
END(__switch_to_asm)
最终都返回了__switch_to 这个函数。这个函数对于32位和64位操作系统虽然有不同的实现,但里面做的事情是差不多的。这里仅列出64位操作系统做的事情,如下所示:
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread;
struct thread_struct *next = &next_p->thread;
......
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
......
load_TLS(next, cpu);
......
this_cpu_write(current_task, next_p);
/* Reload esp0 and ss1. This changes current_thread_info(). */
load_sp0(tss, next);
......
return prev_p;
}
这里面有一个Per CPU的结构体tss。这是个什么呢?在x86体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86希望在内存里面维护一个TSS(Task State Segment,任务状态段)结构,这里面有所有的寄存器值。另外还有一个特殊的寄存器TR(Task Register,任务寄存器),指向某个进程的TSS。更改TR的值,将会触发硬件保存CPU所有寄存器的值到当前进程的TSS中,然后从新进程的TSS中读出所有寄存器值,加载到CPU对应的寄存器中。下图就是32位的TSS结构:
可以看到保存的值还是很多的,这样有个缺点,做进程切换的时候,没必要每个寄存器都切换,这样每个进程一个TSS,就需要全量保存,TR更改后全量切换开销太大了。于是Linux系统想了一个办法,系统初始化的时候会调用cpu_init,这里面会给每一个CPU关联一个TSS,然后将TR指向这个TSS,然后在操作系统的运行过程中,TR就不切换了,永远指向这个TSS。TSS用数据结构tss_struct表示,在x86_hw_tss中可以看到和上图相应的结构,如下所示:
void cpu_init(void)
{
int cpu = smp_processor_id();
struct task_struct *curr = current;
struct tss_struct *t = &per_cpu(cpu_tss, cpu);
......
load_sp0(t, thread);
set_tss_desc(cpu, t);
load_TR_desc();
......
}
struct tss_struct {
/*
* The hardware state:
*/
struct x86_hw_tss x86_tss;
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
}
在Linux中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。于是在task_struct里面,还有一个原来没有注意的成员变量thread,这里面保留了要切换进程的时候需要修改的寄存器。如下所示:
/* CPU-specific state of this task: */
struct thread_struct thread;
所谓的进程切换,就是将某个进程的thread_struct里面的寄存器的值,写入到CPU的TR指向的tss_struct,对CPU来讲,这就算是完成了切换。例如前面__switch_to中的load_sp0,就是将下一个进程的thread_struct的sp0(栈顶指针)的值加载到tss_struct里面去。
32. 进程主动调度的过程如下所示,即一个运行中的进程主动调用__schedule让出CPU,在__schedule里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换:
主动调度,即由于IO等操作主动让出CPU是进程调度的第一种方式。第二种是被动的,就是抢占式调度。最常见的现象就是一个进程执行时间太长了,是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢?在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统,时间又过去一个时钟周期,可以查看是否是需要抢占的时间点。时钟中断处理函数会调用scheduler_tick(),代码如下:
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
......
curr->sched_class->task_tick(rq, curr, 0);
cpu_load_update_active(rq);
calc_global_load_tick(rq);
......
}
这个函数先取出当前cpu的运行队列,然后得到这个队列上当前正在运行中的进程的task_struct,然后调用这个task_struct的调度类的task_tick函数,顾名思义这个函数就是来处理时钟事件的。如果当前运行的进程是普通进程,则调度类为fair_sched_class,调用的处理时钟的函数为 task_tick_fair。来看一下它的实现:
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued);
}
......
}
根据当前进程的task_struct,找到对应的调度实体sched_entity和cfs_rq队列,调用entity_tick。entity_tick的实现如下所示:
static void
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
{
update_curr(cfs_rq);
update_load_avg(curr, UPDATE_TG);
update_cfs_shares(curr);
.....
if (cfs_rq->nr_running > 1)
check_preempt_tick(cfs_rq, curr);
}
在entity_tick里面,又见到了熟悉的update_curr,它会更新当前进程的vruntime,然后调用check_preempt_tick,顾名思义,检查是否是时候该被抢占了,check_preempt_tick的实现如下所示:
static void
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
s64 delta;
ideal_runtime = sched_slice(cfs_rq, curr);
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
if (delta_exec > ideal_runtime) {
resched_curr(rq_of(cfs_rq));
return;
}
......
se = __pick_first_entity(cfs_rq);
delta = curr->vruntime - se->vruntime;
if (delta < 0)
return;
if (delta > ideal_runtime)
resched_curr(rq_of(cfs_rq));
}
check_preempt_tick先是调用sched_slice函数计算出ideal_runtime,它就是一个调度周期中,这个进程应该运行的实际时间。sum_exec_runtime指进程总共执行的实际时间,prev_sum_exec_runtime指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的se->prev_sum_exec_runtime = se->sum_exec_runtime,所以sum_exec_runtime-prev_sum_exec_runtime就是这次调度占用实际时间。如果这个时间大于ideal_runtime,则应该被抢占了。
除了这个条件之外,上面还会通过__pick_first_entity取出红黑树中最小的进程。如果当前进程的vruntime大于红黑树中最小的进程的vruntime,且这个差值大于ideal_runtime,则也应该被抢占了。
当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。为什么呢?因为所有进程调度必须经过__schedule()函数,一定要等待正在运行的进程调用__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检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走当然进程,而也是将当前进程标记为应该被抢占。ttwu_do_wakeup的逻辑如下所示:
static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
struct rq_flags *rf)
{
check_preempt_curr(rq, p, wake_flags);
p->state = TASK_RUNNING;
trace_sched_wakeup(p);
33. 到这里会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下__schedule。当然不可能某个进程代码运行着,突然要去调用__schedule,代码里不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。
先来看用户态的抢占时机。对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。在系统调用的时候,64 位的系统调用链路为do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop,现在来看一下exit_to_usermode_loop这个函数:
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
{
while (true) {
/* We have work to do. */
local_irq_enable();
if (cached_flags & _TIF_NEED_RESCHED)
schedule();
......
}
}
在exit_to_usermode_loop函数中,上面打的标记起了作用,如果被打了_TIF_NEED_RESCHED,就会调用schedule()进行调度,调用的过程会选择一个进程让出CPU,做上下文切换。
对于用户态的进程来说,从中断中返回的那个时刻,也是一个被抢占的时机。在arch/x86/entry/entry_64.S中有中断的处理过程,是一段汇编代码,重点领会它的意思就行,不需每一行看懂:
common_interrupt:
ASM_CLAC
addq $-0x80, (%rsp)
interrupt do_IRQ
ret_from_intr:
popq %rsp
testb $3, CS(%rsp)
jz retint_kernel
/* Interrupt came from user space */
GLOBAL(retint_user)
mov %rsp,%rdi
call prepare_exit_to_usermode
TRACE_IRQS_IRETQ
SWAPGS
jmp restore_regs_and_iret
/* Returning to kernel space */
retint_kernel:
#ifdef CONFIG_PREEMPT
bt $9, EFLAGS(%rsp)
jnc 1f
0: cmpl $0, PER_CPU_VAR(__preempt_count)
jnz 1f
call preempt_schedule_irq
jmp 0b
中断处理调用的是do_IRQ函数,中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态,这个通过注释也能看出来。先来看返回用户态这一部分,先不管返回内核态的那部分代码,retint_user会调用prepare_exit_to_usermode,最终调用exit_to_usermode_loop,和上面的逻辑一样,发现有标记则调用schedule()。
34. 接下来看内核态的抢占时机。对内核态的执行中,被抢占的时机一般发生在preempt_enable()中。在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用preempt_disable()关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。就像下面代码中展示的一样,preempt_enable()会调用preempt_count_dec_and_test(),判断preempt_count和TIF_NEED_RESCHED看是否可以被抢占。如果可以,就调用preempt_schedule->preempt_schedule_common->__schedule进行调度,这里还是满足进程调度必须使用__schedule的规律的:
#define preempt_enable() \
do { \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
#define preempt_count_dec_and_test() \
({ preempt_count_sub(1); should_resched(0); })
static __always_inline bool should_resched(int preempt_offset)
{
return unlikely(preempt_count() == preempt_offset &&
tif_need_resched());
}
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)
static void __sched notrace preempt_schedule_common(void)
{
do {
......
__schedule(true);
......
} while (need_resched())
在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态,这个时候也是一个执行抢占的时机。现在再来上面中断返回的代码中返回内核的那部分汇编代码,调用的是preempt_schedule_irq,如下所示:
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
......
do {
preempt_disable();
local_irq_enable();
__schedule(true);
local_irq_disable();
sched_preempt_enable_no_resched();
} while (need_resched());
......
}
果然,preempt_schedule_irq里还是调用了__schedule进行进程的实际调度。
35. 整个进程的调度体系如下图所示:
里面第一条就是进程调度的核心函数__schedule的执行过程,第二条总结了标记为可抢占的场景,第三条是所有的抢占发生的时机,这里是真正验证了进程调度必须经过__schedule这个函数的规律。
五、进程的创建
36. 之前提到过如何使用fork创建进程,那么来看一看创建进程这个动作在内核里都做了什么事情。fork是一个系统调用,调用流程的最后会在sys_call_table中找到相应的系统调用sys_fork。根据SYSCALL_DEFINE0这个宏的定义,下面这段代码就定义了sys_fork,如下所示:
SYSCALL_DEFINE0(fork)
{
......
return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
}
可以看到sys_fork会调用_do_fork,_do_fork的逻辑如下所示:
long _do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr,
unsigned long tls)
{
struct task_struct *p;
int trace = 0;
long nr;
......
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace, tls, NUMA_NO_NODE);
......
if (!IS_ERR(p)) {
struct pid *pid;
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
......
wake_up_new_task(p);
......
put_pid(pid);
}
......
_do_fork里面做的第一件事就是copy_process,即通过复制父进程的方式来创建进程。这里再把task_struct的结构图拿出来,对比着看如何一个个复制,如下所示:
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);
首先copy_process 调用的是dup_task_struct,它主要做了下面几件事情:
(1)调用alloc_task_struct_node分配一个task_struct结构;
(2)调用alloc_thread_stack_node来创建内核栈,这里面调用__vmalloc_node_range分配一个连续的THREAD_SIZE的内存空间,赋值给task_struct的void *stack成员变量;
(3)调用arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),将task_struct进行复制,其实就是调用memcpy;
(4)调用setup_thread_stack设置thread_info。
到这里整个task_struct复制了一份,而且内核栈也创建好了。再接着看copy_process,如下所示:
retval = copy_creds(p, clone_flags);
轮到权限相关了,copy_creds主要做了下面两件事情:
(1)调用prepare_creds,准备一个新的struct cred *new。如何准备呢?其实还是从内存中分配一个新的struct cred结构,然后调用memcpy复制一份父进程的cred;
(2)接着p->cred = p->real_cred = get_cred(new),将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的cred。
接下来,copy_process重新设置进程运行的统计量。如下所示:
p->utime = p->stime = p->gtime = 0;
p->start_time = ktime_get_ns();
p->real_start_time = ktime_get_boot_ns();
接下来,copy_process开始设置调度相关的变量。如下所示:
retval = sched_fork(clone_flags, p);
sched_fork主要做了下面几件事情:
(1)调用__sched_fork,在这里面将on_rq设为0,初始化sched_entity,将里面的exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime都设为 0,这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了,就靠它们几个;
(2)设置进程的状态p->state = TASK_NEW;
(3)初始化优先级prio、normal_prio、static_prio;
(4)设置调度类,如果是普通进程,就设置为p->sched_class = &fair_sched_class;
(5)调用调度类的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,也就是说,把父进程设置为应该被调度,这样下次调度的时候,父进程会被子进程抢占。
接下来,copy_process开始初始化与文件和文件系统相关的变量。如下所示:
retval = copy_files(clone_flags, p);
retval = copy_fs(clone_flags, p);
copy_files主要用于复制一个进程打开的文件信息。这些信息用一个结构files_struct来维护,每个打开的文件都有一个文件描述符。在copy_files函数里面调用dup_fd,在这里会创建一个新的files_struct,然后将所有的文件描述符数组fdtable拷贝一份。
copy_fs主要用于复制一个进程的目录信息。这些信息用一个结构fs_struct来维护。一个进程有自己的根目录和根文件系统root,也有当前目录pwd和当前目录的文件系统,都在fs_struct里面维护。copy_fs函数里面调用copy_fs_struct,创建一个新的fs_struct,并复制原来进程的fs_struct。
接下来,copy_process开始初始化与信号相关的变量。如下所示:
init_sigpending(&p->pending);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
copy_sighand会分配一个新的sighand_struct。这里最主要的是维护信号处理函数,在copy_sighand里会调用memcpy,将信号处理函数sighand->action从父进程复制到子进程。init_sigpending和copy_signal用于初始化,并且复制用于维护发给这个进程的信号的数据结构。copy_signal函数会分配一个新的signal_struct,并进行初始化。
接下来,copy_process开始复制进程内存空间。如下所示:
retval = copy_mm(clone_flags, p);
各进程都自己的内存空间,用mm_struct结构来表示。copy_mm函数中调用dup_mm,分配一个新的mm_struct结构,调用memcpy复制这个结构。dup_mmap用于复制内存空间中内存映射的部分。在系统调用中提到过,mmap可以分配大块的内存,其实mmap也可以将一个文件映射到内存中,方便可以像读写内存一样读写文件。
接下来,copy_process开始分配pid,设置tid、group_leader,并且建立进程之间的亲缘关系。如下所示:
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
......
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->exit_signal = -1;
p->group_leader = current->group_leader;
p->tgid = current->tgid;
} else {
if (clone_flags & CLONE_PARENT)
p->exit_signal = current->group_leader->exit_signal;
else
p->exit_signal = (clone_flags & CSIGNAL);
p->group_leader = p;
p->tgid = p->pid;
}
......
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent;
p->parent_exec_id = current->parent_exec_id;
} else {
p->real_parent = current;
p->parent_exec_id = current->self_exec_id;
}
经过了这样一堆流程之后,上面图中的组件也初始化的差不多了。
37. _do_fork做的第二件事是wake_up_new_task。新任务刚刚建立,有没有机会抢占获得CPU呢?wake_up_new_task的逻辑如下所示:
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;
......
p->state = TASK_RUNNING;
......
activate_task(rq, p, ENQUEUE_NOCLOCK);
p->on_rq = TASK_ON_RQ_QUEUED;
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK);
......
}
首先,需要将进程的状态设置为TASK_RUNNING,即就绪可以运行的状态。接下来activate_task函数中会调用enqueue_task,如下所示:
static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
.....
p->sched_class->enqueue_task(rq, p, flags);
}
如果是CFS的调度类,则执行相应的enqueue_task_fair,如下所示:
static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
struct cfs_rq *cfs_rq;
struct sched_entity *se = &p->se;
......
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags);
......
cfs_rq->h_nr_running++;
......
}
在enqueue_task_fair中取出的队列就是cfs_rq,然后调用enqueue_entity。在enqueue_entity函数里面,会调用update_curr,更新运行的统计量,然后调用__enqueue_entity,将sched_entity加入到红黑树里面,然后设置se->on_rq = 1代表在队列上。回到enqueue_task_fair后,将这个队列上运行的进程数目加一。
然后,上面的wake_up_new_task会调用check_preempt_curr,看是否能够抢占当前进程。在check_preempt_curr中,会调用相应调度类的rq->curr->sched_class->check_preempt_curr(rq, p, flags),对于CFS调度类来讲,调用的是check_preempt_wakeup,如下所示:
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
struct task_struct *curr = rq->curr;
struct sched_entity *se = &curr->se, *pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
......
if (test_tsk_need_resched(curr))
return;
......
find_matching_se(&se, &pse);
update_curr(cfs_rq_of(se));
if (wakeup_preempt_entity(se, pse) == 1) {
goto preempt;
}
return;
preempt:
resched_curr(rq);
......
}
在check_preempt_wakeup函数中,前面调用task_fork_fair的时候,如果设置了sysctl_sched_child_runs_first,就已经将当前父进程的TIF_NEED_RESCHED设置了,则直接返回。否则,check_preempt_wakeup还是会调用update_curr更新一次统计量,然后wakeup_preempt_entity将父进程和子进程PK一次,看是不是要抢占,如果要则调用resched_curr标记父进程为TIF_NEED_RESCHED。
如果新创建的进程应该抢占父进程,在什么时间抢占呢?别忘了fork是一个系统调用,从系统调用返回的时候,是抢占的一个好时机,如果父进程判断自己已经被设置为TIF_NEED_RESCHED,就让子进程先跑,抢占自己。
38. fork系统调用的过程包含两个重要的事件,一个是将task_struct结构复制一份并且初始化,另一个是试图唤醒新创建的子进程。该过程如下图所示:
这个图的上半部分是复制task_struct结构,可以对照着右面的task_struct结构图,看这里面的成员是如何一部分一部分的被复制的。图的下半部分是唤醒新创建的子进程,如果条件满足就会将当前进程设置应该被调度的标识位,就等着当前进程执行__schedule了。
六、线程的创建
38. 创建一个线程调用的是pthread_create,但它背后的机制依然需要分析。无论是进程还是线程,在内核里面都是任务,管起来不是都一样吗?如果不一样,那怎么在内核里面加以区分呢?其实,线程不是一个完全由内核实现的机制,它是由内核态和用户态合作完成的。pthread_create不是一个系统调用,是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);
首先它处理的是线程的属性参数。例如写程序的时候,通过设置attr来设置线程栈大小。如果没有传入线程属性attr,就取默认值,就像下面的代码所示:
const struct pthread_attr *iattr = (struct pthread_attr *) attr;
struct pthread_attr default_attr;
if (iattr == NULL)
{
......
iattr = &default_attr;
}
接下来,就像在内核里一样,每一个进程或者线程都有一个task_struct结构,在用户态也有一个用于维护线程的结构,就是这个pthread结构,如下所示:
struct pthread *pd = NULL;
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈,那接下来就是创建线程栈了,如下所示:
int err = ALLOCATE_STACK (iattr, &pd);
ALLOCATE_STACK是一个宏,找到它的定义之后,发现它其实就是一个函数。只是这个函数有些复杂,所以这里只把主要的代码列一下,如下所示:
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)
static int
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
ALLOCATE_STACK_PARMS)
{
struct pthread *pd;
size_t size;
size_t pagesize_m1 = __getpagesize () - 1;
......
size = attr->stacksize;
......
/* Allocate some anonymous memory. If possible use the cache. */
size_t guardsize;
void *mem;
const int prot = (PROT_READ | PROT_WRITE
| ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
/* Adjust the stack size for alignment. */
size &= ~__static_tls_align_m1;
/* Make sure the size of the stack is enough for the guard and
eventually the thread descriptor. */
guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
size += guardsize;
pd = get_cached_stack (&size, &mem);
if (pd == NULL)
{
/* If a guard page is required, avoid committing memory by first
allocate with PROT_NONE and then reserve with required permission
excluding the guard page. */
mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
/* Place the thread descriptor at the end of the stack. */
#if TLS_TCB_AT_TP
pd = (struct pthread *) ((char *) mem + size) - 1;
#elif TLS_DTV_AT_TP
pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
#endif
/* Now mprotect the required region excluding the guard area. */
char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
setup_stack_prot (mem, size, guard, guardsize, prot);
pd->stackblock = mem;
pd->stackblock_size = size;
pd->guardsize = guardsize;
pd->specific[0] = pd->specific_1stblock;
/* And add to the list of stacks in use. */
stack_list_add (&pd->list, &stack_used);
}
*pdp = pd;
void *stacktop;
# if TLS_TCB_AT_TP
/* The stack begins before the TCB and the static TLS block. */
stacktop = ((char *) (pd + 1) - __static_tls_size);
# elif TLS_DTV_AT_TP
stacktop = (char *) (pd - 1);
# endif
*stack = stacktop;
......
}
allocate_stack主要做了以下这些事情:
(1)如果在线程属性里面设置过栈的大小,需要把设置的值拿出来;
(2)为了防止栈的访问越界,在栈的末尾会有一块空间guardsize,一旦访问到这里就错误了;
(3)其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程,不可能不断地去申请和清除线程栈使用的内存块,这样就需要有一个缓存。get_cached_stack就是根据计算出来的size大小,看一看已经有的缓存中,有没有已经能够满足条件的;如果缓存里面没有,就需要调用__mmap创建一块新的;
(4)线程栈也是自顶向下生长的,每个线程要有一个pthread结构,这个结构也是放在栈的空间里面的,在栈底的位置,其实是地址最高位;
(5)计算出guard内存的位置,调用setup_stack_prot设置这块内存是受保护的;
(6)接下来,开始填充 pthread 这个结构里面的成员变量 stackblock、stackblock_size、guardsize、specific。这里的 specific 是用于存放 Thread Specific Data 的,也即属于线程的全局变量;
(7)将这个线程栈放到stack_used链表中,其实管理线程栈总共有两个链表,一个是stack_used,也就是这个栈正被使用;另一个是stack_cache,就是上面说的,一旦线程结束先缓存起来不释放,等有其他的线程创建的时候,给其他的线程用。
39. 搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。接下来接着pthread_create看。其实有了用户态的栈,接着需要解决的就是用户态的程序从哪里开始运行的问题,如下所示:
pd->start_routine = start_routine;
pd->arg = arg;
pd->schedpolicy = self->schedpolicy;
pd->schedparam = self->schedparam;
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
atomic_increment (&__nptl_nthreads);
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);
start_routine就是给线程的函数,start_routine、start_routine的参数arg、调度策略都要赋值给pthread。接下来__nptl_nthreads加一,说明有多了一个线程。真正创建线程的是调用create_thread函数,这个函数定义如下:
static int
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
/* It's started now, so if we fail below, we'll have to cancel it
and let it clean itself up. */
*thread_ran = true;
}
这里面有很长的clone_flags,接下来的过程要特别的关注一下这些标志位。然后就是ARCH_CLONE,其实调用的是__clone。看到这里应该就有感觉了,马上就要到系统调用了,如下所示:
# define ARCH_CLONE __clone
/* The userland implementation is:
int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
the kernel entry is:
int clone (long flags, void *child_stack).
The parameters are passed in register and on the stack from userland:
rdi: fn
rsi: child_stack
rdx: flags
rcx: arg
r8d: TID field in parent
r9d: thread pointer
%esp+8: TID field in child
The kernel expects:
rax: system call number
rdi: flags
rsi: child_stack
rdx: TID field in parent
r10: TID field in child
r8: thread pointer */
.text
ENTRY (__clone)
movq $-EINVAL,%rax
......
/* Insert the argument onto the new stack. */
subq $16,%rsi
movq %rcx,8(%rsi)
/* Save the function pointer. It will be popped off in the
child in the ebx frobbing below. */
movq %rdi,0(%rsi)
/* Do the system call. */
movq %rdx, %rdi
movq %r8, %rdx
movq %r9, %r8
mov 8(%rsp), %R10_LP
movl $SYS_ify(clone),%eax
......
syscall
......
PSEUDO_END (__clone)
如果对于汇编不太熟悉也没关系,可以重点看上面的注释,能看到最后调用了syscall,这一点clone和其他系统调用几乎是一致的。但是,也有少许不一样的地方。如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行调用clone的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
但是对于子线程来说,这些都要变。因为希望当clone这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用返回到用户态的时候,用户态的栈应该是刚才创建的线程的栈,栈顶指针应该指向这个线程的栈,指令指针应该指向线程将要执行的那个函数。
所以这些都需要自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。接下来就要进入内核了。内核里面对于clone系统调用的定义是这样的:
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
{
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
}
看到这里,发现了熟悉的面孔_do_fork,这里重点关注几个区别:
(1)第一个区别是上面复杂的标志位设定,对于copy_files原来是调用dup_fd复制一个files_struct的,现在因为CLONE_FILES标识位(clone_flags)变成将原来的files_struct引用计数加一。如下所示:
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
{
struct files_struct *oldf, *newf;
oldf = current->files;
if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count);
goto out;
}
newf = dup_fd(oldf, &error);
tsk->files = newf;
out:
return error;
}
对于copy_fs,原来是调用copy_fs_struct复制一个fs_struct,现在因为CLONE_FS标识位变成将原来的fs_struct的用户数加一。如下所示:
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
{
struct fs_struct *fs = current->fs;
if (clone_flags & CLONE_FS) {
fs->users++;
return 0;
}
tsk->fs = copy_fs_struct(fs);
return 0;
}
对于copy_sighand,原来是创建一个新的sighand_struct,现在因为CLONE_SIGHAND标识位变成将原来的sighand_struct引用计数加一。如下所示:
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
{
struct sighand_struct *sig;
if (clone_flags & CLONE_SIGHAND) {
atomic_inc(¤t->sighand->count);
return 0;
}
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
atomic_set(&sig->count, 1);
memcpy(sig->action, current->sighand->action, sizeof(sig->action));
return 0;
}
对于copy_signal,原来是创建一个新的 signal_struct,现在因为 CLONE_THREAD 直接返回了。如下所示:
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
{
struct signal_struct *sig;
if (clone_flags & CLONE_THREAD)
return 0;
sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
tsk->signal = sig;
init_sigpending(&sig->shared_pending);
......
}
对于copy_mm,原来是调用dup_mm复制一个mm_struct,现在因为CLONE_VM标识位而直接指向了原来的mm_struct,如下所示:
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
oldmm = current->mm;
if (clone_flags & CLONE_VM) {
mmget(oldmm);
mm = oldmm;
goto good_mm;
}
mm = dup_mm(tsk);
good_mm:
tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}
(2)第二个就是对于亲缘关系的影响,毕竟要识别多个线程是不是属于一个进程。如下所示:
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->exit_signal = -1;
p->group_leader = current->group_leader;
p->tgid = current->tgid;
} else {
if (clone_flags & CLONE_PARENT)
p->exit_signal = current->group_leader->exit_signal;
else
p->exit_signal = (clone_flags & CSIGNAL);
p->group_leader = p;
p->tgid = p->pid;
}
/* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent;
p->parent_exec_id = current->parent_exec_id;
} else {
p->real_parent = current;
p->parent_exec_id = current->self_exec_id;
}
从上面的代码可以看出,使用了CLONE_THREAD标识位之后,使得亲缘关系有了一定的变化:
如果是新进程,那这个进程的group_leader就是他自己,tgid是它自己的pid,自己是线程组的头。如果是新线程,group_leader是当前进程的group_leader,tgid是当前进程的tgid,也就是当前进程的pid,这个时候还是拜原来进程为老大。如果是新进程,新进程的real_parent是当前的进程,在进程树里面又向下了一个层级;如果是新线程,线程的real_parent是当前进程的real_parent,其实进程和线程是平辈的。
(3)第三个区别,就是对于信号的处理,如何保证发给进程的信号虽然可以被一个线程处理,但是影响范围应该是整个进程的。例如kill一个进程,则所有线程都要被干掉。如果一个信号是发给一个线程的pthread_kill,则应该只有被发送的线程能够收到。在copy_process的主流程里面,无论是创建进程还是线程,都会初始化struct sigpending pending,也就是每个task_struct都会有这样一个成员变量,这就是一个信号列表。如果这个task_struct是一个线程,这里面的信号就是发给这个线程的;如果这个task_struct是一个进程,这里面的信号是发给进程里的主线程的。如下所示:
init_sigpending(&p->pending);
另外,上面copy_signal的时候,可以看到在创建进程的过程中,会初始化signal_struct里面的struct sigpending shared_pending。但是,在创建线程的过程中,连signal_struct都共享了。也就是说,整个进程里的所有线程共享一个shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。如下所示:
init_sigpending(&sig->shared_pending);
至此,clone在内核的调用完毕,要返回系统调用,回到用户态。
39. 根据__clone的第一个参数,回到用户态也不是直接运行指定的那个函数,而是一个通用的start_thread,这是所有线程在用户态的统一入口,如下所示:
#define START_THREAD_DEFN \
static int __attribute__ ((noreturn)) start_thread (void *arg)
START_THREAD_DEFN
{
struct pthread *pd = START_THREAD_SELF;
/* Run the code the user provided. */
THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
/* Call destructors for the thread_local TLS variables. */
/* Run the destructor for the thread-local data. */
__nptl_deallocate_tsd ();
if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
/* This was the last thread. */
exit (0);
__free_tcb (pd);
__exit_thread ();
}
在start_thread入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。例如线程本地数据thread_local variables,线程数目也减一。如果这是最后一个线程了,就直接退出进程,另外__free_tcb用于释放pthread,如下所示:
void
internal_function
__free_tcb (struct pthread *pd)
{
......
__deallocate_stack (pd);
}
void
internal_function
__deallocate_stack (struct pthread *pd)
{
/* Remove the thread from the list of threads with user defined
stacks. */
stack_list_del (&pd->list);
/* Not much to do. Just free the mmap()ed memory. Note that we do
not reset the 'used' flag in the 'tid' field. This is done by
the kernel. If no thread has been created yet this field is
still zero. */
if (__glibc_likely (! pd->user_stack))
(void) queue_stack (pd);
}
__free_tcb会调用__deallocate_stack来释放整个线程栈,这个线程栈要从当前使用线程栈的列表stack_used中拿下来,放到缓存的线程栈列表stack_cache中。这样,整个线程的生命周期到这里就结束了。
40. 下图对比了创建进程和创建线程在用户态和内核态的不同,创建进程调用的系统调用是fork,在copy_process函数里面,会将五大结构files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程调用的是系统调用clone,在copy_process函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构,如下所示: