C#图像内存高速拷贝:使用Marshal.AllocHGlobal与ArrayPool实现内存拷贝的几个方法

之前做的一个项目,500万像素的相机,30fps。回调里拿到的数据是IntPtr,想转成byte\[\]做后续处理。

我用的Marshal.Copy,每帧拷5MB,看着挺正常。后来换了一台100fps的相机,CPU直接飙到80%,GC一秒触发好几次,程序开始丢帧。

问题不在Marshal.Copy本身慢,在频率太高了。每秒100次,每次从非托管内存拷5MB到托管数组,500MB/s的内存搬运,加上GC压力,CPU扛不住了。

前面聊过ArrayPool、Span这几套内存拷贝方案,但是没有系统地进行和OpenCvSharp的Mat和Halcon的HImage进行对比,它们的底层都是非托管内存。用好它们提供的零拷贝机制,能省掉不少不必要的数据搬运。

一、Marshal.Copy:最直接,但不是最快的

标准用法:

复制代码
IntPtr pData = GetImageFromCamera();
int dataSize = width * height;
byte[] buffer = new byte[dataSize];
Marshal.Copy(pData, buffer, 0, dataSize);
// 处理buffer

Marshal.Copy干的是"从非托管内存复制到托管数组"的事,这是跨内存边界的数据拷贝,开销比托管数组之间的复制大。

另一层开销来自每次new byte[dataSize]------每帧分配5MB的托管数组,100fps就是每秒500MB的分配量。GC频繁触发Gen0和Gen1回收,导致STW(Stop-The-World)暂停。

实测数据(500万像素Mono8,100fps)

方式 CPU占用 GC触发频率 丢帧率
Marshal.Copy + new byte\[\] 65% 每秒多次
Marshal.Copy + ArrayPool 35% 几乎为零
Marshal.Copy + 预分配数组 40% 几乎为零

ArrayPool解决了GC问题,但Marshal.Copy的拷贝开销本身还在。有没有办法减少拷贝次数?

二、ArrayPool:借了再还,GC不慌

ArrayPool的核心思路:从池里借一块内存,用完还回去,下次继续用

复制代码
byte[] buffer = ArrayPool<byte>.Shared.Rent(dataSize);
Marshal.Copy(pData, buffer, 0, dataSize);
// 处理buffer
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);

Rent可能返回比dataSize更大的数组,处理时用精确长度。Return时的clearArray: false避免清空数据开销。

每个图像保存的完整模式

复制代码
void ImageCallback(IntPtr pData, ref MV_FRAME_OUT_INFO_EX info, IntPtr user)
{
    int dataSize = (int)(info.nWidth * info.nHeight);
    byte[] buffer = ArrayPool<byte>.Shared.Rent(dataSize);
    try
    {
        Marshal.Copy(pData, buffer, 0, dataSize);
        _queue.Enqueue(buffer);
    }
    catch
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

三、MemCpy + 预分配:绕开GC和池

如果不想依赖ArrayPool、想完全控制内存生命周期,可以预分配一个大数组,每次拷贝到固定偏移位置。

复制代码
private byte[] _preallocatedBuffer;
private long _writeOffset;
public void Init(int bufferSize)
{
    _preallocatedBuffer = new byte[bufferSize];
    _writeOffset = 0;
}
public void WriteImage(IntPtr pData, int dataSize)
{
    // 检查空间
    if (_writeOffset + dataSize > _preallocatedBuffer.Length) return;
    // 拷贝到预分配数组的指定偏移位置
    Marshal.Copy(pData, _preallocatedBuffer, (int)_writeOffset, dataSize);
    _writeOffset += dataSize;
}

这样全程只有启动时的一次大内存分配,运行时零GC分配。适合环形缓冲或连续录制的场景。

四、终极方案:直接操作IntPtr,跳过托管内存

如果后续处理可以不依赖托管数组,为什么不直接在IntPtr上操作?

Span<byte>可以包装非托管内存:​​​​​​​

复制代码
IntPtr pData = GetImageFromCamera();
int dataSize = width * height;
// 直接把IntPtr包装成Span,零拷贝
Span<byte> imageSpan = new Span<byte>(pData.ToPointer(), dataSize);
// 在Span上做处理(读取像素值、计算直方图等)
for (int i = 0; i < imageSpan.Length; i++)
{
    byte val = imageSpan[i];
    // 计算...
}

但有一个限制:如果要把数据传给其他库(如OpenCV的Mat、SkiaSharp的Bitmap),有的要求数据在托管内存或固定的非托管内存中。这时候必须拷贝。

什么时候可以直接操作IntPtr? 自己做像素级运算、算直方图、均值滤波这类纯读取操作,完全不需要拷贝,直接在IntPtr上操作就行。

五、OpenCvSharp的Mat包装(零拷贝)

OpenCvSharp的Mat可以直接包装IntPtr指向的内存,不需要拷贝。​​​​​​​

复制代码
IntPtr pData = GetImageFromCamera();
Mat mat = new Mat(height, width, MatType.CV_8UC1, pData);
// 直接在Mat上做处理
Cv2.GaussianBlur(mat, mat, new Size(3, 3), 0);

mat直接使用pData指向的内存,没有任何数据拷贝。后续mat引用的是同一个内存地址,pData在回调返回后可能被释放,所以mat必须在回调内用完,或者用Clone做深拷贝。

六、Halcon的HImage包装(零拷贝)

Halcon也可以直接包装IntPtr。​​​​​​​

复制代码
IntPtr pData = GetImageFromCamera();
HImage image = new HImage("byte", width, height, pData);
// 直接处理
HImage filtered = image.GaussFilter(3.0);

同样零拷贝,同样要注意生命周期。

七、几种方案怎么选

场景 推荐方案 原因
高频采集(>50fps) ArrayPool + Marshal.Copy 避免GC,复用内存
超高频采集(>200fps) 直接操作IntPtr/Span 零拷贝,开销最小
传给OpenCV处理 Mat包装IntPtr 零拷贝,Mat直接引用
传给Halcon处理 HImage包装IntPtr 零拷贝,HImage直接引用
跨线程/异步传递 必须拷贝(ArrayPool) 原始内存生命周期不可控
长时间连续采集 预分配大数组 + 偏移写入 零分配,环形缓冲

核心原则 :能零拷贝就不拷贝,需要拷贝就用ArrayPool复用内存,不到万不得已不用new byte[]

八、三个容易踩的坑

坑一:IntPtr指向的内存被释放了,还在用

相机回调返回后,pData指向的内存可能被SDK回收。如果要在回调之外使用,必须拷贝到托管内存。零拷贝方案只适用于回调函数内部同步处理。

坑二:ArrayPool借出来的数组比请求的大

ArrayPool<byte>.Shared.Rent(dataSize)返回的数组长度可能大于dataSize。传给其他函数时,用buffer.AsSpan(0, dataSize)截取精确长度。

坑三:Return时没清空数据,下一帧读到了残留

如果Return用了clearArray: false,下次Rent拿到的数组可能还留着上一帧的数据。覆盖写没问题,但如果是部分填充,可能有残留。不确定的时候用clearArray: true

九、总结

内存拷贝这事,核心就四个字------减少拷贝

    1. 能直接操作IntPtr就操作,不用转托管数组(自己写像素运算)
    1. 必须转数组就用ArrayPool,别new byte\[\](高频采集场景)
    1. 传给OpenCV/Halcon直接用Mat/HImage包装,零拷贝(第三方库支持)
    1. 真要拷贝就用Marshal.Copy + ArrayPool组合(低GC、高吞吐)

把回调里的new byte[]换成ArrayPool,GC压力能降一个量级。把不用跨线程传递的数据直接用Span包装在IntPtr上,连拷贝都省了。

小提示:不确定自己的数据是否需要拷贝,先看生命周期------数据只在回调函数里用,不用拷贝,直接用Span包装IntPtr;数据要传到后台线程或存盘,必须拷贝,用ArrayPool。先把这句话记住,再根据具体场景选方案。

原文链接:C#图像内存高速拷贝:使用Marshal.AllocHGlobal与ArrayPool实现内存拷贝的几个方法

相关推荐
Java面试题总结1 小时前
Python,单引号和双引号有何区别
开发语言·python
鱼子星_1 小时前
【C++】内存管理:内存分布,new/delete的使用及细节处理,new/delete的底层,定位new表达式
开发语言·c++·笔记
学逆向的1 小时前
汇编——函数
开发语言·汇编·网络安全
咸鱼翻身小阿橙1 小时前
多线程解析
java·开发语言
AOwhisky2 小时前
Python 学习笔记(第四期)——字符串:格式化、操作与文本处理——核心知识点自测与详解
开发语言·笔记·python·学习·列表·元组·字典
@@@@@@@@542 小时前
JavaScript 的缺点与 TypeScript 的弥补
开发语言·javascript·typescript
এ慕ོ冬℘゜2 小时前
深度解析 JavaScript:赋予 Web 灵魂的“全栈语言”
开发语言·前端·javascript
汤米粥3 小时前
Python调用百度搜索触发安全验证
开发语言·python
影寂ldy3 小时前
C# 五大加密算法全套实战(AES/DES对称、RSA非对称、MD5/SHA256哈希)
开发语言·c#·哈希算法