不如D:D语言GC暂停时间简单测试

注:测试不严谨,只是为了了解下D语言GC不同堆下暂停情况,目的是为了根据实际项目使用部分@nogc进行堆大小控制。实际情况下堆中一般还有string,ubyte[]等一些可以块跳过扫描的,理论上暂停会更小

D 语言默认GC算法

D语言默认是保守式扫描清除算法。默认执行时期为 新分配内存,GC堆中可用内存不足时才触发(也就是如果欲分配堆够大,并且手动归还GC内存,GC堆中一直有可分配内存就不出触发GC)。D的GC使用的算法比较简单,GC耗时主要在标记和清除两个阶段。其中标记是GC增加的耗时,清除是不可避免的耗时,只是清除是把原来手动管理散点式,集中起来一起执行(RC可能出现集中清除产生的短暂暂停)。GC标记时程序会STOP THE WORLD(暂停程序所有线程执行),这个耗时也就是大家常说的GC暂停时间。GC清除时间只会占用启动GC的线程。GC标记的时间受堆大小与存活对象数量相关,GC清除与不存活对象数目相关。

触发GC的执行工作流程:

  1. 停止除了分配内存的当前线程外的所有受GC管理的线程(core.thread新建的线程,以及thread_attachThis的线程)(STOP THE WORLD开始)。
  2. 劫持当前线程开始GC工作(分配逻辑转而调用GC收集工作)。
  3. 扫描所有root区域(线程和程序栈区,静态区,TLS, GC.addRoot和GC.addRange)查找指向GC的内存指针(类/接口引用,指针).这块区域很小,扫描很快的。
  4. 递归扫描根指向的所有已分配内存,以查找更多指向 GC 分配内存的指针。
  5. 直接释放不需要析构函数运行的GC分配的不使用内存(归还到GC堆)。
  6. 需要执行析构函数的GC分配的不使用内存,放入到析构队列中
  7. 恢复其他线程进行工作。(至此STOP THE WORLD结束)。
  8. 运行队列中析构函数(注意D中的析构函数,不一定是在结构/对象的线程)。
  9. 释放析构队列中的内存(归还到GC堆)。
  10. 返回当前线程,继续进行内存分配工作。

备注:

  • D中允许不受GC管辖的线程,也就是不参与STOP THE WORLD 的线程。注意里面使用@nogc
  • D语言的扫描标记阶段现在是并行的,默认线程数是CPU核数。

本次测试主要关注STOP THE WORLD 时间,也就是GC标记时间。

测试环境

  • 系统: OpenSUSE-tumbleweed(20231016版本,内核版本: 6.5.6-1-default 6.5.6-1-default 6.5.6-1-default)
  • CPU : 16 × AMD Ryzen 7 5825U with Radeon Graphics
  • 内存: 30.7 GiB 内存
  • D编译器:ldc 1.35.0(based on DMD v2.105.2 and LLVM 16.0.6)
  • 测试编译参数: -b release

测试时正常使用计算机。

测试方法:

基于一个vibe的简单接口程序。并且构建一定数目的对象常驻,并在栈中保留引用。测试不同堆大小,并且几乎全是存活对象情况下GC扫描暂停时间。
通过更改固定数字调整堆大小,默认50 为8G内存占用。启动后通过ab进行一轮http压力测试,然后停止,查看GC统计输出。
注意:接口数据不是写死的,是每次都去调用系统API获取的。
ip地址

核心代码:

struct IPsInfo
{
    string name;
    string ip;
    string mark;
    string gateway;
}

class TestGCALLOC{
    private int[10] _tmp;
    TestGCALLOC next;
    TestGCALLOC prev;
    TestGCALLOC randoud;
    this(){
        _tmp[3] = 45;
    }
}

TestGCALLOC buildCout(){
    uint maxsize =  uint.max / 50; // 通过调整此参数,进行控制堆大小
    TestGCALLOC ret = new TestGCALLOC();
    TestGCALLOC ptr = new TestGCALLOC();
    ret.next = ptr;
    ptr.prev = ret;
    for(uint i = 0; i < maxsize; ++i){
        auto tv = new TestGCALLOC();
        ptr.next = tv;
        tv.prev = ptr;
        ptr = tv;
    }

    writeln("alloc TestGCALLOC size : ", maxsize);
    return ret;
}

extern (C) __gshared string[] rt_options = ["gcopt=profile:1"];

void main()
{
    sysConfig.load(absoluteExePath("config.json"));
    auto settings = new HTTPServerSettings;
    settings.port = 8084;
    auto router = new URLRouter();

    router.get("/api/ips",&loadIps);
    router.post("/api/ips",&saveIps);
    router.get("*",serveStaticFiles(absoluteExePath("public")));
    auto listener = listenHTTP(settings, router);

    scope (exit)
        listener.stopListening();
    auto tv = buildCout();
    tv.randoud = new TestGCALLOC();

    logInfo("Please open 127.0.0.1:8084 in your browser.");
    runApplication();
}

void loadIps(HTTPServerRequest req, HTTPServerResponse res)
{
    res.headers["Content-Type"] = "application/json";
    auto list = privateAddresses4();
    res.writeBody(list.serializeToJson());
}

nothrow IPsInfo[] privateAddresses4() { // 获取系统中的IP地址
    return privateAddresses(Exclude.IPV6);
}

主要依赖:

{
    "asdf": "~>0.7.17",
    "vibe-d:http": "~>0.9.7",
    "vibe-d:tls": "~>0.9.7",
    "yu": {
    "path": "../yu"
    }
}

编译命令:

dub build -b release -f

AB测试命令

ab -n 500000 -c 500 http://127.0.0.1:8084/api/ips

测试结果

8G左右堆情况测试

  1. 程序启动后任务管理情况:
    8GB启动前
  2. 程序执行中占用情况
    8GB运行中
  3. 执行结束后GC结果显示
    8GB情况
    执行了两次,一次最大暂停1.56s,一次1.72s。预制存活对象:85899345

4GB左右情况测试

  1. 程序启动后人物管理器情况
    4GB前
  2. 程序执行中占用
    4GB执行中
  3. 执行后GC结果显示
    4GB情况
    执行了两次,一次最大暂停1.11s,一次0.77s。预制存活对象:42949672

2GB左右情况测试

  1. 程序启动后人物管理器情况
    2GB前
  2. 程序执行中占用
    2GB执行中
  3. 执行后GC结果显示
    2GB情况
    执行了两次,一次最大暂停605ms,一次375ms。预制存活对象:21474836. (第一次测试时在跑其他编译任务)

1GB左右情况测试

  1. 程序启动后人物管理器情况
    1GB前
  2. 程序执行中占用
    1GB执
  3. 执行后GC结果显示
    1GB情况
    执行了两次,一次最大暂停162ms,一次237ms。预制存活对象:10737418

500MB左右情况测试

  1. 程序启动后人物管理器情况
    500MB前
  2. 程序执行中占用
    500MB
  3. 执行后GC结果显示
    500MB情况
    执行了两次,一次最大暂停152ms,一次126ms。预制存活对象:5368709

不预制对象情况测试

  1. 程序启动后人物管理器情况
    no前
  2. 程序执行中占用
    no执行中
  3. 执行后GC结果显示
    no情况
    执行了两次,一次最大暂停162ms,一次237s。预制存活对象:0

测试结果

测试堆大小 最大暂停时间1 最大暂停时间2 平均
4G~4.1G 1107 765 936
2G~2.1G 605 375 490
1G~1.1G 237 162 200
0.6G ~ 0.6G 152 126 129
27M 2 1 2

结论

D语言GC在大堆情况下GC暂停还是不可控的。实际使用还是需要控GC堆。个人感觉GC堆保持百M左右,暂停应该可以忽略不计的。因为实际场景中string,ubyte[]会占用不少的,这些在扫描时会自动忽略跳过的。百M对于小的client程序或小服务一般够用了。
对于大型程序,D的GC还是不堪大用,还是要配合@nogc,以@nogc为主,保证程序的响应实时性。