避免D版GC:栈,缓冲与圆形
在讲手动管理内存(MMM)时,新手D用户常会谈论@nogc和-betterC.
这很合理.
两者都与名字密切相关.
其中一个字面上叫"无GC".
但实际上,无论是@nogc还是-betterC,都不是MMM.
它们不会告诉你如何分配的内存,也不会告诉你分配的时间.
可以分配,也可不分配@nogc函数.
他们告诉你的是,没有使用GC分配器.
这只排除了一个分配器.
总之,这两个特性都是编译器强制的GC限制:
1,@nogc:也是类型系统的一部分.
2,-betterC:同时去除D的运行时.
这里,跳过-betterC.
栈内存
这是最简单的随时可用的选项.
要用它,需要定义一个静态类型,并在栈上创建一个实例:
cpp
void main() {
alias StringData = char[64][4];
//类型.
StringData stringData = void;
//实例.
}
如上,程序可保存4个最多64个字符的串.
=空部分告诉编译器不要默认初化串数据,因为它稍后会按需初化.
如:
cpp
import std.stdio;
void main() {
alias StringData = char[64][4];
StringData stringData = void;
auto text = stringData[0][0 .. 3];
//创建一个串.
text[] = "Hi!";
//初化串.
writeln(text);
//用串.
}
文本变量指向stringData的前三个字节,并用Writeln来打印Hi!.
游戏开发中类似的模式是,为实例或关卡等创建临时串.
这样,可无需动态分配生成层级名:
cpp
import std.stdio;
void main() {
alias StringData = char[64][4];
StringData stringData = void;
auto level = 68;
auto name = stringData[0][0 .. 8];
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
writeln(name);
//`输出`:`level_68`
}
很好.
不需要任何释放内存,因为栈会自动处理.
下一步是将生成串部分包装成函数,使主函数噪声更小:
cpp
import std.stdio;
void main() {
auto level = 68;
auto name = level.toLevelName();
writeln(name);
}
const(char)[] toLevelName(int level) {
alias StringData = char[64][4];
StringData stringData = void;
auto name = stringData[0][0 .. 8];
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
注意到,输出现在错误了.
这是因为返回的片指向被调栈帧所拥有的内存,而当函数返回后,栈帧就失效了.
则,如果想从函数中返回临时数据,该如何解决它?
静态缓冲
方案是静态缓冲.
它们在整个项目的生命期内有效,表示可任意传递它.
注意,D中的静态变量``默认是线本的,因此每线程有自己的缓冲:
cpp
import std.stdio;
void main() {
auto level = 68;
auto name = level.toLevelName();
writeln(name);
}
const(char)[] toLevelName(int level) {
alias StringData = char[64][4];
static StringData stringData = void;
//`<,`固定.
auto name = stringData[0][0 .. 8];
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
不过还有个问题:4个静态串,只使用了1个.
即每次调用toLevelName都会返回相同数据,可能会导致任何包含该函数结果的变量的内存失效.
如:
cpp
import std.stdio;
void main() {
auto name1 = 1.toLevelName();
auto name2 = 2.toLevelName();
writeln(name1);
//`输出`:`level_02`
writeln(name2);
//`输出`:`level_02`
}
const(char)[] toLevelName(int level) {
alias StringData = char[64][4];
static StringData stringData = void;
auto name = stringData[0][0 .. 8];
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
name1和name2应该有不同值,但因为上述问题,它们是相同的.
为此,要创建一个新的静态变量,来改变toLevelName函数当前使用的串:
cpp
import std.stdio;
void main() {
auto name1 = 1.toLevelName();
auto name2 = 2.toLevelName();
writeln(name1);
//`输出`:`level_01`
writeln(name2);
//`输出`:`level_02`
}
const(char)[] toLevelName(int level) {
alias StringData = char[64][4];
static StringData stringData = void;
static ubyte stringDataIndex = 0;
//`<,`固定`1`.
stringDataIndex = (stringDataIndex + 1) % stringData.length;
//`<,`固定`2`.
auto name = stringData[stringDataIndex][0 .. 8];
//`<,`固定`3`.
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
这是因为stringDataIndex会按顺序循环4个可用串,直到结束时会再回到起点.
这叫做循环缓冲,是不用动态分配管理固定临时数据池的常见模式.
静态缓冲对已知最大大小的短生命期数据效果很好,但它们会强制函数管理自身内存.
如果你不想那样怎么办?
传递内存
一个方案是将传递内存给它:
cpp
import std.stdio;
void main() {
char[256] buffer = void;
auto name1 = 1.toLevelName(buffer[0 .. 16]);
auto name2 = 2.toLevelName(buffer[16 .. 32]);
writeln(name1);
//`输出`:`level_01`
writeln(name2);
//`输出`:`level_02`
}
const(char)[] toLevelName(int level, char[] data) {
auto name = data[0 .. 8];
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
这里的缓冲是主函数中的栈分配,但可用来自任何地方.
可能是来自malloc甚至可能是GC的内存.
该例是可以工作的,但如上手动跟踪缓冲哪片是空闲的,很快就会麻烦.
这就是分配器的工作.
分配器
当前在缓冲中手动跟踪区域.
分配器正是为了实现自动化.
对这段代码,搞一个圆形/冲突分配器是合理的.
圆形分配器是通过在缓冲中保存指针,并在每次分配时向前进实现的.
当缓冲满时,返回空值/无效.
圆形如下:
cpp
struct Arena {
char[] buffer;
size_t offset;
char[] makeChars(size_t size) {
if (offset + size > buffer.length) return null;
auto result = buffer[offset .. offset + size];
offset += size;
return result;
}
}
然后更新toLevelName来用它:
cpp
const(char)[] toLevelName(int level, ref Arena arena) {
auto name = arena.makeChars(8);
if (name.length == 0) return null;
name[0 .. 6] = "level_";
name[6] = cast(char) ('0' + (level / 10) % 10);
name[7] = cast(char) ('0' + level % 10);
return name;
}
最后更新主:
cpp
void main() {
char[256] buffer = void;
auto arena = Arena(buffer);
auto name1 = 1.toLevelName(arena);
auto name2 = 2.toLevelName(arena);
writeln(name1);
//`输出`:`level_01`
writeln(name2);
//`输出`:`level_02`
}
圆形拥有缓冲并负责切片.
要一次释放所有内存,只需将偏移重置回零即可.
抽象分配器
如前,分配器传递也有个问题:它们会高亮函数,即必须把特定的分配器传递给每个需要它的函数.
或是在多个函数指针后面抽象分配器.
该机制叫做分配器API.
它比直接使用它们慢点,但避免了特化.
该交易是否合理,看需要.
如果需要,下面是Joka的分配器API,可轻松复制粘贴到项目中:
cpp
struct MemoryContext {
void* allocatorState;
AllocatorReallocFunc reallocFunc;
void* malloc(size_t alignment, size_t size, const(char)[] file, size_t line) {
return reallocFunc(allocatorState, alignment, null, 0, size, file, line);
}
void* realloc(size_t alignment, void* oldPtr, size_t oldSize, size_t newSize, const(char)[] file, size_t line) {
return reallocFunc(allocatorState, alignment, oldPtr, oldSize, newSize, file, line);
}
void free(size_t alignment, void* oldPtr, size_t oldSize, const(char)[] file, size_t line) {
reallocFunc(allocatorState, alignment, oldPtr, oldSize, 0, file, line);
}
}
alias AllocatorReallocFunc = void* function(void* allocatorState, size_t alignment, void* oldPtr, size_t oldSize, size_t newSize, const(char)[] file, size_t line);
那GC呢
已讲了基础,接着关注常见问题:在没有@nogc怎么知道用没用GC?
有些事情要注意.
第一个是数组字面.
因为切片自身无法保存数据,在赋值给切片时分配:
cpp
//分配.
int[] a = [1, 2, 3];
//不分配.
int[3] b = [1, 2, 3];
第二个是抓外部域变量的闭包.
分配它们,是因为被抓的变量需要比当前栈帧更持久:
cpp
//分配.
auto offset = 10;
auto a = (int x) => x + offset;
//不分配.
auto b = (int x) => x + 10;
auto c = function(int x) => x + 10;
第三个是~符号.
它有时会分配,因为需要运行时创建新数组:
cpp
//分配.
import std.conv;
auto a = "level_" ~ to!string(9);
//不分配.
enum b = "level_" ~ to!string(9);
用-vgc标志检测上述隐式分配:
DMDVGC应用
cpp
dmd -vgc app.d
# ldc2 --vgc app.d
# gdc -ftransition=nogc app.d
示例输出:
cpp
[alex/Documents/code] dmd -vgc app.d
`App.D(31)`:`VGC`:`数组字面`可能导致`GC`分配
`app.d(33)`:`vgc`:`'~'`符号可能导致`GC`分配
`App.D(28)`:`VGC`:使用`闭包`会导致`GC`分配
对-vgc未覆盖的预编译代码函数,一般很容易判断.
以writeln为例:如果你传递一个数字(它不会,只是假设),它可能会分配,但如果你传递串,则不会,因为无可转换成串的对象.
writeln也是一个很好的示例,说明为什么你不应按@nogc标记所有东西.它的任务只是打印文本.它不应关注是否用GC分配类型的toString方法.
这是调用者必须做出的决定,对模板函数,编译器可推导@nogc(writeln则因异常无法推导).
因为@nogc是函数的一部分,一旦函数接受用户提供的回调,它就即为API合约.
这是个强约束,所以最好出现在重要的地方:API边界,或像makeChars和toLevelName这样从不接触GC的自包含函数.
使用@nogc前应问一个问题:编译器是否需要强制该保证,或是否可用注解.
注意:
注1:调试@nogc函数很难,因为禁止用writeln.简单变通方法是使用调试语句:
cpp
void debugWriteln(A...)(A args) {
import std.stdio;
debug writeln(args);
}
@nogc
void myFunction() {
debugWriteln("Something only for debugging.");
}
注2:可用-profile=gc标志来创建profilegc.log文件.
它跟踪的是运行时分配内容.
结论
在D语言中手动管理内存不需要@nogc或-betterC.
如上,仅靠栈,静态缓冲和简单的圆形分配器就可做很多事情.
总结:
| 情况 | 使用 |
|---|---|
| 临时且自包含 | 栈 |
需要超过函数生命期 |
静态缓冲 |
多调用者且自包含 |
传递内存 |
多分配或块释放 |
圆形分配器 |
或使用MMM库,比如NuMem,Joka或core.stdc.stdlib.在一些D库中使用NuMem,而Joka是我个人项目.
这里
这里
这里