工作中是如何保证线程安全的
为什么线程是不安全的?
从三个特性出发来看为什么:
- 园子性:最终都会编码为机器码运行,一个高级语言的一行代码会被编译为多个机器码指令
- 有序性:CPU的乱序指令优化 是指对于无关联无依赖性的指令其顺序可以进行调整加快执行效率。比如此时io总线资源紧张,那么对于需要io操作的指令可以放到后面去执行;对于耗时比较长的指令同样可以移到后面执行,提升运行效率
- 可见性:由于ALU和cpu和内存和硬存之间有一定的距离,因此当ALU需要数据时最差的情况下要去从硬存读耗时比较长,拖慢指令执行速度。因此cpu的核心提出了三级缓存(也叫线程本地工作内存,共享变量的副本就在这个地方存储着,最小的存储单位和cache同步主存的最小单位叫做cache line缓存行),也因此多核之间是存在数据不是最新的情况(什么时候才会把缓存的内容同步到主存中呢?当)
可见性问题:缓存一致性协议是如何保证的数据同步?
数据为什么不同步?
因为每个线程都有自己的工作缓存(L1,L2,L3),修改只是在缓存行里面进行修改了并没有同步到主存中,因此会出现数据不同步的现象,解决就是标记好缓存行的内容状态,根据mesi状态进行不同的处理。
为了保证 CPU 间缓存数据的一致性,科学家们引入了缓存一致性协议,比较常用的缓存一致性协议为 MESI 协议。
MESI状态流转过程
实际上盖状态流程有十几种状态,只强调MESI这四个状态的流转情况。
E:只有当前线程有这个缓存行的数据,其他线程没有
之后有两种种情况发生,对应的是不同的状态变更:
- 当前线程进行修改缓存行的数据,当前线程的缓存行状态会被修改为M(缓存行里面的数据被修改)
- 当前线程没有修改,但是其他线程进行读取了这块缓存行的数据,那么两个线程的缓存行状态都会被修改为S(当前线程和其他线程都有这块数据)
在共享缓存行的情况下进行修改会发生下面两种情况,也就是在***多核cpu下缓存同一个缓存行的内容不同线程进行修改如何保持的数据同步的思想***
- 在第二个的基础上其他线程和当前线程都有同一块缓存行的数据,此时A线程进行了修改A的缓存行***,那么A线程的这个cpu会往总线上广播一条invalidate消息*,其他cpu检测是不是自己有没有这块缓存行:如果有就会设置自己的缓存行内容为I状态 也就是无效缓存行,接着会发一个invalidate ack消息给A cpu,A cpu收到后会写入自己的缓存行然后同步到主存中,最后A线程的缓存行会被设置为E(只有当前线程有这个缓存行,因为其他线程的缓存行已经无效了)。I状态是当前 CPU 中的缓存行数据无效,这往往是由于其他 CPU 对缓存行中的数据进行修改导致的,当前 CPU 如果去缓存中读数据的话,由于数据已经无效,会重新从内存中加载缓存行
- 当其他线程再次读取这块缓存行的内容时,由于是i状态,所以这些线程会广播一个读请求,这个步骤会将独享这块缓存行的状态由E状态变为S状态,然后将这块缓存行的内容同步传输到其他cpu线程。(为什么不是从主存读取,因为主存慢而且当前内容只有一个缓存行持有所以安全。没有必要从主存中读)
可见性问题:为什么有了缓存一致性协议还需要再加volitile保证可见性?
借鉴文章: https://blog.csdn.net/m0_72088858/article/details/127275149
MESI的发展:mesi协议需要同步等待且同步主存效率太低,增加store buffer解决
前情提要:因为如果按照上面所说的那样走,那么当cpu中的缓存行是s状态且进行了修改时,就会一直发广播并且同步等待其他cpu发过来的invalidate ack消息再进行修改缓存行还需要同步到到主存中。
这样的效率太低了,因此工程师提出了个解决方案就是在cpu和cache之间再加一个store buffer,大致的顺序是下面这张图:
改善后的流程是这样的:
- cpu修改了s状态的缓存行数据时不需要同步进行等待其他cpu给发的invalidate ack消息了,只需要存放到store buffer里面然后立马返回。
- 之后store buffer会异步执行发送广播invalidate消息和写入cache写入主存的操作
也就是说 把cpu同步等待之后的操作放到了stroe buffer的异步操作中去了。当前线程读取同样需要从store buffer中读,保证是最新的数据
MESI的发展:store buffer存放数据会出现什么问题?是如何解决的?
store buffer和cache buffer不同,首先store buffer的空间非常小,存储的数据量非常小因此只能够作为缓解同步等待信号的压力;而cache buffer是作为缓存,其数据丢失后会从主存中再次读取。
这个设定会导致 其他 CPU 在收到 store buffer发出的 invalidate 消息时正在忙其他事,还来不及将缓存行状态置为 I,向cpu发出invalidate ack信号,这样就会造成 store buffer 不断堆积,直至溢出
MESI的发展:添加Invalidate Queue进行解决
解决思路其实很简单,cpu的store buffer数据堆积导致溢出的原因是因为他在等其他cpu发过来的invalidate ack信号,如果其他cpu在忙别的那么 信号发送就会有延迟就会导致堆积。
解决方案就是 其他cpu收到这个信号之后立马回复 ack即可,然后其他cpu在异步的将当前缓存行设置为i无效状态。
需要将哪些缓存行设置为无效i状态呢?如果有多个线程通知,那么就需要有关链表去存储,这个链表就是Invalidate Queue,顾名思义就是需要处理这个信号的缓存行队列
MESI的发展导致的问题:只能做到最终一致性协议(弱一致性),不能做到实时性(同步降低性能)
即然你用了store buffer做了异步的invalidate通知,那么在这期间如果其他线程读取同一块缓存行的内容时就不会发现是过期的数据(其他线程没有收到这个广播消息所以不会设置缓存行内容状态为i),就会导致数据的短暂不一致。
即然mesi是弱一致性那么如何保证数据同步?内存屏障
首先识别出弱一致性导致的问题:
- 对于写操作导致的当前线程的store buffer没有及时异步发送广播 会出现其他线程没有及时标注自己缓存的数据为过期(没有标记为无效缓存行) 而用的是错误的旧数据
解决这个问题很简单,添加write barrier写屏障,就是通过加lock信号立即同步到主存中(比起mesi就是把异步又换成了同步),保证了storebuffer的数据同步到了主存中并且其他线程标记了无效缓存行
- 对于读操作导致的其他核心没有及时处理异步处理广播信号,没有及时标记缓存行为I无效状态
解决这个问题是用的读屏障(read barrier),读屏障会保证nvalidate Queue中的信号处理完,也就是保证了缓存行可以被标记为无效状态
- 由于指令是乱序执行,此处的乱序仅仅考虑了单核线程执行的情况并么有考虑多线程下的逻辑问题(也无法去考虑,否则效率会很差)
解决这个问题也很简单,就是读写内存屏障,对于多个指令的赋值读取操作不会被优化为乱序。
- happen before原则
上面所说的volatile 添加的读写屏障只是java happen before原则中的一个,可以扩展看下 jvm规定了哪些操作不允许乱序执行。
总结:volatile的最终作用是什么?
volatile 关键字在多线程编程中有特殊的作用。它用于确保可见性和禁止指令重排。
在 Java 中,当一个变量被声明为 volatile 时,它的值的改变将会立即被写入主内存,并且读取该变量的线程将会从主内存中获取最新的值。 这样可以确保不同线程之间对该变量的读写操作是同步的,避免了使用缓存导致的数据不一致性问题。
但是MESi协议不是立即同步到主存中,而是先存放到store buffer中异步发送广播,其他线程也是异步的处理广播。
此外,volatile 关键字还可以禁止编译器和处理器对指令进行重排序优化。在多线程环境下,指令重排可能会导致程序执行结果出现错误。通过使用 volatile 关键字,可以保证特定的变量读写操作不会发生重排序,从而避免了可能出现的问题。
总结来说,volatile 关键字用于保证变量的可见性和禁止指令重排,以确保多线程环境下的正确性和一致性。
你是如何保证的?
线程安全已经有很多方法论了,但是还想提的一点是 要根据具体指令的情况进行优化,不要引入过多的处理和资源浪费。
典型的情况比如说
一说线程安全第一个想到的就是synchronized关键字(简单粗暴),但是你的实际情况是什么呢?他的消耗知道多少呢?有没有可替代的?
如果你知道这个关键字的原理,你就会知道他有非常大的消耗,比如偏向锁栈帧中存储markword数据,自旋锁cas修改markword 空转等待,重量级锁走入内核。
cpu的乱序优化是有效率提升的,缓存是有写入读取速度提升的,你用了这个关键字这些cpu的优化措施都不能够进行。
因此需要你实际根据情况,并不一定这三个特性都需要满足才能保证你的安全。分析线程不安全的原因,看需要仅仅保证园子性就可以使用atomic类,仅仅需要保证可见性只需要使用volatile,如果涉及大量逻辑指令就先用最轻量的lock,然后慢慢升级,最后实在不行采用关键字。保持这个习惯,会对你分析多线程原因 有很多进步,而不只是套用一个关键字抛之脑后了