C++ 工程实践(2):不要重载全局 ::operator new()
陈硕 (giantchen_AT_gmail)
Blog.csdn.net/Solstice
本文只考虑 Linux x86 平台,服务端开发(不考虑 Windows 的跨 DLL 内存分配释放问题)。本文假定读者知道 ::operator new() 和 ::operator delete() 是干什么的,与通常用的 new/delete 表达式有和区别和联系,这方面的知识可参考侯捷先生的文章《池内春秋》[1],或者这篇文章。
C++ 的内存管理是个老生常谈的话题,我在《当析构函数遇到多线程》第 7 节“插曲:系统地避免各种指针错误”中简单回顾了一些常见的问题以及在现代 C++ 中的解决办法。基本上,按现代 C++ 的手法(RAII)来管理内存,你很难遇到什么内存方面的错误。“没有错误”是基本要求,不代表“足够好”。我们常常会设法优化性能,如果 profiling 表明 hot spot 在内存分配和释放上,重载全局的 ::operator new() 和 ::operator delete() 似乎是一个一劳永逸好办法(以下简写为“重载 ::operator new()”),本文试图说明这个办法往往行不通。
内存管理的基本要求
如果只考虑分配和释放,内存管理基本要求是“不重不漏”:既不重复 delete,也不漏掉 delete。也就说我们常说的 new/delete 要配对,“配对”不仅是个数相等,还隐含了 new 和 delete 的调用本身要匹配,不要“东家借的东西西家还”。例如:
用系统默认的 malloc() 分配的内存要交给系统默认的 free() 去释放;
用系统默认的 new 表达式创建的对象要交给系统默认的 delete 表达式去析构并释放;
用系统默认的 new[] 表达式创建的对象要交给系统默认的 delete[] 表达式去析构并释放;
用系统默认的 ::operator new() 分配的的内存要交给系统默认的 ::operator delete() 去释放;
用 placement new 创建的对象要用 placement delete (为了表述方便,姑且这么说吧)去析构(其实就是直接调用析构函数);
从某个内存池 A 分配的内存要还给这个内存池。
如果定制 new/delete,那么要按规矩来。见 Effective C++ 相关条款。
做到以上这些不难,是每个 C++ 开发人员的基本功。不过,如果你想重载全局的 ::operator new(),事情就麻烦了。
重载 ::operator new() 的理由
Effective C++ 第三版第 50 条列举了定制 new/delete 的几点理由:
检测代码中的内存错误
优化性能
获得内存使用的统计数据
这些都是正当的需求,文末我们将会看到,不重载 ::operator new() 也能达到同样的目的。
::operator new() 的两种重载方式
1. 不改变其签名,无缝直接替换系统原有的版本,例如:
#include
void* operator new(size_t size);
void operator delete(void* p);
用这种方式的重载,使用方不需要包含任何特殊的头文件,也就是说不需要看见这两个函数声明。“性能优化”通常用这种方式。
2. 增加新的参数,调用时也提供这些额外的参数,例如:
void* operator new(size_t size, const char* file, int line); // 其返回的指针必须能被普通的 ::operator delete(void*) 释放
void operator delete(void* p, const char* file, int line); // 这个函数只在析构函数抛异常的情况下才会被调用
然后用的时候是
Foo* p = new (__FILE, __LINE__) Foo; // 这样能跟踪是哪个文件哪一行代码分配的内存
我们也可以用宏替换 new 来节省打字。用这第二种方式重载,使用方需要看到这两个函数声明,也就是说要主动包含你提供的头文件。“检测内存错误”和“统计内存使用情况”通常会用这种方式重载。当然,这不是绝对的。
在学习 C++ 的阶段,每个人都可以写个一两百行的程序来验证教科书上的说法,重载 ::operator new() 在这样的玩具程序里边不会造成什么麻烦。
不过,我认为在现实的产品开发中,重载 ::operator new() 乃是下策,我们有更简单安全的办法来到达以上目标。
现实的开发环境
作为 C++ 应用程序的开发人员,在编写稍具规模的程序时,我们通常会用到一些 library。我们可以根据 library 的提供方把它们大致分为这么几大类:
C 语言的标准库,也包括 Linux 编程环境提供的 Posix 系列函数。
第三方的 C 语言库,例如 OpenSSL。
C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 IOStream 吧?)
第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。
在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说 library A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配的内存(比如 std::vector)。
如果所有的 C++ library 都用同一套内存分配器(就是系统默认的 new/delete ),那么内存的释放就很方便,直接交给 delete 去释放就行。如果不是这样,那就得时时刻刻记住“这一块内存是属于哪个分配器,是系统默认的还是我们定制的,释放的时候不要还错了地方”。
(由于 C 语言不像 C++ 一样提过了那么多的定制性,C library 通常都会默认直接用 malloc/free 来分配和释放内存,不存在上面提到的“内存还错地方”问题。或者有的考虑更全面的 C library 会让你注册两个函数,用于它内部分配和释放内存,这就就能完全掌控该 library 的内存使用。这种依赖注入的方式在 C++ 里变得花哨而无用,见陈硕写的《C++ 标准库中的allocator是多余的》。)
但是,如果重载了 ::operator new(),事情恐怕就没有这么简单了。
重载 ::operator new() 的困境
首先,重载 ::operator new() 不会给 C 语言的库带来任何麻烦,当然,重载它得到的三点好处也无法让 C 语言的库享受到。
以下仅考虑 C++ library 和 C++ 主程序。
规则 1:绝对不能在 library 里重载 ::operator new()
如果你是某个 library 的作者,你的 library 要提供给别人使用,那么你无权重载全局 ::operator new(size_t) (注意这是上面提到的第一种重载方式),因为这非常具有侵略性:任何用到你的 library 的程序都易做图使用了你重载的 ::operator new(),而别人很可能不愿意这么做。另外,如果有两个 library 都试图重载 ::operator new(size_t),那么它们会打架,我估计会发生 duplicated symbol link error。干脆,作为 library 的编写者,大家都不要重载 ::operator new(size_t) 好了。
那么第二种重载方式呢?首先,::operator new(size_t size, const char* file, int line) 这种方式得到的 void* 指针必须同时能被 ::operator delete(void*) 和 ::operator delete(void* p, const char* file, int line) 这两个函数释放。这时候你需要决定,你的 ::operator new(size_t size, const char* file, int line) 返回的指针是不是兼容系统默认的 ::operator delete(void*)。
如果不兼容(也就是说不能用系统默认的 ::operator delete(void*) 来释放内存),那么你得重载 ::operator delete(void*),让它的行为与你的 operator new(size_t size, const char* file, int line) 匹配。一旦你决定重载 ::operator delete(void*),那么你必须重载 ::operator new(size_t),这就回到了情况 1:你无权重载全局 ::operator new(size_t)。
如果选择兼容系统默认的 ::operator delete(void*),那么你在 operator new(size_t size, const char* file, int line) 里能做的事情非常有限,比方说你不能额外动态分配内存来做 house keeping 或保存统计数据(无论显示还是隐式),因为系统默认的 ::operator delete(void*) 不会释放你额外分配的内存。(这里隐式分配内存指的是往 std::map<> 这样的容器里添加元素。)
看到这里估计很多人已经晕了,但这还没完。
其次,在 library 里重载 operator new(size_t size, const char* file, int line) 还涉及到你的重载要不要暴露给 library 的使用者(其他 library 或主程序)。这里“暴露”有两层意思:1) 包含你的头文件的代码会不会用你重载的 ::operator new(),2) 重载之后的 ::operator new() 分配的内存能不能在你的 library 之外被安全地释放。如果不行,那么你是不是要暴露某个接口函数来让使用者安全地释放内存?或者返回 shared_ptr ,利用其“捕获”deleter 的特性?听上去好像挺复杂?这里就不一一展开讨论了,总之,作为 library 的作者,绝对不要动“重载 operator new()”的念头。
事实 2:在主程序里重载 ::operator new() 作用不大
这不是一条规则,而是我试图说明这么做没有多大意义。
如果用第一种方式重载全局 ::operator new(size_t),会影响本程序用到的所有 C++ library,这么做或许不会有什么问题,不过我建议你使用下一节介绍的更简单的“替代办法”。
如果用第二种方式重载 ::operator new(size_t size, const char* file, int line),那么你的行为是否惠及本程序用到的其他 C++ library 呢?比方说你要不要统计 C++ library 中的内存使用情况?如果某个 library 会返回它自己用 new 分配的内存和对象,让你用完之后自己释放,那么是否打算对错误释放内存做检查?
C++ library 从代码组织上有两种形式:1) 以头文件方式提供(如以 STL 和 Boost 为代表的模板库);2) 以头文件+二进制库文件方式提供(大多数非模板库以此方式发布)。
对于纯以头文件方式实现的 library,那么你可以在你的程序的每个 .cpp 文件的第一行包含重载 ::operator new 的头文件,这样程序里用到的其他 C++ l
补充:软件开发 , C++ ,