如何在 C++20 中实现异步文件读取?

在 C++20 里,协程(coroutine)已正式成为标准的一部分。它们为我们提供了在不使用线程或回调的情况下实现异步 I/O 的方式。本文将演示如何使用 std::asyncstd::futurestd::filesystemstd::fstream 以及 C++20 协程相关的 co_awaitco_return 来实现一个简单的异步文件读取框架,并讨论其优势与局限。

1. 协程基础回顾

协程由以下核心组成:

关键字 作用
co_await 暂停协程并等待一个 awaitable 对象完成
co_yield 产生一个值并暂停协程,等待下次 co_await
co_return 结束协程并返回最终值
co_resume 触发协程继续执行(内部由调度器管理)

标准库提供了 std::futurestd::promise 等类,并配合 std::async 可以直接返回 std::future 对象,用作最简易的异步执行。

2. 异步文件读取的基本需求

  • 非阻塞:读取文件时不阻塞主线程,允许继续处理其他任务。
  • 流式读取:对于大文件,避免一次性将全部内容读入内存。
  • 可组合:能够与其他协程链式调用,形成清晰的异步流程。

3. 设计一个 async_read_file 协程

下面的实现将演示如何:

  1. 使用 std::filesystem 获取文件大小;
  2. 通过 std::ifstream 以块方式读取文件;
  3. 通过 co_await 暂停直到块读取完成;
  4. 将每块数据返回给调用者。
#include <iostream>
#include <fstream>
#include <filesystem>
#include <vector>
#include <coroutine>
#include <future>
#include <thread>

namespace fs = std::filesystem;

// 1. Awaitable 结构体,用于包装异步读取块
template <typename T>
struct Awaitable {
    std::future <T> fut;
    Awaitable(std::future <T>&& f) : fut(std::move(f)) {}

    bool await_ready() const noexcept { return fut.wait_for(std::chrono::seconds(0)) == std::future_status::ready; }
    void await_suspend(std::coroutine_handle<> h) noexcept {
        // 在后台线程里完成读取
        std::thread([f = std::move(fut), h]() mutable {
            f.wait();
            h.resume();
        }).detach();
    }
    T await_resume() { return fut.get(); }
};

// 2. 读取块的异步函数
std::future<std::vector<char>> async_read_block(std::ifstream& in, std::size_t block_size) {
    return std::async(std::launch::async, [&in, block_size]() -> std::vector <char> {
        std::vector <char> buf(block_size);
        in.read(buf.data(), static_cast<std::streamsize>(block_size));
        buf.resize(in.gcount()); // 调整实际读取长度
        return buf;
    });
}

// 3. 协程包装器
struct AsyncFileReader {
    struct promise_type {
        AsyncFileReader get_return_object() {
            return AsyncFileReader{ std::coroutine_handle <promise_type>::from_promise(*this) };
        }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    std::coroutine_handle <promise_type> coro;
    explicit AsyncFileReader(std::coroutine_handle <promise_type> h) : coro(h) {}
    ~AsyncFileReader() { if (coro) coro.destroy(); }
};

// 4. 主协程函数
AsyncFileReader async_read_file(const fs::path& file_path, std::size_t block_size = 8192) {
    std::ifstream file(file_path, std::ios::binary);
    if (!file) throw std::runtime_error("Cannot open file.");

    while (file) {
        // 异步读取块
        auto fut = async_read_block(file, block_size);
        Awaitable<std::vector<char>> awaiter(std::move(fut));
        std::vector <char> chunk = co_await awaiter;
        if (chunk.empty()) break;

        // 这里可以把块送到下游或直接输出
        std::cout.write(chunk.data(), static_cast<std::streamsize>(chunk.size()));
    }
}

代码说明

  • Awaitable:包装 std::future,在 await_suspend 时在后台线程完成等待,随后恢复协程。这样主线程不会被阻塞。
  • async_read_block:使用 std::async 在后台线程读取指定大小的数据块。
  • AsyncFileReader:定义一个可协程对象的包装器。实际的文件读取逻辑放在 async_read_file 协程里,利用 co_await 让读取变得异步。

4. 如何使用

int main() {
    try {
        async_read_file("bigfile.dat");
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
    }
    // 由于协程是异步执行,主线程需要保持活跃或加入同步点
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

注意:上例中我们在 main 里使用 sleep_for 保证后台线程有足够时间完成。真实项目中建议使用事件循环或同步机制来终止程序。

5. 与传统 std::async 的比较

特点 传统 std::async C++20 协程 + Awaitable
可读性 难以串联多步异步 代码更像同步,易读
堆栈占用 每个线程占 2-4 MB 协程共享调用栈,低占用
错误传播 通过 future.get() 抛异常 直接抛异常,易捕获
资源管理 需要手动 join / detach 自动销毁 coroutine handle
性能 每次 spawn 线程开销 轻量级状态机,几乎无开销

6. 局限与未来展望

  • 线程池:当前实现使用 std::async 直接 spawn 线程。实际应用中建议使用线程池来复用线程。
  • 文件系统异步:标准库仅提供同步 I/O。若需要真正的 OS 异步 I/O(如 Linux 的 aio),需结合平台特定 API。
  • 错误处理:在协程链中若出现异常,必须确保上层可以捕获并处理。可借助 co_await 的异常传播机制实现。
  • 调度器:更复杂的异步框架会自定义调度器,决定何时 resume 协程。此处我们使用简易 std::thread

未来的 C++ 标准会进一步完善协程特性,例如引入 std::generatorstd::task 等,使异步编程更易上手。对开发者而言,掌握协程基本概念并结合现有异步 I/O 库(如 Boost.Asio、cppcoro 等)将是实现高性能网络或文件 I/O 的关键。


结语:通过上述示例,我们可以看到 C++20 协程提供了一种既简洁又高效的方式来实现异步文件读取。虽然仍有细节需要完善(如线程池、真正的 OS 异步 I/O),但它已足以在多数业务场景中替代传统回调或线程池模型,显著提升代码可维护性与运行效率。祝你编码愉快!

发表评论