多线程文件I/O:异步I/O与多线程I/O的对比

说到文件I/O,很多搞多线程的朋友第一反应就是「开几个线程一起读文件」。嗯,这个想法很自然,但实际坑不少。我最早做服务器开发时,也这么干过——结果发现性能没提升,反而把磁盘搞成了瓶颈。

今天咱们就好好聊聊,多线程环境下怎么跟文件打交道才算专业。

异步I/O vs 多线程I/O:到底选哪个?

先搞清楚概念。异步I/O和多线程I/O,虽然都能实现「不阻塞主流程」,但底层逻辑完全不同。

对比维度 异步I/O (AIO) 多线程I/O
底层机制 内核直接处理,完成后通知 线程阻塞等待,操作系统调度
线程开销 无需额外线程 每个I/O需要一个线程
编程复杂度 回调/事件驱动,较复杂 同步模型,相对直观
适用场景 高并发、大量小I/O 大文件、批量读写
Linux支持 libaio,但限制较多 POSIX线程,成熟稳定

我个人习惯是:小文件、高并发场景用异步I/O;大文件、批量处理用多线程I/O。为什么?你想想看,异步I/O省去了线程创建和上下文切换的开销,但回调逻辑写起来确实费劲。多线程I/O虽然笨重,但代码可读性好,调试也方便。

核心观点:没有银弹。异步I/O不是万能的,多线程I/O也不是落后的。选型要看你的I/O模式。

线程安全的文件操作

多线程操作同一个文件,最怕什么?数据错乱。我记得有一次线上事故,就是因为两个线程同时写日志文件,结果日志内容交叉混在一起,排查问题花了整整两天。

线程安全的文件操作,说白了就三条原则:

  • 写操作必须加锁——用互斥锁保护文件描述符
  • 读操作可以共享——如果只是读,多个线程可以同时打开
  • 定位+读写要原子化——用pread/pwrite代替lseek+read/write

来看一段代码,这是我项目中常用的线程安全文件写入封装:

#include <pthread.h>
#include <unistd.h>
#include <fcntl.h>

typedef struct {
    int fd;
    pthread_mutex_t lock;
} SafeFile;

ssize_t safe_write(SafeFile *sf, const void *buf, size_t count) {
    pthread_mutex_lock(&sf->lock);
    ssize_t ret = write(sf->fd, buf, count);
    pthread_mutex_unlock(&sf->lock);
    return ret;
}

ssize_t safe_pwrite(SafeFile *sf, const void *buf, size_t count, off_t offset) {
    // preadv/pwritev 本身是原子的,但多线程场景下仍需保护文件状态
    return pwrite(sf->fd, buf, count, offset);
}

小技巧:能用pread/pwrite就别用lseek+read/write。前者是原子操作,后者不是。我曾经因为这个坑,排查了一个下午的并发bug。

内存映射文件的多线程访问

内存映射文件(mmap)在多线程环境下,表现非常亮眼。为什么?因为映射后的内存区域,多个线程可以直接共享访问,不需要经过系统调用。

但这里有个关键点:写操作仍然需要同步。mmap只是省去了数据从内核拷贝到用户空间的开销,但多个线程同时写同一块内存,该加锁还是得加锁。

我建议的做法是:

  • 读多写少:用读写锁(pthread_rwlock_t)
  • 写多读少:用互斥锁,或者分片处理
  • 纯读场景:完全不用锁,性能最佳

来看一个mmap多线程读的例子:

#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>

typedef struct {
    char *data;
    size_t size;
    int thread_id;
} ThreadArg;

void *reader_thread(void *arg) {
    ThreadArg *ta = (ThreadArg *)arg;
    size_t chunk = ta->size / 4;
    size_t start = ta->thread_id * chunk;
    size_t end = (ta->thread_id == 3) ? ta->size : start + chunk;
    
    // 直接读取映射内存,无需系统调用
    for (size_t i = start; i < end; i++) {
        volatile char c = ta->data[i];  // 防止编译器优化
    }
    return NULL;
}

int main() {
    int fd = open("largefile.bin", O_RDONLY);
    size_t filesize = lseek(fd, 0, SEEK_END);
    char *map = mmap(NULL, filesize, PROT_READ, MAP_PRIVATE, fd, 0);
    
    pthread_t threads[4];
    ThreadArg args[4];
    for (int i = 0; i < 4; i++) {
        args[i] = (ThreadArg){map, filesize, i};
        pthread_create(&threads[i], NULL, reader_thread, &args[i]);
    }
    // ... 等待线程结束,清理资源
}

注意:mmap映射的文件大小不能超过虚拟地址空间限制。32位系统上,单个映射通常不能超过2GB。64位系统基本不用担心这个问题。

I/O密集型任务的线程优化

I/O密集型任务,说白了就是CPU大部分时间在等磁盘。这时候你开100个线程,大部分都在阻塞等待,反而增加了调度开销。

我的经验是:线程数 = 磁盘队列深度 × 2。比如你的磁盘支持32个并发请求,那就开64个线程左右。太多反而性能下降。

具体优化策略:

  • 使用线程池——避免频繁创建销毁线程
  • I/O合并——小请求合并成大请求,减少磁盘寻道
  • 预读与缓存——利用局部性原理,提前加载数据
  • 绑定CPU核心——避免线程在不同核心间迁移导致缓存失效

下面这张图,是我总结的多线程I/O优化决策流程:

多线程I/O优化决策流程 I/O任务 文件大小? 小文件 异步I/O (AIO) 高并发,低延迟 大文件 多线程I/O 批量处理 访问模式? 读多写少 mmap + 读写锁 共享映射 写多读少 分片 + 互斥锁 独立文件段

你看,决策路径其实很清晰。先看文件大小,再看访问模式,最后选具体技术方案。我在项目中一直用这个框架,基本没出过问题。

避坑指南

最后分享几个我踩过的坑:

  • 不要用fread/fwrite做多线程——这些函数内部有缓冲区,不是线程安全的。用底层的read/write或者pread/pwrite。
  • 注意文件打开模式——多线程写同一个文件,要用O_APPEND模式,否则会覆盖数据。
  • mmap的同步问题——多线程写mmap区域,记得用MS_ASYNC或MS_SYNC刷回磁盘,否则断电会丢数据。
  • 线程数不是越多越好——我曾经在一个项目里开了200个线程读文件,结果磁盘利用率只有30%,CPU全花在线程切换上了。后来降到32个线程,吞吐量反而翻了一倍。

总结一下:多线程文件I/O,核心在于理解底层硬件特性。磁盘是机械的,SSD是电子的,它们的并发能力完全不同。别盲目开线程,先搞清楚你的I/O瓶颈在哪。

公众号:蓝海资料掘金营,微信deep3321