之前做的一个项目,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。
九、总结
内存拷贝这事,核心就四个字------减少拷贝。
-
- 能直接操作IntPtr就操作,不用转托管数组(自己写像素运算)
-
- 必须转数组就用ArrayPool,别new byte\[\](高频采集场景)
-
- 传给OpenCV/Halcon直接用Mat/HImage包装,零拷贝(第三方库支持)
-
- 真要拷贝就用Marshal.Copy + ArrayPool组合(低GC、高吞吐)
把回调里的new byte[]换成ArrayPool,GC压力能降一个量级。把不用跨线程传递的数据直接用Span包装在IntPtr上,连拷贝都省了。
小提示:不确定自己的数据是否需要拷贝,先看生命周期------数据只在回调函数里用,不用拷贝,直接用Span包装IntPtr;数据要传到后台线程或存盘,必须拷贝,用ArrayPool。先把这句话记住,再根据具体场景选方案。