编程思考:对象生命周期的问题
Posted qcrao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了编程思考:对象生命周期的问题相关的知识,希望对你有一定的参考价值。
前情提要
只要写过 c/c++ 的项目的童鞋应该对对象生命周期的问题记忆犹新。怕有人还不理解这个问题,笔者先介绍下什么是生命周期的问题?
一个 struct 结构体生命周期分为三个步骤:
出生:
malloc
分配结构体内存,并且初始化;使用:这个就是对内存的常规使用了;
销毁:
free
释放这个内存块;
最典型结构体“生命周期”问题的场景就是:你在使用对象正嗨的时候,被人偷偷把对象销毁了。举个例子:
12:00 时刻:ObjectA 内存 malloc 出来,地址为 0x12345 ;
12:10 时刻:ObjectA 内存地址 0x12345 释放了;
12:12 时刻:程序猿小明拿到了 ObjectA 的地址 0x12345 ,准备大干一场(但他并不知道的是,这个 ObjectA 结构体已经结束了生命,0x12345 地址已经被释放了)于是,踩内存了,全剧终;
生命周期问题的维度
一般来讲,生命周期的问题其实有两个方面:
第一个是结构体本身内存的生命周期 ;
第二个是结构体对象管理的资源( 比如资源句柄 );
1 结构体本身内存
对象结构体本身的生命周期这个很容易理解,这个就是内存的分配和释放。
// 步骤一:分配
obj_addr = malloc(...);
// 步骤二:使用 ...
// 步骤三:释放
free(obj_addr);
如果违反了这条(使用了已经释放的内存块),就会发生踩内存,野指针,未定义地址等一系列奇异事件。如果没正确释放,那么就是内存泄漏。
2 对象管理的资源
这个也很容易理解,比如一个代表 fd_t
的结构体,里面有一个整型字段,代表这个结构体管理的一个文件句柄。当 fd_t
结构体内存被释放的时候,它管理的文件句柄 sys_fd
也是需要 close
的。
struct fd_t
int sys_fd; // 系统句柄(这个需要在合适的时机释放)
struct list_head list; // 链表挂接件
//...
;
如果违反了这条(使用释放了的资源,比如句柄),那么就会出现 bad descriptor
等一系列情况。
怎么才能解决生命周期的问题?
生命周期的问题是每个程序猿都可能遇到的,只要程序中涉及到资源的创建、使用、释放,这三个过程,那么生命周期的问题就是你必经之路,这是一个通用的问题。
上面我们提到生命周期问题的两个维度,那么解决也是这两个维度的针对性解决。遵守两个原则:
对象在有人使用的时候不能释放;
对象不仅要释放自身内存还要释放管理的资源;
思考下:你在编程的时候,怎么处理的?
下面我从 c 这种底层语言,还有 Go 这种自带 GC 的语言对比出发,来体验下不同语言下的生命周期的问题怎么解决。
c 编程的惯例
c 怎么才能保证内存的安全,资源的安全释放呢?
以下面的场景举例:
现在有一个 fd_t 的 list 链表,为了保护这个链表,用一个互斥锁来保护 ;
创建 fd_t 的时候,需要添加进 list(添加会加互斥锁);
正常使用的时候,会遍历 list ,取合适的元素使用;
fd_t 销毁的时候,会从全局链表中摘除;
首先,list 链表的并发安全可以用互斥锁来解决,但是怎么保证你取出来元素之后,还在处理的时候,一直是安全的呢(不被释放)?
你可能会自然想到一个思路:全程在锁内不就可以了。
确实如此,对象的创建,使用,删除,全程用锁保护,确实可以解决这个问题。但是锁度变得非常大,在现实生产环境的编程中,很少见。
其实,解决资源释放的场景,有一个通用的技术:引用计数。 wiki 上的解释:
引用计数是计算机编程语言中的一种内存管理技术,是指将资源(可以是对象、内存或磁盘空间等等)的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。
引用计数是一种通用的资源管理技术,简述引用计数用法:
资源初始化的时候,计数为 1 ;
就是在资源获取的时候,对资源计数加 1 ;
资源使用完成的时候,对资源计数减 1 ;
计数为 0 的时候,走释放流程 ;
这样,只需要用户对资源的使用上遵守一个规则:获取的时候,计数加 1,处理完了,计数减 1 ,就能保证不会有问题。因为在你使用期间,不管别人怎么减,都不可能会到 0 。
思考下:引用计数有什么缺点呢?
第一个问题,非常容易出错,加减引用一定要配对,一旦有些地方多加了,或者多减了,就会引发资源问题。要么就是泄漏,要么就是使用释放了的资源;
第二个问题,在于流程上变复杂了,因为计数为 0 的地方点变得不确定了。可能会出现在读元素的流程上,走释放流程;
以上两点,其实对程序猿的能力、细致提出了很高的要求。
Go 就厉害了
引用计数是通用的技术,适用于所有的语言。笔者在写 Go 的时候就用引用计数来解决过资源释放的问题。
但后来发现,Go 语言其实可以把代码写的更简单,Go 的创建则从两个的角度解决了对象生命周期的问题:
第一,根本不让用户释放内存;
Go 的内存,程序猿只能触发分配,无法主动释放。释放内存的动作完全交给了后台 GC 流程。这就很好的解决了第一个问题,由于不让粗心的程序猿参与到资源的管理中,内存资源的管理完全由框架管理(框架强,则我强,嘿嘿),根本就不用担心会被程序猿用到生命终结的内存块。
第二,提供析构回调函数机制;
上面说了,GC 能够保证内存结构体本身的安全性,但是一些句柄资源的释放却无法通过上面保证,怎么办?
Go 提供了一个非常好的办法:设置析构函数。使用 runtime.SetFinalizer
来设置,将一个对象的地址和一个析构函数绑定起来,并且注册到框架里。当对象被 GC 的时候,析构函数将会被框架调用,程序猿则可以把资源释放的逻辑写到析构函数中,这样就配合上了呀,就能保证:在对象永远不能被程序猿摸到的前提下,调用了析构函数,从而完成资源释放。
1 生命结束的回调
函数原型:
func SetFinalizer(obj interface, finalizer interface)
参数解析:
参数
obj
必须是指针类型参数
finalizer
是一个函数,参数为 obj 的类型,无返回值
函数调用 runtime.SetFinalizer
把 obj
和 finalizer
关联起来。对象 obj 被 Gc 的时候,Go 会自动调用 finalizer
函数,并且 obj 作为参数传入。
就这样,关于生命周期的问题,在 Go 里面就非常优雅的解决了,对象内存释放交给了 Gc,资源释放交给了 finalizer
,程序猿又可以躺好了。
扩展思考
c++ 和 Python 这两种语言又是怎么解决内存的生命周期,还有资源的安全释放呢?
提示:这两种语言都有构造函数和析构函数,但各有不同。这个问题留给读者朋友思考。
c++ 有构造函数和析构函数,也很方便,但是 c++ 的类却是非常复杂的。且 c++ 是没有 GC 的,内存释放的动作还是交给了程序猿,所以在 c++ 编程中,引用计数技术还是大量使用的;
python 是一个自带 GC ,并且提供构造和析构函数的。所以 python 的使用,程序猿完全不管内存释放,资源释放则只需要定义在类的析构函数里即可;
总结
生命周期的问题是老大难的问题,分为结构内存的安全释放,内部管理资源的安全释放两个维度;
c/c++ 大量采用引用计数技术来完成对资源的安全释放;
引用计数的难点在于加减计数的配套使用,并且释放的现场不确定;
Go 通过内存自动 Gc ,且提供析构函数绑定到对象地址的方法,从而完美解决了对象生命周期的问题;
用
runtime.SetFinalizer
替代引用计数的使用,太香了;
后记
你 open 一个文件得到句柄 fd,紧接 unlink 这个文件,此时,还可用 fd 来正常读写文件。直到 close 这个文件的时候,这个文件才会永远的消失。你能猜到其中原理吗?
~完~
往期推荐
往期推荐
自制文件系统 —— 03 Go实战:hello world 的文件系统
坚持思考,方向比努力更重要。关注我:奇伢云存储
以上是关于编程思考:对象生命周期的问题的主要内容,如果未能解决你的问题,请参考以下文章