
前情提要

只要寫過 c/c++ 的項目的童鞋應該對對象生命周期的問題記憶猶新。怕有人還不理解這個問題,筆者先介紹下什么是生命周期的問題?
一個 struct 結構體生命周期分為三個步驟:
出生: malloc
分配結構體內存,并且初始化;使用:這個就是對內存的常規使用了; 銷毀: free
釋放這個內存塊;

最典型結構體“生命周期”問題的場景就是:你在使用對象正嗨的時候,被人偷偷把對象銷毀了。舉個例子:
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
等一系列情況。

怎么才能解決生命周期的問題?

生命周期的問題是每個程序猿都可能遇到的,只要程序中涉及到資源的創建、使用、釋放,這三個過程,那么生命周期的問題就是你必經之路,這是一個通用的問題。
上面我們提到生命周期問題的兩個維度,那么解決也是這兩個維度的針對性解決。遵守兩個原則:
對象在有人使用的時候不能釋放; 對象不僅要釋放自身內存還要釋放管理的資源;
思考下:你在編程的時候,怎么處理的?
下面我從 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 的時候,析構函數將會被框架調用,程序猿則可以把資源釋放的邏輯寫到析構函數中,這樣就配合上了呀,就能保證:在對象永遠不能被程序猿摸到的前提下,調用了析構函數,從而完成資源釋放。
函數原型:
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 這個文件的時候,這個文件才會永遠的消失。你能猜到其中原理嗎?
~完~

往期推薦

往期推薦
堅持思考,方向比努力更重要。關注我:奇伢云存儲