详情

首页手游攻略 C#使用Marshal.AllocHGlobal与ArrayPool实现方式内存拷贝的几种做法实用指南

C#使用Marshal.AllocHGlobal与ArrayPool实现方式内存拷贝的几种做法实用指南

佚名 2026-10-24 16:00:01

平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“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能够包装非托管内存:

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.Shared.Rent(dataSize)得到的数组长度可能大于dataSize。传给其他函数时,用buffer.AsSpan(0, dataSize)截取精确长度。

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

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

九、总结

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

1. 能直接操作IntPtr就操作,不用转托管数组(自己写像素运算)

2. 必须转数组就用ArrayPool,别new byte[](高频采集场景)

3. 传给OpenCV/Halcon直接用Mat/HImage包装,零拷贝(第三方库兼容)

4. 真要拷贝就用Marshal.Copy + ArrayPool组合(低GC、高吞吐)

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

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

实际处理时,总的来说,C# Marshal.AllocHGlobal与ArrayPool内存拷贝适合结合实际项目边做边理解。先抓住核心思路,再逐步补上细节和边界处理,最后效果会更稳定,也更容易复用。

相关资讯
点击查看更多
游戏推荐
推荐专题
热门阅读
推荐下载