Chuyển đến nội dung

RAII: Mẫu tài nguyên cơ bản

Tìm hiểu RAII (Thu thập tài nguyên là khởi tạo) - mẫu C++ cốt lõi để quản lý tài nguyên an toàn ngoại lệ. Triển khai trình bao bọc RAII, hiểu lock_guard vàscoped_lock, đồng thời tìm hiểu lý do tại sao các loại RAII phải ở chế độ chỉ di chuyển hoặc được tính lại.

RAII: Nhận trong Constructor, Phát hành trong Destructor

RAII (Khởi tạo thu thập tài nguyên) là mẫu quan trọng nhất trong C++. Ý tưởng rất đơn giản:

1. Nhận tài nguyên trong hàm tạo (mở tệp, khóa mutex, phân bổ bộ nhớ, kết nối với cơ sở dữ liệu).
2. Giải phóng tài nguyên trong hàm hủy (đóng tệp, mở khóa mutex, giải phóng bộ nhớ, ngắt kết nối).

Bởi vì C++ đảm bảo rằng các hàm hủy sẽ được gọi khi một đối tượng nằm ngoài phạm vi — ngay cả khi một ngoại lệ được ném ra — RAII giúp việc quản lý tài nguyên tự động và an toàn với ngoại lệ. Bạn không bao giờ quên phát hành và bạn không bao giờ phát hành hai lần.

Mọi loại C++ được thiết kế tốt để quản lý tài nguyên đều sử dụng RAII:std::string,std::vector,std::unique_ptr,std::fstream,std::lock_guard,std::unique_lock.

Triển khai Trình bao bọc RAII

Đây là trình bao bọc RAII hoàn chỉnh cho bộ mô tả tệp (POSIX). Nó thu nhận trong hàm tạo, giải phóng trong hàm hủy và hỗ trợ ngữ nghĩa di chuyển để chuyển quyền sở hữu.

raii_file_descriptor.cpp
#include <unistd.h>   // open, close, read, write
#include <fcntl.h>    // O_RDONLY, etc.
#include <stdexcept>
#include <utility>
#include <iostream>

class FileDescriptor {
    int fd_ = -1;  // -1 = no resource
public:
    // Acquire: open file in constructor
    explicit FileDescriptor(const char* path, int flags)
        : fd_(::open(path, flags)) {
        if (fd_ < 0) throw std::runtime_error("Cannot open file");
    }

    // Release: close file in destructor
    ~FileDescriptor() {
        if (fd_ >= 0) ::close(fd_);
    }

    // Non-copyable (copying a file descriptor is meaningless)
    FileDescriptor(const FileDescriptor&) = delete;
    FileDescriptor& operator=(const FileDescriptor&) = delete;

    // Moveable (transfer ownership)
    FileDescriptor(FileDescriptor&& other) noexcept
        : fd_(std::exchange(other.fd_, -1)) {}

    FileDescriptor& operator=(FileDescriptor&& other) noexcept {
        if (this != &other) {
            if (fd_ >= 0) ::close(fd_);
            fd_ = std::exchange(other.fd_, -1);
        }
        return *this;
    }

    int get() const { return fd_; }
};

int main() {
    try {
        FileDescriptor fd("/tmp/raii_test.txt", O_RDONLY);
        std::cout << "File opened: fd=" << fd.get() << '\n';
        // Use fd...
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
    // fd.~FileDescriptor() called automatically — file closed
    // Even if an exception was thrown, the file is closed
}

An toàn ngoại lệ qua RAII

Nếu không có RAII, mã an toàn ngoại lệ sẽ yêu cầu các khối thử/bắt tẻ nhạt cho mọi tài nguyên. Với RAII, việc này diễn ra tự động — mỗi hàm hủy sẽ kích hoạt trong quá trình giải phóng ngăn xếp.

exception_safety.cpp
#include <memory>
#include <mutex>
#include <fstream>
#include <vector>
#include <stdexcept>

std::mutex g_mutex;

// WITHOUT RAII — manual cleanup is error-prone
void bad_example() {
    std::mutex* m = &g_mutex;
    m->lock();
    int* data = new int[100];
    // If this throws, mutex stays locked AND data leaks!
    // process(data);
    delete[] data;
    m->unlock();
}

// WITH RAII — automatic, exception-safe cleanup
void good_example() {
    std::lock_guard<std::mutex> lock(g_mutex);  // RAII lock
    auto data = std::make_unique<int[]>(100);   // RAII allocation
    // If this throws:
    //   1. data's destructor frees the memory
    //   2. lock's destructor unlocks the mutex
    //   Stack unwinding handles everything
    // process(data.get());
}

// Multiple resources — RAII composes naturally
void multi_resource() {
    std::lock_guard<std::mutex> lock(g_mutex);
    auto buffer = std::make_unique<char[]>(4096);
    std::ofstream file("/tmp/output.txt");

    // If any operation throws, ALL resources are released
    // in reverse order of construction:
    //   1. file closed
    //   2. buffer freed
    //   3. mutex unlocked
}

lock_guard, Unique_lock & scoped_lock

Thư viện chuẩn cung cấp trình bao bọc RAII cho mutexes:

- std::lock_guard — khóa RAII đơn giản nhất. Khóa trong hàm tạo, mở khóa trong hàm hủy. Không thể di chuyển, không thể sao chép. Sử dụng khi bạn cần một khóa có phạm vi đơn giản.

- std::unique_lock — khóa RAII linh hoạt. Hỗ trợ khóa chậm, khóa hẹn giờ, mở khóa/khóa lại thủ công và có thể di chuyển. Cần thiết để sử dụng vớistd::condition_variable.

- std::scoped_lock (C++17) — khóa đồng thời nhiều mutex bằng thuật toán tránh khóa chết. Đây là loại khóa ưa thích khi khóa một hoặc nhiều mutex.

std::mutex m1, m2;
// C++17: lock both without deadlock risk
std::scoped_lock lock(m1, m2);
Lỗi thường gặp

1. Các đối tượng RAII không được đặt tên sẽ bị hủy ngay lập tức:

std::lock_guard<std::mutex>(mtx); // BUG! Temporary — locks and immediately unlocks
std::lock_guard<std::mutex> lock(mtx); // Correct — named, lives until scope end

2. Never naively copy RAII types: Copying a file handle or mutex lock makes no sense. RAII types should be:
- Move-only (like unique_ptr, unique_lock) for exclusive ownership, or
- Reference-counted (like shared_ptr) for shared ownership.

3. Destructor should not throw: If a destructor throws during stack unwinding (another exception is active), std::terminate is called. Destructors are noException theo mặc định trong C++11+.

Thực hành tốt

1. Mọi tài nguyên phải thuộc sở hữu của đối tượng RAII — bộ nhớ, tệp, khóa, ổ cắm, kết nối cơ sở dữ liệu, bộ điều khiển GPU.
2. Một tài nguyên cho mỗi lớp RAII — tập trung vào các trình bao bọc.
3. Xóa các thao tác sao chép trừ khi việc sao chép tài nguyên có ý nghĩa.
4. Thực hiện các thao tác di chuyển để cho phép chuyển quyền sở hữu.
5. Sử dụngstd::exchangetrong các thao tác di chuyển — nó đọc và đặt lại nguồn một cách nguyên tử:fd_ = std::exchange(other.fd_, -1);
6. Ưu tiên các loại RAII tiêu chuẩn (unique_ptr,lock_guard,fstream) — chỉ viết trình bao bọc tùy chỉnh cho các tài nguyên không có trình bao bọc tiêu chuẩn.

Soạn các loại RAII (Quy tắc số 0)

Biểu thức cơ bản của RAII là Quy tắc số 0: nếu tất cả các biến thành viên của bạn là loại RAII thì lớp của bạn không cần không có thành viên đặc biệt nào do người dùng khai báo. Các thao tác hủy, sao chép và di chuyển do trình biên dịch tạo ra sẽ tự động thực hiện đúng.

class DatabaseSession {
    std::unique_ptr<Connection> conn_;    // RAII: auto-closed
    std::unique_lock<std::mutex> lock_;   // RAII: auto-unlocked
    std::vector<std::string> log_;        // RAII: auto-freed
    // No destructor, copy, or move needed — all member types handle it
};

Quy tắc số 0 là mục tiêu. Quy tắc Năm (xác định rõ ràng cả 5 thành viên đặc biệt) dành cho các loại trình bao bọc tài nguyên cấp thấp. Không có điểm trung gian - nếu bạn khai báo bất kỳ điều nào trong số năm điều đó, hãy khai báo tất cả chúng.

Điểm chính
  • RAII: thu thập trong hàm tạo, giải phóng trong hàm hủy - tự động và an toàn ngoại lệ
  • Hàm hủy được gọi trong quá trình giải phóng ngăn xếp, do đó RAII xử lý các ngoại lệ một cách tự nhiên
  • Các loại RAII phải ở dạng chỉ di chuyển (quyền sở hữu độc quyền) hoặc được tính tham chiếu (quyền sở hữu chung)
  • Sử dụngstd::scoped_lock(C++17) để khóa đa mutex không có bế tắc
  • Các đối tượng RAII không được đặt tên sẽ bị hủy ngay lập tức - luôn đặt tên cho chúng
  • Tuân theo Quy tắc số 0: sử dụng các thành viên RAII để trình biên dịch tạo ra các thành viên đặc biệt chính xác

Câu đố — Kiểm tra kiến thức

(15 XP)

1. Có gì sai với`std::lock_guard<std::mutex>(mtx);`(lưu ý: không có tên biến)?

2. Why should RAII types typically delete their copy operations?

3. What is the Rule of Zero?