一、那年双十一: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_ptr、unique_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 是共享数据,几百个请求同时读写它,会发生什么?"
小陈倒吸一口凉气:"数据竞争……超卖!"
"对。那是下一章——并发与异步。这次,我给你看一个真实的超卖事故。"