多线程出现
传统的进程都是单线程的程序。我们总是希望自己的程序有更高的并行性,传统进程也是有办法实现这种并行性,那就是通过子进程,但是子进程是独立的数据空间,很多时候程序的不同任务都是需要访问相同数据的,因此子进程有很大的局限性。
在这种需求场景下,多线程出现了,它弥补了子进程的缺陷,因为进程内的线程共享进程的资源,可以很容易实现数据共享。
线程概念
线程在进程内部,它共享进程的地址空间。
线程属性
线程共享执行代码,data分段和打开的文件。但是有独立寄存器、程序计数器、状态和栈。第一列是线程共享的进程属性,第二列是线程独立的属性。
线程和进程关系
下图是一个进程包含3个线程
线程状态
同传统进程一致
多线程实现
用户态多线程
用户态多线程结构
- Run-Time System:用户态实现的多线程,线程的创建、调度、销毁都是在用户态,因此运行时系统负责管理线程调度,每个进程都有一个运行时系统
- Thread table:线程表,记录当前进程拥有的线程
用户态多线程的优点
- 线程切换不需要切换上下文
- 线程切换不需要刷新缓存
- 可以在不支持线程的系统中实现多线程
用户态多线程的缺点
- 虽然实现了多线程,但是因为对系统是透明的,系统并不知道进程是多线程,因此用户态多线程并不能真正并行,只能利用单核
- 阻塞系统调用,当进程的一个线程调用了阻塞系统调用时,整个进程都会陷入阻塞状态。内核线程阻塞了之后,无法通知进程运行时系统线程调度器导致的进程阻塞。解决方案是jacket,将阻塞系统调用替换为非阻塞系统调用,比如同步的I/O替换为异步I/O
- 缺页中断也会导致进程阻塞
- 用户态线程因为没有时钟中断,因此不能实现轮转调度
内核态多线程
内核态多线程结构
- 内核线程由系统调度
- 线程表,在内核空间
内核态多线程优点
- 可以利用多核cpu,能真正提升进程的并行
- 阻塞系统调用问题不会导致进程阻塞,因为内核多线程是由系统调用,系统管理着线程,因此知道线程已阻塞,就可以调度其他线程执行
- 缺页中断时可以切换其他线程执行
内核态多线程缺点
- 内核线程每次切换都会陷入内核,从用户态切换到内核态,这个过程是有开销的
- 创建和销毁线程开销,线程复用
- 信号是发送给进程而不是线程
- 当线程创建新的进程时,新进程属性(含有线程数)问题
混合实现
混合实现结构
混合模式下,一个内核进程可以映射多个用户态线程
混合实现的优点
实现了最大的灵活性
线程调度激活机制
线程调度激活机制结构
避免阻塞
为了避免用户态线程在调用阻塞系统调用时阻塞进程,内核在发现线程执行阻塞调用时会通过upcall通知进程,进程run-time system设置当前线程为block状态,然后调度其他线程运行;当阻塞操作调用完成会再次通知进程,run-time system可以选择立即运行或者加入就绪队列
硬件中断
如果进程对该中断不感兴趣,可能是其他进程的I/O完成,run-time system可以忽略,然后恢复线程到中断前的状态继续运行;如果进程对该中断感兴趣,比如可能是某个线程的缺页加载完成,此时运行哪个线程取决于run-time system
多线程程序设计的挑战
识别任务
识别出可以独立、并发的任务,可以独立运行在多核处理器上
平衡
考虑多核运行是否值得, 根据Amdahl定律,一个应用通过增加cpu可以获得的加速,S代表任务必须串行执行占比,N代表核心数
一个例子,横坐标是核心数,纵坐标是加速倍数,曲线代表不同必须串行执行占比在不同核心数下的加速
数据划分
如果应用划分为独立的任务,那么任务访问和操作的数据必须划分到不同的cpu上
数据依赖
如果并行的任务访问的数据之间有依赖,需要同步机制来保证数据一致性,在后续的笔记中会详细介绍线程同步
测试和调试
当应用是由多线程实现时,测试和调试都比较困难
多线程应用举例
Java的多线程模型
了解完多线程后,很好奇Java的多线程模型是怎么实现的呢?JVM中并没有说明Java的多线程是用户态实现还是内核态实现。网上查了一番资料后,感觉知乎的R大的回答比较靠谱一点,链接。在较新的Hotspot VM,在除了Solaris的平台上实现的是1:1的模型,也就是内核态实现多线程。感兴趣的可以点击上面的链接查看详情。
Go语言的goroutine
Go语言中的goroutine就是一种用户态多线程,它有自己实现的调度器。
总结
多线程在提升性能的同时也给编程带来了复杂性。不过在开发过程中,不管是用什么语言,都会经过一些封装,使其可用性更高。比如Java的JUC下的线程池,它已经封装了很多细节,让我们只需要关注任务和数据的拆分,而不用关注多线程实现的细节。