编程思考:对象生命周期的问题

前情提要

只要写过 c/c++ 的项目的童鞋应该对对象生命周期的问题记忆犹新。怕有人还不理解这个问题,笔者先介绍下什么是生命周期的问题?

一个 struct 结构体生命周期分为三个步骤:

  1. 出生:malloc 分配结构体内存,并且初始化;
  2. 使用:这个就是对内存的常规使用了;
  3. 销毁:free 释放这个内存块;

13080a4f654869514d5dc606b3da26df.png

最典型结构体“生命周期”问题的场景就是:你在使用对象正嗨的时候,被人偷偷把对象销毁了。举个例子:

  • 12:00 时刻:ObjectA 内存 malloc 出来,地址为 0x12345 ;
  • 12:10 时刻:ObjectA 内存地址 0x12345 释放了;
  • 12:12 时刻:程序猿小明拿到了 ObjectA 的地址 0x12345 ,准备大干一场(但他并不知道的是,这个 ObjectA 结构体已经结束了生命,0x12345 地址已经被释放了)于是,踩内存了,全剧终;

生命周期问题的维度

一般来讲,生命周期的问题其实有两个方面:

  • 第一个是结构体本身内存的生命周期 ;
  • 第二个是结构体对象管理的资源( 比如资源句柄 );

结构体本身内存

对象结构体本身的生命周期这个很容易理解,这个就是内存的分配和释放。

// 步骤一:分配
obj_addr = malloc(...);
// 步骤二:使用 ...
// 步骤三:释放
free(obj_addr);

如果违反了这条(使用了已经释放的内存块),就会发生踩内存,野指针,未定义地址等一系列奇异事件。如果没正确释放,那么就是内存泄漏。

对象管理的资源

这个也很容易理解,比如一个代表 fd_t 的结构体,里面有一个整型字段,代表这个结构体管理的一个文件句柄。当 fd_t 结构体内存被释放的时候,它管理的文件句柄 sys_fd 也是需要 close 的。

struct fd_t {
    int sys_fd; // 系统句柄(这个需要在合适的时机释放)
    struct list_head list; // 链表挂接件
    //...
};

如果违法了这条(使用释放了的资源,比如句柄),那么就会出现 bad descriptor 等一系列情况。

怎么才能解决生命周期的问题?

生命周期的问题是每个程序猿都可能遇到的,只要程序中涉及到资源的创建、使用、释放,这三个过程,那么生命周期的问题就是你必经之路,这是一个通用的问题

上面我们提到生命周期问题的两个维度,那么解决也是这两个维度的针对性解决。遵守两个原则

  1. 对象在有人使用的时候不能释放;
  2. 对象不仅要释放自身内存还要释放管理的资源

思考下:你在编程的时候,怎么处理的?

下面我从 c 这种底层语言,还有 Go 这种自带 GC 的语言对比出发,来体验下不同语言下的生命周期的问题怎么解决。

c 编程的惯例

c 怎么才能保证内存的安全,资源的安全释放呢?

以下面的场景举例:

  1. 现在有一个 fd_t 的 list 链表,为了保护这个链表,用一个互斥锁来保护 ;
  2. 创建 fd_t 的时候,需要添加进 list(添加会加互斥锁);
  3. 正常使用的时候,会遍历 list ,取合适的元素使用;
  4. fd_t 销毁的时候,会从全局链表中摘除;

首先,list 链表的并发安全可以用互斥锁来解决,但是怎么保证你取出来元素之后,还在处理的时候,一直是安全的呢(不被释放)?

你可能会自然想到一个思路:全程在锁内不就可以了。

确实如此,对象的创建,使用,删除,全程用锁保护,确实可以解决这个问题。但是锁度变得非常大,在现实生产环境的编程中,很少见。

其实,解决资源释放的场景,有一个通用的技术:引用计数。 wiki 上的解释:

引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。

引用计数是一种通用的资源管理技术,简述引用计数用法:

  1. 资源初始化的时候,计数为 1 ;
  2. 就是在资源获取的时候,对资源计数加 1 ;
  3. 资源使用完成的时候,对资源计数减 1 ;
  4. 计数为 0 的时候,走释放流程 ;

这样,只需要用户对资源的使用上遵守一个规则:获取的时候,计数加 1,处理完了,计数减 1 ,就能保证不会有问题。因为在你使用期间,不管别人怎么减,都不可能会到 0 。

思考下:引用计数有什么缺点呢?

  1. 第一个问题,加减引用一定要配对,一旦有些地方多加了,或者多减了,就会引发资源问题。要么就是泄漏,要么就是使用释放了的资源;
  2. 第二个问题,在于流程上变复杂了,因为计数为 0 的地方点变得不确定了。可能会出现在读元素的流程上,走释放流程;

以上两点,其实对程序猿的能力、细致提出了很高的要求。

Go 就厉害了

引用计数是通用的技术,适用于所有的语言。笔者在写 Go 的时候就用引用计数来解决过资源释放的问题。

但后来发现,Go 语言其实可以把代码写的更简单,Go 的创建则从两个的角度解决了对象生命周期的问题:

第一,根本不让用户释放内存;

Go 的内存,程序猿只能触发分配,无法主动释放。释放内存的动作完全交给了后台 GC 流程。这就很好的解决了第一个问题,由于不让粗心的程序猿参与到资源的管理中,内存资源的管理完全由框架管理(框架强,则我强,嘿嘿),根本就不用担心会被程序猿用到生命终结的内存块。

第二,提供析构回调函数机制;

上面说了,GC 能够保证内存结构体本身的安全性,但是一些句柄资源的释放却无法通过上面保证,怎么办?

Go 提供了一个非常好的办法:设置析构函数。使用 runtime.SetFinalizer 来设置,将一个对象的地址和一个析构函数绑定起来,并且注册到框架里。当对象被 GC 的时候,析构函数将会被框架调用,程序猿则可以把资源释放的逻辑写到析构函数中,这样就配合上了呀,就能保证:在对象永远不能被程序猿摸到的前提下,调用了析构函数,从而完成资源释放

生命结束的回调

函数原型:

func SetFinalizer(obj interface{}, finalizer interface{})

参数解析:

  • 参数 obj 必须是指针类型
  • 参数 finalizer 是一个函数,参数为 obj 的类型,无返回值

函数调用 runtime.SetFinalizerobjfinalizer 关联起来。对象 obj 被 Gc 的时候,Go 会自动调用 finalizer 函数,并且 obj 作为参数传入。

就这样,关于生命周期的问题,在 Go 里面就非常优雅的解决了,对象内存释放交给了 Gc,资源释放交给了 finalizer ,程序猿又可以躺好了。

扩展思考

c++ 和 Python 这两种语言又是怎么解决内存的生命周期,还有资源的安全释放呢?

提示:这两种语言都有构造函数和析构函数,但各有不同。这个问题留给读者朋友思考。

  • c++ 由构造函数和析构函数,也很方便,但是 c++ 的类却是非常复杂的。且 c++ 是没有 GC 的,内存释放的动作还是交给了程序猿,所以在 c++ 编程中,引用计数技术还是大量使用的;
  • python 是一个自带 GC ,并且提供构造和析构函数的。所以 python 的使用,程序猿完全不管内存释放,资源释放则只需要定义在类的析构函数里即可;

总结

  1. 生命周期的问题是老大难的问题,分为结构内存的安全释放,内部管理资源的安全释放两个维度;
  2. c/c++ 大量采用引用计数技术来完成对资源的安全释放;
  3. 引用计数的难点在于加减计数的配套使用,并且释放的现场不确定;
  4. Go 通过内存自动 Gc ,且提供析构函数绑定到对象地址的方法,从而完美解决了对象生命周期的问题;
  5. runtime.SetFinalizer 替代引用计数的使用,太香了;

后记

你 open 一个文件得到句柄 fd,紧接 unlink 这个文件,此时,还可用 fd 来正常读写文件。直到 close 这个文件的时候,这个文件才会永远的消失。你能猜到其中原理吗?


坚持思考,方向比努力更重要。关注公众号:奇伢云存储,获取更多干货。 关注我公众号, 获取更多干货