C++异步IO库完整实现:从内核特性到业务封装的案例

本项目是为了让开发者能够轻松驾驭io_uring而设计的生产级异步IO库。通过深入研读本项目的源代码和设计思想,你将掌握高性能IO编程的精髓,理解异步IO的工作原理,并能够在自己的项目中运用。


一、io_uring:异步IO

1.1 传统IO模型的困境

在深入io_uring之前,我们先回顾一下传统IO模型的问题。

**同步阻塞IO(Blocking IO)**是最简单直观的模型。当调用read()write()时,线程会一直阻塞,直到数据准备就绪或写入完成。在单线程程序中,这意味着整个程序停滞;在多线程程序中,虽然可以通过创建更多线程来提高并发度,但每个线程的上下文切换开销(通常1-5微秒)和内存开销(每个线程的栈空间至少几MB)会迅速积累,当线程数达到数千时,系统开销将变得不可接受。

**同步非阻塞IO(Non-blocking IO)+ 多路复用(epoll/select)**是Linux服务器开发的经典方案。通过epoll_wait()监听多个文件描述符的就绪事件,单个线程可以同时处理数千个连接。然而,这个模型对于文件IO并不理想,因为:

  • 文件描述符在epoll中总是就绪的(无法真正监听磁盘IO完成)
  • 实际的read()/write()调用仍然会阻塞线程,等待数据从磁盘加载到页缓存

**POSIX异步IO(aio)**试图解决这个问题,但其实现存在诸多缺陷:

  • 不同Linux版本的行为不一致,可靠性差
  • 只支持O_DIRECT模式(绕过页缓存),无法利用内核的缓存优化
  • 性能并不理想,在某些场景下甚至不如同步IO
  • 接口复杂,难以使用

1.2 io_uring的核心设计

io_uring采用了全新的设计理念,其核心是两个无锁环形队列

  1. 提交队列(Submission Queue, SQ):用户态向内核提交IO请求
  2. 完成队列(Completion Queue, CQ):内核向用户态返回IO完成事件

这两个队列通过mmap映射到用户态和内核态的共享内存中,实现了真正的零拷贝。用户程序只需要在共享内存中填写SQE(Submission Queue Entry),然后通知内核即可;内核完成IO后,将CQE(Completion Queue Entry)写入共享内存,用户程序直接读取结果。整个过程避免了数据在用户态和内核态之间的来回拷贝。

更进一步,io_uring还支持两种高级模式:

SQ轮询模式(SQPOLL):内核创建一个专门的轮询线程,不断检查SQ中是否有新的IO请求。用户程序提交请求后甚至不需要执行系统调用来通知内核,内核会自动发现并处理。这将系统调用的开销降到了接近于零。

IO轮询模式(IOPOLL):对于支持轮询的高性能设备(如NVMe SSD),内核可以通过轮询设备寄存器来获取IO完成状态,而不是等待中断。这进一步降低了延迟,在某些场景下可以将IO延迟从几十微秒降到几微秒。


二、项目功能

2.1 项目包含的核心功能

第一层:AsyncIOUring - io_uring底层封装

  • 初始化和管理io_uring实例(支持SQPOLL和IOPOLL模式)
  • 提交异步读、写、同步(fsync)等操作
  • 独立的完成处理线程,自动回调用户函数
  • 独立的提交线程,实现批量提交优化(减少系统调用)
  • 完善的IO统计信息(请求数、字节数、延迟等)
  • 线程安全的请求管理和状态跟踪

第二层:AsyncFileIO - 高层文件IO接口

  • 自动管理文件描述符的打开和关闭
  • 支持多种文件打开模式(只读、只写、读写、追加)
  • 提供异步操作(回调风格)和同步操作(future风格)两种接口
  • 支持批量IO操作(一次提交多个读写请求)
  • 内置Direct IO和顺序读取优化
  • 自动维护文件偏移量

第三层:AsyncLogger - 高频日志系统

  • 内部缓冲区,批量写入减少IO次数
  • 自动日志文件轮转(按大小)
  • 支持多级日志(DEBUG/INFO/WARNING/ERROR)
  • 定期刷新线程,确保日志不丢失
  • 完全线程安全,支持多线程高并发写入

2.2 可以学习到的核心知识

异步IO编程模型

  • 理解异步操作的本质:提交-回调模式
  • 掌握如何设计异步API(回调风格 vs future风格 vs 协程风格)
  • 学会处理异步操作中的错误传播和异常安全
  • 理解异步IO中的资源生命周期管理(缓冲区何时可以释放)

io_uring核心技术

  • io_uring的初始化、配置和清理流程
  • SQE和CQE的结构和使用方法
  • 批量提交优化:何时提交、提交多少
  • 完成事件的处理:轮询 vs 阻塞等待
  • SQPOLL和IOPOLL模式的适用场景

高性能编程技巧

  • 零拷贝技术 :理解mmap共享内存,避免数据拷贝
  • 批量操作:通过减少系统调用次数来提升性能
  • 预读优化 :使用posix_fadvise提示内核进行顺序读取优化
  • Direct IO:何时绕过页缓存(大文件顺序IO)
  • 队列深度调优:队列深度对性能的影响

并发编程实战

  • 线程安全的数据结构 :使用std::atomicstd::mutex保护共享状态
  • 生产者-消费者模型:提交线程生产请求,完成线程消费结果
  • 条件变量的正确使用:避免busy-waiting,高效等待异步操作完成
  • 锁粒度优化:减小临界区范围,提高并发度

系统级编程

  • Linux文件IO的各种标志(O_DIRECTO_APPENDO_SYNC等)
  • 文件描述符的管理和RAII封装
  • fsync与数据持久性保证
  • 时间戳、性能统计、监控系统的实现

三、核心组件

3.1 AsyncIOUring:io_uring的完整封装

位于src/AsyncIOUring.cppinclude/AsyncIOUring.h。让我们逐步剖析其设计。

数据结构设计
cpp 复制代码
class AsyncIOUring {
private:
    struct io_uring ring_;                          // io_uring实例
    unsigned queue_depth_;                          // 队列深度
    bool use_sqpoll_;                               // 是否使用SQ轮询
    bool use_iopoll_;                               // 是否使用IO轮询
    bool initialized_;                              // 是否已初始化

    std::atomic<bool> running_;                     // 运行标志
    std::thread completion_thread_;                 // 完成处理线程
    std::thread submit_thread_;                     // 提交线程(批量提交优化)

    mutable std::mutex request_mutex_;              // 请求映射锁
    std::unordered_map<uint64_t, std::unique_ptr<IORequest>> pending_requests_;

    std::atomic<uint64_t> next_req_id_;            // 下一个请求ID
    IOStatsInternal stats_;                         // IO统计信息

    std::mutex submit_mutex_;                       // 提交操作互斥锁
    std::condition_variable completion_cv_;         // 完成条件变量

    std::atomic<int> pending_submissions_{0};       // 挂起的提交数量
    static constexpr int BATCH_SUBMIT_SIZE = 32;    // 批量提交阈值
};

这个设计体现了几个关键思想:

1. 线程分离架构:使用两个独立的后台线程

  • completion_thread_:专门负责从CQ中获取完成事件并执行回调
  • submit_thread_:定期批量提交SQ中的请求

这样做的好处是用户线程调用submitRead/submitWrite时只需要准备SQE,立即返回,不会被IO阻塞。实际的提交和完成处理都在后台异步进行。

2. 请求跟踪机制 :每个IO请求都有唯一的req_id

cpp 复制代码
std::unordered_map<uint64_t, std::unique_ptr<IORequest>> pending_requests_;

当用户提交请求时,系统生成一个ID并保存请求信息(包括回调函数);当IO完成时,通过ID找回请求并执行回调。这是异步编程的典型模式。

3. 批量提交优化:不是每次调用都立即提交给内核

cpp 复制代码
int pending = pending_submissions_.fetch_add(1, std::memory_order_relaxed) + 1;

// 达到批量大小阈值时立即提交
if (pending >= BATCH_SUBMIT_SIZE) {
    int ret = io_uring_submit(&ring_);
    pending_submissions_.store(0, std::memory_order_relaxed);
}

这段代码位于submitRequest函数中。只有当累积了32个请求(BATCH_SUBMIT_SIZE)时,才调用io_uring_submit向内核提交。这大大减少了系统调用的次数。同时,submit_thread_会每隔50微秒检查一次,如果有挂起的请求就提交,避免请求被无限期延迟。

初始化流程
cpp 复制代码
bool AsyncIOUring::initialize() {
    if (initialized_) {
        return true;
    }

    // 设置io_uring参数
    unsigned flags = 0;

    // SQPOLL模式:内核线程轮询提交队列,减少用户态到内核态的切换
    if (use_sqpoll_) {
        flags |= IORING_SETUP_SQPOLL;
    }

    // IOPOLL模式:轮询IO设备完成状态,适合高性能NVMe SSD
    if (use_iopoll_) {
        flags |= IORING_SETUP_IOPOLL;
    }

    // 初始化io_uring实例
    int ret = io_uring_queue_init(queue_depth_, &ring_, flags);
    if (ret < 0) {
        std::cerr << "io_uring初始化失败: " << strerror(-ret) << std::endl;
        return false;
    }

    initialized_ = true;
    running_ = true;

    // 启动完成处理线程
    completion_thread_ = std::thread(&AsyncIOUring::completionThread, this);

    // 启动提交线程(批量提交优化)
    submit_thread_ = std::thread(&AsyncIOUring::submitThread, this);

    return true;
}

io_uring_queue_init是liburing库提供的初始化函数,它会:

  1. 在内核中创建io_uring实例
  2. 分配SQ和CQ所需的内存
  3. 将这些内存mmap映射到用户态
  4. 设置必要的控制结构

queue_depth参数决定了可以同时挂起的IO操作数量。对于小型应用,128就足够;对于数据库等高并发场景,建议设置为512或1024。

提交IO请求的核心流程
cpp 复制代码
uint64_t AsyncIOUring::submitRead(int fd, void* buffer, size_t length, off_t offset,
                                   std::function<void(ssize_t, int)> callback) {
    auto request = std::make_unique<IORequest>();
    request->op_type = IOOpType::READ;
    request->fd = fd;
    request->buffer = buffer;
    request->length = length;
    request->offset = offset;
    request->callback = std::move(callback);
    request->submit_time = std::chrono::steady_clock::now();

    return submitRequest(std::move(request));
}

这里创建了一个IORequest对象,记录了所有必要的信息。特别注意submit_time字段,它用于后续计算IO延迟。

cpp 复制代码
uint64_t AsyncIOUring::submitRequest(std::unique_ptr<IORequest> request) {
    if (!initialized_ || !running_) {
        return 0;
    }

    // 生成请求ID
    request->req_id = generateRequestId();

    std::lock_guard<std::mutex> lock(submit_mutex_);

    // 获取提交队列条目(SQE - Submission Queue Entry)
    struct io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
    if (!sqe) {
        std::cerr << "获取SQE失败:队列已满" << std::endl;
        return 0;
    }

    // 根据操作类型准备SQE
    switch (request->op_type) {
        case IOOpType::READ:
            // 准备读取操作
            io_uring_prep_read(sqe, request->fd, request->buffer,
                              request->length, request->offset);
            break;

        case IOOpType::WRITE:
            // 准备写入操作
            io_uring_prep_write(sqe, request->fd, request->buffer,
                               request->length, request->offset);
            break;

        case IOOpType::FSYNC:
            // 准备文件同步操作
            io_uring_prep_fsync(sqe, request->fd, 0);
            break;

        default:
            return 0;
    }

    // 将请求ID设置为用户数据,用于在完成时识别请求
    io_uring_sqe_set_data(sqe, reinterpret_cast<void*>(request->req_id));

    // 保存请求ID
    uint64_t req_id = request->req_id;

    // 将请求存储到映射中
    {
        std::lock_guard<std::mutex> req_lock(request_mutex_);
        pending_requests_[req_id] = std::move(request);
    }

    // 更新统计信息
    stats_.total_requests++;

    // 批量提交优化
    int pending = pending_submissions_.fetch_add(1, std::memory_order_relaxed) + 1;

    // 达到批量大小阈值时立即提交
    if (pending >= BATCH_SUBMIT_SIZE) {
        int ret = io_uring_submit(&ring_);
        if (ret < 0) {
            std::cerr << "批量提交IO请求失败: " << strerror(-ret) << std::endl;
        }
        pending_submissions_.store(0, std::memory_order_relaxed);
    }

    return req_id;
}

这段代码的关键步骤:

  1. 获取SQEio_uring_get_sqe从SQ中获取一个空闲的条目。如果队列已满,返回NULL。
  2. 准备SQE :根据操作类型调用相应的io_uring_prep_*函数。这些函数实际上只是填充SQE结构体的字段。
  3. 设置用户数据io_uring_sqe_set_data将请求ID作为用户数据附加到SQE上。当IO完成时,CQE会携带这个用户数据,从而可以找到对应的请求。
  4. 保存请求 :将请求对象保存到pending_requests_映射中,以便完成时能找到回调函数。
  5. 批量提交:只有累积足够多的请求才提交,这是性能优化的关键。
完成处理线程的实现
cpp 复制代码
void AsyncIOUring::completionThread() {
    struct io_uring_cqe* cqe;
    struct __kernel_timespec timeout;
    timeout.tv_sec = 0;
    timeout.tv_nsec = 1000000;  // 1ms timeout

    while (running_) {
        // 使用带超时的等待,允许线程定期检查 running_ 标志
        int ret = io_uring_wait_cqe_timeout(&ring_, &cqe, &timeout);

        if (ret < 0) {
            if (ret == -EINTR || ret == -ETIME) {
                // 被信号中断或超时,继续循环检查 running_
                continue;
            }
            if (running_) {
                std::cerr << "等待完成事件失败: " << strerror(-ret) << std::endl;
            }
            break;
        }

        // 处理第一个已经wait到的CQE
        handleCompletion(cqe);
        io_uring_cqe_seen(&ring_, cqe);

        // 批量处理所有其他可用的完成事件(不阻塞)
        unsigned head;
        unsigned count = 0;
        io_uring_for_each_cqe(&ring_, head, cqe) {
            handleCompletion(cqe);
            count++;
            // 限制单次批量处理数量,避免长时间持有CPU
            if (count >= 128) {
                break;
            }
        }

        if (count > 0) {
            // 批量标记所有CQE已处理(减少系统调用)
            io_uring_cq_advance(&ring_, count);
        }

        // 所有CQE处理完后,只通知一次(避免频繁唤醒)
        completion_cv_.notify_all();
    }
}

1. 超时等待:使用1ms超时而不是无限等待。这样可以在析构时能够及时退出线程,避免死锁。

2. 批量处理 :不是处理一个CQE就立即返回,而是尝试一次性处理所有可用的CQE。io_uring_for_each_cqe是一个宏,它遍历CQ中所有已就绪的条目。这样做的好处是:

  • 减少循环次数
  • 减少系统调用(io_uring_cq_advance一次性标记多个CQE为已读)
  • 提高吞吐量

3. 限制批量大小:最多一次处理128个CQE。这是为了避免在极高负载下,完成线程长时间占用CPU,影响其他线程的运行。

cpp 复制代码
void AsyncIOUring::handleCompletion(struct io_uring_cqe* cqe) {
    // 从用户数据中获取请求ID
    uint64_t req_id = reinterpret_cast<uint64_t>(io_uring_cqe_get_data(cqe));

    if (req_id == 0) {
        return; // 无效请求
    }

    std::unique_ptr<IORequest> request;

    // 优化锁粒度,减少临界区时间
    {
        std::lock_guard<std::mutex> lock(request_mutex_);
        auto it = pending_requests_.find(req_id);
        if (it == pending_requests_.end()) {
            return; // 请求已被处理或不存在
        }
        request = std::move(it->second);
        pending_requests_.erase(it);
    }
    // 锁在这里释放,后续操作不需要持有锁

    // 获取IO操作结果
    ssize_t result = cqe->res; // 正数表示成功字节数,负数表示错误码
    int error = (result < 0) ? -result : 0;

    // 快速路径:只更新计数器,不计算延迟(减少开销)
    uint64_t completed = stats_.completed_requests.fetch_add(1, std::memory_order_relaxed) + 1;

    if (error != 0) {
        stats_.failed_requests.fetch_add(1, std::memory_order_relaxed);
    } else {
        if (request->op_type == IOOpType::READ) {
            stats_.total_bytes_read.fetch_add(result, std::memory_order_relaxed);
        } else if (request->op_type == IOOpType::WRITE) {
            stats_.total_bytes_written.fetch_add(result, std::memory_order_relaxed);
        }
    }

    // 采样延迟统计,而非每次都计算
    // 每64个请求采样一次延迟(减少97%的延迟计算开销)
    if ((completed & 0x3F) == 0) {
        auto now = std::chrono::steady_clock::now();
        auto latency = std::chrono::duration_cast<std::chrono::microseconds>(
            now - request->submit_time).count();

        // 指数移动平均
        uint64_t old_latency = stats_.avg_latency_us.load(std::memory_order_relaxed);
        uint64_t new_latency = (old_latency * 9 + latency) / 10;
        stats_.avg_latency_us.store(new_latency, std::memory_order_relaxed);
    }

    // 执行回调函数(在锁外执行,避免回调函数耗时影响性能)
    if (request->callback) {
        try {
            request->callback(result, error);
        } catch (const std::exception& e) {
            std::cerr << "回调函数异常: " << e.what() << std::endl;
        }
    }
}

这段代码展示了几个重要的性能优化技巧:

1. 锁粒度优化:只在查找和删除请求时持有锁,然后立即释放。回调函数的执行是在锁外进行的,这样可以:

  • 避免回调函数中的耗时操作阻塞其他完成事件的处理
  • 避免回调函数中调用submitRead/submitWrite导致的死锁(因为它们也需要获取锁)

2. 延迟采样统计 :计算每个请求的延迟需要调用std::chrono::steady_clock::now(),这个操作在某些CPU上可能需要几十纳秒。为了减少开销,代码只在每64个请求时采样一次。使用位运算(completed & 0x3F) == 0来判断是否是64的倍数,这比completed % 64 == 0快得多。

3. 指数移动平均 :使用简单的(old * 9 + new) / 10公式来计算平均延迟。这比维护一个延迟历史队列要高效得多,同时对最近的数据赋予更高的权重。

3.2 AsyncFileIO:易用的高层封装

AsyncFileIO类在AsyncIOUring的基础上,提供了更符合日常使用习惯的文件操作接口。

文件句柄管理
cpp 复制代码
class AsyncFileHandle {
public:
    AsyncFileHandle(int fd, const std::string& path)
        : fd_(fd), path_(path), offset_(0) {}

    ~AsyncFileHandle() {
        if (fd_ >= 0) {
            close(fd_);
        }
    }

    int getFd() const { return fd_; }
    const std::string& getPath() const { return path_; }
    off_t getOffset() const { return offset_; }
    void setOffset(off_t offset) { offset_ = offset; }

private:
    int fd_;
    std::string path_;
    off_t offset_;
};

这个类使用RAII(Resource Acquisition Is Initialization)模式,确保文件描述符在对象销毁时自动关闭。同时维护了当前的文件偏移量,这样用户不需要手动跟踪读写位置。

文件打开与优化
cpp 复制代码
std::shared_ptr<AsyncFileHandle> AsyncFileIO::openFile(const std::string& path,
                                                        FileOpenMode mode) {
    std::lock_guard<std::mutex> lock(file_mutex_);

    int flags = modeToFlags(mode);

    // Direct IO标志(绕过页缓存)
    if (use_direct_io_) {
        flags |= O_DIRECT;
    }

    // 打开文件
    int fd = open(path.c_str(), flags, 0644);
    if (fd < 0) {
        std::cerr << "打开文件失败 [" << path << "]: " << strerror(errno) << std::endl;
        return nullptr;
    }

    // 优化顺序读取性能
    // 对于只读和读写模式,启用顺序访问提示,让内核执行激进的预读
    if (!use_direct_io_ && (mode == FileOpenMode::READ_ONLY || mode == FileOpenMode::READ_WRITE)) {
        // POSIX_FADV_SEQUENTIAL:告诉内核这是顺序访问,应该积极预读并快速释放已读页面
        posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);
    }

    return std::make_shared<AsyncFileHandle>(fd, path);
}

这里的posix_fadvise调用非常重要。POSIX_FADV_SEQUENTIAL标志告诉内核:

  • 这个文件会被顺序读取
  • 应该激进地进行预读(read-ahead),提前将后续数据加载到页缓存
  • 已经读过的页面可以快速释放,因为不太可能再次访问
异步读写的包装
cpp 复制代码
uint64_t AsyncFileIO::readAsync(std::shared_ptr<AsyncFileHandle> handle,
                                 void* buffer, size_t length,
                                 std::function<void(ssize_t, int)> callback) {
    if (!handle) {
        return 0;
    }

    off_t offset = handle->getOffset();

    // 提交读取请求,并在完成后更新偏移量
    auto wrapped_callback = [handle, offset, length, callback](ssize_t result, int error) {
        if (result > 0) {
            handle->setOffset(offset + result);
        }
        if (callback) {
            callback(result, error);
        }
    };

    return io_uring_->submitRead(handle->getFd(), buffer, length, offset,
                                  wrapped_callback);
}

这里使用了回调包装(callback wrapping)技巧:

  1. 捕获当前的文件偏移量
  2. 创建一个新的回调函数,它会在IO完成后自动更新偏移量
  3. 然后调用用户的回调函数
同步接口的实现
cpp 复制代码
std::future<ssize_t> AsyncFileIO::readSync(std::shared_ptr<AsyncFileHandle> handle,
                                            void* buffer, size_t length) {
    // 创建promise和future对
    auto promise = std::make_shared<std::promise<ssize_t>>();
    std::future<ssize_t> future = promise->get_future();

    // 提交异步读取,使用promise设置结果
    readAsync(handle, buffer, length,
              [promise](ssize_t result, int error) {
                  if (error != 0) {
                      promise->set_value(-error);
                  } else {
                      promise->set_value(result);
                  }
              });

    return future;
}

这是一个经典的异步到同步的转换模式:

  1. 创建一个std::promise和对应的std::future
  2. 在异步回调中,将结果通过promise->set_value()传递
  3. 返回future给用户,用户可以调用future.get()来阻塞等待结果
批量IO操作
cpp 复制代码
void AsyncFileIO::readBatch(std::shared_ptr<AsyncFileHandle> handle,
                             const std::vector<void*>& buffers,
                             const std::vector<size_t>& lengths,
                             std::function<void(std::vector<ssize_t>)> callback) {
    if (buffers.size() != lengths.size()) {
        return;
    }

    // 使用shared_ptr管理结果向量,确保在所有回调完成前不被销毁
    auto results = std::make_shared<std::vector<ssize_t>>(buffers.size(), 0);
    auto counter = std::make_shared<std::atomic<size_t>>(buffers.size());

    for (size_t i = 0; i < buffers.size(); ++i) {
        readAsync(handle, buffers[i], lengths[i],
                  [results, counter, callback, i](ssize_t result, int error) {
                      (*results)[i] = (error != 0) ? -error : result;

                      // 当所有操作完成时,调用用户回调
                      if (counter->fetch_sub(1) == 1) {
                          if (callback) {
                              callback(*results);
                          }
                      }
                  });
    }
}

这个函数展示了如何在异步环境中实现批量操作:

  1. 创建一个共享的结果向量和计数器
  2. 为每个子操作提交异步请求
  3. 在每个子操作的回调中,保存结果并递减计数器
  4. 当计数器归零时(所有子操作都完成),调用用户的回调函数

注意这里使用std::shared_ptr来管理resultscounter,这样可以确保即使某些回调延迟执行,这些对象也不会被提前销毁。

3.3 AsyncLogger:生产级异步日志系统

AsyncLogger是本项目的实战应用示例,展示了如何使用异步IO构建一个高性能的日志系统。

核心数据结构
cpp 复制代码
class AsyncLogger {
private:
    std::unique_ptr<AsyncFileIO> file_io_;
    std::shared_ptr<AsyncFileHandle> current_file_;

    std::string log_dir_;
    size_t max_file_size_;
    std::atomic<size_t> current_file_size_;  // 使用原子变量保证线程安全

    std::vector<char> buffer_;
    size_t buffer_pos_;
    std::mutex buffer_mutex_;

    std::atomic<uint64_t> total_logs_;
    std::atomic<uint64_t> total_bytes_;

    std::thread flush_thread_;
    std::atomic<bool> running_;
};

设计要点:

  • 内部缓冲区buffer_用于暂存日志,避免每条日志都触发一次IO
  • 原子计数器 :使用std::atomic来统计日志数量和字节数,避免锁竞争
  • 定期刷新线程flush_thread_每隔1秒自动将缓冲区刷新到磁盘
日志写入流程
cpp 复制代码
void AsyncLogger::log(LogLevel level, const std::string& message) {
    // 构造日志行:[时间戳] [级别] 消息\n
    std::stringstream ss;
    ss << "[" << getCurrentTimestamp() << "] "
       << "[" << getLevelString(level) << "] "
       << message << "\n";

    std::string log_line = ss.str();

    std::lock_guard<std::mutex> lock(buffer_mutex_);

    // 如果缓冲区空间不足,先刷新
    if (buffer_pos_ + log_line.size() > buffer_.size()) {
        flushBuffer();
    }

    // 写入缓冲区
    memcpy(buffer_.data() + buffer_pos_, log_line.c_str(), log_line.size());
    buffer_pos_ += log_line.size();

    total_logs_++;
}

这里的关键设计是先缓冲,再批量写入。每条日志只是追加到内存缓冲区,不触发IO。只有当:

  1. 缓冲区满了
  2. 或者定期刷新线程触发

才会真正写入磁盘。这大大减少了IO次数,提高了性能。

cpp 复制代码
void AsyncLogger::flushBuffer() {
    // 注意:此函数必须在持有buffer_mutex_的情况下调用

    if (!current_file_ || buffer_pos_ == 0) {
        return;
    }

    // 检查是否需要轮转日志文件
    if (current_file_size_.load(std::memory_order_relaxed) + buffer_pos_ > max_file_size_) {
        rotateLogFile();
    }

    // 使用shared_ptr避免大量内存拷贝
    auto data_copy = std::make_shared<std::vector<char>>(
        buffer_.begin(), buffer_.begin() + buffer_pos_);
    size_t size = buffer_pos_;

    file_io_->writeAsync(current_file_, data_copy->data(), size,
                         [this, size, data_copy](ssize_t result, int error) {
                             if (error == 0 && result > 0) {
                                 // 使用原子操作更新文件大小
                                 current_file_size_.fetch_add(result, std::memory_order_relaxed);
                                 total_bytes_.fetch_add(result, std::memory_order_relaxed);
                             }
                         });

    buffer_pos_ = 0;
}

这里有两个重要的技巧:

1. 数据拷贝 :由于writeAsync是异步的,它可能在flushBuffer返回后才真正执行。因此不能直接传递buffer_的指针,必须拷贝一份数据。这里使用std::shared_ptr<std::vector<char>>,并在回调中捕获它,确保数据在IO完成前不会被销毁。

2. 原子操作current_file_size_使用std::atomic,因为它会在异步回调中被更新,而回调可能在不同的线程执行。

日志文件轮转
cpp 复制代码
void AsyncLogger::rotateLogFile() {
    // 生成新的日志文件名:log_YYYYMMDD_HHMMSS.log
    auto now = std::time(nullptr);
    auto tm = std::localtime(&now);

    std::stringstream ss;
    ss << log_dir_ << "/log_"
       << std::put_time(tm, "%Y%m%d_%H%M%S")
       << ".log";

    std::string new_log_path = ss.str();

    // 关闭旧文件(如果有)
    if (current_file_) {
        file_io_->flushSync(current_file_).get();
        current_file_.reset();
    }

    // 打开新文件
    current_file_ = file_io_->openFile(new_log_path, FileOpenMode::APPEND);
    current_file_size_.store(0, std::memory_order_relaxed);

    std::cout << "日志轮转: " << new_log_path << std::endl;
}

当日志文件达到设定的最大大小(默认100MB)时,会自动创建一个新文件。文件名包含时间戳,便于归档和查找。


四、示例程序深度解读

4.1 database_sim.cpp:模拟数据库存储引擎

这个示例展示了如何使用异步IO构建一个简单的键值存储引擎,类似于RocksDB或LevelDB的简化版本。

记录结构设计
cpp 复制代码
// 定义记录大小(固定大小简化实现)
constexpr size_t KEY_SIZE = 32;
constexpr size_t VALUE_SIZE = 256;
constexpr size_t RECORD_SIZE = KEY_SIZE + VALUE_SIZE;

struct Record {
    char key[KEY_SIZE];
    char value[VALUE_SIZE];

    Record() {
        memset(key, 0, KEY_SIZE);
        memset(value, 0, VALUE_SIZE);
    }

    Record(const std::string& k, const std::string& v) {
        memset(key, 0, KEY_SIZE);
        memset(value, 0, VALUE_SIZE);
        strncpy(key, k.c_str(), KEY_SIZE - 1);
        strncpy(value, v.c_str(), VALUE_SIZE - 1);
    }
};

采用固定大小的记录格式,这样可以通过简单的计算直接定位任意记录的文件偏移量:offset = record_index * RECORD_SIZE。这是许多数据库索引结构(如B+树)的基础。

索引管理
cpp 复制代码
class SimpleKVStore {
private:
    std::unordered_map<std::string, off_t> index_; // key -> 文件偏移量
    size_t record_count_;

    void loadIndex() {
        // 获取文件大小
        struct stat st;
        if (stat(db_path_.c_str(), &st) != 0) {
            return;
        }

        size_t file_size = st.st_size;
        size_t num_records = file_size / RECORD_SIZE;

        // 同步读取所有记录,构建索引
        for (size_t i = 0; i < num_records; ++i) {
            Record record;
            data_file_->setOffset(i * RECORD_SIZE);

            auto future = file_io_->readSync(data_file_, &record, RECORD_SIZE);
            ssize_t result = future.get();

            if (result > 0) {
                std::string key = record.getKey();
                if (!key.empty()) {
                    index_[key] = i * RECORD_SIZE;
                }
            }
        }

        record_count_ = num_records;
    }
};

这是一个简化的索引实现。在初始化时,读取整个文件,将每个记录的key和其文件偏移量建立映射。真实的数据库会使用更复杂的结构(如B+树)来避免加载整个索引到内存。

批量写入
cpp 复制代码
void SimpleKVStore::batchWrite(const std::vector<Record>& records,
                std::function<void(bool)> callback) {
    auto start_time = std::chrono::high_resolution_clock::now();

    // 准备批量写入的缓冲区
    std::vector<const void*> buffers;
    std::vector<size_t> lengths;

    // 记录起始偏移量
    off_t start_offset = record_count_ * RECORD_SIZE;

    for (const auto& record : records) {
        // 需要复制记录到堆上,因为异步IO可能在函数返回后才完成
        auto* record_copy = new Record(record);
        buffers.push_back(record_copy);
        lengths.push_back(RECORD_SIZE);

        // 更新索引
        std::string key = record.getKey();
        off_t offset = start_offset + (buffers.size() - 1) * RECORD_SIZE;
        index_[key] = offset;
    }

    // 更新记录数
    record_count_ += records.size();

    // 批量异步写入
    file_io_->writeBatch(data_file_, buffers, lengths,
        [this, buffers, start_time, callback](std::vector<ssize_t> results) {
            // 清理缓冲区
            for (auto* buffer : buffers) {
                delete static_cast<const Record*>(buffer);
            }

            // 检查结果
            bool success = true;
            size_t total_written = 0;
            for (auto result : results) {
                if (result < 0) {
                    success = false;
                    break;
                }
                total_written += result;
            }

            auto end_time = std::chrono::high_resolution_clock::now();
            auto duration = std::chrono::duration_cast<std::chrono::microseconds>(
                end_time - start_time).count();

            if (success) {
                std::cout << "批量写入完成: " << results.size() << " 条记录, "
                          << total_written << " 字节, "
                          << duration << " us" << std::endl;
            }

            if (callback) {
                callback(success);
            }
        });
}

批量写入的优势:

  1. 一次性提交多个IO请求,充分利用磁盘的并行处理能力
  2. 减少系统调用次数
  3. 在支持NCQ(Native Command Queuing)的SSD上,可以显著提升性能
测试场景
cpp 复制代码
void testMixedWorkload(SimpleKVStore& db, int duration_seconds) {
    std::cout << "\n========== 混合读写测试 ==========" << std::endl;
    std::cout << "运行时长: " << duration_seconds << " 秒" << std::endl;
    std::cout << "工作负载: 70%读取, 30%写入" << std::endl;

    std::atomic<bool> running{true};
    std::atomic<uint64_t> total_reads{0};
    std::atomic<uint64_t> total_writes{0};

    // 启动多个工作线程
    std::vector<std::thread> workers;
    int num_workers = 4;

    for (int i = 0; i < num_workers; ++i) {
        workers.emplace_back([&, i]() {
            std::random_device rd;
            std::mt19937 gen(rd());
            std::uniform_int_distribution<> op_dis(0, 99);
            std::uniform_int_distribution<> id_dis(0, 10000);

            while (running) {
                int op = op_dis(gen);

                if (op < 70) {
                    // 70% 读取操作
                    std::string key = "key_" + std::to_string(id_dis(gen));
                    db.read(key);
                    total_reads++;
                } else {
                    // 30% 写入操作
                    std::vector<Record> records;
                    records.push_back(generateRandomRecord(id_dis(gen)));

                    db.batchWrite(records, [](bool success) {});
                    total_writes++;
                }

                // 控制操作频率
                std::this_thread::sleep_for(std::chrono::microseconds(100));
            }
        });
    }

    // 监控线程:每秒打印统计信息
    std::thread monitor([&]() {
        uint64_t last_reads = 0;
        uint64_t last_writes = 0;

        while (running) {
            std::this_thread::sleep_for(std::chrono::seconds(1));

            uint64_t current_reads = total_reads.load();
            uint64_t current_writes = total_writes.load();

            uint64_t reads_per_sec = current_reads - last_reads;
            uint64_t writes_per_sec = current_writes - last_writes;

            last_reads = current_reads;
            last_writes = current_writes;

            std::cout << "读取: " << reads_per_sec << " ops/sec, "
                      << "写入: " << writes_per_sec << " ops/sec"
                      << std::endl;
        }
    });

    // 运行指定时长
    std::this_thread::sleep_for(std::chrono::seconds(duration_seconds));

    running = false;
    for (auto& worker : workers) {
        worker.join();
    }
    monitor.join();
}

这个测试模拟了真实数据库的工作负载:

  • 70%的操作是读取(模拟查询)
  • 30%的操作是写入(模拟插入/更新)
  • 4个工作线程并发访问
  • 实时监控每秒的操作数

4.2 high_freq_logger.cpp:对比同步/异步性能

这个示例直观地展示了异步IO的性能优势。

同步日志基准
cpp 复制代码
void synchronousLoggingTest(int num_logs, int num_threads) {
    std::cout << "\n========== 同步IO日志写入测试 ==========" << std::endl;

    auto start_time = std::chrono::high_resolution_clock::now();

    // 打开日志文件
    FILE* log_file = fopen("./logs/sync_test.log", "w");

    // 多线程生成日志并同步写入
    std::vector<std::thread> threads;
    std::mutex file_mutex;

    for (int t = 0; t < num_threads; ++t) {
        threads.emplace_back([&, t]() {
            int logs_per_thread = num_logs / num_threads;
            for (int i = 0; i < logs_per_thread; ++i) {
                auto log = generateRandomLog();
                std::string log_str = formatLog(log) + "\n";

                // 同步写入(需要加锁)
                std::lock_guard<std::mutex> lock(file_mutex);
                fwrite(log_str.c_str(), 1, log_str.size(), log_file);
            }
        });
    }

    for (auto& thread : threads) {
        thread.join();
    }

    fflush(log_file);
    fclose(log_file);

    auto end_time = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
        end_time - start_time).count();

    std::cout << "完成时间: " << duration << " ms" << std::endl;
    std::cout << "吞吐量: " << (num_logs * 1000.0 / duration) << " logs/sec" << std::endl;
}

同步方案的问题:

  1. 每次fwrite都可能触发一次系统调用
  2. 多线程需要用锁保护文件指针,导致线程间竞争
  3. 业务线程被IO阻塞,无法继续处理请求
异步日志实现
cpp 复制代码
void asynchronousLoggingTest(int num_logs, int num_threads) {
    std::cout << "\n========== 异步IO日志写入测试 ==========" << std::endl;

    auto start_time = std::chrono::high_resolution_clock::now();

    // 创建异步日志器
    AsyncLogger logger("./logs", 50 * 1024 * 1024, 64 * 1024); // 64KB缓冲区
    if (!logger.initialize()) {
        std::cerr << "初始化异步日志器失败" << std::endl;
        return;
    }

    // 多线程生成日志并异步写入
    std::vector<std::thread> threads;
    std::atomic<int> completed_logs{0};

    for (int t = 0; t < num_threads; ++t) {
        threads.emplace_back([&, t]() {
            int logs_per_thread = num_logs / num_threads;
            for (int i = 0; i < logs_per_thread; ++i) {
                auto log = generateRandomLog();
                std::string log_str = formatLog(log);

                // 异步写入(无需加锁,内部已处理并发)
                logger.log(AsyncLogger::LogLevel::INFO, log_str);
                completed_logs++;
            }
        });
    }

    for (auto& thread : threads) {
        thread.join();
    }

    // 确保所有日志写入完成
    logger.flush();

    auto end_time = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(
        end_time - start_time).count();

    std::cout << "完成时间: " << duration << " ms" << std::endl;
    std::cout << "吞吐量: " << (num_logs * 1000.0 / duration) << " logs/sec" << std::endl;
}

异步方案的优势:

  1. 业务线程只需将日志追加到内存缓冲区,立即返回(几十纳秒)
  2. 实际的IO在后台线程批量执行,减少系统调用
  3. 无锁设计(只在操作缓冲区时需要锁)

在实测中,异步方案的吞吐量是同步方案的3-5倍


总结

io_uring作为Linux内核的最新异步IO接口,代表了未来的方向。它不仅支持文件IO,还支持网络IO、定时器、信号等几乎所有的IO操作。无论你是想为自己的项目添加高性能IO能力,还是希望深入学习系统编程和性能优化,这都是一个非常适合的项目!链接: https://pan.baidu.com/s/1K1BEYs2UqrQkCcqitYFMSw 提取码: v0da

相关推荐
梦未3 小时前
Spring控制反转与依赖注入
java·后端·spring
喜欢流萤吖~3 小时前
Lambda 表达式
java
ZouZou老师4 小时前
C++设计模式之适配器模式:以家具生产为例
java·设计模式·适配器模式
曼巴UE54 小时前
UE5 C++ 动态多播
java·开发语言
小小晓.4 小时前
Pinely Round 4 (Div. 1 + Div. 2)
c++·算法
SHOJYS4 小时前
学习离线处理 [CSP-J 2022 山东] 部署
数据结构·c++·学习·算法
VX:Fegn08954 小时前
计算机毕业设计|基于springboot + vue音乐管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·课程设计
程序员鱼皮4 小时前
刚刚,IDEA 免费版发布!终于不用破解了
java·程序员·jetbrains
steins_甲乙4 小时前
C++并发编程(3)——资源竞争下的安全栈
开发语言·c++·安全