文章目录
-
- 前言:术语解释(我也是后来才搞懂的)
- 一、我为什么要写这篇文章
- [二、Queue<T> - 新手的第一个坑](#二、Queue<T> - 新手的第一个坑)
- [三、ConcurrentQueue<T> - 我以为的救星](#三、ConcurrentQueue<T> - 我以为的救星)
- [四、BlockingCollection<T> - 老前辈的智慧](#四、BlockingCollection<T> - 老前辈的智慧)
- [五、Channel<T> - 真正的现代方案](#五、Channel<T> - 真正的现代方案)
- 六、我踩过的坑
- 七、我现在的标准模板
- 八、最后的建议
- 补充几个问题
-
- [Q1: Channel和BlockingCollection到底差多少?](#Q1: Channel和BlockingCollection到底差多少?)
- [Q2: 为什么要分三级队列?](#Q2: 为什么要分三级队列?)
- [Q3: 内存会不会爆?](#Q3: 内存会不会爆?)
- 创作权保护
前言:术语解释(我也是后来才搞懂的)
写这篇文章之前,先解释几个让我困惑了很久的词,免得你看后面一头雾水。
什么是"无界队列"和"有界队列"?
说人话就是:
无界队列 = 无限大的购物车
→ 你往里扔多少东西都行
→ 好处:永远不会拒绝你
→ 坏处:可能把内存撑爆
有界队列 = 只能装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解决了我所有痛点
- 线程安全
- 自动阻塞等待
- async/await支持
- 优雅关闭
- 性能还特别好
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();
}
}
}
}
这个模板我用了一年多,很稳定。
八、最后的建议
给新手
如果你是第一次接触队列:
- 先别想多线程 ,用
Queue<T>把逻辑跑通 - 需要多线程了 ,直接上
Channel<T> - 如果是老项目(.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 ] 学习总结编写,水平有限,内容仅供参考,作为个人记录使用。若有疏漏,请不吝赐教。版权归作者所有,未经授权,禁止转载、摘编或以其他方式使用本文内容。如需合作或转载本文,请联系作者获得授权。