目录
- 0.预备知识
- 1.页表的映射
- 2.二级页表
- 1.线程基础概念
- 1.什么是线程?
- 2.理解流程梳理 -- 如何理解线程?
- 3.线程优点
- 4.线程缺点
- 5.线程异常
- 6.线程用途
- 2.进程VS线程
- 1.进程和线程
- 2.进程和线程的资源共享
- 3.进程和线程的关系
- 4.关于进程线程的问题
- 3.线程控制
- 1.POSIX线程库
- 2.线程创建
- 3.线程终止
- pthread_exit()
- pthread_cancel()
- 4.线程等待
- pthread_join()
- 5.线程分离
- pthread_detach()
- 6.线程ID深入理解
- pthread_self()
- 7.进程地址空间布局
0.预备知识
1.页表的映射
- 在32位平台下一共有232个地址,也就意味着有232个地址需要被映射
-
每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,如用户级页表和内核级页表,实际就是通过权限进行区分的
-
每个应表项中存储一个物理地址和一个虚拟地址就需要8个字节,考虑到还需要包含权限相关的各种信息,这里每一个表项就按10个字节计算
- 这里一共有232个表项,也就意味着存储这张页表需要用232 * 10个字节,也就是40GB
- 而在32位平台下我们的内存可能一共就只有4GB,也就是说我们根本无法存储这样的一张页表
2.二级页表
- 虚拟地址在被转化的过程中,不是直接转化的!而是拆分成了10 + 10 + 12
- 以32位平台为例,其页表的映射过程如下:
- 选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表
- 再选择虚拟地址的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址。
- 最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据
- 相关说明:
- 物理内存实际是被划分成一个个4KB大小的页框的,而磁盘上的程序也是被划分成一个个4KB大小的页帧的,当内存和磁盘进行数据交换时也是以4KB大小为单位进行加载和保存的
- 4KB = 212个字节,一个页框中有212个字节,而访问内存的基本大小是1字节,因此一个页框中就有2^12个地址,于是就可以将剩下的12个比特位作为偏移量,从页框的起始地址处开始向后进行偏移,从而找到物理内存中某一个对应字节数据
- 这实际上就是所谓的二级页表,其中页目录项是一级页表,页表项是二级页表
- 每一个表项还是按10字节计算,页目录和页表的表项都是210个,因此一个表的大小就是210 * 10个字节,也就是10KB
- 页目录有210个表项也就意味着页表有210个,也就是说一级页表有1张,二级页表有2^10张,总共算下来大概就是10MB,内存消耗并不高,因此Linux中实际就是这样映射的
- **注意:**Linux中,32位平台用的是二级页表,64位平台用的是多级页表
1.线程基础概念
1.什么是线程?
- 一个程序里的一个执行流就叫做线程(thread)
- 即:线程是"一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部执行,是OS调度的基本单位
- 线程在进程内部运行,本质是在进程地址空间内运行
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
2.理解流程梳理 – 如何理解线程?
-
每个进程都有自己独立的进程地址空间和独立的页表,也就意味着所有进程在运行时本身就具有独立性
- 在创建进程时,它要创建PCB,页表,建立代码和数据的映射关系…
- 所以创建一个进程的成本非常高
-
如果创建"进程"时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表
- 现在创建的进程不再给你独立分配地址空间和页表,而是都指向同一块地址空间,共享同一块页表
- 所以这四个task_struct看到的资源都是一样的,后续可以通过某种方式把代码区拆分成4块,让这四个task_struct执行不同的代码区域
- 上述的区域(数据区,堆区,栈区)也是类似处理方式
- 换言之,后续创建的3个task_struct都各自有自己的一小份代码和数据,把这样的一份task_struct称之为线程
- 其中每一个线程都是当前进程里面的一个执行流,也就是常说的"线程是进程内部的一个执行分支"
-
线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的
- 线程比进程更细,是因为其执行的代码和数据更小了
- 线程的调度成本更低了,是因为它将来在调度的时候,核心数据结构(地址空间和页表)均不用切换了
-
上述线程仅仅是在Linux下的实现,不同平台对线程管理可能不一样
- 如Windows有真正的有关多线程的数据结构
- 而Linux并没有真正的对线程创建对应的数据结构
- Linux的线程是用进程PCB模拟的
- 所以Linux并不能直接提供线程相关的接口,只能提供轻量级进程的接口
- 在用户层实现了一套用户层多线程方案,以库的方式提供给用户进行使用
- pthread线程库
-
CPU视角下,Linux下,PCB <= 其他OS内的PCB
- Linux下的进程,统一称之为:轻量级进程
3.线程优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
4.线程缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器
- 如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失
- 这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
5.线程异常
- 线程一旦异常,会导致整个进程整体退出
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
6.线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验
- 如:一边写代码一边下载开发工具,就是多线程运行的一种表现
2.进程VS线程
1.进程和线程
-
进程是资源分配的基本单位
-
线程是调度的基本单位
- 线程ID
- 一组寄存器
- 栈
- errno
- 信号屏蔽字
- 调度优先级
-
为什么线程切换的成本更低?
- 地址空间和页表不需要切换
- CPU内部是有L1~L3 cache,如果进程切换,cache就立即失效,新进程过来,只能重新缓存
2.进程和线程的资源共享
- 进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的
- 如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到
- 除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
- 补充说明:
__thread int g_val = 100; // 修饰全局变量,让每一个线程各自拥有一个全局的变量 -- 线程的局部存储
3.进程和线程的关系
4.关于进程线程的问题
-
如何看待之前学习的单进程?
- 具有一个线程执行流的进程
-
引入线程后,如何重新理解之前的进程?
- 红色方框框起来的内容,将这个整体称作进程
- 曾经理解的进程 = 内核数据结构 + 进程对应的代码和数据
- 现在的进程,从内核角度看:承担分配系统资源的基本实体
- 所有进程最大的意义是向系统申请资源的基本单位
- 所有进程最大的意义是向系统申请资源的基本单位
-
一个进程内部一定存在多个执行流,那么这些执行流在CPU角度有区别吗?
- 没有任何区别,CPU不关心当前是进程还是线程这样的概念,只关心PCB,CPU调度的时候照样以task_struct为单位来进行调度
- 只是这里task_struct背后的代码和页表只是曾经的代码和页表的一小部分而已
- 所以CPU执行的只是一小块代码和数据,但并不妨碍CPU执行其它执行流
- 所以就可以把原本串行的所有代码转变成并发或并行的,让这些代码在同一时间点得以推进
- 总结如下:
- 以前CPU看到的所有的task_struct都是一个进程,现在CPU看到的所有的task_struct都是一个执行流(线程)
- 没有任何区别,CPU不关心当前是进程还是线程这样的概念,只关心PCB,CPU调度的时候照样以task_struct为单位来进行调度
3.线程控制
1.POSIX线程库
- pthread****线程库是应用层的原生线程库:
- 应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方提供的
- 原生指的是大部分Linux系统都会默认带上该线程库。
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
- 错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误
- C语言中errno是个全局变量,线程很可能有多个,大家共用一个errno,这个errno就成了临界资源,在实际使用中就不安全了
- pthreads函数出错时不会设置全局变量errno(而大部分POSIX函数会这样做),而是将错误代码通过返回值返回
- 对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小
- pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码
2.线程创建
- **功能:**创建一个新的线程
- 函数原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void * (*start_routine)(void*), void *arg);
- 参数:
- **thread:**输出型参数,获取创建成功的线程ID
- **attr:**设置线程的属性,attr为nullptr表示使用默认属性
- **start_routine:**是个函数地址,线程启动后要执行的函数
- **arg:**传给线程启动函数的参数
- **返回值:**成功返回0,失败返回错误码
3.线程终止
- 如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return
- 这种方法对主线程不适用,从main函数return相当于调用exit
- 线程可以调用pthread_ exit终止自己
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程
- 从线程函数return
- 注意:
- 不能使用exit()终止线程!
- **exit()**的作用是终止进程,任何一个线程调用exit()都会导致整个进程终止
pthread_exit()
- **功能:**进程终止
- 原型:
void pthread_exit(void *retval);
- **参数:retval:**线程退出时的退出码信息
- **返回值:**无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
- 注意:
- pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配
- 因为当其它线程得到这个返回指针时线程函数已经退出了
- 若主线程调用pthread_exit,则只退出主线程,并不会导致进程的退出
- pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配
pthread_cancel()
- **功能:**取消一个执行中的线程
- 原型:
int pthread_cancel(pthread_t thread);
- 参数:thread:线程ID
- **返回值:**成功返回0,失败返回错误码
4.线程等待
- 一个线程被创建出来,这个线程就如同进程一般,也是需要被等待的
- 为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
- 创建新的线程不会复用刚才退出线程的地址空间
- 如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的
- 所以线程需要被等待,如果不等待会产生类似于"僵尸进程"的问题,也就是内存泄漏
pthread_join()
- **功能:**等待线程结束,默认阻塞等待
- 原型:
int pthread_join(pthread_t thread, void **retval);
- 参数:
- thread**:**线程ID
- retval**:**利用其带回线程返回值,需深刻理解
- 返回值:
- 线程等待成功返回0,失败返回错误码
- 说明:
- 调用该函数的线程将阻塞式挂起等待,直到ID为thread的线程终止
- thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值
- 如果thread线程被别的线程调用pthread_ cancel异常终止,retval所指向的单元里存放的是常数PTHREAD_ CANCELED
- 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数
- 如果对thread线程的终止状态不感兴趣,可以传nullptr给retval参数
- 带回返回值例子
void *threadRoutine(void* args)
{
return (void *)233; // 返回给主线程
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadRoutine, (void *)"thread 1");
void *ret = nullptr;
pthread_join(tid, &ret); // 默认会阻塞等待新线程退出
cout << "new thread retval:" << (long long)(ret) << endl;
return 0;
}
5.线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统资源泄漏
- 如果不关心线程的返回值,join是一种负担
- 这个时候,可以告诉系统,当线程退出时,自动释放线程资源
- 可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离
- 一个线程如果被分离了,这个线程依旧要使用该进程的资源,依旧在该进程内运行,甚至这个线程崩溃了一定会影响其他线程
- 只不过这个线程退出时不再需要主线程去join了,当这个线程退出时系统会自动回收该线程所对应的资源
- joinable和分离是冲突的,一个线程不能既是joinable又是分离的
pthread_detach()
- **功能:**分离线程
- 原型:
int pthread_detach(pthread_t thread);
- 参数:thread:线程ID
- **返回值:**成功返回0,失败返回错误码
6.线程ID深入理解
- pthread_ create()会产生一个线程ID,存放在第一个参数指向的地址中
- 该线程ID和内核中的LWP不是一回事
- 内核中的LWP属于进程调度的范畴
- 因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程
- 该线程ID和内核中的LWP不是一回事
- pthread_ create()第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID, 属于NPTL线程库的范畴
- 线程库的后续操作,就是根据该线程ID来操作线程的
- 如何获取线程ID? – 通常有两种
- 创建线程时通过输出型参数获得
- 通过调用pthread_self()取得
pthread_self()
- **功能:**获得线程自身ID
- 函数原型:
pthread_t pthread_self(void);
- **说明:**用pthread_self()获得的线程ID与内核的LWP的值是不相等的
- pthread_self()获得的是用户级原生线程库的线程ID
- LWP是内核的轻量级进程ID
7.进程地址空间布局
- pthread_t****到底是什么?
- 本质就是一个进程地址空间上的一个地址
- 同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程
- 所谓线程ID可以理解为每个新线程在库当中的内存位置的起始地址,线程控制块的起始地址
- 每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的
- 除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性
- 每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据
- 每一个新线程在共享区都有这样一块区域对其进行描述
- 因此要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息
- 上面所用的各种线程函数,本质都是在库内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了
- 也就是说线程数据的管理本质是在共享区的。
- 也就是说线程数据的管理本质是在共享区的。