Gc 是一种失败的内存管理模式


#1

只要写程序, 就免不了要和各种资源打交道, 其中最频繁的莫过于内存了.

任何一个程序都需要内存管理. 它不管是简单的还是复杂的, C 语言的还是 java 语言的. 所不一样的是, 内存管理的细节掌握在谁的手里.

对于 C 语言, 毫无疑问的, 程序员掌握所有细节. 程序员获得了最大的灵活性, 作为代价, 编译器不对任何***内存管理上的疏忽***负责. 而人是 最容易犯错 的生物. 意味着, 程序员总会犯错, 因此很难写出在内存管理上没有瑕疵的程序.

毫无疑问的, 将内存管理的重担全部丢给程序员, 是编译器水平低下的时代无奈的选择. 随着编译器技术的发展, 将内存管理的任务从程序员手中接管是必然的.

对于如何接管内存管理, 语言作者们分成了截然不同的两派

  • 垃圾收集
  • RAII (Resource Acquisition Is Initialization) + 智能指针

在带有垃圾收集的语言里, 程序员只管分配内存, 无需操心释放. 垃圾收集器间歇性的运作, 会将不再使用的内存释放掉. 至于如何标记哪些内存是不再使用的, 几十年间发展出了各种算法. 许多语言都带有多种标记算法供选择. “没有哪一种垃圾收集策略是适合所有程序的, 所以各种语言都发展出多套垃圾收集器, 供运行时选择.”

在许多语言里, 垃圾收集并不是编译器实现的, 而是由语言附带的运行时环境实现的, 编译器为运行时提供了附加的信息. 这就导致了语言和运行时的强耦合. 让人无法分清语言的特性和运行时的特性.

垃圾收集不是完美的, 使用垃圾收集并不意味着就可以高枕无忧了. 垃圾收集并不意味着内存泄漏成为过去式, 倒是野指针确实成为了过去式, 因为只要还有指针引用一个对象, 这个对象就绝对不会被释放. (不过, 带有垃圾收集的语言或多或少都废除了指针吧, 用引用替代了指针)

有很多很多复杂的原因丢会导致垃圾收集器无法回收特定的内存, 导致这部分内存泄漏. 更严重的是, 你很难将内存泄漏和还未被清除的内存完全区别开来. 到底是延迟收集策略 还是真的发生了内存泄漏 ? 你永远都无法正确分辨.

结果是, 程序员最终不得不回到 C 语言的老路上, 小心的检查所有的内存分配, 确保没有触发垃圾收集器的bug或者特定的一些策略 . 几乎所有使用带垃圾收集的语言开发的程序, 在其开发的后期都要经历惨痛的 “内存检查” , 回顾所有可能导致内存泄漏的代码.

垃圾收集器的另一个问题是, 除了内存, 它无法对程序使用的其他资源执行垃圾收集. 垃圾收集是以内存管理为目标产生的, 只能收集不再使用的内存, 而不能收集程序使用的其他资源, 如消息列队, 文件描述符, 共享内存, 锁.等等. 程序员不得不对其他资源执行手工管理, 像 C 程序员那样小心翼翼的操作.

最终垃圾收集仍然没有解决 “人容易犯错” 的问题, 还是把其他资源的泄漏问题丢给了程序员.

C++ 从来不认为垃圾收集是有用的东西, 和 C 派不一样 , C 派不喜欢垃圾收集纯粹是因为喜欢 “自己控制一切” (天生的 M 属性). C++ 派同样认为, 要把程序员从资源管理的重担里解放出来. 同 “投机取巧” 的 GC派不同, C++ 做了很多思考, 并最终经历了 30年的时间终于找到了解决的办法. 写入了 C++11 标准.

在这30年的时间里, C++ 的资源管理是逐步发展的. C++11 最终提出的智能指针, 源于 C++30年的探索.

C++ 要想实现 RAII + 智能指针, 两大技术缺一不可 1. 自动确定并调用 构造函数和析构函数 2. 模板

C++ 的第一步试图解放资源管理重任, 是为 C 加入了构造函数和析构函数. 构造函数和析构函数由编译器调用, 生命期终止的对象会自动调用析构函数. 不管生命期终止的原因是 return 返回, 还是 抛出了异常, 编译器总是保证, 生命期终止的对象一定会被调用析构函数.

以 “编译器自动保证对象生命期” 的技术依托下, C++ 发明了 RAII 技术, 将资源的管理变成了对象的管理,而自动变量 (创建在栈上的对象, 类的成员变量) 的生命期由编译器自动保证, 只要在构造函数里申请资源, 在析构函数里正确的释放资源, RAII 技术解决了一大部分的资源管理问题.

模板的引入使得 RAII 技术得以 “一次实现到处使用”. 如实现一次 std::vector 就可以到处使用在需要数组的情况下, 而无需为每种类型的分别实现数组RAII类. STL 内置了大量的容器, 几乎满足了所有的需求. STL的容器无法满足需求的情况下, 程序员仍然能借用 STL 的理念实现自己的 RAII 容器.

但是, 如果对象分配于堆上, 程序员不得不手工调用 delete 删除对象. 忘记将不用的对象 delete 就成了头号资源泄漏原因.

如果指针也是自动对象就好了.

C++ 标准的第一次尝试是纳入 std::auto_ptr . 但是效果并不好, 不是所有指针都可以为 auto_ptr 所代替. 最要命的是, STL 容器无法使用 auto_ptr.

C++ 标准的第二次尝试就是纳入了 std::shared_ptr , shared_ptr 在进入 C++11 标准之前, 已经在 Boost 库里实践了相当长的时间.

首先得益于 C++ 的模板技术, shared_ptr 只需实现一次, 即变成可用于任何类型的指针. 其次, 得益于 C++ 的自动生命期管理, 智能指针将需要程序员管理的堆对象也变成了能利用编译器自动管理的自动变量.

也就是, 智能指针彻底的将 delete 关键字 变成了 shared_ptr 才能使用的内部技术. 编译器能自动删除 shared_ptr 对象, 也就是编译器能自动的发出 delete 调用.

模板是智能指针技术必不可少的一部分, 否则要利用 RAII 实现智能指针就只能利用 “单根继承” 这一老土办法了. 没错, 这也是 MFC 使用的. ( MFC 诞生在 模板还没有加入 C++ 的年代. )

直到 1998 年 C++ 标准纳入了模板, C++ 才最终具备了实现自动内存管理所必须的特性.

但是准备好这些特性, 到利用这些特性发明出真正能用的智能指针, 则又花了13年的时间. ( 2011年加入了 shared_ptr. )

发明出编译器实现的自动内存管理需要时间, C++ 花了 30年的时间. 没有耐心的语言走了捷径, GC 就是这条捷径.


[系列教程] 通往现代c++之路之1 --- 你要摒弃的几个 c++ 陋习
关于某人说的,循环引用必须使用 gc 的探讨
#2

C++ 花了 30年的时间. 没有耐心的语言走了捷径, GC 就是这条捷径. 说的好啊


#3

java要是泄漏了 ,我真没什么办法了…


#4

写下我的读后感吧。

语言的发展是一个实践+平衡的过程。通过实践才能知道设计的功能别人是会怎么用,实际效果如何;通过平衡,才能让大家都接收认可并共同执行这一标准。

从c到最初接触c++,感觉c++的确为实现提供了很多支持,但设计中很多oop的支持,在实际应用中最终几乎成了噩梦,人家感觉永远无法达到完美或实现理想。

“人容易犯错”,你无法保证团队中每个人都能充分理解C++那庞大琐碎的OOP实现,无法保证每个人都小心使用每个看起来很炫的语法糖。

经过这么多年的实践,最终,人们发现C++的模板才是最适合C++实现解决未来应用的。虽然设计之初的功能其实很朴素。

gc也是一种尝试,它有效缓解了开发人员水平不一致的问题,随着gc技术的发展,人们似乎找到了灵丹妙药。最终我们发现,它看起来很美好,但仍然无法彻底解决资源管理问题。人们为了避免那隐蔽的泄漏,不得不去了解gc的实现底层,在开发时,无意中仍然有可能引发资源的长期占用得不了释放的问题。而且gc的研究似乎进入到了一个瓶颈,无法再有大的发展了,所能进步的也只是少许改良。

C++中的自动内存管理最终能发一展到什么地步,现在不能下定论,但目前来看,能比gc走得更远。


#5

使用现代C++方式编程,了解shared_ptr指针用法,内存泄露将成为历史。


#6

深有感触,boost智能指针家族的成员shared_ptr, scoped_ptr, weak_ptr, 需要好好学习下


#7

我觉得GC是一个pythonic的设计,有着pythonic的优势(简洁)和pythonic的缺陷(在大单位上破坏设计)。smart_ptr优点多多,可是要参与一个项目的成员都使用自如,应该不简单。


#8

不管是什么类型我都用std::shared_ptr绝对没错嗯嗯


#9

C++11 支持度还有限, 还是 boost::shared_ptr 靠谱. 哪哪都能用


#10

忍不住吐槽一下……

只说了reference counting的优点,缺点也是非常明显的。选择GC并非仅仅是“捷径”,而是reference counting需要太多的人为控制才能work。

  • 环形引用。这时候reference counting彻底失效。要人为构造weakref来解决。
  • 效率问题。每次ref()/unref()(也就是shared_ptr构造/析构的时候),需要atomic_inc()一个counter,这个操作在NUMA上会有效率问题。

你说的resource leak在GC下是个大问题是因为

  • GC和RAII是冲突的
  • 当resource leak发生时,在GC的环境下大多都是implicit的,而在reference counting的时候都必须是explicit。其实只是让问题更容易呈现而已。

reference counting是折中的方案,而且最容易实现。没什么“伟大”的地方。而且,据我知道的来说,boost::shared_ptr/std::shared_ptr除了其中atomic operation可能会用到barrier以外,剩下的都是C++/C+±11标准语法,和编译器没有任何关系。


#11

不担心 GC 的效率, 却要担心一个 atomic 操作的效率。 这 。。。。。。


#12
  • GC的效率问题很难说。要看你的程序是怎么样的。
  • GC的overhead是clean up的时候的overhead。而不是accessing overhead。access是critical path。所以相比之下,reference counting未必就有优势。比如我创建了一个图,然后在上面跑两天的挖掘。这时候如果你都用C++(比如bhgc),那么GC的次数是0,如果reference counting的话,就很吃亏了
  • GC的主要问题不是overhead而是pause。如果说overhead,那么malloc和free也有overhead。

GC的最大问题,除了你说的resource leak以外,我觉得应该还有不知什么时候pause。但是相比之下,reference counting也没有你说的那么完美。GC的实现难度比reference counting要难很多,对这个问题的解决也进一步,只是藏起来了更多问题,让程序员失去了控制。而C++是一种强调可控性的语言,所以选择了reference counting。但这并不意味着强调不可控的语言不需要GC。


#13

笑而不语言。

如果你仔细研究过各大 GC 的实现就知道了。GC 的性能只能比 atomic 更差。


#14

C/C++中直接操作内存出现的问题如,内存泄漏,访问越界,一般都是可以根除的,即使没有根除,也可以不断的FIX中接近于没有内存问题;但是基于GC的语言,如Java,在语言层面回避内存管理问题,把它丢给jvm去处理,因为GC的不确定性,所以GC中出现的问题你基本无法解决,任何的FIX只是止痛药式的补丁,不能从根本上解决GC带来的问题。内存管理是"本质复杂的问题",最好的解决办法是直接面对(也就是手动管理内存),这样的可控性和灵活性更高。


#15

我说过,一个是background的overhead,一个是critical path的overhead。

如果你觉得不可能出现critical path比background更重要,我会拿kernel里面的rcu来说。rcu也有一个类似的garbage collection的机制,来避免atomic带来的overhead。

PS 我想你估计误解了atomic的效率问题。我是说,如果你用shared_ptr,每次boost::shared_ptr创建的时候,都要做原子操作,而且不是你所谓的“一次原子操作”,是每一次你读一个object之前,都要做一次原子操作。

在NUMA上,这个原子操作说不好要和其他处理器通讯,并且让其他处理器把它自己的memory controller锁住。这个硬件代价不小,主要原因是offchip通信。


#16

别的我不反驳吧

这个,如果你要是能把你说的问题在内核态实现,并且用可接受的效率,那肯定足够了。(我甚至都怀疑你也就做成kmemleak那样……)


#17

用带 GC 的语言写 critical 的软件?

另外, 正是因为内存是程序员控制的, 所以程序员当然知道 cirtical path 上不要执行费时的操作。

当然, 如果是 GC 的话, 免不了要在不经意间中断了。


#18

你已经需要到 “人身攻击” 的地步才能勉强维持 GC 的尊严了么?


#19

不觉得NUMA能走很远 理想中的有限个内核的cpu +协助计算GPU就 很好 @mikeandmore说的内存栅栏硬件代价不小就是他的硬伤


#20

NUMA 走的远走不远, 现在还不好说。

GC 只能收集内存, 就已经是硬伤了。 而且 GC 导致语言不得不放弃 RAII , 导致所有其他类型的资源回到 C 的老路上。