线程 Thread
线程基础
- 线程是程序执行流的最小单元
- 一个线程由线程 ID、当前指令指针 PC、寄存器集合和堆栈组成
- 一个进程(Process)通常由一个或多个线程组成,各线程之间共享该进程的内存空间(代码段、数据段、堆等)和进程级的资源(打开文件、信号等)
- 为什么使用线程:
- 某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行;多线程执行可以有效利用等待时间
- 多线程可以让一个线程负责交互,另一个线程负责计算
- 程序逻辑本身要求并发操作
- 多核 CPU 具备同时执行多个线程的能力
- 多线程比多进程在数据共享方面效率要高得多
线程的访问权限
- 线程也拥有私有的存储空间:
- 栈
- 线程局部存储 TLS
- 寄存器
- 从 C 语言的角度看
- 以下数据和资源由进程所有,在线程间共享
- 全局变量
- 堆上的变量
- 函数里的静态变量
- 程序代码,任何线程都有权利读取并执行任何代码
- 打开的文件,A 线程打开的文件可以由 B 线程读写
- 以下数据由各个线程私有
- 局部变量
- 函数的参数
- TLS 数据
- 以下数据和资源由进程所有,在线程间共享
线程调度 thread schedule
- 线程调度就是不断在一个处理器上(或处理器的一个核上)切换不同的线程
- 在线程调度中,线程通常拥有至少三种状态:
- 运行(running):线程正在执行
- 就绪(ready):线程可以立即执行,但 CPU 已被占用
- 等待(waiting):线程正在等待某一事件(I/O 或同步)发生,无法执行
- 线程状态的转移:
- 运行中的线程拥有一段可以执行的时间,称为时间片(time slice);当时间片用尽时该进程将进入就绪状态,即运行 --> 就绪
- 如果在时间片用尽之前线程就开始等待某事件,那么它将进入等待状态,即运行 --> 等待
- 在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态,即等待 --> 就绪
- 每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行,即就绪 --> 运行
- 上述状态转移的图解如下:
- 线程的优先级 priority:
- 可由用户指定优先级
- 根据进入等待状态的频繁程度提升或降低优先级
- 长时间得不到执行而被提升优先级
线程安全
竞争与原子操作
- 有些操作被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,转去执行别的代码
- 把单指令的操作称为原子的(atomic),显然原子操作不会被打断
同步和锁 synchronization and lock
- 为了避免多个线程同时读写同一个数据而产生不可预料的后果,需要将各个线程对同一个数据的访问进行同步
- 同步就是在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问
- 同步使得对数据的访问原子化
- 同步的最常用方式是使用锁(lock):每个线程在访问数据或使用资源之前先请求获取(acquire)锁,在访问数据或使用资源结束后则释放(release)锁;在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用
信号量 semaphore
- 数据或资源只能由一个线程独占时,使用二元信号量(binary semaphore);二元信号量只有占用和非占用两种状态,处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放
- 数据或资源可被多个线程共享时,使用多元信号量,简称信号量;一个初始值为 N 的信号量允许 N 个线程并发访问。线程访问资源时会发生如下操作:
- 首先获取信号量 s
- 如果 s == 0,说明该数据或资源已经达到最大共享量,于是线程进入等待状态
- 如果 s > 0,则将信号量的值减 1,即 --s,然后执行线程
- 线程访问资源结束后,将信号量的值加 1,即 ++s,然后释放信号量
- 此时 s 必然大于 0,于是唤醒一个等待中的就绪线程
- 同一个信号量可以被系统中的一个线程获取之后由另一个线程释放
互斥量 mutex
- 与信号量不同,互斥量要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁
临界区 critical section
- 临界区是比互斥量更严格的锁
- 互斥员和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试阳去获取该锁是合法的
- 临界区的作用范围仅限于本进程,其他的进程无法获取该锁
读写锁 read-write lock
- 多个线程可以同时读取一段数据,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错
- 如果大多数操作都是读操作,只有少量写操作,那么每次都同步会非常低效
- 读写锁通过两种获取方式解决上述问题:共享获取(shared)和独占获取(exclusive)
- 当锁处于自由的状态时,可以以任何一种方式获取锁,并将锁置于对应的状态
- 如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程
- 如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放
- 如果锁处于独占状态,便会阻止任何其他线程获取该锁
条件变量 conditional variable
- 对千条件变量,线程可以有两种操作
- 线程可以等待条件变量,一个条件变量可以被多个线程等待
- 线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持
- 使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行
函数重入和线程安全
- 一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行
- 多个线程同时执行这个函数
- 函数自身(可能是经过多层调用之后)调用自身
- 一个函数可重入,表明该函数被重入之后不会产生任何不良后果
- 一个函数要成为可重入的,必须具有如下几个特点:
- 不使用任何(局部)静态或全局的非 const 变量
- 不返回任何指向(局部)静态或全局的非 const 变量的指针
- 仅依赖于调用方提供的参数
- 不依赖任何单个资源的锁
- 不调用任何不可重入的函数
- 可重入是并发安全的强力保证,一个可重入的函数可以在多线程环境下放心使用
多线程的内部情况
- 内核线程的并发执行由多处理器或操作系统调度来实现
- 用户态线程不一定在操作系统内核里对应同等数量的内核线程
一对一模型
- 一个用户态线程唯一对应一个内核使用的线程
- 这个模型下用户线程具有和内核线程一致的优点,线程之间的并发是真正的并发
- 一个线程因为某原因阻塞时,其他线程执行不会受到影响
- 一对一模型也可以让多线程程序在多处理器的系统上有更好的表现
- 一般直接使用 API 或系统调用创建的线程均为一对一的线程
- 一对一线程缺点有两个:
- 许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制
- 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降
多对一模型
- 多个用户线程对应一个内核线程,线程之间的切换由用户态的代码来进行
- 多对一模型的优点在于:
- 相对于一对一模型,多对一模型的线程切换要快得多
- 相对于一对一模型,多对一模型可以支持几乎无限制的用户态线程数量
- 多对一模型的缺点在于:
- 如果其中一个用户线程阻塞,那么所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了
- 处理器的增多对多对一模型的线程性能没有明显的帮助
多对多模型
- 多个用户线程对应少数但多于一个的内核线程