前言
本文基于32位系统下,自然的,内存大小为4GB。
一、什么是线程,长什么样子
1、实验代码及现象
void *runthread(void *arg)
{
while(1)
{
cout<<"我是新线程"<<endl;
sleep(5);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,runthread,NULL);
while(1)
{
sleep(5);
cout<<"我是主线程"<<endl;
}
return 0;
}
实验现象:(ps -aL)这个指令查看线程的详细内容
可以发现在我是主线程和新线程并发运行,于此同时看到右边标红的部分可以看到PID相同,但是LWP不同的两个thread,那么这个thread就是我们的线程,一个新线程一个主线程。这里就会有一个疑问就是,为什么一个进程(PID相同)可以跑两个执行流,之前写过的代码不都是要么在main里边运行要么在函数内部运行,此时在main函数内部并没有一个调用函数的代码,还有LWD是什么,那么这是怎么回事呢?
2、线程定义及相关内容
1、 线程是进程内部的执行序列(执行流)。
2、线程在进程内部运行,共享进程的虚拟地址空间。
3、Linux内核不会为线程单独设计数据结构,而是task_struct(PCB),又叫做轻量级进程,因为不需要构建页表等内容,都是共享的,不像进程和进程之间是独立的。
总而言之就是,把进程内部的资源和空间划分为多个执行流,每一个执行流就是一个线程。
3、额外扩展
CPU调度的对象是执行流不是地址空间,所以说线程是最小的调度单位,而进程是最小的资源分配单位。
当LWP==PID的,就是主线程。
ps -aL 中同一进程的所有线程PID相同、LWP 不同,这说明内核为每个线程分配了独立的task_struct。
二、虚拟地址空间和分页管理
第一节我们说了,线程会共享进程的虚拟地址空间,并且线程会对空间做划分,那么应该如何划分就是本节着重讲述的。
1、为什么存在虚拟地址空间与页表
在之前写的博客中其实已经讲述了不少关于虚拟地址空间的内容,为什么会出现虚拟地址空间与页表映射这样的东西呢?原因就在于如果直接把用户程序直接放入物理内存之中的话必然是连续存放的,但是程序代码大小不一样,退出时间也不一样,此时就会产生很多的碎片,此时就会造成内存资源的浪费(比如说一个大空间被分成了很多小份,此时malloc一个空间就有可能失败)。所以此时引入虚拟地址空间加页表的映射这种方式,给用户的虚拟地址空间是连续的,允许物理内存是离散的。
2、页与页框
页框:就是在物理内存中划分的单元,一般为4kb。
页:虚拟地址空间中划分的单元,大小应于页框大小完全匹配。页的数据可以存放到页框中或者磁盘中。
所以总结上述两个部分的论述,有了页表就可以通过,虚拟地址空间--页表--物理内存这样的形式去放数据,此时就可以解决内存碎片化的问题。页表的映射其实就相当于页与页框的映射。
3、管理页框
通过上文我们知道了,一个页框大小一般为4kb,如果说我们有4g的物理内存就会存在非常多的页框,此时我们就需要对这些页框进行管理,也就是先描述再组织的这么一个过程,所以此时就会存在一个结构体去对页框进行描述。
struct page,这个结构体就会对页框进行描述,具体描述什么呢简单来说说,比如说,这个页框有没有被页表指着呀,有没有数据啊,有没有被修改过之类的。总而言之就有了一个对页框进行描述的这样一个结构体
4、多级页表
计算单张页表的大小:4GB内存中会存在2的20次方个页表项,一个页表项内部存放一个物理页号,一个整形页号大小为4字节,所以此时一张单张页表就是4MB。当然,页表也是存放在物理内存之中的,所以这也就要求了页表这4MB需要一段连续的物理内存。同时有一个问题,一个进程不是说所有的数据都需要访问页表的,所以完全没有必要把4MB的页表同时放入内存之中。同时,每一个进程的页表都是不同的,难道说几个进程就需要在物理内存之中放入几个连续的4MB吗?不可能,这样的话也会产生很多的碎片,会造成大量的内存资源浪费,所以为了解决这个问题引入了多级页表这个概念。通过下图简单了解一下页目录以及页表。

页目录表其实就是一级页表,而页表其实就是二级页表,两个都是一样的大小一样存储的也都是地址,只不过对象不一样而已。
在这里有一个点非常绕,就是单级页表一张表大小为4MB,而多级页表一张表大小为4KB。这里非常的绕,为什么呢?如果是单级页表的话是不是就要覆盖4GB,一张表就要覆盖4GB,同时上文得出单级页表大小为4MB,但是引入了二级页表之后,就会有1024个页表来覆盖4GB,1024*1024*4KB=4GB(一个页目录有1024个二级页表,一个二级页表又有1024项,一项又有4字节),所以说多级页表的大小都是4KB,结合上图看就明白了。总而言之就是,因为表数量少所以要覆盖一块等大的内存空间需要表的大小需要更大,表数量多所以覆盖一块等大的内存空间所需要的表的大小自然就更小一些。
所以此时如果要向物理内存放入10mb的数据,就只需要一张页目录外加3张二级页表(一张页表1024项,一个页框大小为4KB,所以一张二级页表能对应物理内存为4MB),也就是说需要4*4KB=16KB的内存大小。同时这里注意在多级页表中,页目录是必然存在的,按需创建二级页表。
5、地址转换(虚拟到物理)---多级页表
该部分的话可以给出虚拟地址到物理地址的转换过程,当然了32位系统下的。看下边详解。

假设上图是我们的逻辑地址,并通过一个规定划分位一级页号,二级页号,以及偏移量,在上图中我们看到了CR3寄存器会保存页目录的地址对吧,此时我们把视野移动到我们的页目录上,看一级页号是0000000000,注意:这个一级页号,二级页号其实就是二进制表示下的数组下标。那么一级页号就是0,找到页目录下下标为0的那个数,得到一个地址,这个地址就是二级页表的其实地址。所以我们把视野转哟到二级页表中去,然后看二级页号,发现是1111111110,转成十进制就是1022,也就是数组下标找到对应的地址,然后加上这个偏移量就是物理内存的真实地址了。上述就是一个简单的地址转换没有图片,大家可以看上图也就是我自己画的那个图片。
6、双刃剑以及解决思路--TLB(快表)
当我们创建多级页表的话就会发现,本来一张页表的话写入数据只会是查一次页表然后写入数据,当引入二级页表的时候就会多查一次表然后发送写入,此时效率就会低下,这也是二级页表的双刃剑,那么为了解决这个问题就引入了TLB这个东西。这个和dentry树类似。
就是说应当处理一个虚拟地址时首先去问TLB,如果有则就是命中直接就有物理地址,如果没有的话就还是走上文讲述的地址转换,同时把这一条加到TLB中。但是有一点和dentry树不一样的时什么呢?就是进程切换的时候就会切CR3寄存器,此时你的TLB不就被刷新了吗,这也是需要注意的一个点。同时这也引申出一个点就是,进程切换回切CR3寄存器,TLB被全部刷新,但是线程切换就不会。
7、缺页异常
这部分其实和信号就产生了关联。
缺页中断分为以下三类:
| 类型 | 别名 | 场景 | 内核动作 | 代价 |
|---|---|---|---|---|
| Hard Page Fault | Major | 物理内存没有对应页,要从磁盘读(文件映射、换出页换入) | 读磁盘 → 填物理页 → 建映射 | 高(毫秒级) |
| Soft Page Fault | Minor | 物理页已在内存,只是当前页表没映射(多进程共享内存、写时拷贝) | 只更新页表,建立映射 | 低(微秒级) |
| Invalid Page Fault | --- | 地址非法:越界、空指针解引用 | 转成 SIGSEGV 进程终止 | --- |
这里用malloc申请内存空间讨论。之前无论是malloc还是new我们都以为是在物理内存上开辟空间,但是并没有那么简单,当我们malloc或者new的时候是在虚拟地址空间上划分了空间,当你准备写入的时候发现没有分配物理页(页框),此时就会触发缺页处理,也就是我们上边表格列出的三种情况。
Hard Page Fault,可以看到它的场景就是没有物理页此时就会去创建物理页然后产生映射对应的不就是申请空间时不会首先创建物理页的情景吗。
Soft Page Fault,可以看到这个场景是物理页已经存在了,但是呢权限不对了或者说页表没有映射就会产生这个异常。比如说,fork(),我们之前已经讲过fork这个东西了,在fork后子进程创建会和父进程共享物理页,但是呢权限是只读的,如果你要写不就是写权限吗,此时权限不对然后就会触发这个异常随后陷入内核处理。
Invalid Page Fault,可以看到这个的话是越界,空指针解引用就会触发这个异常,很明显当触发这个异常之后就会陷入内核,发送信号进行终止。
三、线程控制
1、线程创建
void * work(void * args)
{
while(true)
{
sleep(1);
cout<<"我是子线程"<<endl;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,NULL,work,(void *)"thread-1");
while(true)
{
sleep(1);
cout<<"我是主线程 tid:"<<tid<<endl;
}
return 0;
}
运行效果:
指令:ps -aL
这个指令的话可以看到存在两个线程,与此同时可以看到这两个线程的PID是一样的,也就说明了这两个线程是属于这一个进程的,与此同时可以看到LWP,这个LWP往后会详细的说明,当PID==LWP时这个就是主线程。
同时可以发现,在运行效果上可以发现是比较乱的,我是主线程后打印tid中间插入了一个我是子线程,那么这个情况也是正常的,这个情况就是两个线程竞争同一个显示器文件,属于并发,后续会详细的讲解。
2、获取线程的ID:
函数:pthread_self();
同时看下边的过程:
发现pthread_self会得到一个ID,这个ID是一个虚拟ID。我们之前听过一句话就是线程是系统调度的最小单位,那么我们从这里得知就是线程想要被系统调度就需要有一个ID,此时就有了LWP,也就是说这个LWP就是系统认识的真实的线程ID,而用户态部分线程又是被pthread库维护的,也就是说pthread库是一个库它会被放在虚拟地址空间中的共享区中,此时呢线程的描述TCB就会在虚拟地址空间之中,所以此时又需要一个能在虚拟地址空间中找到这个线程的东西所以pthread_self返回的ID就出现了。总而言之就是线程这个东西在进程中和系统中有两个描述,分别为用户层面的TCB以及内核层面的task_struct,所以此时就有了两个不同的ID。
3、线程终止
这部分讲述三种线程终止的方式。
1、return终止
void *work1(void* args)
{
int cnt=0;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程"<<endl;
if(cnt==5)
{
return 0;
}
}
}
int main()
{
pthread_t tid1;
pthread_create(&tid1,NULL,work1,(void *)"thread -1");
while(true)
{
sleep(1);
cout<<"我是主线程"<<endl;
}
return 0;
}
运行结果及现象:可以看到通过return的方式子进程退出了。
2、调用pthread_exit()
--pthread_exit(void *)
调用这个函数也能实现线程终止的这么一个现象,这里的代码只给work1了,main函数不变。
void *work1(void* args)
{
int cnt=0;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程"<<endl;
if(cnt==5)
{
pthread_exit(args);
}
}
}
一样的效果,只不过相较于return,这里需要给一个参数,这个参数就是我们创建线程时传入的那个参数。
3、调用pthread_cancel()
--pthread_cancel(pthread_t pthread)
void *work1(void* args)
{
int cnt=0;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程"<<endl;
if(cnt==5)
{
pthread_cancel(pthread_self());
}
}
}效果跟上边的一样。
与此同时可以发现,这个pthreaad的参数是传入的是线程ID,所以就出现了在我线程内部关闭其他线程的操作。
pthread_t id=0;
void *work1(void* args)
{
int cnt=0;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程1"<<endl;
if(cnt==5)
{
pthread_cancel(id);
}
}
}
void* work2(void* args)
{
id=pthread_self();
while(true)
{
sleep(1);
cout<<"我是子线程2"<<endl;
}
return NULL;
}
int main()
{
pthread_t tid1,tid2;
pthread_create(&tid1,NULL,work1,(void *)"thread -1");
pthread_create(&tid2,NULL,work2,(void *)"thread -2");
while(true)
{
sleep(1);
cout<<"我是主线程"<<endl;
}
return 0;
}
4、线程等待
线程等待和进程等待类似,线程退出时,空间没有释放,新创建的线程不会复用之前没有释放空间线程的空间,有点类似于僵尸线程的感觉。
函数详情:
返回值:
第二个参数是输出型参数,可以把线程的返回值带出来,当然如果你不想访问返回值的话这个参数设置为NULL也是没有问题的。具体看下边两个代码来巩固join,具体来讲需要线程返回值要么malloc,要么设置为全局变量,因为当你弄一个局部变量时出了作用域这个变量就会消失,此时在main函数内部想要访问这个返回值就会产生一个空指针的问题。具体看下边的代码。
void *work1(void* args)
{
int cnt=0;
static int p=1;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程1"<<endl;
if(cnt==3)
{
return &p;
}
}return NULL;
}
void* work2(void* args)
{
int cnt=0;
static int p=2;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程2"<<endl;
if(cnt==3)
{
pthread_exit(&p);
}
}
return NULL;
}
int main()
{
pthread_t tid1,tid2;
void *ret=0;
void *rets=0;
pthread_create(&tid1,NULL,work1,(void *)"thread -1");
pthread_join(tid1,&ret);
cout<<*(int *)ret<<endl;
pthread_create(&tid2,NULL,work2,(void *)"thread -2");
pthread_join(tid2,&rets);
cout<<*(int*)rets<<endl;
return 0;
}
实验现象:
5、线程分离
上边讲了线程等待,需要join。如果不关心线程返回值以及跑完线程自己就退出的情况下,线程分离是更方便的一种做法。
1、函数详情:
2、返回值:
代码:
void *work(void* args)
{
pthread_detach(pthread_self());
int cnt=0;
while(true)
{
sleep(1);
cnt++;
cout<<"我是子线程"<<endl;
if(cnt==3)
{
return NULL;
}
}
}
这个是不用join了,但是怎么退出还是你说了算。
四、线程共享与独占
1、首先看一个实验--fd共享
int g_fd;
using namespace std;
void *work(void *args)
{
sleep(1);
char* s="hello from pthread";
write(g_fd,s,strlen(s));
return NULL;
}
int main()
{
g_fd = open("./test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
pthread_t tid;
pthread_create(&tid,NULL,work,NULL);
pthread_join(tid,NULL);
cout<<endl;
close(g_fd);
return 0;
}
这个实验的目的是在主线程内部打开一个叫test.txt的文件,然后创建一个子线程在这个子线程内部去像这个文件写入,此时观察实验现象。
可以发现一个现象就是这个文件是被写入了,所以可以得出结论,文件fd是被同一进程内的线程之间是共享的,fd表是进程级资源所以被进程内部线程共享。
2、第二个实验--线程栈的位置
void* work(void* args)
{
int thread_address;
sleep(1);
cout<<"子线程的栈的位置:"<<&thread_address<<endl;
return NULL;
}
int main()
{
int local_address;
cout<<"主线程的栈位置:"<<&local_address<<"PID:"<<getpid()<<endl;
pthread_t tid;
pthread_create(&tid,NULL,work,NULL);
pthread_join(tid,NULL);
sleep(60);
return 0;
}
这个实验的话呢,是分别在主线程和子线程内部定义了一个局部变量,我们知道当创建临时变量的时候,就会在栈区开辟空间。此时我们通过&找到主线程和子线程的地址就可以找到两者的栈的位置。然后通过指令:cat /proc/pid/maps,查看地址空间图,得到下边这张图片。
可以发现,主线程的栈是位于虚拟地址空间的栈区,而子线程的栈区在虚拟地址空间栈区以及堆区的中间位置,也就是mmap区。用一张图总结。
子线程的栈也是独立的,但是由于子线程的栈是在mmap区所以说大小是固定的。
用一张图片来总结一下。
| 维度 | 进程(之间) | 线程(之间) |
|---|---|---|
| 定义五字诀 | 资源分配的基本单位 | 调度基本单位 |
| 地址空间 / 页表 | 独立 | 共享(同 CR3,切换不刷 TLB) |
| 代码段、数据段、堆 | 独立 | 共享(全局变量直接互相可读写) |
| 文件描述符表 | 独立 | 共享 |
| 信号处理方式(handler) | 独立 | 共享 |
| 当前工作目录 / umask | 独立 | 共享 |
| 用户 ID / 组 ID | 独立 | 共享 |
| 线程 ID | --- | 私有 (pthread_t) |
| 一组寄存器(上下文) | 私有 | 私有 |
| 栈 | 私有 | 私有(每条线程自己的栈) |
errno |
私有 | 私有(本质是 TLS) |
| 信号屏蔽字 | 私有 | 私有 |
| 调度优先级 | 私有 | 私有 |
五、线程在内核中是如何被创建的
1、实验及现象
#define stack_size (1024*1024)
int g=0;
int child_func(void* args)
{
g=100;
cout<<"子线程的g:"<<g<<endl;
return 0;
}
int main()
{
char* stack=(char *)malloc(1024*1024);
pid_t pid = clone(child_func, stack + stack_size, CLONE_VM | SIGCHLD, NULL);
waitpid(pid,NULL,0);
cout<<"主线程的g:"<<g;
return 0;
}
实验现象:
观察代码,可以发现我们并没有说用pthread_create一个线程只是说调用了clone而已,竟然说达到了线程一般的作用,所以说,pthread_create的底层就是调用的clone,并且进程其实也是底层调用的clone。怎么说呢,当把clone参数里边的CLONE_VM去掉之后观察现象
当然了,这里没有改称呼,不重要看结果,发现两次的结果竟然是不相同的,这里也就直说了,当把CLONE_VM去掉之后,这里就变成了进程,子进程想要改动一个全局变量就会触发写时拷贝。所以g的值是不一样的,而没有去掉CLONE_VM的话,clone就是创建了一个线程,此时共享虚拟地址空间,一个线程修改一个全局变量就会导致另一个线程看到的就是被修改之后的值。下边详细讲述一下。
关于clone
上文说过,当我们创建进程或者线程时其实底层调用的就是clone,而clone具有很多的参数,对这些参数做出调整也就创建出了进程或者线程。我们知道进程和线程在内核中都是叫task_struct,那么内核是如何分清楚哪个是进程哪个是线程的呢?
上文提到了,当clone加上CLONE_VM就是线程,去掉CLONE_VM就是进程,为什么呢?这里给一张表格看一下。
| clone 标志 | 共享了什么 | |
|---|---|---|
CLONE_VM |
地址空间 / 页表 | |
CLONE_FS |
cwd、根目录、umask | |
CLONE_FILES |
文件描述符表 | |
CLONE_SIGHAND |
信号处理函数表 | |
CLONE_THREAD |
同一线程组(tgid 相同) | |
CLONE_SETTLS |
新线程的 TLS 块 | |
CLONE_PARENT_SETTID |
内核把 tid 写回 TCB | |
CLONE_CHILD_CLEARTID |
线程退出时清 tid + futex 唤醒 |
可以发现,CLONE_VM代表着共享了地址空间及页表,这不正是线程吗,线程是会共享进程的虚拟地址空间以及页表的,那么去掉这个标志位之后也就是意味着不共享地址空间以及页表,那么这个就是进程。所以当你创建进程或者线程时写的参数本质上就是添加或者减去这些标志位,而我们的fork以及pthread_create()本质上就是对clone进行了封装而已。
关于深入理解线程以及进程之间的关系
我们上文说过,在内核之中不管是调用的是fork还是pthread_create底层均是调用的clone,并且都是以task_struct在内核中呈现,之前之说CLONE_VM区分线程进程过于浅薄。我们之前听说过线程是进程内部的执行流,怎么理解呢。当我们创建一个主线程的时候内核会为我们分配虚拟地址空间并且分配新的页表,这个虚拟地址空间以及页表属于进程不属于线程,主线程也只能使用,在这里只是时序是主线程创建同时分配了空间。如果是单线程进程的话,那么只有一个线程使用,当多线程进程的时候,创建主线程之后,我们创建其他的子线程不会分配mm_struct以及页表,此时就会共享主线程创建时内核分配的mm_struct和页表,所以这也就是共享的原因。还有说进程是资源分配的基本单元,是因为创建子线程的时候只能是共享不会分配新的虚拟地址空间,创建进程此时就会分配空间了。
后记:
后边更新线程封装或者网络的部分
















