C# 固定对象探索
2024-03-17 17:33:41

封送数据会产生副本

最近踩了一个坑,场景是C#调用非托管DLL函数,这个函数是异步的,参数是一个结构体指针,函数需要长期持有这个指针,因为函数在运行期间会对结构进行读写,同时调用方也会对这个结构进行读写。
那么问题来了,在C#中,与非托管DLL交互的标准做法是通过 Marshal.PtrToStructureMarshal.StructureToPtr 方法进行封送,但是需要注意,这两个方法的本质是将结数据创建一个副本,也就是托管内存区域一个结构,非托管内存区域一个结构,两者互不干扰。
对于我的需求,这种方法行不通,因为在函数异步执行期间,在调用方对结构的修改反映不到非托管内存,反之亦然,这样两边的数据就是不同步的。
于是我想是否有办法能获得托管对象的指针地址并且保证其不被GC移动。

用 GCHandle 固定对象

GCHandle.Alloc 是一个用于将托管对象的引用固定在内存中的方法。在.NET环境中,托管对象的内存管理是由垃圾回收器自动处理的。当托管对象不再被引用时,垃圾回收器会自动回收其内存。但是,在某些情况下,需要将对象的引用固定在内存中,以确保对象的内存不会被回收,例如:

  1. 将托管对象的引用传递给非托管代码。
  2. 在使用指针操作时需要固定对象的引用。

在这些情况下,可以使用 GCHandle.Alloc 方法将对象的引用固定在内存中,以确保对象的内存不会被移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.Runtime.InteropServices;

struct MyStruct
{
public int x;
public int y;
}

class Program
{
static unsafe void Main(string[] args)
{
// 创建一个结构体对象
MyStruct myStruct = new MyStruct();
myStruct.x = 10;
myStruct.y = 20;

// 固定结构体引用
GCHandle handle = GCHandle.Alloc(myStruct, GCHandleType.Pinned);
IntPtr ptr = handle.AddrOfPinnedObject();

// 在指针上执行操作
int* p = (int*)ptr.ToPointer();
Console.WriteLine("x = {0}, y = {1}", p[0], p[1]);

// 释放结构体引用
handle.Free();

Console.ReadLine();
}
}

我的想法是,将这个托管结构体固定住,然后将指针传递给DLL,问题不就解决了吗?事实证明我天真了,因为它只支持所谓的 Blittable 类型。
对于不支持的类型会报错:Object contains non-primitive or non-blittable data
使用GCHandle.Alloc方法时,只有包含基元类型或可以直接复制到本机结构中的托管类型的实例才能被转换为GCHandle
意味着不可以使用数组(引用类型)

1
2
3
4
5
6
7
8
9
10
11
12
struct MyData
{
public int x;
}

struct MyStruct
{
public MyData[] data;
}

var myStruct = new MyClass();
GCHandle handle = GCHandle.Alloc(myStruct, GCHandleType.Pinned); // System.ArgumentException: Object 包含非基元或非直接复制到本机结构中的数据。

但在实际开发中,我们的数据结构肯定是带有数组的,所以这个方法行不通。

用 fixed 固定对象

fixed关键字用于将一个变量固定在内存中,以便在指针操作期间避免垃圾回收器的干扰。fixed只能用于值类型的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential)]
struct MyStruct
{
public int x;
public int y;
}

unsafe class Program
{
static void Main(string[] args)
{
MyStruct myStruct = new MyStruct();
myStruct.x = 10;
myStruct.y = 20;

fixed (MyStruct* pMyStruct = &myStruct)
{
// 在指针上执行操作
Console.WriteLine("x = {0}, y = {1}", pMyStruct->x, pMyStruct->y);
pMyStruct->x = 30;
pMyStruct->y = 40;
}

Console.WriteLine("x = {0}, y = {1}", myStruct.x, myStruct.y);
}
}

但是这个方法只保证在fixed区域内内存不会被GC移动,所以对于我的问题,需要将DLL接口函数改为同步模式,阻塞函数防止其离开fixed区域。
当我开始尝试时,又报错了:error CS1663: 固定大小的缓冲区类型必须为下列类型之一: bool、byte、short、int、long、char、sbyte、ushort、uint、ulong、float 或 double
因为数组也只能是基础数据类型,比如这样的结构体就编译不了

1
2
3
4
5
6
7
8
9
unsafe struct MyData
{
public int x;
}

unsafe struct MyStruct
{
public fixed MyData data[8];
}

所以此方案也无法解决我的问题。

结论

C#的P/Invoke技术虽然已经很好用了,但仍然还是有一些局限性。就我遇到的问题可以看出来,理想情况下,数据最好是单向流动的。

相关阅读

Pinning an updateble struct before passing to unmanaged code?
Will struct modifications in C# affect unmanaged memory?
Can C# structs coming from the unmanaged world be “live”-updating?
Why can fixed size buffers only be of primitive types?
Workaround on declaring a unsafe fixed custom struct array?
Blittable and Non-Blittable Types