关于.NET中的队列理解


文章目录


前言:术语解释(我也是后来才搞懂的)

写这篇文章之前,先解释几个让我困惑了很久的词,免得你看后面一头雾水。

什么是"无界队列"和"有界队列"?

说人话就是:

复制代码
无界队列 = 无限大的购物车
  → 你往里扔多少东西都行
  → 好处:永远不会拒绝你
  → 坏处:可能把内存撑爆

有界队列 = 只能装100个的购物车
  → 满了就不让你继续放了
  → 好处:不会爆内存
  → 坏处:满了怎么办?等?还是扔掉?
csharp 复制代码
// 无界队列:爱放多少放多少
var unlimited = Channel.CreateUnbounded<int>();
for (int i = 0; i < 100000000; i++)  // 一亿个
    await unlimited.Writer.WriteAsync(i);  // 不会报错,但可能OOM

// 有界队列:最多100个
var limited = Channel.CreateBounded<int>(100);
for (int i = 0; i < 1000; i++)
    await limited.Writer.WriteAsync(i);  // 写到第101个时会等待

什么是"背压"(Backpressure)?

这个词听起来很装X,其实很简单:

复制代码
想象你在工厂流水线上装箱:
  前面的人疯狂往传送带上放产品(生产者)
  你在后面装箱(消费者)
  
如果没有背压:
  前面的人不管你装不装得完,一直放
  → 传送带堆满了
  → 产品掉地上
  → 崩了

有了背压:
  传送带满了(队列满了)
  → 前面的人自动停下来等你(生产者阻塞)
  → 你装完一箱,他才继续放下一个
  → 整个系统速度由最慢的环节决定,但不会崩
csharp 复制代码
// 有背压的队列
var queue = Channel.CreateBounded<int>(capacity: 10);

// 生产者:疯狂生产
Task.Run(async () =>
{
    for (int i = 0; i < 1000; i++)
    {
        await queue.Writer.WriteAsync(i);  // 队列满了会在这里等
        Console.WriteLine($"生产: {i}");
    }
});

// 消费者:慢慢处理
Task.Run(async () =>
{
    await foreach (var item in queue.Reader.ReadAllAsync())
    {
        await Task.Delay(100);  // 故意慢
        Console.WriteLine($"  消费: {item}");
    }
});

// 你会看到:生产打印到10就停了,等消费一个才继续
// 这就是背压在起作用

什么是"无锁队列"?

复制代码
有锁队列 = 公共厕所(一次只能进一个人)
  线程A进去了(拿到锁)
  线程B在门口等(阻塞)
  线程C也在等
  ...
  A出来了,B才能进

无锁队列 = 多个厕所隔间
  线程A、B、C同时进不同隔间
  通过巧妙的"占坑"机制(CAS原子操作)
  → 不需要等待
  → 但如果两个人抢同一个隔间,一个要重新找(重试)

好,术语解释完了,开始正文。


一、我为什么要写这篇文章

去年做一个项目,100台下位机每隔10ms推送一次数据,我需要接收、处理、存数据库。

一开始我这么写的:

csharp 复制代码
// 我的第一版代码(现在看真是惨不忍睹)
private List<DataRecord> _dataList = new List<DataRecord>();

// 接收线程
void OnDataReceived(DataRecord data)
{
    _dataList.Add(data);  //  多线程直接操作List,崩了
}

// 数据库线程
void SaveToDatabase()
{
    foreach (var data in _dataList)
    {
        db.Insert(data);  //  一秒1000条,逐条插入,数据库炸了
    }
    _dataList.Clear();
}

然后:

  • 程序跑着跑着就崩溃
  • 数据库CPU 100%
  • 领导问我啥时候能上线

我才意识到我需要一个队列。

但C#里有Queue、ConcurrentQueue、BlockingCollection、Channel...选哪个?每篇教程都说自己好,我整整研究了两天,踩了无数坑。

所以写这篇文章,希望后来人少走弯路。


二、Queue<T> - 新手的第一个坑

官方文档说:Queue<T> 是一个先进先出(FIFO)集合。

我一开始理解成"队列肯定是线程安全的啊,排队嘛"。

错了。

csharp 复制代码
// 我当时的代码
var queue = new Queue<int>();

// 启动10个线程同时写入
Parallel.For(0, 10, threadId =>
{
    for (int i = 0; i < 10000; i++)
    {
        queue.Enqueue(i);  //  这里会崩
    }
});

// 运行结果:
// - 有时候正常
// - 有时候报错 InvalidOperationException
// - 有时候数据丢失
// - 有时候死循环

去查文档,发现一行小字:

Queue<T> 不是线程安全的。

我:???那你为什么不在醒目位置写?

后来才明白:Queue<T> 设计就是给单线程用的,性能优先,不管线程安全。

什么时候能用Queue<T>?

csharp 复制代码
// 场景:UI事件处理(单线程)
public class EventProcessor
{
    private Queue<UIEvent> _events = new Queue<UIEvent>();
    
    public void AddEvent(UIEvent evt)
    {
        _events.Enqueue(evt);  // 只在UI线程调用,安全
    }
    
    public void ProcessEvents()
    {
        while (_events.Count > 0)
        {
            var evt = _events.Dequeue();
            evt.Handle();
        }
    }
}

总结:Queue<T> 只在单线程用,多线程别碰。


三、ConcurrentQueue<T> - 我以为的救星

发现Queue不行后,我找到了ConcurrentQueue

csharp 复制代码
var queue = new ConcurrentQueue<int>();

// 10个线程同时写
Parallel.For(0, 10, threadId =>
{
    for (int i = 0; i < 10000; i++)
    {
        queue.Enqueue(i);  //  不会崩了!
    }
});

这次没崩,我以为问题解决了。

但新问题来了:

csharp 复制代码
// 我写的数据库线程
Task.Run(() =>
{
    while (true)
    {
        if (queue.TryDequeue(out var data))
        {
            SaveToDatabase(data);
        }
        else
        {
            Thread.Sleep(10);  // 没数据就睡一会儿
        }
    }
});

这代码跑起来后:

  • CPU占用10%(一直在空转)
  • 数据延迟不稳定(有时候睡过头)
  • 不知道怎么优雅地停止这个循环

ConcurrentQueue的尴尬点

它线程安全,但不会阻塞等待

csharp 复制代码
// 场景:队列空了
if (queue.TryDequeue(out var item))
{
    // 有数据
}
else
{
    // 没数据...然后呢?
    // 自己写循环?Sleep多久合适?
    // while(true) 空转?
}

我需要一个"队列空了就等着,有数据了就叫我"的功能。

ConcurrentQueue 没有。


四、BlockingCollection<T> - 老前辈的智慧

然后我发现了BlockingCollection

csharp 复制代码
var queue = new BlockingCollection<int>();

// 生产者
Task.Run(() =>
{
    for (int i = 0; i < 1000; i++)
    {
        queue.Add(i);
    }
    queue.CompleteAdding();  // 告诉消费者"没了"
});

// 消费者
Task.Run(() =>
{
    foreach (var item in queue.GetConsumingEnumerable())
    {
        SaveToDatabase(item);
        // 队列空了会自动等待
        // CompleteAdding后自动退出循环
    }
});

这个终于符合我的需求了!

但它也有问题

csharp 复制代码
// 我想用 async/await
await foreach (var item in queue.GetConsumingEnumerable())  // 不支持
{
    await SaveToDatabaseAsync(item);
}

// 只能这样写
foreach (var item in queue.GetConsumingEnumerable())
{
    SaveToDatabaseAsync(item).Wait();  //  阻塞等待,浪费线程
}

BlockingCollection 是2010年的产物,没有async/await支持。


五、Channel<T> - 真正的现代方案

直到我发现Channel,才感觉找到了归宿。

csharp 复制代码
var channel = Channel.CreateUnbounded<int>();

// 生产者
Task.Run(async () =>
{
    for (int i = 0; i < 1000; i++)
    {
        await channel.Writer.WriteAsync(i);
    }
    channel.Writer.Complete();
});

// 消费者
Task.Run(async () =>
{
    await foreach (var item in channel.Reader.ReadAllAsync())
    {
        await SaveToDatabaseAsync(item);  // 完美支持 async
    }
});

Channel解决了我所有痛点

  1. 线程安全
  2. 自动阻塞等待
  3. async/await支持
  4. 优雅关闭
  5. 性能还特别好

Channel的两个模式

csharp 复制代码
// 模式1:无界队列(想放多少放多少)
var unbounded = Channel.CreateUnbounded<int>();

// 模式2:有界队列(限制容量,有背压)
var bounded = Channel.CreateBounded<int>(capacity: 1000);

// 有界队列的好处
Task.Run(async () =>
{
    for (int i = 0; i < 100000; i++)
    {
        await bounded.Writer.WriteAsync(i);
        // 队列满了(1000个)会在这里自动等待
        // 等消费者消费一些后才继续
        // → 不会爆内存
    }
});

六、我踩过的坑

坑1:忘记调用Complete()

csharp 复制代码
var channel = Channel.CreateUnbounded<int>();

// 生产者
Task.Run(async () =>
{
    for (int i = 0; i < 100; i++)
        await channel.Writer.WriteAsync(i);
    
    // 忘记写这行
    // channel.Writer.Complete();
});

// 消费者
Task.Run(async () =>
{
    await foreach (var item in channel.Reader.ReadAllAsync())
    {
        Console.WriteLine(item);
    }
    Console.WriteLine("完成");  // ← 永远不会打印
});

消费者会永远等待,因为它不知道"没有更多数据了"。

记住:生产完了一定要调Complete()

坑2:搞混Bounded和Unbounded

csharp 复制代码
// 我当时写的代码
var channel = Channel.CreateUnbounded<DataRecord>();

// 然后程序运行一段时间后内存爆了
// 因为生产速度 > 消费速度
// 队列无限增长

// 改成这样
var channel = Channel.CreateBounded<DataRecord>(new BoundedChannelOptions(10000)
{
    FullMode = BoundedChannelFullMode.DropOldest  // 满了丢旧数据
});

// 现在内存稳定了

坑3:在UI线程阻塞

csharp 复制代码
// WinForms UI线程
private void button1_Click(object sender, EventArgs e)
{
    var channel = Channel.CreateUnbounded<int>();
    
    //  错误:在UI线程等待
    var item = channel.Reader.ReadAsync().Result;  // UI卡死
    
    //  正确:用 async
    var item = await channel.Reader.ReadAsync();  // 但Click事件要改成async
}

// 改成
private async void button1_Click(object sender, EventArgs e)
{
    var item = await channel.Reader.ReadAsync();  //  不卡UI
}

七、我现在的标准模板

经过一年多的实战,我总结出一个模板:

csharp 复制代码
public class DataCollector
{
    // 三级流水线
    private readonly Channel<byte[]> _rawDataChannel;      // 网络→解析
    private readonly Channel<DataRecord> _parsedChannel;   // 解析→业务
    private readonly Channel<DataRecord> _dbChannel;       // 业务→数据库
    
    public DataCollector()
    {
        // 第1级:网络数据缓冲(有界,满了丢旧的)
        _rawDataChannel = Channel.CreateBounded<byte[]>(
            new BoundedChannelOptions(5000)
            {
                FullMode = BoundedChannelFullMode.DropOldest
            });
        
        // 第2级:解析后数据(有界,满了等待)
        _parsedChannel = Channel.CreateBounded<DataRecord>(
            new BoundedChannelOptions(5000)
            {
                FullMode = BoundedChannelFullMode.Wait
            });
        
        // 第3级:待写入数据(无界,攒批写入)
        _dbChannel = Channel.CreateUnbounded<DataRecord>();
    }
    
    public async Task RunAsync(CancellationToken ct)
    {
        var tasks = new[]
        {
            ReceiveLoopAsync(ct),   // 接收线程
            ParseLoopAsync(ct),     // 解析线程
            ProcessLoopAsync(ct),   // 业务线程
            DbWriterLoopAsync(ct)   // 数据库线程
        };
        
        await Task.WhenAll(tasks);
    }
    
    private async Task ReceiveLoopAsync(CancellationToken ct)
    {
        // 从网络接收数据,写入第1级
        while (!ct.IsCancellationRequested)
        {
            byte[] data = await ReceiveFromDeviceAsync();
            await _rawDataChannel.Writer.WriteAsync(data, ct);
        }
    }
    
    private async Task ParseLoopAsync(CancellationToken ct)
    {
        // 从第1级读取,解析后写入第2级
        await foreach (var raw in _rawDataChannel.Reader.ReadAllAsync(ct))
        {
            var parsed = Parse(raw);
            await _parsedChannel.Writer.WriteAsync(parsed, ct);
        }
    }
    
    private async Task ProcessLoopAsync(CancellationToken ct)
    {
        // 从第2级读取,业务处理后写入第3级
        await foreach (var data in _parsedChannel.Reader.ReadAllAsync(ct))
        {
            // 做一些业务逻辑(报警判断等)
            if (data.Temperature > 80)
                RaiseAlarm(data);
            
            await _dbChannel.Writer.WriteAsync(data, ct);
        }
    }
    
    private async Task DbWriterLoopAsync(CancellationToken ct)
    {
        // 从第3级读取,批量写数据库
        var batch = new List<DataRecord>();
        
        await foreach (var data in _dbChannel.Reader.ReadAllAsync(ct))
        {
            batch.Add(data);
            
            // 攒够1000条或1秒,批量写入
            if (batch.Count >= 1000)
            {
                await BulkInsertAsync(batch);
                batch.Clear();
            }
        }
    }
}

这个模板我用了一年多,很稳定。


八、最后的建议

给新手

如果你是第一次接触队列:

  1. 先别想多线程 ,用Queue<T>把逻辑跑通
  2. 需要多线程了 ,直接上Channel<T>
  3. 如果是老项目(.NET Framework) ,用BlockingCollection

给老手

如果你已经在用BlockingCollection

  • 新项目可以考虑迁移到Channel,性能和易用性都更好
  • 老项目稳定运行的话,没必要为了迁移而迁移

选择指南(我的个人建议)

复制代码
单线程场景         → Queue<T>
多线程 + 简单需求  → ConcurrentQueue<T>
多线程 + 需要等待  → Channel<T> (首选)
.NET Framework    → BlockingCollection<T>
需要背压控制       → Channel.CreateBounded()

补充几个问题

Q1: Channel和BlockingCollection到底差多少?

我实测过,同样100万次操作:

  • BlockingCollection: ~220ms
  • Channel: ~95ms

快了一倍多。但实际项目中,瓶颈通常不在队列,而在数据库、网络。所以差距没那么明显。

Q2: 为什么要分三级队列?

因为每个环节速度不一样:

  • 接收很快(0.1ms)
  • 解析也快(0.5ms)
  • 业务处理中等(2ms)
  • 数据库很慢(10ms)

如果不分级,最慢的环节会拖累整个系统。分级后,慢的环节自己慢慢处理,不影响其他环节。

Q3: 内存会不会爆?

这就是为什么第1级我用DropOldest,第2级用Wait,第3级用Unbounded

  • 网络数据可以丢(最多丢10ms的数据)
  • 解析数据不能丢(会等待)
  • 数据库数据必须写入(攒批写很快)

这样设计,内存最多占用:5000 + 5000 + (短时间积压) ≈ 1万条数据,可控。


创作权保护

本文由 [ leonkay ] 学习总结编写,水平有限,内容仅供参考,作为个人记录使用。若有疏漏,请不吝赐教。版权归作者所有,未经授权,禁止转载、摘编或以其他方式使用本文内容。如需合作或转载本文,请联系作者获得授权。

相关推荐
斌味代码2 小时前
Redis 分库分表实战:从垂直拆分到水平扩容完整记录
数据库·redis·bootstrap
CSharp精选营2 小时前
C# 如何减少代码运行时间:7 个实战技巧
性能优化·c#·.net·技术干货·实战技巧
Percep_gan2 小时前
在芋道自定义数据权限
java·数据库
Trouvaille ~2 小时前
【MySQL篇】表的约束:保证数据完整性
数据库·mysql·约束·数据完整性·实体完整性·域完整性·参照完整性
rchmin2 小时前
阿里Tair分布式锁与Redis分布式锁的实现区别
数据库·redis·分布式
等....11 小时前
Minio使用
数据库
win x12 小时前
Redis 使用~如何在Java中连接使用redis
java·数据库·redis
迷枫71212 小时前
DM8 数据库安装实战:从零搭建达梦数据库环境(附全套工具链接)
数据库
XDHCOM13 小时前
PostgreSQL 25001: active_sql_transaction 报错原因分析,故障修复步骤详解,远程处理解决方案
数据库·sql·postgresql