fixed in CSharp

1、固定托管对象的地址

.NET的GC在进行可达性遍历后会将所有使用到的内存进行压缩(Compact),空出一整块未使用的内存方便后续申请使用,这时候,原先代码里还在使用的内存就可能会变动位置,指针就会失效。所以fixed在这里的作用就是告诉GC不要compact我这个托管对象,常常配合指针进行较为底层的操作。

1
2
3
4
5
6
7
8
9
10
unsafe
{
int[] arr = {1, 2, 3};

fixed (int* p = arr)
{
// scope范围内arr会被pin住,GC不会移动它
Console.WriteLine(p[0]);
}
}
  • 这种方式会有一定内存损耗,可能会影响GC产生内存碎片。
  • fixed只用用于内建类型数组,别用自己引用类型元素数组,它是不会递归pin的

2、固定大小缓冲区字段fixed buffer

在C#中,在结构体里int[] Arr声明的数组,实际在内存中的结构体里存的是一个引用,这个引用指向堆内存中的数组,需要进行二次访问。这样很合理,因为结构体需要事先确定好大小,但是数组又可以运行时动态构造大小,所以只能存一个固定大小的指针,然后实际内容你自己去堆里申请吧。

对于性能要求较高的领域,比如ECS系统中,本身就是为了极致利用硬件缓存命中设计的,ECS系统里会有大量组件,存储在连续的内存里。如果一个组件需要包含数组,这时候你用普通的数组,CPU在Query组件并批量访问的时候,就需要在期间跳跃到堆内存的各个地方,性能就会大打折扣。这时候就轮到fixed闪亮登场了。

这里的用法就让我想起C语言了,在声明时严格要求编译期确认数组大小,这样数组固定下来,自然可以存放在一起。这时数组就和普通的内建值类型一样。

  • 一般包含固定缓冲区的结构体都比较大,建议传参时用in和ref传引用而非副本。
  • 这个固定缓冲区写法和数组是有区别的,数组是int[] arr => type varName,数组的类型就是int[],而这里是fixed int fixedBuffer[3] => fixed type varName[length]
1
2
3
4
5
6
7
8
// 数组会嵌入结构体那段连续内存里[A,arr[0],arr[1],arr[2],arr[3],B]
public unsafe struct MyData
{
public int A
public fixed int arr[4];
public int B;
}

2.1 实际应用

在ECS项目里,经常遇到各种表示状态、标志位、Debuff的临时组件被添加到Entity身上,在基于Archetype-Chunk的ECS体系里,意味着实体以及其组件数据经常需要被移动(内存抖动)。

简单介绍下ECS相关背景:Archetype表示的是特定类型组件的集合,内部的chunk会连续存储包含的所有组件。举例,一个Archetype包含2个组件,CompA占用20B,CompB占用12B,总体占用32B。16KB/32B=512,也就是一个Chunk最多可以存储512个包含CompA和CompB的实体(假设实体本身不占用chunk)。最后,chunk里的内存类似[[CompA1][CompA2]...[CompA512][CompB1][CompB2]...[CompB512]]这种SoA形式
说这么多,只想明确一件事:一个chunk在被某个Archetype创建出来后就确定了内部的内存布局,它不会重新划分布局去容纳新加入或删除的组件,如果发生了组件的添加和移除,需要到另一个Archetype下重新创建或者找到位置将数据拷贝过去。因此在用ECS的时候我们总希望所有组件都是事先确定的。

为了缓解内存抖动,我们希望引入一个复合组件来维护这类状态标志位,并包含计数功能,这样即使频发添加移除,也不会触发内存的搬移。

下面的例子有几个关注点:

  • 通过fixed的固定缓冲区字段充当标志位(Flags枚举)的计数器
  • 维护_mask来快速判断有无
  • 用哈希映射替换Log2(x)或者循环移位计算Log2(x)
  • 缺点就是,无法通过独立组件灵活进行ECS的Query,比如想查询带有Debuff1的实体,原先只需要WithAll()就可以直接找到,现在需要WithAll然后再检回的结果内再判断状态是否包含Debuff1的标志位
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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 作为一个ECS组件
public unsafe struct BattleStatus
{
private fixed int _counts[32];
private int _statesMask;

public void AddStatus(BattleStatusFlags status)
{
var left = (int)status;
while (left > 0)
{
var lowestBit = left & -left;
left -= lowestBit;
var idx = GetIndex(lowestBit);
if (++_counts[idx] == 1)
_statesMask |= lowestBit;
}
}

public void RemoveStatus(BattleStatusFlags status)
{
var left = (int)status;
while (left > 0)
{
var lowestBit = left & -left;
left -= lowestBit;
if ((_statesMask & lowestBit) == 0) continue;
var idx = GetIndex(lowestBit);
if (--_counts[idx] == 0)
_statesMask &= ~lowestBit;
}
}

public bool HasAllStatus(BattleStatusFlags status)
{
return (_statesMask & (int)status) == (int)status;
}

public bool HasAnyStatus(BattleStatusFlags status)
{
return (_statesMask & (int)status) != 0;
}

public int GetStatusCount(BattleStatusFlags status)
{
int bit = (int)status;
return (_statesMask & bit) == 0 ? 0 : _counts[GetIndex(bit)];
}

private static readonly int[] _deBruijnIdx32 =
{
0, 1, 28, 2, 29, 14, 24, 3,
30, 22, 20, 15, 25, 17, 4, 8,
31, 27, 13, 23, 21, 19, 16, 7,
26, 12, 18, 6, 11, 5, 10, 9,
};

private static int GetIndex(int v)
{
#if DEBUG
Debug.Assert(v != 0 && (v & (v - 1)) == 0);
#endif
return _deBruijnIdx32[((uint)v * 0x077CB531U) >> 27];
}
}