为什么需要锁——多线程的数据竞争是怎么引发错误的
在进行多线程编程时,如果两个线程可能同时访问一个对象,我们会不加思考地在访问这个对象前加锁。本篇想仔细考虑一下加锁的目的。有时我会糊涂,我会这么想:比如一个线程往共享队列里 push,另一个线程读队列中的数据(只读不写),我会不明白这时候为什么需要加锁,读线程要么读到写线程更新后的值,要么写线程更新了,读线程还没有同步到数据,那就下次读的时候再读新数据就好了,我们也不要求写线程更新后读线程在多长时间内读到啊。其实这里是有个很简单很基础的误区的。
下面我罗列一些数据竞争的引发错误,分析“错误”到底是怎么出现的(肯定不全,不时补充)
读改写问题
在教学时最常见,实际上几乎遇不到的教学用数据竞争错误。两个线程对同一个变量进行++操作(x++,x 是 int)。老生常谈,++ 并非一条原子指令,而是先把内存值读到寄存器,修改寄存器,再把寄存器的值写回内存。硬件决定了++是一个过程,在这个过程中另一个线程可能会修改变量值,那么++线程+1的操作就基于了错误的旧值,最终的计算结果肯定也就错误了。这里的关键点在于++操作是一个过程,该过程基于的原始条件可能会在过程中被更改,导致过程的结果无效。
但还是会糊涂,比如我程序员在写 x++ 时,如果知道其他线程可能会更改,导致 x 并非 ++ 前的值 +1。那么上述问题就不是一个错误,只是预期中的现象。只不过可能很少有情况能容忍程序这么运行,这程序就很难可控了。我只是想强调这里的错误是指和预期的不符,而不是说硬件的崩溃,只要程序行为和预期相符,就没有错误可言了。
比如如果多个线程不加以同步的设置某一个变量的值,比如设置一个 int 变量,那么最终这个 int 变量肯定是某个线程最终设置的值,而不会是一个任何线程都没有设置的值。因为设置 int 变量这个操作是原子的,即使在硬件层面也根本没有过程。
读异常问题
再说一个比较实际的,两个线程,一个往共享队列里加元素,另一个读(只读不写),就是开篇说的那种情况。这里往往默认会加锁,但不加锁有什么问题呢?问题在于这里的“往共享队列里加元素”的写操作并非原子的,这是我们可以基于常识判断出来的。那么加元素本身就是一个过程,过程的初始和结尾都是正确的状态,而过程的中间就不一定了。多线程的调度又是不可控的,如果在这个写过程中间调度执行读线程,那么读出来的结果就不一定正确了。这就引发了错误。
即使不用严谨的分析,我们也愿意相信,对一般的容器、对象等的写操作基本不是原子的,它很可能会导致一个错误的中间状态。所以这时如果不加同步(锁)地与其他读线程合作的话,那读线程就可能读到错误的中间状态。这里的错误也是指的程序执行和预期不符,也不会出现任何的硬件崩溃哈。
而且一般也少有单纯的读线程吧,如果共享对象用的是 C++ 容器,程序员也很难保证逻辑上的读端不会改容器中的属性吧,那么是不是又容易涉及到读改写的问题,甚至更可能的是两个“写过程”同时执行,互相覆盖一部分对方写的内容,那最后内存里的值就是两个写过程的结果的糅合,是不是就更错误了。