我想,很多的程序员都知道如何利用环形队列来避免使用锁。那就是,生产者只更改写指针,消费者只更改读指针。当然这里的指针一般可能是整数下标。
例如: read_pos为消费者更改的读指针,每读走一个元素 则 read_pos = (read_pos + 1) % SIZE,
write_pos为生产者更改的写指针,每写完一个元素, write_pos = (write_pos + 1) % SIZE,
在消费者看来,只要队列不空,就可以读取当前读指针处的元素,在生产者看来,只要队列不满,就可以在当前的写位置上写一个元素。
队列为空的条件为: read_pos = write_pos(对吗?)
队列为满的条件为: (write_pos + 1) % SIZE = read_pos(对吗?)
现在所有的东西都已经考虑完了?
其实这种实现方案的最大的隐患在于如何能够正确的比较read_pos和write_pos之间的关系。例如,很多人都觉得在read_pos = read_pos + 1执行完之前,read_pos会始终保持原来的值不变。这种错误很多人都会犯。可是又有人会反驳道,我就是这么实现的,而且程序运行良好,从来没出过问题。下面我们就讲讲,在什么情况下,这种实现方法是正确的。这个我们必须得讨论一下计算机的数据总线宽度。数据总线宽度标识这CPU一次能够读取的数据的大小。常见的有16位,32位,64位。现在我们讨论一下read_pos与write_pos的类型,如果两个都是int型,即都是32位,那么如果你的计算机的数据总线宽度是32或64,那么很幸运你将得到一个"不易出错"的代码,因为这种情况下read_pos或write_pos在完成加1操作之前一定是原来的值。而如果你的计算机数据总线宽度为16的话,我想你不出错都很难,这时read_pos或write_pos在加1过程中很可能出现首先更改read_pos或write_pos的低16位,之后再更改它们的高16位。而恰恰如果我们在这两次更改之间进行了读,那么读到数肯定就会是错误的。那么后果不言自明,这会导致判断出现错误,因此,读写就会发生错误。同样的问题会出现在read_pos和write_pos为long long类型,而却应用在数据总线32位,16位机器上。
我们再回头看看"不易出错",说他不易出错原因就是因为即使考虑了数据总线的宽度也是不全面的,因为我上面的代码就是错误的。大家如果能仔细分析其实就不难猜到,队列为空或满的判断条件是不对的,应该改为
队列为空:read_pos % SIZE = write_pos % SIZE
队列为满:(write_pos + 1) % SIZE = read_pos % SIZE
理由是,read_pos = (read_pos + 1) % SIZE,被编译器分解为 read_pos = read_pos + 1, read_pos = read_pos % SIZE两个操作,write_pos同理。那么如果我们在第二个操作完成之前第一个操作完成之后读取read_pos,那么就读到了错误的数,因为read_pos很可能等于SIZE。而我们却规定read_pos与write_pos在0-SIZE-1之间。
当我写到这的时候,您认为就已经考虑的足够了吗?答案是没有。
我们还需要把read_pos和write_pos声明为volatile,这个关键字的最主要用途就是防止编译器优化,并且每次强制从内存中读取值,而不是从寄存器。因为内存中的值才是最安全的。例如,如果计算机的加1操作被编译器弄成了先+2后-1这种形式去实现,而+2之后的值存在寄存器中,而程序偏偏就在这时读取了寄存器中的数据,那么程序就会出错。
写到这,就接近尾声了。对于环形队列消锁的问题可能并不存在十全十美的解决方案,即使考虑了上面的那些因素,也不能说明程序是正确的,因为我们不了解编译器会怎么做,也不了解计算机会怎么做,我们这么做只是让我们的程序出错的概率变小而已。而可能在现在的计算机体系结构和操作系统以及编译器恰好是对的,但是如果哪一天计算机的体系结构变了,我们的系统可能会因上面的代码随时崩溃。