注:测试不严谨,只是为了了解下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的执行工作流程:
- 停止除了分配内存的当前线程外的所有受GC管理的线程(core.thread新建的线程,以及thread_attachThis的线程)(STOP THE WORLD开始)。
- 劫持当前线程开始GC工作(分配逻辑转而调用GC收集工作)。
- 扫描所有root区域(线程和程序栈区,静态区,TLS, GC.addRoot和GC.addRange)查找指向GC的内存指针(类/接口引用,指针).这块区域很小,扫描很快的。
- 递归扫描根指向的所有已分配内存,以查找更多指向 GC 分配内存的指针。
- 直接释放不需要析构函数运行的GC分配的不使用内存(归还到GC堆)。
- 需要执行析构函数的GC分配的不使用内存,放入到析构队列中
- 恢复其他线程进行工作。(至此STOP THE WORLD结束)。
- 运行队列中析构函数(注意D中的析构函数,不一定是在结构/对象的线程)。
- 释放析构队列中的内存(归还到GC堆)。
- 返回当前线程,继续进行内存分配工作。
备注:
- 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获取的。
核心代码:
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左右堆情况测试
- 程序启动后任务管理情况:
- 程序执行中占用情况
- 执行结束后GC结果显示
执行了两次,一次最大暂停1.56s,一次1.72s。预制存活对象:85899345
4GB左右情况测试
- 程序启动后人物管理器情况
- 程序执行中占用
- 执行后GC结果显示
执行了两次,一次最大暂停1.11s,一次0.77s。预制存活对象:42949672
2GB左右情况测试
- 程序启动后人物管理器情况
- 程序执行中占用
- 执行后GC结果显示
执行了两次,一次最大暂停605ms,一次375ms。预制存活对象:21474836. (第一次测试时在跑其他编译任务)
1GB左右情况测试
- 程序启动后人物管理器情况
- 程序执行中占用
- 执行后GC结果显示
执行了两次,一次最大暂停162ms,一次237ms。预制存活对象:10737418
500MB左右情况测试
- 程序启动后人物管理器情况
- 程序执行中占用
- 执行后GC结果显示
执行了两次,一次最大暂停152ms,一次126ms。预制存活对象:5368709
不预制对象情况测试
- 程序启动后人物管理器情况
- 程序执行中占用
- 执行后GC结果显示
执行了两次,一次最大暂停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为主,保证程序的响应实时性。