concurrentqueue介绍

本文探讨了在单线程数据处理项目中引入多线程优化的方法,通过生产者消费者模式分离IO密集型与CPU密集型任务。详细介绍了乐观锁与CAS操作在实现线程安全队列中的应用,以及concurrentqueue库的特性与使用方法。

由于最近在做一个项目,但是框架本身有个不合理的设计。其中的代码是单线程的,数据的读取和计算都在一个线程里面完成。也就是说,我们的程序有很大的一部分时间在读取文件数据,导致最终的运行速度很慢。这里就可以使用多线程来优化。

这里需要使用最基本的生产者消费者模式

使用若干个线程作为生产者,负责数据的读取和预处理,这部分任务是IO密集型的,也就是不太占CPU,但是比较占带宽,而且有延时。在处理完数据之后,将数据放到一个队列中。

同时,使用若干个线程充当消费者,从这个队列里面获取数据,然后进行计算。计算的部分是CPU密集型的(其实我这里计算是GPU做的,就只有一个消费者),计算完成之后输出结果。

那么贯穿这一整套方案的,就是我们的队列。

在并发任务中,通常都需要一个队列机制,将并行的任务转化成串行的任务,或者将串行的任务提供给并行工作的线程。这个队列会同时被多个线程读写,因此也必须是线程安全的。

一、线程安全的实现策略

对于线程安全的队列的实现,似乎经常成为企业的面试题,常见的实现方法就是互斥量和条件变量,本质上就是锁的机制。同一时间只有一个线程具有读写的权限。锁的机制在并发量不大情况下,十分的清晰有效。在并发量较大的时候,会因为对锁的竞争而越发不高效。同时,锁本身也需要维护一定的资源,也需要消耗性能。

这时候,大家肯定会想问,不使用锁机制,还可以处理这种并发的情况吗?

答案是肯定的,首先我们知道锁主要有两种,悲观锁和乐观锁。对于悲观锁,它永远会假定最糟糕的情况,就像我们上面说到的互斥机制,每次我们都假定会有其他的线程和我们竞争资源,因此必须要先拿到锁,之后才放心的进行我们的操作,这就使得争夺锁成为了我们每次操作的第一步。乐观锁则不同,乐观锁假定在很多情况下,资源都不需要竞争,因此可以直接进行读写,但是如果碰巧出现了多线程同时操控数据的情况,那么就多试几次,直到成功(也可以设置重试的次数)。

我们生活的时候,总会碰到很多的不顺心的事情,比如模型训练崩了,被某些库搞得头大,或者女票又生气了什么的,不妨学习一下乐观锁的精神,再训一次?再编译一次?大不了再哄一次。一次不行就两次。

回到乐观锁上,乐观锁中,每次读写都不考虑锁的存在,那么他是如何知道自己这次操作和其他线程是冲突的呢?这就是Lock-free队列的关键——原子操作。原子操作可以保证一次操作在执行的过程中不会被其他线程打断,因此在多线程程序中也不需要同步操作。在C++的STL中其实也提供了atomic这个库,可以保证多线程在操控同一个变量的时候,即使不加锁也能保证起最终结果的正确性。而我们乐观锁需要的一个原子操作就是CAS(Compare And Swap),绝大多数的CPU都支持这个操作。

CAS操作的定义如下(STL中的一个):

 

1

bool atomic_compare_exchange_weak (atomic<T>* obj, T* expected, T val);

首先函数会将obj与expected的内容作比较:

  1. 如果相等,那么将交换obj和val的值,并返回true。
  2. 如果不相等,则什么也不做,之后返回false。

那么使用这个奇怪的操作,为什么就可以实现乐观锁了呢?这里我们看一个例子。这也是我学习的时候看的例子。

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

struct list {

    std::atomic<node*> head;

};

 

...

 

void append(list* s, node* n)

{

    node* head;

    do {

        head = s->head;

        n->next = head;

    } while (!std::atomic_compare_exchange_weak(&(s->head), &head, n));

    // or while (!s->head.compare_exchange_weak(head, n));

}

在我们向list中插入元素的时候,首先获取到当前的头指针的值head,然后我们在写数据的时候,首先和此刻的头指针值作对比,如果相同,那么就把新的节点插入。如果不相同,说明有线程先我们一步成功了,那么我们就多尝试一次,直到写入成功。

以上就是使用CAS操作实现的乐观锁。上面的这个append就是最简单的Lock-free且线程安全的操作。

二、concurrentqueue

最近在做这个项目的时候,就被安利了一个header only的C++并发队列库 concurrentqueue。本着不重复造轮子的原则,我在项目中用了这个库,由于它只是两个头文件,特别方便的就加入到了项目中。关于这个库的特点,项目的github上写了很多。这里直接照搬下来,不做解释。

  • Knock-your-socks-off blazing fast performance.
  • Single-header implementation. Just drop it in your project.
  • Fully thread-safe lock-free queue. Use concurrently from any number of threads.
  • C++11 implementation — elements are moved (instead of copied) where possible.
  • Templated, obviating the need to deal exclusively with pointers — memory is managed for you.
  • No artificial limitations on element types or maximum count.
  • Memory can be allocated once up-front, or dynamically as needed.
  • Fully portable (no assembly; all is done through standard C++11 primitives).
  • Supports super-fast bulk operations.
  • Includes a low-overhead blocking version (BlockingConcurrentQueue).
  • Exception safe.

我体验了一下,感觉最舒服的有以下几点:

  1. 这个库确实可以很好的实现线程安全队列,而且速度很快。接口也比较简单。很容易上手。
  2. 整个库就是两个头文件,而且没有其他的依赖,使用C++11实现,兼容各大平台,很容易融入项目。
  3. 这个并发队列支持阻塞和非阻塞两种。(只在获取元素的时候可以阻塞)

因为这个队列的用法十分简单,这里就直接贴上官网的介绍,然后针对一些细节,补充说明一下,下面是非阻塞队列的常用接口:

  • ConcurrentQueue(size_t initialSizeEstimate) Constructor which optionally accepts an estimate of the number of elements the queue will hold
  • enqueue(T&& item) Enqueues one item, allocating extra space if necessary
  • try_enqueue(T&& item) Enqueues one item, but only if enough memory is already allocated
  • try_dequeue(T& item) Dequeues one item, returning true if an item was found or false if the queue appeared empty

ConcurrentQueue(size_t initialSizeEstimate)这个没什么好说的,一个构造函数,可以指定队列的容量。

enqueue(T&& item)入队操作。比较有意思的是,如果我们的队列已经满了的话,那么这个还是会把数据放到队列里,使得队列的容量变大。所以,如果希望队列的长度不变的话,尽量还是不要使用这个函数。

try_enqueue(T&& item)这个也是入队操作,与上一个不同,这个函数当队列已经满了的时候,并不会进行入队操作,而是返回一个bool类型的值,表示是否入队成功。我在使用的时候,会判断这个bool值,如果是false,就让线程等待10ms之后重试。

try_dequeue(T& item)这个是出队操作,如果队列有值的话,则得到数据(放到参数item里面)。他也会返回一个bool类型的值,表示时候出队成功。

阻塞版本的队列中,主要多了如下两个函数:

wait_dequeue(T&& item)
wait_dequeue_timed(T&& item, std::int64_t timeout_usecs)。这两个函数的功能类似,都是进行出队操作,如果队列为空,则等待。唯一的区别是,前者永久等待,而后者可以指定等待的时间,如果超时,则会停止等待并返回false。

最后是块操作,两种模式的队列都支持批量插入的操作。这里,我没有用过这些接口,所以大家自行看文档就好。

 

转自https://www.miaoerduo.com

<think>嗯,用户这次要求详细了解C#中的Channels和ConcurrentQueue,需要包括概念、使用方法和区别。用户提供了四个站内引用,这些材料很有价值。 从引用内容看,Channel是基于ConcurrentQueue的封装,但更专注于异步场景(引用1)。引用2详细比较了各种并发工具:Channel适合异步且内置背压机制,ConcurrentQueue更轻量但需要手动实现等待逻辑。引用4提到Channel的有界模式支持四种满队列策略,这是ConcurrentQueue不具备的特性。 用户可能正在设计高并发系统,需要选择适合的线程通信方案。ta的关注点明显在技术细节而非概念定义,所以重点应该放在实践对比上。上次回答时已经列出过核心区别,这次需要深化场景说明。 特别注意用户引用的Quic和CAP案例(引用1),这说明ta可能接触过网络编程或分布式系统。在区别部分要强调Channel的异步优势,比如ValueTask优化和与async/await的契合度。对于ConcurrentQueue,则突出其在CPU密集型场景的无锁性能。 使用示例需要体现关键差异:Channel的ReadAsync/WriteAsync方法对比ConcurrentQueue的TryDequeue手动轮询。有界Channel的DropOldest策略示例可以直接引用用户提供的枚举值(引用4)。 性能比较部分要谨慎,根据引用2的结论,Channel在异步场景吞吐量更高,但同步操作时ConcurrentQueue可能更优。这个细微差别必须说明,避免用户误用。 最后的问题设计要引导用户思考自身场景:如果ta正在处理数据流水线,可能需要对比TPL Dataflow(引用2提到的备选方案);若涉及线程调度,则延伸BlockingCollection的适用性。 </think>以下是关于 **C# 中 `Channels` 和 `ConcurrentQueue<T>` 的详细介绍、使用场景及核心区别**,结合站内引用整理: --- ### **1. 概念与设计目标** | **类型** | **核心设计** | **适用场景** | |----------------------|-----------------------------------------------------------------------------|----------------------------------| | **`ConcurrentQueue<T>`** | 无锁线程安全队列,基于 CAS(Compare-And-Swap)实现。仅提供基础入队/出队操作(`Enqueue`/`TryDequeue`)。 | 高吞吐量场景,无需协调生产消费速率。 | | **`Channels`** | 基于 `ConcurrentQueue<T>` 的高级封装,提供**异步API**和**背压控制**(生产者-消费者速率协调)。 | 异步编程模型(如 `async/await`)。 | --- ### **2. 使用示例对比** #### **(1) `ConcurrentQueue<T>` 基础用法** ```csharp var queue = new ConcurrentQueue<int>(); // 生产者线程 Task.Run(() => { for (int i = 0; i < 10; i++) { queue.Enqueue(i); Thread.Sleep(100); } }); // 消费者线程(需手动轮询) Task.Run(() => { while (true) { if (queue.TryDequeue(out var item)) { Console.WriteLine($"Dequeued: {item}"); } Thread.Sleep(50); // 轮询间隔 } }); ``` > ⚠️ 缺点:需手动实现等待逻辑,易导致CPU空转。 #### **(2) `Channel` 异步用法** ```csharp var channel = Channel.CreateUnbounded<int>(); // 生产者(异步写入) _ = Task.Run(async () => { for (int i = 0; i < 10; i++) { await channel.Writer.WriteAsync(i); await Task.Delay(100); } channel.Writer.Complete(); // 标记完成 }); // 消费者(异步读取) _ = Task.Run(async () => { await foreach (var item in channel.Reader.ReadAllAsync()) { Console.WriteLine($"Received: {item}"); } }); ``` > ✅ 优点:通过 `ReadAllAsync()` 自动等待数据,无轮询开销。 --- ### **3. 核心区别总结** | **特性** | `ConcurrentQueue<T>` | `Channels` | |------------------------|---------------------------------------|-------------------------------------| | **线程模型** | 同步阻塞(需手动轮询) | 原生支持 `async/await` 异步模型 | | **背压控制** | ❌ 需自定义实现 | ✅ 内置(通过 `BoundedChannelOptions`) | | **队列满策略** | ❌ 不支持 | ✅ 支持 `DropNewest`/`DropOldest` 等[^4] | | **性能优化** | 低开销(无锁) | 更高(基于 `ValueTask` 减少分配)[^1] | | **使用复杂度** | 低(基础API) | 中(需理解异步流) | --- ### **4. 如何选择?** - **选 `ConcurrentQueue<T>` 当**: 需要极简队列,且能接受手动实现消费者轮询逻辑(如高性能计算)。 - **选 `Channels` 当**: - 需协调生产/消费速率(如有界队列、背压)。 - 需无缝集成 `async/await`(如网络通信、文件IO)。 - 需内置满队列策略(如实时数据处理)[^4]。 > ℹ️ **关键趋势**: > .NET 5+ 新组件(如 QUIC)已用 `Channels` 替代 `BlockingCollection`;CAP 等库也迁移至 `Channels`[^1],因其更适合现代异步架构。 --- ### **相关问题** 1. **如何实现 `Channels` 的有界容量和背压控制?** (涉及 `BoundedChannelFullMode` 的四种策略[^4]) 2. **在 CPU 密集型场景下,`ConcurrentQueue<T>` 是否仍比 `Channels` 更有优势?** 3. **`Channels` 与 `TPL Dataflow` 如何协作或替代?** (引用提到 TPL 适合复杂管道,但更重[^2]) [^1]: Channels 封装 ConcurrentQueue,优化异步支持,被 .NET 5 的 QUIC 和 CAP 采用。 [^2]: Channels 在异步场景性能更高,BlockingCollection 适合同步场景。 [^3]: 容器结合设计模式(如生产者-消费者)可提升多线程程序质量。 [^4]: 有界 Channel 支持 Wait/DropNewest/DropOldest/DropWrite 四种满队列策略。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值