C# 图像异步落盘存储:基于Channel 配合 ArrayPool 实现异步落盘

图像存盘总丢帧?Channel配ArrayPool搭个异步管道试试

做高速视觉的朋友应该都经历过这种场面:相机采集帧率跑得好好的,一开存图功能就开始丢帧。采集线程被存盘操作堵住,新的图像数据来了没地方放,只能扔掉。

后来换了异步落盘这套架构------采集只管往队列里扔,存盘在独立线程慢慢写,两边各干各的,再也没丢过帧。

先搞清楚问题出在哪

普通的存图代码长这样:

csharp 复制代码
void OnFrame(byte[] data)
{
    File.WriteAllBytes($"frame_{i}.jpg", data);  // 堵在这里等磁盘
}

File.WriteAllBytes返回之前,采集线程就卡着不动。磁盘写入速度只有几百MB/s,相机数据流每秒几个GB,一帧存盘的时间够相机拍好几帧了。不丢帧才怪。

解耦的核心:Channel做缓冲

System.Threading.Channels是.NET Core 3.0之后引入的生产者-消费者队列。用有界Channel把采集和存盘隔开------采集线程只负责把数据丢进Channel,立刻返回;消费线程从Channel里取数据慢慢写文件。

关键配置是有界容量和满队列策略:

csharp 复制代码
using System.Threading.Channels;

var options = new BoundedChannelOptions(capacity: 50)
{
    FullMode = BoundedChannelFullMode.DropOldest  // 队列满了丢最旧的数据
};
var channel = Channel.CreateBounded<byte[]>(options);

citation:1里实际项目用的就是这个配置------FullMode = BoundedChannelFullMode.DropOldest,队列满了自动丢弃最旧的帧,保证采集线程永远不被堵住。

生产者:采集线程只管往里扔

相机回调里拿到图像数据,入队就走:

csharp 复制代码
void OnFrameReceived(IntPtr pData, int size)
{
    // 从ArrayPool租一个缓冲区
    byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
    Marshal.Copy(pData, buffer, 0, size);
    
    // 写入Channel,如果队列满了,DropOldest会自动丢弃旧帧
    channel.Writer.TryWrite(buffer);
}

ArrayPool<byte>.Shared.Rent()复用缓冲区,避免每帧都new byte[]触发GC。

消费者:独立线程慢慢存盘

后台线程从Channel里读数据,写文件:

csharp 复制代码
Task.Run(async () =>
{
    await foreach (var buffer in channel.Reader.ReadAllAsync())
    {
        try
        {
            string path = Path.Combine(saveDir, $"frame_{Interlocked.Increment(ref _counter)}.raw");
            await File.WriteAllBytesAsync(path, buffer.AsMemory(0, dataSize));
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }
});

ReadAllAsync会一直等待直到Channel被标记为完成。用了ArrayPool.Rent,用完要Return把缓冲区还给池子,防止内存泄漏。

内存池:减少GC压力的关键

如果每帧都new byte[size],高频采图下GC每秒触发好几次,程序卡顿不可避免。用ArrayPool共享缓冲区,租借和归还都是O(1)操作,分配次数从每帧一次降到几乎为零。

有个细节要注意:Rent返回的数组长度可能大于请求值 。比如要存1MB数据,Rent可能返回1.5MB的数组。所以实际写入的时候要用原始尺寸,而不是buffer.Length。

完整示例:串起来跑

把上面几块拼成一个完整的异步落盘服务:

csharp 复制代码
public class AsyncImageSaver : IDisposable
{
    private readonly Channel<byte[]> _channel;
    private readonly CancellationTokenSource _cts;
    private readonly Task _consumerTask;
    private int _counter;
    private readonly string _saveDir;

    public AsyncImageSaver(string saveDir, int capacity = 50)
    {
        _saveDir = saveDir;
        Directory.CreateDirectory(saveDir);
        _cts = new CancellationTokenSource();
        
        var options = new BoundedChannelOptions(capacity)
        {
            FullMode = BoundedChannelFullMode.DropOldest
        };
        _channel = Channel.CreateBounded<byte[]>(options);
        
        _consumerTask = Task.Run(ConsumerLoop);
    }

    public void Enqueue(IntPtr pData, int size)
    {
        byte[] buffer = ArrayPool<byte>.Shared.Rent(size);
        Marshal.Copy(pData, buffer, 0, size);
        if (!_channel.Writer.TryWrite(buffer))
        {
            // TryWrite返回false的情况很少见,DropOldest会保证入队成功
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    private async Task ConsumerLoop()
    {
        await foreach (var buffer in _channel.Reader.ReadAllAsync(_cts.Token))
        {
            try
            {
                int index = Interlocked.Increment(ref _counter);
                string path = Path.Combine(_saveDir, $"{index:D8}.raw");
                await File.WriteAllBytesAsync(path, buffer);
            }
            finally
            {
                ArrayPool<byte>.Shared.Return(buffer);
            }
        }
    }

    public void Dispose()
    {
        _channel.Writer.Complete();
        _cts.Cancel();
        _consumerTask.Wait(5000);
        _cts.Dispose();
    }
}

几个翻车点

1. ArrayPool.Rent的数组要完整覆盖再归还

Rent借出来的数组里可能有旧数据残留。这里每次都会完整覆盖所有字节(从指针复制了size字节),归还前不需要清零。如果数据敏感可以加clearArray: true参数。

2. 异步写文件用File.WriteAllBytesAsync

在消费线程里用异步IO,避免阻塞消费循环。await File.WriteAllBytesAsync在写入过程中不会占着线程,系统层会用IOCP完成后续工作。

3. 退出时要优雅关闭

Dispose里要先Complete()让消费者知道不再有新数据,再等待消费者处理完队列里剩余的数据。直接退出会丢数据。

总结

这套方案的核心就是:用Channel把采集和存盘解耦,用ArrayPool复用内存减少GC。实测在高帧率场景下,采集线程的阻塞时间从几十毫秒降到了微秒级,存盘帧率跟上了采集帧率,再也没丢过帧。

如果对性能要求更高,还可以把非托管内存池也加进来------直接用IntPtr在内存池里操作,连Marshal.Copy那一下都省了。不过那是下一步的事了,先把Channel+ArrayPool这套跑起来,效果已经足够明显。

相关推荐
软件黑马王子1 小时前
11.缓存池优化:窗口布局
开发语言·前端框架·c#
u86881 小时前
常发携手上海脉信落地电话客服智能体,解决客服进线痛点
大数据·人工智能
鲜于言悠9051 小时前
OpenSpec+Superpowers实战:AI驱动SDD+TDD完整开发工作流
人工智能
m0_614523551 小时前
故障排查:移动物体表面贴图为什么会漂移?从稳定纹理到连续帧验收
网络·人工智能·贴图
七牛云行业应用1 小时前
Codex 502怎么办?先判断官方故障,再按网络、登录和日志排查
人工智能·大模型·ai编程
小程故事多_801 小时前
从快速迭代到稳定存续,Google五大设计模式重构长效AI智能体落地逻辑
人工智能·设计模式·重构
深圳市益普科技有限公司1 小时前
半导体MES的数字孪生:虚拟调试如何把上线风险提前清零
java·开发语言
IT_陈寒1 小时前
Redis的订阅丢失消息?你可能忘了这个配置
前端·人工智能·后端
蓝鲨硬科技1 小时前
海信的“AI时刻”
人工智能