2605d,d可以没有GC

原文

避免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;
}

name1name2应该有不同值,但因为上述问题,它们是相同的.

为此,要创建一个新的静态变量,来改变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边界,或像makeCharstoLevelName这样从不接触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,Jokacore.stdc.stdlib.在一些D库中使用NuMem,而Joka是我个人项目.
这里
这里
这里

相关推荐
fqbqrr2 个月前
2603,d去年9月会议
d
fqbqrr7 个月前
2510d,C++虚混杂
c++·d
fqbqrr1 年前
2501d,d作者,炮打C语言!
c语言·d
fqbqrr1 年前
2501d,d的优势之一与C互操作
d
fqbqrr1 年前
2412d,d的6月会议
d
fqbqrr1 年前
2411d,右值与移动
d
fqbqrr2 年前
2407d,D2024三月会议
d
fqbqrr2 年前
2403d,d的com哪里错了
d
fqbqrr2 年前
2402d,d的变参
d