zoukankan      html  css  js  c++  java
  • 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 <new>

    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 的提供方把它们大致分为这么几大类:

    1. C 语言的标准库,也包括 Linux 编程环境提供的 Posix 系列函数。
    2. 第三方的 C 语言库,例如 OpenSSL。
    3. C++ 语言的标准库,主要是 STL。(我想没有人在产品中使用 IOStream 吧?)
    4. 第三方的通用 C++ 库,例如 Boost.Regex,或者某款 XML 库。
    5. 公司其他团队的人开发的内部基础 C++ 库,比如网络通信和日志等基础设施。
    6. 本项目组的同事自己开发的针对本应用的基础库,比如某三维模型的仿射变换模块。

    在使用这些 library 的时候,不可避免地要在各个 library 之间交换数据。比方说 library A 的输出作为 library B 的输入,而 library A 的输出本身常常会用到动态分配的内存(比如 std::vector<double>)。

    如果所有的 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++ library 也会转而使用你的 ::operator new 来分配内存。当然这是一种相当有侵略性的做法,如果运气好,编译和运行都没问题;如果运气差一点,可能会遇到编译错误,这其实还不算坏事;运气更差一点,编译没有错误,运行的时候时不时出现非法访问,导致 segment fault;或者在某些情况下你定制的分配策略与 library 有冲突,内存数据损坏,出现莫名其妙的行为。

    对于以库文件方式实现的 library,这么做并不能让其受惠,因为 library 的源文件已经编译成了二进制代码,它不会调用你新重载的 ::operator new(想想看,已经编译的二进制代码怎么可能提供额外的 new (__FILE__, __LINE__) 参数呢?)更麻烦的是,如果某些头文件有 inline function,还会引起诡异的“串扰”。即 library 有的部分用了你的分配器,有的部分用了系统默认的分配器,然后在释放内存的时候没有给对地方,造成分配器的数据结构被破坏。

    总之,第二种重载方式看似功能更丰富,但其实与程序里使用的其他 C++ library 很难无缝配合。

    综上,对于现实生活中的 C++ 项目,重载 ::operator new() 几乎没有用武之地,因为很难处理好与程序所用的 C++ library 的关系,毕竟大多数 library 在设计的时候没有考虑到你会重载 ::operator new() 并强塞给它。

    如果确实需要定制内存分配,该如何办?

    替代办法

    很简单,替换 malloc。如果需要,直接从 malloc 层面入手,通过 LD_PRELOAD 来加载一个 .so,其中有 malloc/free 的替代实现(drop-in replacement),这样能同时为 C 和 C++ 代码服务,而且避免 C++ 重载 ::operator new() 的阴暗角落。

    对于“检测内存错误”这一用法,我们可以用 valgrind 或者 dmalloc 或者 efence 来达到相同的目的,专业的除错工具比自己山寨一个内存检查器要靠谱。

    对于“统计内存使用数据”,替换 malloc 同样能得到足够的信息,因为我们可以用 backtrace() 函数来获得调用栈,这比 new (__FILE__, __LINE__) 的信息更丰富。比方说你通过分析 (__FILE__, __LINE__) 发现 std::string 大量分配释放内存,有超出预期的开销,但是你却不知道代码里哪一部分在反复创建和销毁 std::string 对象,因为 (__FILE__, __LINE__) 只能告诉你最内层的调用函数。用 backtrace() 能找到真正的发起调用者。

    对于“性能优化”这一用法,我认为这目前的多线程开发中,自己实现一个能打败系统默认的 malloc 的内存分配器是不现实的。一个通用的内存分配器本来就有相当的难度,为多线程程序实现一个安全和高效的通用(全局)内存分配器超出了一般开发人员的能力。不如使用现有的针对多核多线程优化的 malloc,例如 Google tcmalloc 和 Intel TBB 2.2 里的内存分配器。好在这些 allocator 都不是侵入式的,也无须重载 ::operator new()。

    为单独的 class 重载 operator new() 有问题吗?

    与全局 ::operator new() 不同,per-class operator new() 和 operator delete () 的影响面要小得多,它只影响本 class 及其派生类。似乎重载 member operator new() 是可行的。我对此持反对态度。

    如果一个 class Node 需要重载 member operator new(),说明它用到了特殊的内存分配策略,常见的情况是使用了内存池或对象池。我宁愿把这一事实明显地摆出来,而不是改变 new Node 的默认行为。具体地说,是用 factory 来创建对象,比如 static Node* Node::createNode() 或者 static shared_ptr<Node> Node::createNode();。

    这可以归结为最小惊讶原则:如果我在代码里读到 Node* p = new Node,我会认为它在 heap 上分配了内存,如果 Node class 重载了 member operator new(),那么我要事先仔细阅读 node.h 才能发现其实这行代码使用了私有的内存池。为什么不写得明确一点呢?写成 Node* p = Node::createNode(),那么我能猜到 Node::createNode() 肯定做了什么与 new Node 不一样的事情,免得将来大吃一惊。

    The Zen of Python 说 explicit is better than implicit,我深信不疑。

    总结:重载 ::operator new() 或许在某些临时的场合能应个急,但是不应该作为一种策略来使用。如果需要,我们可以从 malloc 层面入手,彻底而全面地替换内存分配器。

    参考文献:

    [1] 侯捷,《池內春秋—— Memory Pool 的設計哲學與無痛運用》,《程序员》2002 年第 9 期。

  • 相关阅读:
    Openstack API 开发 快速入门
    virtualBox虚拟机到vmware虚拟机转换
    使用Blogilo 发布博客到cnblogs
    Openstack Troubleshooting
    hdoj 1051 Wooden Sticks(上升子序列个数问题)
    sdut 2430 pillars (dp)
    hdoj 1058 Humble Numbers(dp)
    uva 10815 Andy's First Dictionary(快排、字符串)
    sdut 2317 Homogeneous squares
    hdoj 1025 Constructing Roads In JGShining's Kingdom(最长上升子序列+二分)
  • 原文地址:https://www.cnblogs.com/Solstice/p/1960301.html
Copyright © 2011-2022 走看看