高级语言内存管理那些事:C++、Go 与 Rust 的对决

2024年4月21日

高级语言内存管理那些事:C++、Go 与 Rust 的对决

高级语言C++,Go,和Rust,是三种典型不同的内存管理风格的语言,它的风格也间接展示高级语言的发展历程,其中重要一项就是内存管理的变化。

一、前言:为什么内存管理很重要

内存管理是高级编程语言的基石,它影响程序的性能,安全和稳定。

  • 性能问题:编制不良的程序会申请大量的内存,但是却很少使用,导致实际使用的内存利用率很低,这样的程序会导致性能问题。
  • 安全问题:安全性问题主要是申请的内存没有恰当释放引起的,然后引用它的指针被其他值覆盖,导致它指向的内存,以后永远不会被访问,这种问题较内存泄露,内存泄露的内存区域可以被插入恶意代码用以攻击,从而导致安全性问题。
  • 稳定问题:高级语言中惯用变量(指针变量)来引用一块内存空间,如果这块内存空间已经释放,但是引用它的变量还继续引用去使用,将会触发内存管理异常,这属于使用悬空指针的现象。

这三个问题是高级语言发展中,遇到过的问题。

对于C++,Go,和Rust语言,三种语言使用不同的内存管理策略:

  • 手动管理:C++使用手动管理,开发者需要更高的技能去做这一项事务。
  • 垃圾回收:Go语言的内存申请和回收是由它的运行时(Runtime)调度进行的,开发者不需要处理内存管理的事项,但是它的运行时调度器在做内存回收事项,所以也会耗费一些资源。
  • 所有权系统:Rust的方式别具一格,它使用编译期检查机制,使内存问题从代码层杜绝,但是它引入了更高级别的抽象机制,学习曲线较陡一些。

二、C++:手动与智能指针的艺术

C++语言是一门古老的语言,一直至今还是一个目前至关重要的语言,但是C++也在不断地发展,有新的版本更迭,所以对于C++来说,可以把它看做传统手动管理方式和现代管理方式。

传统手动管理内存

这种方式其实就是C语言的风格,只不过C++中通常用newdelete关键字来申请和释放内存(类似于C直接调用内存管理函数)。

可见,在这种方式下,每处申请内存new获取的对象,在哪里释放delete,要保证能够被释放,并且不能被重复释放,所以编程上要特别注意这些。但是程序往往是复杂的,这种手动方式,会导致非常容易出错,心智负担也非常的重。

例如如下:

cpp
int* alloc_int() {
  Int* i = new Int(); // 直接堆中开辟内存
  assert(i != NULL);
  return &(i->integer); // 引用对象元素地址,i成为悬空指针
}

int n = *alloc_int();
// 可见在这里,alloc_int()函数中内存申请的Int类型的对象,是无法被释放了,函数虽然返回了一个指针,但是仅仅是对象元素的指针,智能用于获取元素值

int* p = alloc_int();
// 如果直接获取元素指针
delete p; // 程序出现异常
// 这里的p指向的仅仅是对象元素的地址,是不可释放的,可以释放的是对象的指针,但是对象指针没有返回

// 这里再定义一个函数
Int* alloc_int() {
  Int* i = new Int(); // 直接堆中开辟内存
  assert(i != NULL);
  return i; // 对象指针
}

Int* p = alloc_int();
// 如果申请了,用完不释放p,是有问题的
delete p; // 这样释放就没有问题了

这样导致程序员要自己做到newdelete的一一对应,如果程序流程复杂,那么就需要去分析这些流程走向了,检查在不同分支流程上,释放都能满足一一对应的关系。

现代管理内存

现代C++内存管理起于C++0x标准,很多现代C++也是基于这个标准的,当然如果要用更现代的特性,那么就要基于更现代的标准了。现代C++引入了一个重要概念:智能指针,它实际上是利用RAII(Resource Acquisition Is Initialization)机制。

下面分别介绍三大智能指针:

  • unique_ptr:这是独占所有权的智能指针。
  • shared_ptr:这是共享所有权的智能指针,对象内部引入计数管理生命周期。
  • weak_ptr:这是观察者智能指针,主要用于解决循环引用的问题。

C++的指针引入了归属者抽象概念,通过归属者来管理内存,从而避免内存泄露和不当释放的问题,大大缓解了手动管理内存的痛点。这种方式提供了语言级别的高度灵活性,但是需要开发人员具备丰富经验和严谨态度。

以下示例三种智能指针的使用,首先演示unique智能指针和shared共享智能指针:

cpp
class Test {
public:
  void doSomething() {}
};

// 演示unique智能指针
void testUnique() {
  // 创建一个unique_ptr智能指针
  std::unique_ptr<Test> uPtr1 = std::make_unique<Test>();

  // 这里可以像普通指针一样使用智能指针
  uPtr1->doSomething();

  // 因为unique智能指针是独占的,只能进行所有者转移操作
  std::unique_ptr<Test> uPtr2 = std::move(uPtr1);

  // uPtr1此时已经不再有效了
  assert(uPtr1 == nullptr);

  // 和上次调用相同
  uPtr2->doSomething();
}

// 演示shared智能指针
void testShared() {
  // 创建一个shared_ptr智能指针
  std::shared_ptr<Test> sPtr1 = std::make_shared<Test>();
  
  // 像普通指针一样使用
  sPtr1->doSomething();
  
  // 复制指针,引用计数加1
  std::shared_ptr<Test> sPtr2 = sPtr1;
  
  // 这里再次调用这个函数,如果在函数里面更新了对象状态,那么它也会到sPtr1的,因为是共享的对象
  sPtr2->doSomething();
  
  // 作用域示例
  {
    // 再复制一次
    std::shared_ptr<Test> sPtr3 = sPtr1;
    
    // 使用共享对象
    sPtr3->doSomething();
    
    // 退出作用域后,sPtr3指向的对象引用计数会减少1
  }
  
  // 退出作用域,sPtr2, sPtr1也会触发计数减1,当计数变为0时,对象就会自动释放
}

以上也简单演示了shared共享智能指针,下面看一看复杂情况下的shared共享智能指针,而这时weak智能指针就要发挥它的作用了。

对于weak指针的作用,主要是用于解决在shared共享智能指针中遇到的问题,示例如下:

cpp
class T2;

class T1 {
public:
  std::shared_ptr<T2> sPtr;
};

class T2 {
public:
  std::shared_ptr<T1> sPtr;
};

// 演示weak智能指针
void testWeak1() {
  // weak指针是用于解决shared指针的问题的
  
  // 创建两个共享智能指针
  std::shared_ptr<T1> sPtr1 = std::make_shared<T1>(); // 计数1
  std::shared_ptr<T2> sPtr2 = std::make_shared<T2>(); // 计数1
  
  // 让他们互相引用
  sPtr1->sPtr = sPtr2; // 计数2
  sPtr2->sPtr = sPtr1; // 计数2
  
  // 运行到这里退出函数,将导致内存泄露,因为sPtr1和sPtr2的引用计数全部是1,标记为对象还在被使用
  // 整体释放流程如下:
  // 栈中的sPtr1创建 (1) -> 被sPtr2引用 (2) -> 栈中的sPtr1析构 (1)
  // 栈中的sPtr2创建 (1) -> 被sPtr1引用 (2) -> 栈中的sPtr2析构 (1)
}


// 下面是一个修正版本,演示weak智能指针使用
class T3 {
public:
  std::shared_ptr<T3> sPtr;
};

class T4 {
public:
  std::weak_ptr<T3> sPtr; // 引用但不计数
};

void testWeak2() {
  // 创建两个共享智能指针
  std::shared_ptr<T3> sPtr1 = std::make_shared<T3>();
  std::shared_ptr<T4> sPtr2 = std::make_shared<T4>();
  
  // 让他们互相引用
  sPtr1->sPtr = sPtr2; // 正常计数
  sPtr2->sPtr = sPtr1; // weak智能指针,不增加计数
  
  // 运行到这里退出函数,所以内存都会释放
  // 整体释放流程如下:
  // 栈中的sPtr1创建 (1) -> 被sPtr2观察,不引用 (1) -> 栈中的sPtr1析构 (0)
  // 栈中的sPtr2创建 (1) -> 被sPtr1引用 (2) -> sPtr1析构销毁 (1) -> 栈中的sPtr2析构 (0)
}

weak智能指针对象访问问题

因为weak智能指针的对象是不能保证对象释放可以访问(没有被释放),通常需要结合lock()函数来使用,如下:

cpp
if (auto sp = sPtr1->sPtr.lock()) {
    // sp 是一个 shared_ptr<T2>
    // 可以安全使用
} else {
    // 说明 T2 已经被销毁
}

可以看出C++的智能指针虽然缓解了手动内存的管理问题,但是引入的拥有者管理生命周期,尤其涉及复杂场景的,例如循环引用,似乎还是比较麻烦的,需要在代码层面"别出心裁"地进行设计和编码。

三、Go:GC 垃圾回收,程序员的福音?

Go的内存管理核心是自动垃圾回收(Garbage Collection),主要通过调度运行时定期扫描,对于不再引用的内存块,进行自动回收。

类似的GC垃圾回收机制,在其他高级语言也有,例如Java和Python,这种存在运行时的状态虚拟机的,基本都是通过GC来管理内存。

对于GC内存管理的编程方式,开发者对于内存管理的心智负担较小,几乎所有的内存都可以被GC很好地管理,开发者只需要专注于业务逻辑即可。但是GC也有它的不好的地方,首先是它的性能开销,每次的GC调度都会消耗CPU资源,对象越多这种开销也会随之变大,而且GC的调度是随机的,如果程序对于实时性敏感,那么对于Go来说,那就是不适应的(例如游戏引擎就不适合Go去开发)。

下面举例说明这些问题:

go
// 这个函数每隔一秒打印游戏引擎的状态
func logEngineEveryPeriod() {
  for {
    fmt.Printf("[%v] engine status: %v", time.Now(), engine.GetStatus())
    time.Sleep(time.Millisecond * 100)
  }
}

// 这个函数分配大量内存
func allocImage() *Image {
  // ...
}

// 演示函数
func testGo() {
  // 协程后台异步运行
  go logEngineEverySecond()
  
  for sp := range sprites {
    img := allocImage()
    sp.FitImage(img)
  }
}

上面的代码,首先有一个明显的问题,那就是logEngineEveryPeriod()无法完成它的任务,因为内存的分配管理,会导致GC花费的时间变长,它的日志打印会变得延迟,这样就无法保证它一定每隔100毫秒。

最近版本Go对于GC导致延迟问题,进行了很多优化,目前从表现来看,已经非常出色了,只要不是对延迟非常敏感的任务,基本上都是可以胜任的。

除了GC会导致延迟问题,Go内存管理看起来就是完美的吗?答案并非如此。

Go语言仍然存在内存泄露,只不过它的表现形式不一样罢了,主要分为两种:

  1. 全局对象持有引用导致的内存泄露

    这种类型最常见,其本质是:一个生命周期很长(甚至贯穿整个程序)的对象,意外地引用了一个生命周期本应很短的对象。GC 在扫描时,发现这个短期对象仍然被长期对象引用,便认为它“活”着,从而无法回收。

    示例如下:

    go
    // 一个全局缓存,模拟内存泄露
    var cache = make(map[int][]byte)
    
    // 申请一块内存
    func getData(id int) []byte {
    	// 每次调用,都向缓存中添加一个大的字节切片
    	data := make([]byte, 1024*1024) // 1MB
    	cache[id] = data
    	return data
    }
    
    // 演示函数
    func demoGlobalRef() {
      // 迭代调用,内存分配使用
      for item := range GetAllItems() {
        // 申请一块内存
        mem := getData(item.GetIndex())
        // 使用内存,并打印结果
        item.UseMemory(mem)
        item.PrintResult()
      }
    }
    

    在上面的demoGlobalRef函数中,随着item不停地迭代调用,被公共cache引用的逐渐增多,导致内存不停上涨,一直到最后,内存被打爆,服务终止退出。

  2. 不当使用 slice 导致的内存保留

    这是另一个微妙但常见的内存泄露场景。当从一个大的 slice 中截取(slice)出一个小的 slice 时,如果原始的大 slice 仍然在内存中,那么即使小的 slice 不再被引用,它所依赖的底层数组也不会被 GC 回收。

    示例代码如下:

    go
    // 返回一个大的 slice
    func createBigSlice() []byte {
    	return make([]byte, 1024*1024*10) // 10MB
    }
    
    // 错误的方式:从大 slice 中截取
    func getFirstNBytesWrong() []byte {
    	bigSlice := createBigSlice()
    	// 返回一个小的 slice,但其底层数组仍是 bigSlice 的大数组
    	return bigSlice[:1024]
    }
    
    // 正确的方式:复制一份数据
    func getFirstNBytesCorrect() []byte {
    	bigSlice := createBigSlice()
    	// 创建一个新的、小 size 的切片
    	smallSlice := make([]byte, 1024)
    	// 将数据复制过去,让 bigSlice 不再被引用
    	copy(smallSlice, bigSlice)
    	return smallSlice
    }
    
    // 演示函数
    func demoSlice() {
      // 这里获取的slice是有内存泄露的
      leakedSlice := getFirstNBytesWrong()
      
      // 这里的slice是没问题的
      correctSlice := getFirstNBytesCorrect()
    }
    

    上述中,可以看出如果直接从大数组中去子集切片来返回,那么这个大数组还是被引用的,是不会被GC回收的,如果要提高内存使用效率,减少内存开支,就有必要新建小数组切片,然后使用copy复制需要的内容到小数组切片中,然后作为返回值,这样大数组就不会被引用,就可以被GC回收了。

四、Rust:所有权系统,内存安全的终极答案

可以看出C++的智能指针方式,需要开发者务必小心谨慎,这也代理了许多的心理负担,Go的负担一下子就没了,但是它依赖GC,也就附带GC而来的缺陷,此外也要注意变量引用问题,否则也会导致内存不能释放的问题,看起来似乎没有完美的方案。

其实是有完美的解决方案的,那就是Rust的内存管理方式。Rust的核心思想是编译期检查零运行时开销,它引入了三大规则:

  1. 所有权 (Ownership): 每个值都有一个所有者。
  2. 移动 (Move): 所有者离开作用域,值被销毁。
  3. 借用 (Borrowing): 可以通过可变借用不可变借用来访问数据,但不能同时存在。

它这套方式算是借鉴了C++的智能指针的核心设计思想,但是同时它把其推向了更加极致的境界。可以看见C++的智能指针是一种事后补救措施(历史原因),而Rust是事前预防的措施,它将这种所有权设计成强制性,并推广到所有的类型,从根本杜绝问题。

以下代码示例它的这种内存管理机制:

rust
// 一个简单的函数,展示所有权转移
fn take_ownership(some_string: String) { // some_string 获取了所有权
    println!("I own this string: {}", some_string);
} // some_string 离开作用域,其内存被自动释放

// 一个函数,展示借用
fn borrow_and_print(some_string: &String) { // some_string 是一个不可变借用
    println!("I'm borrowing this string: {}", some_string);
} // some_string 离开作用域,借用结束

// 一个函数,展示可变借用
fn borrow_and_change(some_string: &mut String) { // some_string 是一个可变借用
    some_string.push_str(" and I was changed!");
} // some_string 离开作用域,借用结束

// 演示函数
fn demo_rust_mem() {
  // --- 1. 所有权示例 ---
  let s1 = String::from("hello world"); // s1 拥有 "hello world" 的所有权
  
  let s2 = s1; // 所有权从 s1 移动(move)到 s2
  // 在这一点之后,s1 不再有效!编译器会报错
  // println!("s1 is: {}", s1); // 编译错误!value borrowed here after move
  println!("s2 is: {}", s2);
  
  // 调用函数,所有权被转移
  let s3 = String::from("take me away");
  take_ownership(s3);
  // println!("s3 is: {}", s3); // 编译错误!s3 的所有权已被转移
  
  // --- 2. 借用示例 ---
  println!("\n--- 借用示例 ---");
  let s4 = String::from("this is a borrowed string");
  borrow_and_print(&s4); // s4 的借用被传递
  println!("s4 is still valid after borrowing: {}", s4); // 借用结束后,s4 仍然有效

  // --- 3. 可变借用示例 ---
  println!("\n--- 可变借用示例 ---");
  let mut s5 = String::from("I am mutable");
  borrow_and_change(&mut s5);
  println!("s5 has been changed: {}", s5);
  
  // --- 4. 借用冲突示例 ---
  println!("\n--- 借用冲突示例 ---");
  let mut s6 = String::from("I have multiple references");

  // 允许多个不可变借用
  let r1 = &s6;
  let r2 = &s6;
  println!("{} and {}", r1, r2);
  
  // 但是,在不可变借用之后,不允许可变借用
  // let r3 = &mut s6; // 编译错误!cannot borrow as mutable...

  // --- 5. 悬空指针(编译器会阻止) ---
  // 下面的函数无法编译,因为它会返回一个悬空引用
  // fn dangle() -> &String {
  //     let s = String::from("dangling"); // s 在这里被创建
  //     &s // 返回 s 的引用
  // } // s 在这里离开作用域并被销毁,其引用变成悬空指针!
  // 编译器会报错:`s` does not live long enough
}

可以看出,Rust通过这种所有权机制管理内存,在编译阶段就把可能引发内存问题的逻辑给杜绝了,保障了内存的安全性,同时这种方式不依赖GC,效率更高。

五、综合对比与选择指南

结合三种语言的内存管理机制来比较。C++适用于对性能有极致的要求,另外如果要对底层硬件的控制,C++语言是必要的,但是使用C++就会引入复杂的系统编程。对于Go来说,适用于时间不敏感,例如高并发网络服务,后端服务开发,以及快速迭代的业务系统,它相比于C++和Rust来说,语法更加容易入手,相对简单。但是如果要开发的系统对于内存安全性有很高要求,同时又要保证很高的性能,那么Rust将是一个不错的选项,例如证券交易类系统,还有嵌入式系统,以及操作系统都是可以适用的。

总的来说,不同语言有不同的内存管理方案,没有最好的方案,只有最适合的特定项目的方案。不过随着行业发展,内存安全是编程语言的重要发展方向,Rust的所有权模式将是一个新颖的思路。

© 2013 – 2025 陈祥