为什么GCC的ifstream >> double会分配这么多内存?

21
我需要从一个由空格分隔的可读文件中读取一系列数字并进行数学运算,但在读取文件时遇到了一些非常奇怪的内存行为。
如果我读取这些数字并立即丢弃它们...
#include <fstream>

int main(int, char**) {
    std::ifstream ww15mgh("ww15mgh.grd");
    double value;
    while (ww15mgh >> value);
    return 0;
}

我的程序根据valgrind分配了59MB的内存,与文件大小成线性比例:

$ g++ stackoverflow.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==523661==   total heap usage: 1,038,970 allocs, 1,038,970 frees, 59,302,487 

但是,如果我使用ifstream >> string然后使用sscanf解析字符串,我的内存使用看起来更加合理:

#include <fstream>
#include <string>
#include <cstdio>

int main(int, char**) {
    std::ifstream ww15mgh("ww15mgh.grd");
    double value;
    std::string text;
    while (ww15mgh >> text)
        std::sscanf(text.c_str(), "%lf", &value);
    return 0;
}

$ g++ stackoverflow2.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==534531==   total heap usage: 3 allocs, 3 frees, 81,368 bytes allocated

为了排除IO缓冲区的问题,我尝试了ww15mgh.rdbuf()->pubsetbuf(0, 0);(这使得程序需要很长时间并仍然进行了59MB的分配)和使用巨大的栈分配缓冲区的pubsetbuf(仍然是59MB)。无论是在gcc 10.2.0clang 11.0.1编译时,使用来自gcc-libs 10.2.0/usr/lib/libstdc++.so.6glibc 2.32/usr/lib/libc.so.6,行为都会重现。系统语言环境设置为en_US.UTF-8,但如果我设置环境变量LC_ALL=C,这也会重现。

我最初在Ubuntu Focal上交叉编译,使用GCC 9.3.0libstdc++6 10.2.0libc 2.31,我在ARM CI环境中首次注意到此问题。

按照评论中的建议,我尝试了LLVM的libc++,并且原始程序表现出完全正常的行为:

$ clang++ -std=c++14 -stdlib=libc++ -I/usr/include/c++/v1 stackoverflow.cpp
$ valgrind --tool=memcheck --leak-check=yes ./a.out 2>&1 | grep total
==700627==   total heap usage: 3 allocs, 3 frees, 8,664 bytes allocated

所以,这种行为似乎是GCC的fstream实现独有的。在GNU环境下编译时,我是否可以在构建或使用ifstream时采取不同的方法来避免分配大量堆内存?这是他们<fstream>中的一个错误吗?
正如在评论讨论中发现的那样,程序的实际内存占用是完全合理的(84kb),它只是分配和释放了数十万次相同的小内存块,这在使用像ASAN这样的自定义分配器时会创建问题,因为它避免重新使用堆空间。我发布了后续问题,询问如何应对这种“ASAN”级别的问题。 @KamilCuk Stack Overflow用户慷慨地贡献了一个在其CI管道中重现该问题的gitlab项目

2
我不知道,但是出于好奇,我会准备一个两倍或十倍长的文件,并检查内存消耗是否取决于数据大小... - CiaPan
1
似乎是在istream类的operator >>(double&)实现中出现了内存泄漏……仍然不知道如何修复。:( - CiaPan
2
我认为你的代码没有什么明显的问题,你可以尝试使用带有libc++的Clang,并查看该实现是否使用更少的内存。 - Kaldrr
1
在嵌入式ARM板上,这开始成为一个问题。在ASAN下进行测试(它会为每个分配添加填充),实际上破坏了我的ARM CI运行程序。此外,它的大小是输入文件的6.5倍,这是荒谬的。 - Dan
4
没问题,该程序实际内存使用量为81K字节,完全合理。因此,我认为你真正的问题是如何配置ASAN以更高效地处理这种分配模式。 - Nate Eldredge
显示剩余18条评论
2个回答

13

实际上并不是这样的。由valgrind所显示的59,302,487数字是所有分配的总和,而不代表程序实际消耗的内存。

事实证明,相关的operator>>的libstdc++实现会为了临时空间创建一个std::string,并为其保留32字节。使用后立即销毁。详情参见num_get::do_get。加上开销,这可能实际上会分配大约56个字节,乘以大约100万次重复,从某种意义上讲,总共分配了59兆字节,当然这就是为什么这个数字随着输入数量线性增长的原因。但是一直在重复分配和释放相同的56个字节。这是libstdc++完全无害的行为,不是泄漏或过度内存消耗。

我没有检查libc++源代码,但是很可能它使用堆栈上的临时空间而不是堆。

根据评论确定,您真正的问题是在AddressSanitizer下运行,这会延迟释放内存的重用,以帮助捕获use-after-free错误。我有一些关于如何解决这个问题的想法(无意冒犯),并将在如何从ASAN中排除紧密循环中的分配上发布它们。

1
不幸的是,C++基于流的I/O库通常被低估了,因为每个人都“知道”它的性能很差,所以存在一个鸡生蛋的问题——坏的意见导致很少使用,导致很少的错误报告,从而降低了修复的压力。
我认为C++流最大的用户是基本的CS/IT教育部门和“快速一次性脚本”(这些脚本将不可避免地存活超过作者),在那里没有人真正关心性能。
你看到的只是一种浪费的实现——它在内部不断分配和释放内存,但据我所知它不会泄漏内存。我认为,在使用流I/O时,并没有任何一种“模式”可以保证更好的性能,而且不易脆弱。
在嵌入式环境中赢得这场比赛的最佳策略是根本不参与游戏。忘记C++流I/O,一切都会好起来的。有替代格式化I/O库,可以恢复C++的类型安全性并且性能更好,这样你就不必受制于标准库的实现错误/低效性。或者如果你不想添加依赖项,只需使用sscanf

1
在我看来,“在内部不断分配和释放”并不总是“浪费资源的实现方式”。这种说法假设堆分配很慢,特别是这种情况下。事实上,这种模式可以非常快速。 - CouchDeveloper

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接