Linux线程基础
- 一,线程概念
- 什么是线程
- Linux如何实现的线程
- 二,页表与虚拟内存
- 虚拟内存
- 多级页表
- 三,线程的特点
- 线程的优点
- 线程的缺点
- 线程异常
- 线程用途
- 线程与进程比较
- 四,线程控制
- 创建线程
- 线程终止
- 线程等待
- 线程取消
- 线程分离
- 五,原生线程库的理解
- 用户级线程库的理解
- 线程id理解
- 线程独立栈结构
- 线程的局部存储
一,线程概念
什么是线程
🚀许多书籍上对线程的定义是这样的:线程是一个执行分支,执行粒度比进程更细,调度的成本更低
。
🚀站在系统的层面上:线程就是进程的一个执行流
。
Linux如何实现的线程
🚀首先既然有线程这么个东西,那么操作系统肯定要对其进行管理,那么就要进行先描述再组织,就要为线程创建结构体对其描述,然后通过某种数据结构将所有的线程组织起来,而上面又说到线程是进程的一个执行流,那么势必就要维护线程与进程之间的关系,等等。
🚀然而在Linux中,并没有专门属于线程的数据结构,而是对进程的那套东西进行了复用。
🚀在Linux下,是通过轻量级进程来模拟的线程,并没有真正的线程,这一个个task_struct都指向同一个进程地址空间,它们就是进程的一个执行流,CPU调度的基本单位就是task_struct。
🚀之前传统的进程其实就是一个单执行流的进程,也就是单线程的进程。
🚀每个tack_struct就是一个线程,而进程是包括pcb,虚拟地址空间,以及页表,和映射到物理内存的代码和数据等各种资源的集合。所以,线程是CPU调度的基本单位,而进程是承担资源分配的基本单位。
🚀线程就是在进程的地址空间上运行的,所以说执行的粒度比进程更细。
🚀Linux下不在区分进程和线程,而是将调度的执行流统一为轻量化进程。
如何理解线程的调度成本更低
CPU中存在大量的寄存器,其中有得寄存器指向pcb,有的指向地址空间,有的指向页表,由于进程是承担资源分配得基本单位,那么地址空间,页表资源是属于某个进程得,当进程切换得时候那么这些东西都要切换,那么就需要修改CPU中寄存器的指向。并且现代计算机的整个体系结构是多级缓存的结构的,在物理内存和CPU之间存在了cache(高速缓存)来减轻由于物理内存和CPU之间速度差过大的问题,CPU获取的数据或者指令是从cache中获取的,当进程切换的时候,cache中的数据都会被认定为失效的数据,需要从新从物理内存中加载。
而线程的切换只需要修改CPU中指向pcb的寄存器的内容即可,因为线程是在进程的地址空间上运行的,所以线程切换时,地址空间,页表等资源不用切换,并且多个线程看到的是同一个地址空间,一个线程在cache缓存中的内容,可能也会被另一个线程用到,所以cache的内容也不会失效。
所以,线程的调度成本更低。
二,页表与虚拟内存
🚀众所周知,我们可执行程序中的地址都是虚拟地址,而CPU需要的是物理地址,所以CPU在执行我们的代码的过程中,势必发生虚拟地址到物理地址的转化过程,页表中存储了虚拟地址和物理地址间的映射关系,而转化的工作是CPU中的MMU内存管理单元硬件来完成的。
虚拟内存
🚀虚拟内存其实就是对内存的一种建模,并且虚拟内存的这种规则不仅约束着操作系统,进程,还约束着编译器,编译器在编译代码的时候,就是按照虚拟内存的方式来编译的,在编译的时候就已经形成了虚拟地址。
如何理解可执行程序被加载到内存的
可执行程序被加载到内存实际是分两种情况的,当可执行程序的体积较小的时候,是一下都加载到内存中的,在加载到内存之前OS就为其创建好了各种数据结构,包括地址空间等等,由于我们代码中使用的都是虚拟地址所以在可执行程序加载到内存之后还要填写页表完成虚拟地址到物理地址的转换。而当程序的体积很大的时候,OS首先会将一部分的代码加载到内存中,然后构建虚拟地址和物理地址间的映射关系,由于是部分加载的,势必还有一部分在磁盘中,所以页表中虚拟地址的旁边那栏,有的指向了物理地址,有的指向了磁盘,当程序运行中发现某个虚拟地址没有与其映射的物理地址,那么就会触发缺页中断,此时OS就会将物理内存中选中一些已经使用过的页(叫做牺牲页),将他们换回到磁盘上,然后将刚刚虚拟地址对应的那部分代码和数据从磁盘上加载到物理内存中,重新完善页表的信息,然后CPU再次获取该虚拟地址对应的物理地址的时候,就可以继续运行了。
🚀现在你就能理解,为什么有的游戏几十GB,而物理内存只有几GB,但是依然能跑起来的原因,就是因为这种部分加载的原理,先加载一批代码当这批代码跑完后换一批新的代码继续,这样反复迭代就可以使一个很大的程序跑起来了。
多级页表
🚀物理内存与磁盘之间的交互是以块为单位的,通常一个数据块的大小是4KB,之所以采用这种较大的数据块的形式而不是以字节为单位,是因为物理内存与磁盘的速度差是很大的,所以每次IO的效率是很低的,所以磁盘在向物理内存加载数据的时候都是以数据块的形式加载的,但这也意味着数据刷新的时候,即使只改了1个字节的内容,也要刷新整个4KB的内容。
🚀所以,物理内存也是实行分页管理的,每页的大小是4KB,以32位系统为例,进程地址空间的大小是4GB,假设物理内存也为4GB,那么物理内存是要被分成(4* 1024 * 1024 * 1024)/ (4 * 1024) = 2^20个物理页,并且内核中为了更好的管理物理内存,还为物理内存创建了struct page结构体(以字节为单位),总体也就是 5M左右,在结构体中描述了该物理内存页是否被使用等等的相关属性。
🚀32位机器的虚拟地址是32位的,如果只有一级页表的情况下,为了能够精确定位到物理内存的具体某个页的某个字节,所以32为地址是分为两部分使用,前20位能够确定是属于哪个物理页,后12位确定的是在某个物理页中距页首地址的偏移量。那么一级页表的行数至少为2^20行,假设每行占4字节,那么整个页表占用的空间就为4* (2^20) = 4MB,并且这4MB是要一直存在的,而且在64位下情况会变得更复杂,所以通常会使用多级页表来压缩。
🚀所以提出了多级页表的概念,这里以2级页表为例子,将32位分为三部分使用,10 + 10 + 12的方式,前10位确定属于哪个2级页表,第二个10位确定在物理内存的哪个页上,后12位是页内偏移量。
🚀这种二级页表的结构,单个页表的大小是4B(假设每个条目的大小是4字节),那么如果所有页表都存在的话占用的空间为 4MB + 4KB,但是如果一级页表的某一行是没有使用的话,那么其指向的二级页表是不存在的,所以说大部分情况下只有一级页表和少部分二级页表是存在的,所以其实采用二级页表的形式是更节省空间的,并且检索的效率也是更高的。
三,线程的特点
线程的优点
1,创建一个新的线程的代价比创建一个新的进程小得多。
在Linux下,创建一个新的线程只需要创建一个新的task_struct即可,而对于进程而言还有有与其配套的进程地址空间,页表等。
2,与进程相比,线程的切换需要操作系统做的工作更少。
线程的切换只需要改变CPU中特定寄存器的内容,而对于进程而言,还要修改存放页表地址寄存器,存放地址空间地址的寄存器的内容,并且之前的cache缓存都是失效的。
3,线程占用的资源更少。
4,充分利用多处理器的可并行数量。
5,在等待慢速IO操作结束的同时,程序可执行其他计算任务。
典型的操作就是边下载边观看电影。
6,计算密集型应用,为了能够在多处理器系统上运行,将计算分解到多个线程中是实现。
7,I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
1,性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
2,健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。一个线程出现问题,整个进程都会受到影响。
3,缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数,可能会对整个进程造成影响。
线程异常
1,单个线程如果出现除零,野指针问题导致线程崩溃,那么进程也会随之崩溃。
2,线程是进程的执行分支,线程出现异常,就类似于进程出现异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也会终止。
线程用途
1,合理的使用多线程,能提高CPU密集型程序执行效率。
2,合理的使用多线程,能提高IO密集型程序的用户体验。
线程与进程比较
1,进程是承担分配资源的基本单位。
2,线程是调度的基本单位。
3,线程共享进程的数据,但是也有自己的一部分数据:
- 线程ID(内核角度:LWP,用户角度:pthread_t)
- 一组寄存器(线程也要维护自己的上下文数据)
- 栈
- errno
- 信号屏蔽字
- 调度优先级
4,进程的多个线程共享进程的地址空间,因此进程的代码段,数据段都是共享的,如果定义一个函数,在各线程中都可以使用,如果定义一个全局变量,那么所有的线程也都可以访问到,除此之外线程还共享以下资源:
- 文件描述符表
- 各种信号处理方式
- 当前工作目录
- 用户id和组id
5,线程与进程的关系图:
四,线程控制
创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
🚀 第一个参数:这是一个输出型参数,传入的是线程id(pthread_t类型)的地址。
🚀 第二个参数:设置线程的属性,通常设置为nullptr即可。
🚀 第三个参数:线程的入口函数,这是一个函数指针变量,创建线程成功后通过回调的方式执行这个函数。
🚀 第四个参数:这是线程入口函数的参数为void*类型。
int g_val = 100;
void *thread_run(void *args)
{
const char *tname = (const char *)args;
while (true)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
sleep(1);
}
}
const int NUM = 3;
int main()
{
pthread_t tids[NUM];
for (int i = 0; i < NUM; i++)
{
char *tname = new char[64];
snprintf(tname, 64, "thread-%d", i + 1);
pthread_create(tids + i, nullptr, thread_run, tname);
}
while (true)
{
cout << "i am main thread ,pthread id :" << pthread_self() << endl;
sleep(1);
}
return 0;
}
这段代码也验证了,线程共享进程的地址空间,全局变量g_val是在数据段的,每个线程都可以访问到g_val变量,定义的线程入口函数是在代码段的,如果线程能运行证明每个线程都能看到这部分空间,同时给给线程入口函数传递的参数(线程的名字),本质是传递了一个地址,指向的空间是在进程地址空间的堆区,这也能验证线程共享进程的地址空间。
注意: 由于Linux系统没有真正的线程,只有轻量级进程,所以只有轻量级进程的接口,我们使用的是原生线程库,属于第三方库所以在编译时要加 -l的编译选项。
thread:thread.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f thread
线程终止
🚀线程终止有三种方式:
1,return
当线程的函数执行完成后,return返回值,可以终止线程,并且return的值可以被主线程接受到。
void *thread_run(void *args)
{
const char *tname = (const char *)args;
int cnt = 5;
while (cnt)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
sleep(1);
cnt--;
}
return (void *)0;
}
2,pthread_exit
void pthread_exit(void *retval);//参数就是其要返回的值
pthread_exit的作用与return类似,都是终止进程,并返回某个变量,主线程可以接受其返回值。
void *thread_run(void *args)
{
const char *tname = (const char *)args;
int cnt = 5;
while (cnt)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
3,exit
void *thread_run(void *args)
{
const char *tname = (const char *)args;
int cnt = 5;
while (cnt)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
sleep(1);
cnt--;
}
exit(0);
}
注意:
exit是终止进程,调用exit会使整个进程都会终止,那么除了此线程以外的线程也都会被终止。
void *thread_run(void *args)
{
const char *tname = (const char *)args;
int cnt = 5;
while (cnt)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
exit(0);//打印一句就退出
sleep(1);
cnt--;
}
}
🚀可以看到线程2打印了一句就将进程终止了,至于打印出了哪句都是随机的,可能调度器最先调度的是线程2,同时还有可能打印完这句后还没到exit时就被OS被切换执行另一个线程去了等等,情况不唯一,但是此处要验证的是通过exit终止线程,那么整个进程都会被终止。
线程等待
🚀我们创建线程就是要让其完成某项工作,那么新线程势必要给主线程回馈,主线程也要接受其返回值。同时线程退出后其相关资源并没有被释放,主线程通过等待的方式释放其资源。
int pthread_join(pthread_t thread, void **retval);
🚀第一个参数:线程id,选择等待哪一个线程。
🚀第二个参数:这是一个输出型参数,就是将线程的返回值void* 类型的值带出来,所以传递的是void* 类型变量的地址。
🚀pthread_join是一种阻塞式等待。
int g_val = 100;
void *thread_run(void *args)
{
const char *tname = (const char *)args;
int cnt = 5;
while (cnt)
{
cout << tname << " : &gval :" << &g_val << " g_val : " << g_val << endl;
sleep(1);
cnt--;
}
delete tname;
pthread_exit((void *)0);
}
const int NUM = 3;
int main()
{
pthread_t tids[NUM];
for (int i = 0; i < NUM; i++)
{
char *tname = new char[64];
snprintf(tname, 64, "thread-%d", i + 1);
pthread_create(tids + i, nullptr, thread_run, tname);
}
void *res;
for (int i = 0; i < NUM; i++)
{
pthread_join(tids[i], &res);
// cout << "thread-" << i + 1 << "exit code : " << (int)res << endl;
cout << "thread-" << i + 1 << "exit code : " << (uint64_t)res << endl;
}
cout << "all thrread quit" << endl;
return 0;
}
注意一个小细节:
由于我们使用的云服务器是64位的环境,所以指针变量的大小为8个字节,所以上面代码中注释掉的那行是有问题的。
线程取消
🚀线程是可以被取消的,并且被取消的线程返回值为PTHREAD_CANCELED
本质就是-1。
int pthread_cancel(pthread_t thread);
🚀参数为要取消的线程的id。
void *thread_run(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "i am a new thread" << endl;
sleep(1);
cnt--;
}
// PTHREAD_CANCELED;
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, thread_run, nullptr);
sleep(2);
pthread_cancel(tid);
void *res;
pthread_join(tid, &res);
cout << "new thread exit code : " << (int64_t)res << endl;
return 0;
}
问题: 新线程能自己取消自己吗?新线程能取消主线程吗?
void *thread_run(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "i am a new thread" << endl;
pthread_cancel(pthread_self());//自己取消自己
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, thread_run, nullptr);
void *res;
pthread_join(tid, &res);
cout << "new thread exit code : " << (int64_t)res << endl;
return 0;
}
事实证明,新线程是可以自己取消自己的。
void *thread_run(void *args)
{
pthread_t *tid = (pthread_t *)args;
int cnt = 5;
while (cnt)
{
cout << "i am a new thread" << endl;
pthread_cancel(*tid);
sleep(1);
cnt--;
}
// PTHREAD_CANCELED;
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_t self = pthread_self();
pthread_create(&tid, nullptr, thread_run, &self);
void *res;
pthread_join(tid, &res);
cout << "11111111111111" << endl;
cout << "new thread exit code : " << (int64_t)res << endl;
return 0;
}
新线程是可以取消主线程的,但是当你干掉主线程之后,主线程退出但是进程并没有退出,还剩下一个线程在跑,进程的状态为Zl 表示不可被回收,因为此时其内部还有线程在运行。
线程分离
int pthread_detach(pthread_t thread);
🚀默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放其资源,从而造成内存泄漏。
🚀 如果主线程不关心新线程的返回值,那么我们可以告诉操作系统,当线程退出的时候,自动释放线程的资源。
线程可以自已分离自己,也可以主线程分离新线程,也可以是其他进程分离目标进程,推荐是主线程分离新线程。
一旦线程被分离后,其属性就不再是joinable的,不能再对其join,否则会出错。
void *thread_run(void *args)
{
int cnt = 6;
while (cnt)
{
cout << "i am a new thread" << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, thread_run, nullptr);
// 分离线程
pthread_detach(tid);
int n = pthread_join(tid, nullptr);
if (n != 0)
{
cout << "join fail : " << strerror(n) << endl;
}
return 0;
}
五,原生线程库的理解
用户级线程库的理解
🚀由于Linux操作系统没有真正的线程,只有轻量级进程,但是我们用户使用的是线程,所以原生线程库就是对轻量级进程的系统调用的封装,从而方便用户使用线程。
🚀由于我们使用的pthread库是一个第三方库,那么pthread库会被加载到进程地址空间的共享区,并且这个库内势必要对线程做管理,那么就要对线程先描述在组织,所以库内部会有类似于TCB这样的结构体对线程描述,然后将一个个这样的结构体,用某种数据结构组织起来。
🚀 类似于TCB的结构体内,包含 struct pthread(里面存放的是线程的属性信息,以及线程独立栈的大小等等。),线程局部存储,线程独立的栈结构等等。并且会将线程的数据,栈结构等传递给轻量级进程的系统调用去运行的。
线程id理解
🚀所谓线程的id(这里谈的是库级别的,不是LWP)其实就是库中对于线程描述的结构体的起始地址,就作为线程的id。
string toHex(pthread_t id)
{
char buffer[128];
snprintf(buffer, sizeof(buffer), "0x%x", id);
return buffer;
}
void *thread_run(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "new thread id : " << toHex(pthread_self()) << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
int main()
{
pthread_t tids[3];
for (int i = 0; i < 3; i++)
{
pthread_create(tids + i, nullptr, thread_run, nullptr);
}
for (int i = 0; i < 3; i++)
{
pthread_join(tids[i], nullptr);
}
return 0;
}
线程独立栈结构
🚀进程的地址空间中存在栈结构,但是这个栈是主线程使用的,当有多个线程并发调度的时候,如果都是用这一个栈结构,那就会十分混来很难管理。所以没有线程都有属于其自己的独立栈结构,并且这个栈结构是在线程库中维护的。
🚀线程内部创建的所有局部变量,都是存放在线程独立栈上的。
void *thread_run(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "new thread id : " << toHex(pthread_self()) << " &cnt " << &cnt << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
🚀 如何理解同一个变量名但是对应的地址不同?
这是在线程函数内部创建的局部变量,是创建在线程的独立栈的结构上的,每个线程各自私有一份,所以看到的地址是不同的。
局部变量的创建都是转换为代码,当程序运行的时候去开辟的,并且是通过与rbp(栈底指针)的距离,来确定某个局部变量的地址的,由于每个线程都有独立的栈结构,所以当调度一个线程的时候,rbp和rsp指向的是线程独立栈的栈底和栈顶,所以在创建局部变量的时候,用通过rbp - 偏移量的形式完成的,所以这个局部变量虽然名字相同,但是在每个线程中的地址是不同的。
线程的局部存储
🚀线程的局部存储,就是将全局变量让每个线程私有一份,成为线程内部的全局变量。在gcc/g++中可以通过__thread 修饰去全局变量来完成。
__thread int g_val = 100;
void *thread_run(void *args)
{
int cnt = 5;
while (cnt)
{
cout << "g_val : " << g_val << " &g_val : " << &g_val << endl;
sleep(1);
cnt--;
}
pthread_exit((void *)0);
}
🚀可以看到,g_val全局变量确实被每个线程各自私有一份,是存储在线程的局部存储空间上的。