多线程文件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优化决策流程:
你看,决策路径其实很清晰。先看文件大小,再看访问模式,最后选具体技术方案。我在项目中一直用这个框架,基本没出过问题。
避坑指南
最后分享几个我踩过的坑:
- 不要用fread/fwrite做多线程——这些函数内部有缓冲区,不是线程安全的。用底层的read/write或者pread/pwrite。
- 注意文件打开模式——多线程写同一个文件,要用O_APPEND模式,否则会覆盖数据。
- mmap的同步问题——多线程写mmap区域,记得用MS_ASYNC或MS_SYNC刷回磁盘,否则断电会丢数据。
- 线程数不是越多越好——我曾经在一个项目里开了200个线程读文件,结果磁盘利用率只有30%,CPU全花在线程切换上了。后来降到32个线程,吞吐量反而翻了一倍。
总结一下:多线程文件I/O,核心在于理解底层硬件特性。磁盘是机械的,SSD是电子的,它们的并发能力完全不同。别盲目开线程,先搞清楚你的I/O瓶颈在哪。