多线程 编程模型
线程 安全名词
串行、并发和并行
- 串行:一个人,将任务一个一个完成
- 并发:一个人,有策略地同时做多件事情
- 并行:多个人,每人做一个事情
竞态
名词
竞态 共享变量
竞态产生的条件
- read-modify-write
- check-then-act
线程安全性
如果一个类在多线程环境下无需做任何改变也能运作正常,则称其为 线程安全的 。
线程安全问题
原子性
要点
- 访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作 要么已经执行结束 要么 尚未发生 ,即其他线程不会“看到”该操作执行了部分的中间效果。
- 原子性只有在多线程环境下才有意义。
如何实现原子性?
- 使用锁
- 利用CAS指令
Java语言中的原子性操作
- 对所有变量的 读操作 都具有原子性
- 对 long 和 double 以外的任何类型的变量(基础类型、引用类型)的 写操作 都是原子性的
可见性
要点
- 可见性 就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。
- 多线程程序在可见性方面的问题意味着 某些线程会读取到旧的数据 ,从而导致不可预期的后果。
问题产生的原因
对内存的访问不是直接进行的,为了提高访问的速度,会先在高速缓存中进行相关操作;另外,每个处理器都有其 寄存器 ,这也可能导致不同的线程看到的数据不一致。
处理器不是直接与主内存打交道,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(Store Buffer)和无效化队列(Invalidate Queue)等部件执行内存的读写操作的。
有序性
重排序概念
重排序是什么
- 编译器可能改变两个操作的先后顺序,而不是完全按照程序的目标代码所指定的顺序执行
- 一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致
重排序的机遇与挑战
重排序是对内存访问有关的操作所做的一种优化,可以在不影响单线程程序正确性的情况下提升程序的性能。 但是,它可能对多线程程序的正确性产生影响。
重排序的来源
- 编译器(如 JIT编译器 )
- 处理器和存储子系统(包括写缓冲器 Store Buffer、高速缓存Cache)
几个相关的术语
源代码顺序 程序顺序 执行顺序 感知顺序
在此基础上,重排序可以做如下划分:
指令重排序
回顾: Java 平台包含两种编译器: 静态编译器 ( javac )和 动态编译器 (JIT编译器)。前者的作用是将Java源代码(.java文本文件)编译为 字节码 (.class二进制文件),它是在代码编译阶段介入的。后者的作用是将字节码动态编译为 Java虚拟机 宿主机的本地代码(机器码),它是在Java程序运行过程中介入的。
在Java平台中,静态编译器基本不会执行指令重排序,而 JIT编译器则可能执行指令重排序 。
对于编译器如何优化代码的解释: (摘自《Java多线程编程实战》)
处理器对指令进行重排序也被称为处理器的乱序执行 (Out-of-order Execution)。
现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令的,而是 动态调整 指令的顺序,做到 哪条指令就绪就先执行哪条指令 ,这就是处理器的乱序执行。在乱序执行的处理器中,指令是一条一条按照程序顺序被处理器读取的(亦即“顺序读取”),然后这些指令中哪条就绪了哪条就会先被执行,而不是完全按照程序顺序执行(亦即“乱序执行”)。
这些指令执行的结果(要进行写寄存器或者写内存的操作)会被先存入 重排序缓冲器 (ROB, Reorder Buffer),而不是直接被写入寄存器或者主内存。重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交(Commit,即写入)到 寄存器或者内存 中去(亦即“顺序提交”)。
在乱序执行的情况下,尽管指令的执行顺序可能没有完全依照程序顺序,但是由于指令的执行结果的提交(即反映到寄存器和内存中)仍然是按照程序顺序来的,因此处理器的指令重排序并不会对单线程程序的正确性产生影响。
猜测执行
比如,处理器可以先执行 IF 语句中的内容,并将接过来保存在 ROB 中,然后再判断 IF 是否成立,如果成立就可以直接使用,不成立则丢弃。
当然,在多线程环境下,这也可能造成线程安全问题。
存储子系统重排序
存储子系统
- 写缓冲器:对主内存的操作都是通过写缓冲器进行的
- 高速缓存:处理器通过高速缓存访问主内存
内存重排序
即使在处理器严格依照程序顺序执行两个内存访问操作的情况下,在存储子系统的 作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,即这两个操作的 执 行 顺序看起来像是发生了变化。这种现象就是存储子系统重排序, 也被称为 内存重排序 (Memory Ordering)。
内存重排序的类型
如果把读内存称为 Load,写内存称为 Store,则内存重排序有如下四种可能:
- LoadLoad重排序
- StoreStore重排序
- LoadStore重排序
- StoreLoad重排序
内存重排序与具体的处理器微架构有关,不同微架构的处理器允许的内存重排序也是不同的
貌似串行语义
这个概念类似于 MySQL 中的可串行化和分布式中的 XX 概念
重排序也是遵循一定的规则的,我们要做到一种假象: 貌似 串行 语义 。也就是 从单线程程序的角度保证重排序后的结果不影响程序的正确性 。(但是不保证多线程环境下的正确性)
规则如下:
- 存在 数据依赖 关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。
- 存在 控制依赖 关系的语句可以允许被重排序,如之前的 猜测执行 。
保证有序性
在多线程角度下,从逻辑上(看上去)禁止重排序,从而保证有序性。
Java 的 volatile 关键字、 sychronized 等都能够实现有序性。
多线程模型的其他问题
上下文切换
线程的活性故障
这些由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非 RUNNABLE 状态,或者线程虽然处于 RUNNABLE 状态但是其要执行的任务却一直无法进展的现象就被称为 线程活性故障 :
- 死锁 (哲学家进餐问题)
- 锁死(没有唤醒线程,比如唤醒线程也睡眠了)
- 活锁(一个线程对值做add,另一个做sub,导致程序一直进行,无法停止)
- 饥饿(某些线程无法获得其所需资源,而使得任务无法进展)
资源争用与调度
概念
- 一次只能被一个线程占用的资源被称为 排他性 资源
- 资源被一个线程访问时,其他线程试图访问该资源的现象被称为 资源争用 。我们要达到的理想状态是: 高并发、低争用
资源调度的公平性
资源调度的一个常见特性是:他是否保证 公平性 (是否先到先得)。
非公平调度策略是我们多数情况下的首选资源调度策略,其优点是 吞吐量大 ,缺点是资源申请者申请资源所需时间的是 偏差可能较大 ,并可能导致 饥饿 现象。
公平调度适合在 资源的持有线程占用资源的时间相对长 或 资源的平均申请时间间隔相对长的情况下 ,或 对申请的时间偏差有要求的情况下 使用,优点和缺点则反之。