现代化程序开发笔记(5)——生命周期管理

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将讨论的是现代语言的变量生命周期管理机制。

背景

我们知道,在一个程序运行的时候,任何一个使用的变量在内存中都会占有一定的空间。而除去特殊的静态数据区,在大多数操作系统中,变量要么储存在栈上,要么储存在堆上。创造变量有两种方式,一种是直接在块级作用域内声明局部变量,这种变量是分配在栈上的,另一种则是调用操作系统提供的内存分配函数如malloc等,这种变量是分配在堆上的。而这两种区域也提供了回收变量的方法。当块级作用域结束的时候,其栈上的变量都被回收;当我们调用操作系统提供的内存回收函数如free等,在堆上的变量也就可以得到回收。

我们为什么需要变量内存空间的回收呢?这是因为,在一个进程被创建的时候,操作系统会为该进程分配虚拟内存空间,而虚拟内存空间是与物理内存存在映射的。一个进程的栈和堆实际上都是在这个虚拟内存中的。而物理内存是存在上限的,一般的手机是4GB到8GB,个人电脑则有可能到16GB,服务器的内存则更大,但都存在上限。笼统来说,在一个进程的虚拟内存中,每个存储区域的状态分为两种,一种是被进程使用的,一种是未被进程使用的。栈的增长,malloc的调用,都是将标记为未被使用的存储区域标记为被使用,而栈的收缩,free的调用,则是将相应被标记为已使用的存储区域重新标记为未被使用。

如果我们不断地调用malloc,或者在栈上声明巨多的局部变量,而不去释放相应的内存,那么虚拟内存中越来越多的存储区域被标记为已使用,也就占用了越来越多的物理内存。一旦占用的内存大小超过了操作系统设置的阈值,那么常见的操作系统都会直接结束掉当前的进程。

因此,我们需要垃圾回收机制。调用malloc, free, 在栈上分配回收变量,这些都是操作系统的角度,从编程语言的角度来看,当我们声明一个局部变量,或者调用malloc,那么代表一个变量的生命周期开始了。而我们的块作用域结束,或者调用free,则代表一个变量的生命周期结束了。

常见生命周期管理机制

在栈上分配的变量由于操作系统直接管理,所以不需要编程语言的干涉;在堆上分配的变量则需要编程语言或开发者手动管理,所以接下来,我们讨论的是如何管理在堆上分配的变量。

手动内存管理

手动内存管理是最直接的方法。当我们需要一块内存区域的时候,直接malloc,并返回给我们指向这块内存区域的指针。我们可以在多个函数间,甚至多个线程间传递这个指针,但其在堆上的区域大小始终不变。当开发者决定这个变量寿终正寝的时候,就调用free,然后就没有然后了。

但是,然后真的没有然后了吗?开发者也是有可能犯错的。关于free,开发者最容易犯的两个错是:对于同一个指针地址free了两次,以及在free了之后仍然使用了这个变量。

Double free

我们之前提到,free的作用是将堆上的内存释放,将标记为已使用的内存重新标记为未使用。那如果是未使用的内存,被free的时候就会产生错误,使程序崩溃,如:

void double_free() {
    int *ptr = (int *)malloc(16 * sizeof(int));
    // do something with ptr
    free(ptr);
    // do some other things without ptr
    free(ptr); // BOOM
}

在第二次free(ptr)的时候,由于ptr的值并没有变,所以其指向的仍然是之前那个已经被释放的内存区域,所以就会产生程序的崩溃。

由于操作系统自身对free功能的实现,会有各种各样的漏洞,Double free的问题也有可能会产生一些漏洞,可以参考CTF wiki的Fastbin Double Free

Use after free

比起Double free问题,这个问题就更可怕了。它指的是以下情景:

void use_after_free() {
    int *ptr = (int *)malloc(16 * sizeof(int));
    // do something with ptr
    free(ptr);
    // do some other things without ptr
    int a = ptr[3];
    ptr[4] = 9;
}

当我们释放一个指针指向的堆上的空间时,其内容有可能不会被清空,那么如果开发者没注意,在之后又用到了这块区域,那么就会产生不可知的情况,也可能会产生安全漏洞,可以参考CTF wiki的Use After Free

为了解决这种漏洞的问题,所有的使用汇编语言、C语言或C++语言的开发者都会有这种常识,将指针free之后要置为NULL!也就是

void null_after_free() {
    int *ptr = (int *)malloc(16 * sizeof(int));
    // do something with ptr
    free(ptr);
    ptr = NULL;
}

这虽然不能避免之后的Double free或Use after free使程序崩溃,但能有效避免被恶意地篡改程序产生漏洞。

RAII

对于手动内存管理的OOP语言来说,正如我们刚刚看到的,会在开发者一不留神的情况下就产生许多严重的安全漏洞。因此,一种叫RAII(Resource Acquisition Is Initialization)的开发方案被广泛地使用。

我们知道,在C++中,如果是分配在栈上的类对象,那么在其被创建时会被调用构造函数,在离开作用域时会调用析构函数,比如说:

class PointerWrapper {
public:
	PointerWrapper() {
        std::cout << "Constructor of PointerWrapper is called." << std::endl;
    }
    
    ~PointerWrapper() {
        std::cout << "Destructor of PointerWrapper is called." << std::endl;
    }
}

void foo() {
    PointerWrapper pointer_wrapper;
}

那么,当调用foo时,栈上会分配pointer_wrapper这个类的对象,并调用其构造函数,并输出"Constructor of PointerWrapper is called."。当foo结束时,pointer_wrapper对象会被回收,并调用其析构函数,并输出"Destructor of PointerWrapper is called."。利用C++的这个机制,我们可以将之前万恶的指针包在这个类里:

class PointerWrapper {
    int *ptr;
public:
	PointerWrapper() {
        this->ptr = new int[16];
    }
    
    ~PointerWrapper() {
        if (this->ptr != nullptr) {
        	delete[] this->ptr;
        	this->ptr = nullptr;
        }
    }
}

void foo() {
    PointerWrapper pointer_wrapper;
}

由于C++提供的有效的栈上内存回收的机制,我们可以避免自己写free或者delete,也就解决了很大的问题。事实上,C++的智能指针就是按照这种理念设计的。

引用计数

引用计数(Reference Counting)是由编程语言来控制内存分配与释放的一个最基本,也是最简单的想法。它的想法就是,如果一个变量没有人用了,那么就可以释放了。具体而言,每个新开辟的内存区域会被维护一个引用计数器,每当有一个变量引用该内存区域的时候,它的引用计数器就会自增,如果有变量不再引用这块内存区域的话,引用计数器就会自减。当一个变量的引用计数为0时,就会自动释放这个变量。

Swift就是使用引用计数来进行变量生命周期管理的一个语言,使用它官方教程中的一个例子:

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    deinit {
        print("\(name) is being deinitialized")
    }
}
var reference1: Person?
var reference2: Person?
var reference3: Person?
reference1 = Person(name: "John Appleseed") // [1]
// Prints "John Appleseed is being initialized"
reference2 = reference1 // [2]
reference3 = reference1 // [3]
reference1 = nil // [4]
reference2 = nil // [5]
reference3 = nil // [6]
// Prints "John Appleseed is being deinitialized"

涉及到引用计数管理的一共有六步,在代码中都进行了标注。其中:

  1. 步骤[1]中,操作系统会在堆上分配一个Person类型的对象,然后reference1对这块内存区域进行了一个引用,所以此时其引用计数器为1。
  2. 步骤[2]中,reference2通过reference1,再次引用了这块内存区域,此时这块内存区域一共有reference1reference2两个引用,所以其引用计数器为2。
  3. 步骤[3]中,reference3也类似地引用了这块内存区域,所以引用计数器此时为3。
  4. 步骤[4]中,reference1不再引用这块内存区域,所以引用计数器自减,变成了2。
  5. 步骤[5]中,类似地,引用计数器变成了1。
  6. 最后,在步骤[6]中,引用计数器变成了0,所以操作系统调用了这个对象的析构函数,并释放了这块内存区域。

一切看上去都是这么美好,但这就没问题了吗?并不,引用计数会有两个需要考虑的问题。

第一,引用计数器本身实际上也是一个变量,需要语言的运行时对它进行操控(自增或自减)。这在单线程中是很简单的,但是在多线程中,一块内存区域可能会被多条线程引用,在每条线程内部会对这块内存区域新增引用、减少引用,这样就会导致引用计数器上产生竞争条件。所以,引用计数器需要加上锁,或者使用原子操作,而这实际上是会使性能有所降低的。

第二,就是循环引用的问题。设想一个双向链表,每一个节点同时保存了前一个节点和后一个节点的引用。那么,假设节点A与节点B相连,那么节点A拥有节点B的引用,节点B也拥有节点A的引用。那么,编程语言的运行时是永远不能释放这两个节点的。这是因为,假设要先释放某一个节点,那么其必要条件就是这个节点的引用计数器为0。但是另一个节点仍然存在,并且保持着对这个节点的引用,所以这个节点的引用计数器必然不能为0,产生矛盾。用现实中的例子而言,就像是一个主人拿绳子牵着狗。我们可以通过主人,获得它的狗,所以主人拥有狗的引用;同时,我们也能通过狗,获得它的主人,狗也拥有主人的引用。这就会导致一种循环引用。

强/弱引用

破解循环引用的方法就是,告诉语言的运行时,某一方拥有另一方的引用时,不要自增引用计数器。比如说,主人对运行时说,我牵这条狗的时候,这条狗的引用计数要自增;但这条狗被我牵的时候,我自身的引用计数器不要自增。这样的话,当没有人知道这个人和这条狗的时候,主人此时的引用计数就为0了,然后主人被释放了,此时狗的引用计数器就会随之自减,也变为0,这样也能释放狗了。这种解决方案被称为强引用和弱引用。一般的会增加引用计数器的引用,被称为强引用,而特殊的不会增加引用计数器的引用,被称为弱引用。

强引用和弱引用除了在循环引用的时候可以有效解决问题,在另一种情况下也能很有效地解决问题。试想下面这种情况:

let client = Client()
class Person {
    var book: Book?
    func fetch() {
        client.fetch(completionHandler: { book ->
        	self.book = book
        })
    }
}

在这种回调函数的情况下,乍一看似乎没什么问题,但是,如果在client成功拉取到book时,请求发起这个操作的Person对象已经不再需要了了,也就是说此时只有completionHandler这个闭包保持着对这个对象的引用。那么,即使已经赋值了,但此时的赋值就没用了。但是,改写成弱引用就显得更优雅一些:

let client = Client()
class Person {
    var book: Book?
    func fetch() {
        client.fetch(completionHandler: { [weak self] book ->
            guard let self = self else { return }
        	self.book = book
        })
    }
}

上述的语法中[weak self]表示这个这个闭包只持有self的弱引用。那么,当获得返回值时,如果这个对象已经被析构了,那么guard语句会让闭包直接返回,不仅不需要额外的赋值操作,同时也不会一直持有对象的引用,使对象在正确的时候被析构。

垃圾回收器

Python同样使用了引用计数的变量生命周期管理办法,所以它也同样遇到了循环引用的问题。与Swift不同,Python并没有使用强弱引用的机制,而是引入了一个垃圾回收器,其详细算法可以参考这篇博客

大体来说,Python对于循环引用的处理办法是,每隔一段时间运行一下垃圾回收器,而垃圾回收器通过特定的算法,找出此时由于循环引用而没有被释放掉的变量,然后释放。其具体的算法也并不难,就是首先找出那些有可能存在循环引用的变量,然后让他们之间内部都不互相引用,这样,引用计数器仍不为0的代表被外界所引用,所以不应被释放,而为0的则代表是在内部循环引用的,并且外部没有引用他们的变量了。

垃圾回收

除了手动内存管理,引用计数以外,还有一种变量生命周期管理的方法,就是垃圾回收。刚刚我们提到了,Python为了解决引用计数无法解决的循环引用问题,也引入了垃圾回收机制。所谓垃圾回收机制就是,语言的运行时每隔一段时间,调用一次垃圾回收器,垃圾回收器利用垃圾回收算法确定应该释放的变量,并将其释放。它的核心就在于垃圾回收算法。同时,无论垃圾回收算法如何,使用垃圾回收机制来管理变量生命周期的语言都无法避免的一点就是,如果采用这种语言编写一些服务器程序,并且开发者没有优化到位,那么每隔一段时间就会卡一下,因为被用来垃圾回收。因此,使用垃圾回收机制的语言开发的开发者,往往最津津乐道的,就是如何优化垃圾回收机制,让程序丝滑运行。

JavaScript

严格来说,ECMAScript并没有规定垃圾回收的策略,所以这里应该是具体每种运行时的实现。最常见的JavaScript运行时无非Google的V8引擎和Apple的JavaScriptCore引擎。但JavaScriptCore的资料好像有点少,所以我找的是V8引擎的垃圾回收策略,可以参考这篇文章

总体而言,V8引擎采用的是分代垃圾回收策略。在实际编程中,有的变量总是被频繁地申请然后销毁,而有的变量则常驻内存。因此,对于不同的变量,应该采用不同的垃圾回收策略,所以V8就对变量分代,分为new generation和old generation。不同的代采用不同的垃圾回收策略,这就叫分代垃圾回收策略。

对于new generation,V8采用的是Scavenge算法,总体而言就是用空间换时间的一种策略,十分适合频繁申请释放的空间;对于old generation,V8则采用的是标记清除算法和标记整理算法,这些算法虽然不如Scavenge算法快,但是更适合内存的管理,可以减少内存碎片现象。对于具体的算法,这些都是经过层层优化的策略,这篇文章里还是主要注重于不同策略的讨论。

JVM

和JavaScript类似,JVM标准也没有规定垃圾回收的策略,所以不同的虚拟机实现中也有不同的垃圾回收策略。而基于JVM的语言,如Java和Kotlin等,往往没有语言自身的垃圾回收,而是依赖于JVM的垃圾回收。

JVM最常用的实现,Hotspot虚拟机,采用的依然和V8类似,是分代回收策略。但是,具体的策略,如并行串行等,JVM提供了运行参数,可以让用户在实际运行的时候调配。

Rust

Rust的变量生命周期管理机制是如此特殊,以至于它只能单独列为一类。

从堆上资源分配与释放的角度来看,Rust语言默认内置RAII模式。在一般情况下,我们能直接操作的变量,都是直接分配在栈上的,而栈上的变量,也可能拥有堆上的指针。当栈上的变量被释放时,会自动释放堆上的空间,这和我们之前提到的RAII模式相同。比方说:

struct MyStruct { }
struct PointerWrapper {
    something_on_heap: Box<MyStruct>,
    something_on_stack: MyStruct
}
impl PointerWrapper {
    fn new() -> Self {
        Self {
            something_on_heap: Box::new(MyStruct::new()),
            something_on_stack: MyStruct::new()
        }
    }
}
fn foo() {
    let pointer_wrapper = PointerWrapper::new();
}

在这个例子中,当我们调用foo函数,会是一个什么过程呢?

  1. 在栈上申请PointerWrapper大小的一块区域
  2. 调用PointerWrapper::new()
  3. 调用Box::new(),在堆上申请MyStruct大小的一块区域
  4. 调用MyStruct::new()
  5. 返回MyStruct类型的对象,其内存处于第3步申请的堆内空间里
  6. 返回Box类型的对象,其可以看作是一个指针,此时这个指针位于第1步申请的栈内空间里
  7. 调用MyStruct::new()
  8. 返回MyStruct类型的对象,其内存处于第1步申请的栈内空间里
  9. 返回PointerWrapper类型的对象,其内存处于第1步申请的栈内空间里

由此可见,只有Box对象指向的在堆上,其他都在栈上。在栈上最大的好处,就是可以直接由操作系统来管理内存。那么,假如我们、像foo一样,什么也不做就结束了,那么退出作用域的时候会是什么过程呢?

  1. 调用PointerWrapper实现的Drop trait的drop函数
  2. 调用Box实现的Drop trait的drop函数
  3. 调用MyStruct实现的Drop trait的drop函数,什么也不做,直接返回
  4. Box实现的Drop trait的drop函数将之前申请的堆上的空间释放
  5. 调用MyStruct实现的Drop trait的drop函数,什么也不做,直接返回
  6. PointerWrapper实现的Drop trait的drop函数什么也不做,直接返回
  7. 栈回缩,清空

由此可见,Box就是我们之前提到的RAII模式的一个实践。

这种方案看上去实现起来很简单嘛!那为什么之前的几种语言不采用呢?这不是易如反掌吗?这种方案看上去当然简单,但如果只是单纯用这种方案,就会产生麻烦的情况。我们在这个例子中,foo只是创建了这个变量,什么也不做。那么,如果foo将这个变量赋值给了别的变量呢?如果是传递给别的线程,别的线程结束之前这个函数已经结束了呢?

我们一个一个来看,如果赋值给了别的变量,那么,如果在C++中,会是什么情况呢?

class MyStruct { };
class MyStruct2 {
public:
	MyStruct *my_struct;
};
void foo(MyStruct2 *a, MyStruct2 *b, MyStruct2 *c) {
	MyStruct my_struct;
	a->my_struct = &my_struct;
	b->my_struct = &my_struct;
	c->my_struct = &my_struct;
}

这种方案是绝对错误的,因为一旦栈释放了,那么my_struct的地址就无效了,那么a, b, cmy_struct字段都会有非法的引用了。

即使my_struct通过new在堆上创建,那么究竟是a来释放,还是b,抑或是c呢?反正我知道,肯定不是foo.

如果传递给了别的线程,那么效果更简单,在栈上创建的变量在函数结束的时候自动释放,别的线程也就有了一个非法引用,就会产生谁也不知道怎样的行为。

这一切,都是因为RAII模式中,把堆上变量的释放权交给了在栈上的变量,而栈上变量的释放权却是由操作系统决定的,就会产生一些意外的后果,所以别的语言都不会采用这种模式。

而Rust则是通过了所有权来完成这种管理。Rust的思想很简单,不管是在栈上还是在堆上,它始终是个变量,那么给变量规定一个主人就好了。主人负责它的空间申请,也负责它的空间释放。这种所有权通过转交,可以在函数、线程之间传递,可以很妙地解决这个问题。

具体而言,Rust规定每个变量都有它的主人,比如说:

fn bar1(a: &A) { }
fn bar2(a: A) { }

fn foo() {
    let a = A::new();
    let b = &a;
    let c = a;
    bar1(c);
    bar2(c);
}

foo的第一行,栈上出现了一个A的对象,这个对象可能还持有堆上的一部分区域,它的主人是a。我们可以通过访问a来访问这块内存区域。通过第二行,这块内存区域的主人并没有变,仍然是a,但是b持有对这块内存区域的引用。但b出作用域的时候,并不会导致这个内存区域的释放。通过第三行,a将所有权转移给c,所以我们可以通过c访问这块内存区域了,但是通过a来访问就会在编译器发生错误。而bar1接受的是A类型的引用,所以在bar1的作用域内,即使a出作用域,也不会释放之前那块内存区域。bar2则是直接接收A类型的值,也就是说,通过bar2(c)这一行,c又将所有权转移给了bar2a,当bar2a出作用域时,这块内存终于得到了释放。

通过所有权机制,Rust很巧妙地在保证了RAII模式的同时,解决了实际编程开发中的一些问题。但是,有时候还是会需要多重所有权的,所以Rust也有Rc, Arc这些引用计数的类型,但本质上对内存的管理,还是让人轻松了很多的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值