10 | Unsafe和非托管内存
Unsafe适用于对于性能有较高要求或者需要做某些特殊操作的时候,它把类似C++的指针操作暴露出来,让开发时具有很大的灵活性。Unity 2018以后又提供了UnsafeUtility工具类,让指针操作更加便捷。
10.1 Unsafe优化字符串操作的GC
比较简单的也比较常用的是ToLower:
public static void ToLower(string str) { fixed (char* c = str) { int length = str.Length; for (int i = 0; i < length; ++i) { c[i] = char.ToLower(c[i]); } } }
这样直接修改了原字符串,将其所有字符全都改成小写。
还有一个比较常用的操作是split,通过分隔符生成一个字符串数组,虽然数组中每个字符串要缓存起来比较麻烦,但是数组本身是可以缓存出来的:
public static int Split(string str, char split, string[] toFill) { if (str.Length == 0) { toFill[0] = string.Empty; return 1; } var length = str.Length; int ret = 0; fixed (char* p = str) { var start = 0; for (int i = 0; i < length; ++i) { if (p[i] == split) { toFill[ret++] = (new string(p, start, i - start)); start = i + 1; if (i == length - 1) toFill[ret++] = string.Empty; } } if (start < length) { toFill[ret++] = (new string(p, start, length - start)); } } return ret; }
方法传入了一个缓存的string[],然后根据split遍历分割字符串。但如果是根据split遍历操作每一个字符串,也可以缓存一个单独的长度比较大的字符串,每次分割后的字符都复制进这个缓存里,当然,这就需要动态修改字符串的长度了,字符串的长度修改方法如下:
public static void SetLength(this string str, int length) { fixed (char* s = str) { int* ptr = (int*)s; ptr[-1] = length; s[length] = '\0'; } }
字符串最后一个字符后面的字符必须是‘\0’,这个和C++是一样的,字符串的首字母地址之前的一个int代表了字符串的长度。
可以设置字符串长度以后,也就可以实现Substring了:
public static void Substring(string str, int start, int length = 0) { if (length <= 0) { length = str.Length - start; } if (length > str.Length - start) { throw new IndexOutOfRangeException($"{length} > {str.Length} - {start}"); } fixed (char* c = str) { UnsafeUtility.MemMove(c, c + start, sizeof(char) * length); } SetLength(str, length); }
此外还可以实现字符串拼接、Path.Combine、string.Format等方法。
使用Unsafe操作字符串可以不必生成新的字符串,从而减少GC Alloc,不过需要注意几点:
- 指针操作没有越界检查,如果修改字符串的长度,要确保长度小于等于字符串的原始长度。
- 谨慎修改intern字符串的内容。
- 修改字符串内容会使字符串的hashcode发生改变,如果修改的字符串是某个字典的Key,需要将其从字典中移除,修改后再放进去。
10.2 使用Unsafe优化反射
反射中一个比较常用的东西是fieldInfo.SetValue,或fieldInfo.GetValue,如果字段是值类型的,就会有一次装箱或拆箱,使用Unsafe可以通过字段在内存中的偏移量来赋值,如下给一个int字段赋值:
var offset = (int)Marshal.OffsetOf<T>(fieldName) + IntPtr.Size * 2; var address = (byte*)UnsafeUtility.PinGCObjectAndGetAddress(obj, out var gcHandle); *(int*)(address + offset) = value; UnsafeUtility.ReleaseGCObject(gcHandle);
首先取得字段的偏移量,因为是对象,所以偏移量要加IntPtr.Size * 2(也可以直接使用UnsafeUtility.GetFieldOffset(fieldInfo),不用修改偏移量,但是有GetFieldInfo的开销),然后,PinGCObjectAndGetAddress将对象的地址固定住,通过偏移量来赋值,最后释放对象句柄。
取字段偏移量是一个非常耗时的操作,最好可以提前缓存这个偏移量,再调用时速度就会变得非常快了。
下面是三种方式的对比(设置对象中一个整数的值,10000次迭代):
其中fieldInfo.SetValue用到的fieldInfo是提前缓存了的,可以看出来,Marshal.OffsetOf的开销是非常大的(UnsafeUtility.GetFieldOffset开销也很大),而如果缓存了offset,速度会比fieldInfo.SetValue提升十倍。
10.3 非托管堆
相对于托管堆,非托管堆有一个好处,就是可以手动申请和释放,此外,Unity的DOTS大量使用Native容器也是为了能保证尽量使用连续内存。UnsafeUtility提供了方便的接口手动管理非托管内存,下面是一个使用非托管堆的UnsafeList示例。
可以使用非托管堆的类型必须是Blittable,也就是必须是结构体,而且里面的字段只包含基本值类型和Blittable结构体。所以,声明可以写成:
public unsafe struct UnsafeList<T> where T : unmanaged { static int alignment = UnsafeUtility.AlignOf<T>(); static int elementSize = UnsafeUtility.SizeOf<T>(); const int MIN_SIZE = 4; ArrayInfo* array; ... }
unmanaged约束可以看作是Blittable,但是有个问题就是不包含泛型结构体,如果不需要泛型结构体可以忽略,或者使用struct约束,但是struct约束就不能用指针T * 来存取数据了,需要换一种方式。这里还是使用unmanaged约束。
几个静态变量缓存了申请内存所需要的信息,数据信息存在ArrayInfo的指针中:
unsafe struct ArrayInfo { public int count; public int capacity; public void* ptr; }
信息包含长度、容量,真正的数据保存在ptr中。
首先是构造函数:
public UnsafeList(int capacity) { capacity = Mathf.Max(MIN_SIZE, capacity); array = (ArrayInfo*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<ArrayInfo>(), UnsafeUtility.AlignOf<ArrayInfo>(), Allocator.Persistent); array->capacity = capacity; array->count = 0; array->ptr = UnsafeUtility.Malloc(elementSize * capacity, alignment, Allocator.Persistent); }
UnsafeUtility提供了多种Allocator,生命周期和性能都不相同,具体可以参见官方文档。 然后如果不使用这个List,需要手动将其释放:
public void Dispose() { UnsafeUtility.Free(array->ptr, Unity.Collections.Allocator.Persistent); UnsafeUtility.Free(array, Unity.Collections.Allocator.Persistent); }
当容量不够时,可以像List一样扩容:
void EnsureCapacity(int newCapacity) { if (newCapacity > array->capacity) { newCapacity = Mathf.Max(newCapacity, array->count * 2); var newPtr = UnsafeUtility.Malloc(elementSize * newCapacity, alignment, Allocator.Persistent); UnsafeUtility.MemCpy(newPtr, array->ptr, elementSize * array->count); UnsafeUtility.Free(array->ptr, Allocator.Persistent); array->ptr = newPtr; array->capacity = newCapacity; } }
申请新内存,将旧的数据复制到新的内存中,再释放旧内存。有了这个就可以添加数据了:
public void Add(T t) { EnsureCapacity(array->count + 1); *((T*)array->ptr + array->count) = t; ++array->count; } public void Insert(int index, T t) { EnsureCapacity(array->count + 1); if (0 <= index && index <= array->count) { UnsafeUtility.MemMove((T*)array->ptr + index + 1, (T*)array->ptr + index, (array->count - index) * elementSize); *((T*)array->ptr + index) = t; ++array->count; } else { throw new IndexOutOfRangeException(); } }
如果复制的内存区域重叠,不管是向前还是向后,最好都使用memmove,内部会决定要不要考虑重叠区域。AddRange和Remove也是类似的实现方法。
在List中,Clear方法因为要考虑元素是引用类型的情况,为了能让GC正常回收List中的对象,必须把所有数据全都归零,但是这里因为不存在这种情况,所以Clear方法很简单:
public void Clear() { array->length = 0; }
最后是读写,因为索引器的set方法不支持ref参数,所以可以直接用指针:
public T* this[int index] { get { if (0 <= index && index < array->count) { return ((T*)array->ptr + index); } throw new IndexOutOfRangeException(); } set { if (0 <= index && index < array->count) { *((T*)array->ptr + index) = *value; } throw new IndexOutOfRangeException(); } }
然后也可以提供一个单独的ref return方法:
public ref T Get(int index) { return ref *this[index]; }
这样一个使用非托管堆的容器就诞生了。
10.4 stackalloc、Span<T>和Memory<T>
stackalloc关键词,可以申请栈内存:
void Calculate() { Vector3* s = stackalloc Vector3[10]; ... }
如果计算只需要一组占用比较小的临时数据,使用stackalloc是一个很好的选择,因为它的申请速度非常快,而且不需要手动管理,作用域一结束就会自动释放。
Span<T>
和Memory<T>
这两个类型需要额外的DLL支持。它们可以管理存放在托管堆,非托管堆和栈内存的数据,因为提供了Slice方法分割内存,还提供了各种Copy方法可以在各种类型内存中互相拷贝,比直接用指针来方便一些,Span<T>
和Memory<T>
的区别是,Span<T>
是ref类型的,不能用作字段,也不能跨越yield和await使用。一般Span<T>
和Memory<T>
的执行效率比直接使用指针要低。有兴趣可以看一下 KCP的一个实现 。
10.5 总结
本文包含了一些在我们项目实际开发过程当中用到过的和优化方法,内容概括起来有三点:
- 使用结构代替类
- 缓存对象
- 使用非托管堆
虽然在游戏运行过程当中完全没有GC是非常难的,但是至少在一场战斗过程中,最好可以确保不会出现一次GC。此外,对于低端设备,1GB内存以下的设备要尽量保证堆内存大小控制在一定范围内,这也是非常重要的。希望本文可以对大家进行内存优化方面的工作有一定的帮助。