Skip to content

Stage07|Modern C++ Foundation ​

Lesson084|RAII:用对象生命周期管理资源 ​

Tags: #C++17 #RAII #Resource #ExceptionSafetyDifficulty: ⭐⭐⭐⭐☆


一、问题场景:中途 return 后谁来解锁 ​

cpp
mutex.lock();
if (!ready()) return; // 忘记 unlock
doWork();
mutex.unlock();

资源泄漏不只指内存。锁、文件、Socket、GPU 句柄、临时状态都需要在每条退出路径上恢复。RAII 把资源释放绑定到对象析构,使正常返回和异常路径使用同一套清理机制。

二、本课目标 ​

  1. 理解 RAII 的获取、持有和释放。
  2. 使用标准库管理内存、文件和锁。
  3. 为 C API 句柄设计自定义 deleter。
  4. 理解异常安全的基本保证。
  5. 识别析构函数的责任边界。

三、回到 TaskSystem:谁负责锁和线程 ​

TaskSystem 会持有 mutex、条件变量、线程和可能的文件/Native 句柄。任何 return、异常或构造失败都不能遗留这些资源。RAII 让 owner 的析构负责释放,但析构之前仍要定义停止顺序:先禁止新任务,再唤醒 worker,最后 join。

四、RAII 不是“智能指针技巧” ​

text
构造成功 → 对象拥有资源
对象可用 → 资源可用
离开作用域 → 析构释放资源

标准库例子:

cpp
std::vector<int> buffer(1024);     // 管动态内存
std::ifstream input("config.json"); // 管文件
std::lock_guard<std::mutex> lock(m); // 管锁

它们让资源状态服从词法作用域,不必依赖调用者记住某个 cleanup()。

五、所有退出路径自动清理 ​

cpp
bool save(const std::string& path) {
    std::ofstream out(path, std::ios::binary);
    if (!out) return false;
    out.write("OK", 2);
    return out.good();
}

无论在哪个 return 离开,out 都会析构并关闭文件。注意“关闭”不等于“写入一定成功”,仍要检查错误状态。

六、包装 C 句柄 ​

cpp
#include <cstdio>
#include <memory>

struct FileCloser {
    void operator()(std::FILE* file) const noexcept {
        if (file) std::fclose(file);
    }
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr openFile(const char* path) {
    return FilePtr(std::fopen(path, "rb"));
}

类型本身表达“这个指针退出作用域时应调用 fclose”,避免把 delete 用在错误资源上。

七、异常安全 ​

常见保证:

text
不抛异常保证:操作承诺不抛
强保证:失败后对象保持调用前状态
基本保证:失败后对象仍有效、没有资源泄漏
无保证:失败可能破坏状态

析构函数通常应 noexcept,不能在栈展开期间再次抛异常。可能失败的显式提交动作,例如网络 flush,应提供可检查的 commit(),而不是把所有错误藏到析构中。

八、可编译验证:作用域锁 ​

cpp
#include <iostream>
#include <mutex>

std::mutex mutex;

void update(bool skip) {
    std::lock_guard<std::mutex> guard(mutex);
    std::cout << "locked\n";
    if (skip) return;
    std::cout << "updated\n";
}

int main() {
    update(true);
    update(false);
}

两次调用都不会死锁,因为 guard 在每次函数退出时解锁。然后把它改为手工 lock/unlock,复现早退导致的阻塞。

九、TaskSystem 的停止顺序 ​

析构时不能先销毁队列或 mutex,再让 worker 继续运行;也不能只设置标志而不唤醒等待线程。正确顺序是状态转换、通知、worker 取完允许的任务、join、最后释放共享字段。

十、Cocos/Native 连接 ​

原生扩展常持有纹理、文件、线程任务或平台句柄。应让 C++ wrapper 拥有和释放资源,再通过 JSB 暴露安全操作;不要让 JavaScript 猜测应该调用哪一种平台释放函数。跨线程资源还要规定在哪个线程析构。

十一、诊断清单 ​

text
资源由哪个对象拥有?
构造失败是否泄漏已获取资源?
移动对象后所有权是否唯一?
析构需要在哪个线程执行?
释放函数是否和创建函数匹配?
显式 close 失败如何报告?

十二、练习与答案 ​

1. RAII 为什么能处理异常? ​

异常展开会析构已经完整构造的局部对象,因此资源 wrapper 的析构仍会执行。

2. 为什么不能只依赖 GC 管原生资源? ​

GC 回收时机不确定,且不了解文件、锁、GPU 句柄的线程和释放语义。

3. 哪些资源不适合只在析构中报告错误? ​

需要业务确认结果的提交型操作,例如文件落盘或网络发送,应有显式方法返回错误,析构只兜底释放。

十三、本课总结 ​

RAII 的核心是把“资源可用期”变成“对象生命周期”。它减少清理分支,也是智能指针、容器、锁和可靠 C++ 接口的共同基础。