7.2 线程与并发
线程(Thread)是CPU调度的基本单位,是进程中的一个执行流。多线程技术让一个进程可以同时执行多个任务,充分利用多核CPU的性能,是现代并发编程的基础。
为什么需要线程
早期的操作系统只有进程的概念,每个进程有独立的地址空间,进程之间切换开销很大。随着多核CPU的出现,人们需要一种更轻量的执行单元,能够在同一个进程内并发执行,共享进程的资源,减少切换开销,线程就应运而生了。
相比多进程,多线程有以下优势:
- 创建和切换开销小:线程的创建、销毁、切换比进程快很多,因为线程共享进程的地址空间和资源,不需要重新分配资源
- 通信方便:同一个进程内的线程共享同一块地址空间,可以直接通过全局变量、堆内存通信,不需要复杂的进程间通信机制
- 资源利用率高:一个进程内的多个线程可以共享进程的内存、文件句柄等资源,不需要重复分配
- 充分利用多核CPU:多线程可以在多个CPU核心上并行执行,提升程序性能
什么是线程
线程是进程中的一个执行流,是CPU调度和分派的基本单位。
- 一个进程可以包含多个线程,至少有一个主线程
- 同一个进程内的所有线程共享进程的地址空间和资源
- 每个线程有自己独立的栈、寄存器上下文、线程本地存储(TLS)
进程和线程的区别
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 资源分配的基本单位,有独立的地址空间和资源 | CPU调度的基本单位,共享进程的资源,只有少量独立资源(栈、寄存器等) |
| 切换开销 | 大,需要切换地址空间、页表、缓存等 | 小,只需要切换栈和寄存器 |
| 通信 | 需要进程间通信机制(管道、共享内存等) | 可以直接通过全局变量、堆内存通信,需要注意同步问题 |
| 安全性 | 高,进程之间地址空间隔离,一个进程崩溃不会影响其他进程 | 低,线程之间共享地址空间,一个线程崩溃可能导致整个进程崩溃 |
| 并发性 | 较低,切换开销大 | 较高,切换开销小,适合高并发场景 |
例子:你打开一个浏览器进程,浏览器会有多个线程:一个线程渲染页面,一个线程处理用户输入,一个线程下载资源,多个线程同时工作,提升用户体验。如果每个任务都开一个进程,开销会非常大。
线程的实现模型
线程的实现有三种模型:用户级线程、内核级线程、混合型线程。
1. 用户级线程(User-Level Thread, ULT)
用户级线程完全在用户态实现,由用户态的线程库管理,操作系统内核感知不到线程的存在。
- 优点:
- 线程切换不需要内核参与,开销极小,速度快
- 调度可以由应用程序自己控制,更灵活
- 不需要内核支持,可以在不支持线程的操作系统上实现
- 缺点:
- 内核不知道线程的存在,如果一个线程发起阻塞的系统调用(比如read文件),整个进程都会被阻塞,其他线程也无法执行
- 无法真正利用多核CPU,操作系统调度的单位是进程,同一个进程的多个线程只能在同一个核心上交替执行
- 现在纯用户级线程已经很少使用了,早期的协程就是用户级线程的一种。
2. 内核级线程(Kernel-Level Thread, KLT)
内核级线程由操作系统内核管理,内核负责线程的调度和切换。
- 优点:
- 线程的调度由内核负责,如果一个线程阻塞,其他线程还可以继续执行
- 可以真正利用多核CPU,同一个进程的多个线程可以同时在不同的核心上并行执行
- 内核提供完整的线程功能,支持复杂的调度策略
- 缺点:
- 线程切换需要内核参与,开销比用户级线程大
- 频繁的线程切换会消耗大量CPU资源
- 现在主流操作系统的线程实现都是内核级线程,比如Linux的pthread,Windows的线程。
3. 混合型线程(N:M模型)
混合型线程结合了用户级线程和内核级线程的优点,用户态的多个用户级线程映射到内核的多个内核级线程上。
- 优点:
- 兼顾用户级线程的低切换开销和内核级线程的并发性
- 用户级线程切换开销小,内核级线程可以利用多核CPU
- 缺点:实现复杂,需要用户态和内核态之间的协调
- Go语言的goroutine调度就是N:M模型的实现,M个内核线程上运行G个goroutine,通过Go runtime调度。
上下文切换
上下文切换(Context Switch)是指CPU从一个进程/线程切换到另一个进程/线程执行的过程。
上下文切换的过程
- 保存当前进程/线程的上下文(程序计数器、寄存器的值、栈指针等)到PCB/TCB中
- 加载下一个要执行的进程/线程的上下文,恢复寄存器的值
- 跳转到程序计数器指向的位置,继续执行
上下文切换的开销
上下文切换有一定的开销,主要包括:
- 直接开销:保存和恢复寄存器、切换页表、刷新缓存等操作的CPU时间
- 间接开销:切换后CPU缓存失效,需要重新从内存加载数据,缓存命中率下降,导致程序执行变慢
线程切换的开销比进程切换小很多,因为线程不需要切换地址空间和页表,只需要切换寄存器和栈。
什么时候会发生上下文切换
- 进程/线程的时间片用完了
- 高优先级的进程/线程抢占CPU
- 进程/线程发起阻塞的系统调用(IO、锁、信号等),主动让出CPU
- 中断发生时
注意:过多的上下文切换会消耗大量CPU时间,降低系统性能。比如创建太多线程,会导致频繁的上下文切换,CPU大部分时间都花在切换上,真正执行任务的时间反而很少。
并发 vs 并行
并发(Concurrency)
并发是指多个任务在宏观上同时进行,微观上可能是交替执行的。比如单核CPU上运行多个进程,快速切换,让用户感觉多个程序同时在运行,这就是并发。
并行(Parallelism)
并行是指多个任务在物理上同时执行,需要多核CPU的支持,多个核心同时执行不同的任务。
区别:
- 并发是逻辑上的同时发生,并行是物理上的同时发生
- 单核CPU只能实现并发,不能实现并行
- 多核CPU可以同时实现并发和并行
多线程的性能收益
- CPU密集型任务:计算密集型任务,多线程可以并行执行,加速比接近核心数,理论上n核CPU可以提升n倍性能
- IO密集型任务:IO密集型任务大部分时间在等待IO,多线程可以在等待的时候切换执行其他任务,提升系统吞吐量,即使是单核CPU也能通过多线程提升性能
常见的线程模型
1. 一对一模型(1:1)
一个用户级线程对应一个内核级线程,是现在主流操作系统使用的模型,比如Linux的pthread,Windows的线程。
- 优点:实现简单,支持真正的并行,一个线程阻塞不影响其他线程
- 缺点:线程切换开销较大,线程数量不能太多,通常最多几千个线程
2. 多对多模型(N:M)
N个用户级线程映射到M个内核级线程,比如Go语言的goroutine,Erlang的进程。
- 优点:切换开销小,可以支持大量的并发(几十万甚至上百万),兼顾并发性和性能
- 缺点:实现复杂,需要用户态调度器的支持
3. 多对一模型(N:1)
N个用户级线程映射到1个内核级线程,也就是纯用户级线程,现在很少使用。
线程的组成
每个线程有自己独立的资源:
- 线程栈:每个线程有独立的栈,存储函数调用栈、局部变量,默认大小通常是1MB-8MB
- 寄存器上下文:程序计数器、栈指针、通用寄存器的值,线程切换时保存和恢复
- 线程ID(TID):每个线程有唯一的标识
- 线程本地存储(TLS):线程私有的全局变量,每个线程有独立的副本,其他线程访问不到
- 信号掩码:线程可以独立屏蔽某些信号
同一个进程内的线程共享以下资源:
- 进程的地址空间(代码段、数据段、堆、共享库等)
- 进程打开的文件描述符、Socket
- 进程的用户ID、组ID
- 信号处理函数
- 进程的当前工作目录
思考问题
- 进程和线程有什么区别?什么时候应该用多进程,什么时候应该用多线程?
- 上下文切换的开销主要来自哪里?为什么线程切换比进程切换快?
- 并发和并行有什么区别?单核CPU可以实现并行吗?
- 用户级线程和内核级线程各有什么优缺点?现在主流的实现是哪种?