博客园  :: 首页  :: 新随笔  :: 管理

ZMQ无锁消息队列

Posted on 2025-06-20 10:18  wsg_blog  阅读(55)  评论(0)    收藏  举报

Linux c/c++ 高性能后端

ZMQ无锁消息队列

锁是解决并发问题的万能钥匙,可是并发问题只有锁能解决吗?

  • CAS的含义
  • yqueue_t/ypipe_t无锁队列的设计原理
  • 每次加入元素都动态分配节点对性能的影响(测试ypipe_t<int, 1000>, ypipe_t<int, 10>, ypipe_t<int, 1>性能对比分析)
  • ArrayLockFreeQueue无锁队列如何实现多线程安全
  • 多写多读,多写单读,单写多读、单写单读不同队列的性能测试和分析

什么是CAS?

比较交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知型产生的数据不一致问题。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。

bool CAS(int * pAddr, int nExpected, int nNew)
atomically {
  if( *pAddr == nExpected) {
    *pAddr = nNew;
    return true;
  }
  else
    return false;
}

上面的CAS返回bool告知原子性交换是否成功。


为什么需要无锁队列?

锁引起的问题:1.Cache损坏(Cache trashing)2.在同步机制上争抢队列 3.动态内存分配

Cache损坏(Cache trashing)

在保存和恢复上下文的过程中还隐藏了额外的开销:Cache中的数据会失效,因为它缓存的是将被换出任务的数据,这些数据对于新换进的任务是没用的。处理器的运行速度比主存(内存)快N倍,所以大量的处理器时间被浪费在处理器与主存的数据传输上。这就是在处理器和主存之间引入Cache的原因。Cache是一种速度更快但容量更小的内存(也更加昂贵),当处理器要访问主存只能怪的数据时,这些数据首先被拷贝到Cache中,因为这些数据在不久的将来可能又会被处理器访问。Cache misses对性能有非常大的影响,因为处理器访问Cache中的数据将比直接访问主存快的多。
线程被频繁抢占产生的Cache损坏将导致应用程序性能下降。

在同步机制上的争抢队列

阻塞不是微不足道的操作。它导致操作系统暂停当前的任务或使其进入睡眠状态(等待,不占用任何的处理器)。直到资源(例如互斥锁)可用,被阻塞的任务才可以解除阻塞状态(唤醒)。在一个负载较重的应用程序中使用这样的阻塞队列来在线程之间传递消息会导致严重的争用问题。也就是说,任务将大量的时间(睡眠,等待,唤醒)浪费在获得保护队列数据的互斥锁,而不是处理队列中的数据上。
非阻塞队列大展身手的机会到了。任务之间不争抢任何资源,在队列中预定一个位置,然后在这个位置上插入或提取数据。这中机制使用了一种被称之为CAS(比较和交换)的特殊操作,这个特殊的操作是一种特殊的指令,它可以原子的完成以下操作:它需要3个操作数m,A,B,其中m是一个内存地址,操作将m指向的内存中的内容与A比较,如果相等则将B写入到m指向的内存中并返回true,如果不相等则什么也不做放回false。

volatile int a;
  a = 1;
  
  //this will loop while 'a' is not equal to 1.
  //If it is equal to 1 the operation will atomically set a to 2 and return true
  while (!CAS(&a, 1, 2)){
      ;
  }

动态内存分配

在多线程系统中,需要仔细的考虑动态内存分配。当一个任务从堆中分配内存时,标准的内存分配几乎会阻塞所有与这个任务共享地址空间的其他任务(进程中的所有线程)。这样做的原因是让处理更简单,且它工作的很好。两个线程不会被分配到一块相同的地址的内存,因为它们没办法同时执行分配请求。显然线程频繁分配内存会导致应用程序性能下降(必须注意,向标准队列或map插入数据的时候都会导致堆上的动态内存分配)

小结

image


单生产者单消费者

yqueue/ypipe基于链表的无锁队列
ZMQ中的yqueue是一个高效的无锁队列实现,主要用于一读一写的应用场景,比如1Epoll+线程池里每个线程绑定一个唯一的队列(Epoll fd)
ZMQ中ypipe是一个高效的无锁(lock-free)管道,用于多线程间的数据交换,尤其适用于单生产者-单消费者(SPSC)场景。它是yqueue的增强版,通过批量提交和同步机制优化性能,避免锁竞争。
其设计核心是 分块链表+局部无锁,兼顾高性能和低竞争。

yqueue的核心设计思想

  • 分块存储(Chunked Array)
    将队列元素按固定大小的块(chunk)分组存储,避免动态内存分配的开销。
    • 每个chunk是连续内存,存储多个元素(如ZMQ默认chunk大小为256个元素)。
    • 队列通过链表管理多个chunk,按需分配/释放chunk。
  • 局部无锁(Partial Lock-Free)
    • 通过原子操作(CAS)避免全局锁:生产者和消费者分别维护独立的指针(begin/end),通过预写机制及CAS实现多线程对chunk数据无锁的读取和写入位置确认
    • 在chunk边界可能同步:chunk的分配/回收需短暂同步(CAS)。

ypipe的核心设计思想

  • 线程间通信:在生产者线程和消费者线程之间传递消息,确保无锁、低延迟。
  • 批量提交优化:生产者可以累积多条消息后一次性提交(flush),减少原子操作频率。
  • 条件唤醒机制:当队列为空时,消费者线程可休眠,生产者通过条件变量唤醒它。

image
唤醒机制:业务上的wait(),notify()

关键数据结构

yqueue

template <typename T, int N> // N 为每个 chunk 的元素数
class yqueue {
    struct chunk_t {
        T values[N];        // 固定大小的元素数组
        chunk_t *prev, *next; // 双向链表指针
    };

    chunk_t *begin_chunk;   // 队首 chunk
    int begin_pos;          // 队首在 chunk 中的位置
    chunk_t *end_chunk;     // 队尾 chunk
    int end_pos;            // 队尾在 chunk 中的位置
    atomic_ptr<chunk_t> spare_chunk; // 缓存的空闲 chunk(避免频繁分配)
};

ypipe
ypipe基于yqueue(分块无锁队列)构建,但增加了三个核心指针:

  • w(Writer Pointer):生产者当前写入位置(未提交的数据)。
  • f(Flush Pointer):最后一次批量提交的位置(消费者可见的数据)。
  • r(Read Pointer):消费者当前读取位置。
template <typename T, int N>
class ypipe {
private:
    yqueue<T, N> queue;  // 底层无锁队列
    std::atomic<T*> w, f, r;  // 写、刷新、读指针
    // 其他同步工具(如条件变量)
};

ypipe_t r指针的预读机制不好理解,r可以理解为read_end,r并不是读数据的位置索引,而是我们可以最多读到哪个位置的索引。读数据的索引位置还是begin_pos。

工作流程

入队(Push)
1.检查当前end_chunk是否已满(end_pos == N):

  • 若满,从spare_chunk取缓存chunk或新建一个,链接到链表尾部。
    2.将元素写入end_chunk->values[end_pos].
    3.原子递增end_pos(无需锁,因生产者线程独占尾部)。

出队(Pop)
1.检查当前begin_chunk是否已空(begin_pos == N):

  • 若空,将begin_chunk移入spare_chunk缓存,切换到下一个chunk。
    2.读取begin_chunk->values[begin_pos].
    3.原子递增begin_pos(消费者线程独占头部)。

无锁优化的关键点

  • 生产者-消费者分离
    • 生产者和消费者分别修改end_pos和begin_pos,无竞争。
    • 只有chunk切换时可能短暂竞争(通过原子指针操作)。
  • 缓存chunk复用
    • spare_chunk缓存最近释放的chunk,避免频繁内存分配。
  • 批量操作
    • 每个chunk存储多个元素,分摊同步开销。

image

//flush时才会有CAS调用

两个线程,一个生产者线程,一消费者线程,push2000 0000次,不同队列的耗时
image

小结

yqueue通过 分块存储+局部无锁 设计,在保证线程安全的同时最大化性能,适合高频读写场景。其核心优势在于:
1.减少内存分配次数(chunk复用)。
2.最小化线程竞争(分离生产/消费指针)。
3.兼容性高(仅需原子操作,无需特殊硬件支持)。


多生产者多消费者

ArrayLockFreeQueue基于循环数组的无锁队列,采用多线程竞争(无锁)的方式对数据进行入队和出队,通过CAS确认此线程是否成功入队或出队,从而达到原子性更新入队和出队的位置,这里只简单看下入队和出队接口,及内部的CAS操作。

重点

  • ArrayLockFreeQueue数据结构,可以理解为一个环形数组
  • 多线程写入时候,m_maxmumReadIndex、m_writeIndex索引如何更新
  • 在更新m_maximumReadIndex的时候为什么要让出cpu;
  • 多线程读取的时候,m_readIndex如何更新。
  • 可读位置是由m_maximumReadIndex控制,而不是m_writeIndex去控制。
    • m_maximumReadIndex的更新由m_writeIndex.

enqueue 入队

template <typename ELEM_T, QUEUE_INT Q_SIZE>
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::enqueue(const ELEM_T &a_data)
{
	QUEUE_INT currentWriteIndex;		// 获取写指针的位置
	QUEUE_INT currentReadIndex;
	// 1. 获取可写入的位置
	do
	{
		currentWriteIndex = m_writeIndex;
		currentReadIndex = m_readIndex;
		if(countToIndex(currentWriteIndex + 1) ==
			countToIndex(currentReadIndex))
		{
			return false;	// 队列已经满了	
		}
		// 目的是为了获取一个能写入的位置
	} while(!CAS(&m_writeIndex, currentWriteIndex, (currentWriteIndex+1)));
	// 获取写入位置后 currentWriteIndex 是一个临时变量,保存我们写入的位置
	// We know now that this index is reserved for us. Use it to save the data
	m_thequeue[countToIndex(currentWriteIndex)] = a_data;  // 把数据更新到对应的位置

	// 2. 更新可读的位置,按着m_maximumReadIndex+1的操作
 	// update the maximum read index after saving the data. It wouldn't fail if there is only one thread 
	// inserting in the queue. It might fail if there are more than 1 producer threads because this
	// operation has to be done in the same order as the previous CAS
	while(!CAS(&m_maximumReadIndex, currentWriteIndex, (currentWriteIndex + 1)))
	{
		 // this is a good place to yield the thread in case there are more
		// software threads than hardware processors and you have more
		// than 1 producer thread
		// have a look at sched_yield (POSIX.1b)
		sched_yield();		// 当线程超过cpu核数的时候如果不让出cpu导致一直循环在此。
	}

	AtomicAdd(&m_count, 1);

	return true;

}

dequeue 出队

template <typename ELEM_T, QUEUE_INT Q_SIZE>
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::dequeue(ELEM_T &a_data)
{
	QUEUE_INT currentMaximumReadIndex;
	QUEUE_INT currentReadIndex;

	do
	{
		 // to ensure thread-safety when there is more than 1 producer thread
       	// a second index is defined (m_maximumReadIndex)
		currentReadIndex = m_readIndex;
		currentMaximumReadIndex = m_maximumReadIndex;

		if(countToIndex(currentReadIndex) ==
			countToIndex(currentMaximumReadIndex))		// 如果不为空,获取到读索引的位置
		{
			// the queue is empty or
			// a producer thread has allocate space in the queue but is 
			// waiting to commit the data into it
			return false;
		}
		// retrieve the data from the queue
		a_data = m_thequeue[countToIndex(currentReadIndex)]; // 从临时位置读取的

		// try to perfrom now the CAS operation on the read index. If we succeed
		// a_data already contains what m_readIndex pointed to before we 
		// increased it
		if(CAS(&m_readIndex, currentReadIndex, (currentReadIndex + 1)))
		{
			AtomicSub(&m_count, 1);	// 真正读取到了数据,元素-1
			return true;
		}
	} while(true);

	assert(0);
	 // Add this return statement to avoid compiler warnings
	return false;

}