无锁队列
介绍
在工程上,为了解决两个处理器交互速度不一致的问题,我们使用队列作为缓存,生产者将数据放入队列,消费者从队列中取出数据。这个时候就会出现四种情况,单生产者单消费者,多生产者单消费者,单生成者多消费者,多生产者多消费者。我们知道,多线程往往会带来数据不一致的情况,一般需要靠加锁解决问题。但是,加锁往往带来阻塞,阻塞会带来线程切换开销,在数据量大的情况下锁带来的开销是很大的,因此无锁队列实现势在必行。下面就详细讲一下每种情况的不同实现方法。
单生产者单消费者
从最简单的单生产者单消费者说起,假设我们现在有一个正常的队列,写线程往这个队列的head处push数据,读线程往这个队列的tail处pop数据。试想,如果head和tail不相同,也就是两个线程操作的数据不是同一个,这个时候是不会产生冲突的,这意味着我们只需要在head == tail的时候做处理。这时候可以发现,head == tail的时候,正是队列空的时候,也就是说这个时候读线程是读不到数据的,因此,读线程和写线程是不会产生冲突的,所以实现参考如下伪代码
bool push(T &data) { if (isFull()) { // head == tail + 1 return false; } lockFreeQueue[tail] = data; // 注意编译器或者CPU可能会为了提升性能将代码乱序,为了上下两句代码不颠倒顺序,这里需要加上内存屏障 tail++; return true; } bool pop(T &data) { if (ifEmpty()) { // head == tail return false; } data = lockFreeQueue[head]; // 内存屏障 head++; return true; }
多生产者单消费者/单生成者多消费者
参考单生产者单消费者模型我们可以发现,读和写本身是无竞争的,竞争的是读和读之间,写和写之间。考虑单生产者多消费者问题,有没有方式避免读和读之间的竞争呢?
MUTEX
解决竞争最粗暴的方法,直接上锁。
原子操作CAS
可以利用CAS模拟加锁解锁,定义一个变量当锁,然后将该变量置为0或者1代表未上锁和已上锁状态,因为操作是原子的,所以保证每次只有一个线程可以抢占到锁。c提供了一个__sync_bool_compare_and_swap接口使用,c++也有相关原子变量库。这种加锁解锁非常快,适合频繁加锁解锁的场景
队列分离
如果真的不想加锁,最简单的方法就是,我根据读线程数分配队列数,也就说一个读线程对应一个队列,这样,读和读就可以分离开来,也就不存在竞争了。然后写依此去读每个队列,这样可以达到完全无锁。但是这会带来其他问题,如果某个线程处理比较慢,他绑定的队列里的信息无法及时清空,其他线程也无法帮他清空,可能会带来某些信息无法快速处理,导致超时。所以要根据实际情况选用这种模式。
队列共享
可以发现,将队列分成多个的做法不是完全不可取的。在只有一个队列的情况下,所有读线程都会去竞争队头,在同一时间只会有一个线程可以抢到锁,然后进行读,再释放锁。如果我现在有多个队列,然后每个线程都可以读任意一个队列,当然在读之前还是要先抢占队列使用权,但是因为队列数量很多,可以把冲突尽可能分散掉。如果此时队列数量足够多,冲突率会更低。
但是这又带来一个问题,如何给线程分配队列?最简单的方法是轮询,也就是每个线程一开始都在0号队列处,依次尝试去获取每个队列的使用权,抢不到的话就去获取下一个。这种做法简单粗暴,也可以很明显地看出冲突率十分高,因为每次都是取下一个队列,一旦一个队列抢占成功,后面的队列都要尝试去获取这个队列的使用权,如果前面的队列没有及时释放掉队列使用权,其他队列肯定会获取失败。
我们想要每个线程都能获取到任意一个队列的使用权,又不想冲突率太高,可以用下面的方法。假设我现在有n个线程,k个队列,假设n<k,且n与k互质,一开始第i个线程在第i个队列处,处理完后移动到第(i + n) % k个队列,以此类推。因为n和k互质,可以保证每个线程都能访问到任意一个队列,而且线程之间不容易发生竞争。如果k足够大,基本上不会产生竞争。如果发生了竞争,可以采用自调节方式,下一个队列移动到第(i + n + 1) % k个队列处,这样的做的原因是,可以认为如果发生了竞争,那么后面也很容易竞争,所以改变轨迹是很有必要的。但是这种解决冲突的方法有一定局限性,首先如果队列数太多,那么一个线程累计下来的数据,另一个线程要去处理到它们的时间就会延长,也可能带来超时。所以其实冲突和及时处理本身就是矛盾的,二者无法完全避免。
共享带来的问题
如果你的读写十分占cpu,可能需要每个线程分配一个核的时候,共享带来的问题是不可忽视的。每个CPU都有各自的Cache,具体跟CPU架构有关,但至少L1是每个核一个的。为了保持Cache同步,CPU采用了MESI协议去保证。简单来说就是每个核监听总线,可以知道哪些数据被修改过了,对于脏数据及时同步,同步要经过数据总线。如果每个线程绑定一个核,队列又是共享的,那CPU就要频繁进行同步。同步是必不可少的,但是如果你只有一个写线程,又有多个读线程的话,写线程会被大大影响,从而降低了性能。这是因为读线程所在的核需要和写线程所在的核进行同步,总线容易被占满,写自然就慢下来了。这种情况很难去优化,只能建议在绑核的时候,写线程尽可能和读绑在同个CPU上,跨CPU带来的消耗更大。或者可以利用超线程技术,超线程上的两个核是共享Cache的,可以把线程两两绑定起来,但是这样CPU算力会降低,因为超线程无法达到两个核的算力。这种情况只能从降低同步量去解决了。
降低同步量的方法很多,尽可能使用线程私有的变量,而不是全局变量,这样不会带来同步。或者是一个线程连续处理多次同个队列,降低移动的频率,这样同步数量也可以减少,但是会带来上面讲过的数据处理延迟。
False sharing 问题也是不可忽视的,cache同步的时候,两个连续的内存分别在两个核上,且在同个cache line。cpu每次更新数据的最小单位都是cache line。假设这两个数据都要频繁更新,那他们会不断向对方发生更新数据请求,这样开销十分巨大(具体参考MESI协议,这里不细说)。解决方法就是进行Cache line对齐,每个变量都分配到不同cache line上就好。
总结
在做这次lockFreeQueue的过程中,我对CPU有了更多认识,很多简单的东西往往不是在算法上做优化,而是要在了解了CPU是怎么处理,OS是怎么处理,编译器是怎么处理之后,去做一些常数的优化。其实我做的优化远比上面讲的要多,诸如分支预测,数据预拉取的操作,但是和lockFreeQueue本身没有太大关系。本博文做记录用,有问题或者有更好解决方法的可以留言。