第四章

稳健:内存管理与错误处理

第六层 + 第七层 · 内存管理/错误处理 2005 年双十一,凌晨三点的内存泄漏事故

一、那年双十一:malloc 之后忘了 free

茶泡好了。老周靠在仓库的折叠椅上,开始讲他 2005 年的事。

"那年我在一家电商公司写 C 语言。双十一大促,凌晨零点开始,流量是平时的二十倍。凌晨三点,服务器全部宕机。"

"我冲进机房,屏幕上全是同一个画面——内存占用曲线像火箭一样往上蹿,然后 Out of memory,进程被系统杀掉。"

小陈:"是……内存泄漏?"

"对。"老周在纸上写了四行字:

struct Order *o = (struct Order *)malloc(sizeof(struct Order));  // 申请一块内存
// ……处理订单……
// 忘了 free(o)  ← 这块内存永远还不了了

"malloc 是 C 语言'给我一块内存',free 是'还回去'。我漏写了一个 free——不是一行代码的问题,是每一笔订单都会漏一小块内存。大促每秒几百笔订单,几个小时就把服务器 16G 内存全漏光了。"

"从那以后我明白了两件事:第一,让人类手动管理内存,早晚出事;第二,出错时程序不能默默崩溃,必须把问题喊出来。这就是本章的两个主题:内存管理、错误处理。"


二、第六层:内存管理

2.1 手动管理(malloc/free、new/delete)

"刚才就是手动管理。C 语言 malloc/free,C++ 的 new/delete。优点是极致控制——系统编程(操作系统、数据库、游戏引擎)需要精确知道每块内存何时分配何时释放;缺点是——"

"容易忘!"小陈抢答。

"不止忘,还有两类经典事故:"

// 事故1:悬空指针(dangling pointer)—— 内存已经还了,指针还握着地址
free(o);
printf("%s", o->name);   // 访问已释放的内存 → 未定义行为,可能崩溃

// 事故2:双重释放(double free)—— 同一块内存还了两次
free(o);
// ……o 又被 free 了一次……
free(o);                 // 崩溃:这块内存可能已被分配给别的对象

"手动管理的世界里,程序员就像一边开车一边修引擎——不是不能开,是太容易出事故。于是大家开始想:能不能让程序自己管理内存?"

2.2 自动管理:垃圾回收(GC)

"Java、Python、Go 走的是垃圾回收(GC, Garbage Collection)路线。"老周说,"程序员只管 new,不用管 delete。运行时有个'环卫工人',定期扫一圈:没人再引用的对象,就是垃圾,收走。"

def make_order():
    order = Order(...)   # 创建订单对象
    return order         # 用完之后……不用管它
# 函数结束了,没有变量再引用 order → GC 下次清扫时自动回收

"好处:不用手动释放,悬空指针基本绝迹。代价:GC 要定期'停下世界'扫内存,有停顿(STW, Stop The World);而且你没法精确控制'它什么时候被收走'。对大部分业务系统,这个代价完全值得。"

2.3 所有权(Rust):编译期保证,不用 GC

"但 Rust 不服:GC 有停顿,手动管理有风险,我要两条路都不走。"老周说,"Rust 的绝活是所有权(ownership):每个值只有一个'主人',主人离开作用域,值自动释放——不用 GC,也不用你手动 free,编译器在编译期就检查好了。"

fn main() {
    let s = String::from("三体");  // s 拥有这块内存
    println!("{}", s);
}                                  // 作用域结束,s 被自动释放
// 想转移所有权?
fn consume(s: String) { ... }
let s2 = String::from("活着");
consume(s2);                       // 所有权移进函数
// println!("{}", s2);             // 编译错误!s2 的所有权已经没了

"看,s2 移交给函数之后再用,编译期直接报错——问题在写代码时就被抓住了,根本跑不到运行时。"

2.4 智能指针(C++):半自动,RAII

"C++ 没有 Rust 的编译器检查,但提供了智能指针(smart pointer)shared_ptrunique_ptr。"

#include <memory>

// unique_ptr:独占所有权,离开作用域自动 delete
std::unique_ptr<Order> o = std::make_unique<Order>();
// 不需要手动 delete —— o 消亡时自动释放

// shared_ptr:共享所有权,引用计数归零才释放
std::shared_ptr<Product> p1 = std::make_shared<Product>(...);
std::shared_ptr<Product> p2 = p1;   // 计数变 2
// p1、p2 都消亡后,计数归 0,自动释放

"这叫 RAII(资源获取即初始化):把'释放内存'和'对象的生命周期'绑定——对象一死,析构函数自动把内存还掉。半自动,既有控制又防呆。"

小陈总结:"手动管理=全手动挡,GC=自动驾驶,Rust=教练坐副驾盯着你,智能指针=半自动挡?"

老周笑喷:"总结得不错!我加一句——云间书店现在用 Python,GC 帮我们挡掉了 99% 的内存事故。但'对象死前要清理'这件事,还是得靠析构方法兜底。 比如数据库连接、文件句柄:"

class FileBackup:
    def __init__(self, path):
        self.f = open(path, "w")   # 打开文件

    def __del__(self):             # 析构方法:对象被回收前调用
        self.f.close()             # 把文件关上 —— 清理工作

三、第七层:错误处理

"内存的事聊完,说第二件事:出错时怎么办。"老周说,"2005 年那次宕机,程序不是没有出错——是出错之后静悄悄地继续跑,把错误全吞了。现在我们来盘点人类发明的四种错误处理方案。"

3.1 返回值 / 错误码

"最古老的方式:函数返回一个数字,0 表示成功,非 0 表示错误码。"

int withdraw(int account, int amount) {
    if (amount > balance(account)) {
        return -1;              // 错误码:余额不足
    }
    balance(account) -= amount;
    return 0;                   // 成功
}

// 调用方必须检查
int ret = withdraw(1001, 500);
if (ret != 0) {
    printf("取款失败,错误码 %d\n", ret);
}

"优点:简单、没有额外开销。缺点:太容易被忽略——万一调用方忘了检查返回值,错误就被无声吞掉了。我 2005 年那次宕机,根源就是有人没检查错误码。"

3.2 异常(Exception)

"于是有了异常:错误发生时,程序主动扔出一个异常对象,一路往上抛,直到有 try/catch 接住它。错误处理和正常逻辑彻底分离。"

def sell_book(stock, n):
    if n > stock:
        raise OutOfStockError(f"库存不足:还剩 {stock} 本")   # 抛出异常
    return stock - n

# 调用方
try:
    remaining = sell_book(3, 5)          # 正常逻辑:卖 5 本
    print("卖出成功,剩余", remaining)
except OutOfStockError as e:             # 错误逻辑:接住异常
    print("下单失败:", e)                # 提示顾客"没货了"
finally:
    print("本次交易结束")                 # finally:无论如何都执行(关数据库、记日志)
  • try:把"可能出错"的正常逻辑包起来。
  • except:接住特定类型的异常,处理它。
  • finally:不管成功失败,最后都执行——适合做清理。
  • "栈展开(stack unwinding)":异常抛出后,会沿着调用栈一层层往上找 catch,沿途的函数局部变量依次销毁(析构方法会执行)——这就是为什么异常和析构方法总是成对出现。

"异常的问题也有:性能开销(抛出异常比返回错误码慢),容易滥用(拿异常当 goto 用),以及——某些语言里异常被吞掉时,错误比错误码更隐蔽。但总的来说,它把'出错怎么办'这个问题,第一次变成了头等公民。"

3.3 断言(Assertion)

"第三个是断言。"老周说,"它不处理运行时错误,它检查程序员的假设。"

def ship(order):
    # 发货前,我坚信:订单状态必须是"已支付",否则就是程序逻辑出 bug 了
    assert order.status == "paid", f"程序逻辑错误:订单 {order.id} 未支付就发货了!"
    # 发货……

"assert 只在开发期/测试期开启。它说的是:'我这里相信一定是这样的,如果不是,说明我(程序员)写错了,赶紧崩给我看。' 它是写给程序员自己的哨兵,不是给用户的错误提示。"

"哦,所以断言是'自检',异常是'对外'。"小陈点头。

3.4 Result / Option 类型(函数式)

"最后一种,来自函数式语言(Rust、Haskell、OCaml),这几年被广泛借鉴。"老周说,"它的理念很极端:错误不是'意外',错误是返回值的一部分——你想拿成功的结果,就必须先处理失败的可能。"

fn withdraw(balance: i32, amount: i32) -> Result<i32, String> {
    if amount > balance {
        return Err("余额不足".to_string());   // 失败也是一种"返回值"
    }
    Ok(balance - amount)                       // 成功
}

// 调用方:想拿到 i32,必须先处理 Err 分支 —— 编译器强制你处理!
match withdraw(100, 500) {
    Ok(new_balance) => println!("取款成功,余额 {new_balance}"),
    Err(msg)        => println!("取款失败:{msg}"),
}

"Rust 的 Option<T> 同理:可能有值(Some(v)),可能没有(None)。强制调用者处理错误,想忽略都编译不过去。 没有异常、没有 null——错误根本不可能被无声吞掉。"

小陈感叹:"这四种方案,是从'容易被忽略'到'想忽略都不行'的进化……"

"对。错误码→异常→断言→Result,一路在解决同一个问题:错误别被吞。"


四、章末:老周的"第六、七层"总结

白板补完:

第六层:内存管理(别让程序员手动管内存)
纯文本
内存
├── 手动管理(malloc/free、new/delete)→ 极致控制,容易出事
└── 自动管理
    ├── 垃圾回收(GC)→ 不用手动释放,有停顿
    ├── 所有权(Rust)→ 编译期保证,不用 GC
    └── 智能指针(C++)→ RAII,半自动
第七层:错误处理(出错时别默默崩溃)
纯文本
错误
├── 返回值 / 错误码 → 简单,但容易被忽略
├── 异常(Exception)→ try/catch/finally、栈展开
├── 断言(Assertion)→ 程序员的假设,开发期自检
└── Result / Option 类型 → 强制调用者处理错误

"但是小陈,"老周话锋一转,"内存也管好了,错误也会喊了——我们的系统还有个更大的隐患没解决。"

"什么隐患?"

"王姐昨天通知我:线上小程序要上'秒杀'活动,同一本书可能同时被几百个人抢。 你想想,stock 是共享数据,几百个请求同时读写它,会发生什么?"

小陈倒吸一口凉气:"数据竞争……超卖!"

"对。那是下一章——并发与异步。这次,我给你看一个真实的超卖事故。"

✌ 语言