第十七章 多线程文件处理:多线程文件读写、文件锁与异步I/O
文件处理,说白了就是多线程编程里最绕不开的实战场景。我早年做服务器开发时,就踩过一个大坑——多个线程同时写日志文件,结果日志内容互相穿插,读出来全是乱码。嗯,从那天起,我就开始认真研究多线程下的文件处理了。
这一章,我们聚焦三个核心问题:多线程怎么安全地读写文件?文件锁到底怎么用?异步I/O和线程怎么配合? 你想想看,如果连文件都搞不定,那多线程程序基本就是定时炸弹。
17.1 多线程文件读写的基本问题
先看一个最简单的场景:两个线程同时往一个文件里写数据。你可能会想,不就是write()系统调用吗?内核会处理并发吧?
没错,内核确实会保证每个write()是原子的——但仅限于写入的字节数不超过PIPE_BUF(通常是4096字节)的情况。一旦超过这个限制,或者你用的是fwrite()这种带缓冲的库函数,问题就来了。
核心问题: 多个线程同时操作同一个文件描述符,会导致数据交错、读写位置混乱。因为文件偏移量是共享的。
我在项目中遇到过这样一个案例:一个多线程下载器,每个线程负责下载文件的不同分片,然后往同一个文件里写。结果下载完的文件总是损坏的。原因就是线程A写完后,文件偏移量变了,线程B接着写时位置已经不对了。
解决方案其实不复杂:
- 每个线程独立打开文件——各自持有自己的文件描述符和偏移量
- 使用文件锁——保证同一时刻只有一个线程在写
- 使用pread()/pwrite()——指定偏移量,不改变文件描述符的当前位置
我个人习惯用pread()/pwrite(),因为它们在多线程场景下最干净利落。看个例子:
// 每个线程独立打开文件,用pwrite指定写入位置
void* thread_write(void* arg) {
int fd = open("data.bin", O_WRONLY | O_CREAT, 0644);
if (fd < 0) {
perror("open");
return NULL;
}
off_t offset = (off_t)arg; // 每个线程负责不同的偏移
char buf[1024];
// ... 填充数据 ...
ssize_t ret = pwrite(fd, buf, sizeof(buf), offset);
if (ret != sizeof(buf)) {
perror("pwrite");
}
close(fd);
return NULL;
}
注意,pwrite()是线程安全的,因为它不依赖文件描述符的当前偏移量。你想想看,每个线程写自己的区域,互不干扰,多清爽。
17.2 文件锁:flock与fcntl
但有些场景下,你不能让每个线程独立打开文件。比如多个线程要同时更新同一个配置文件,或者写同一个日志文件。这时候就需要文件锁了。
Linux下有两种文件锁:flock() 和 fcntl()。它们有什么区别?我简单总结一下:
| 特性 | flock() | fcntl() |
|---|---|---|
| 锁类型 | 共享锁(LOCK_SH)、排他锁(LOCK_EX) | 读锁(F_RDLCK)、写锁(F_WRLCK) |
| 锁粒度 | 整个文件 | 文件区域(字节范围) |
| NFS支持 | 部分支持 | 完全支持 |
| 与进程关系 | 关联文件描述符 | 关联进程和inode |
| 使用复杂度 | 简单 | 较复杂 |
我个人习惯:简单场景用flock(),复杂场景用fcntl()。为什么?flock()用起来就像一把大锁,锁住整个文件,简单粗暴。而fcntl()可以锁文件的某一段,精细控制。
小技巧: flock()的锁是和文件描述符绑定的。如果你fork()了子进程,子进程会继承文件描述符,也就继承了锁。但如果你用dup()复制了文件描述符,两个描述符共享同一个锁。
来看一个flock()的实际例子:
void* thread_log(void* arg) {
int fd = open("app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
if (fd < 0) {
perror("open");
return NULL;
}
// 加排他锁
if (flock(fd, LOCK_EX) != 0) {
perror("flock");
close(fd);
return NULL;
}
// 写日志
const char* msg = (const char*)arg;
write(fd, msg, strlen(msg));
write(fd, "\n", 1);
// 解锁
flock(fd, LOCK_UN);
close(fd);
return NULL;
}
嗯,这里要注意:flock()默认是阻塞的。如果你不想阻塞,可以用LOCK_NB标志:
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
if (errno == EWOULDBLOCK) {
// 锁被占用,做其他事情
}
}
我曾经在项目中遇到过一个问题:用flock()锁文件,结果程序崩溃了,锁没释放。重启程序后,发现文件还被锁着。为什么?因为flock()的锁是内核维护的,进程退出时内核会自动释放。但如果程序被kill -9强杀,锁也会被释放。真正的问题出在——如果你用fcntl(),锁是和进程关联的,进程退出时自动释放。但flock()是和文件描述符关联的,如果文件描述符被不小心关闭了,锁就没了。
避坑指南: 我曾经在项目中用fcntl()做记录锁,结果发现锁在fork()后被子进程继承了,导致死锁。后来我加了一行代码:在fork()后立即重新获取锁。记住,fcntl()的锁在fork()后是继承的,但exec()后会丢失。
fcntl()的使用稍微复杂一点,但功能更强大。看个例子:
void* thread_update_config(void* arg) {
int fd = open("config.dat", O_RDWR);
if (fd < 0) {
perror("open");
return NULL;
}
struct flock lock;
lock.l_type = F_WRLCK; // 写锁
lock.l_whence = SEEK_SET; // 从文件开头
lock.l_start = 0; // 起始偏移
lock.l_len = 100; // 锁前100字节
lock.l_pid = 0; // 由内核填充
// 加锁
if (fcntl(fd, F_SETLKW, &lock) < 0) {
perror("fcntl");
close(fd);
return NULL;
}
// 更新配置...
// 注意:这里要用lseek+write,或者pwrite
// 解锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);
return NULL;
}
你想想看,fcntl()可以锁文件的某一段,这样多个线程可以同时读写文件的不同部分,大大提高了并发度。但代价是代码更复杂了。
17.3 异步I/O与线程的结合
说到异步I/O,很多人第一反应是epoll、select这些。但那是网络I/O。文件I/O的异步处理,在Linux下有几种方式:
- POSIX AIO (aio_read/aio_write)——标准异步I/O接口
- Linux AIO (io_submit/io_getevents)——Linux原生异步I/O
- 线程池 + 同步I/O——用线程模拟异步
说实话,POSIX AIO在Linux上的实现并不完美。我记得有一次在项目中用aio_read(),结果发现它在glibc里是用线程池实现的——说白了就是开了一个后台线程帮你做同步I/O。那还不如我们自己用线程池呢。
我个人更推荐的做法是:用线程池来处理文件I/O。这样既简单又可控。
看一个线程池处理文件读写的例子:
// 任务结构体
typedef struct {
int fd;
off_t offset;
void* buf;
size_t count;
int is_write; // 1:写, 0:读
void (*callback)(ssize_t result, void* arg);
void* arg;
} io_task_t;
// 工作线程
void* io_worker(void* arg) {
thread_pool_t* pool = (thread_pool_t*)arg;
while (1) {
io_task_t* task = get_task_from_queue(pool);
if (task == NULL) break;
ssize_t ret;
if (task->is_write) {
ret = pwrite(task->fd, task->buf, task->count, task->offset);
} else {
ret = pread(task->fd, task->buf, task->count, task->offset);
}
// 回调通知结果
if (task->callback) {
task->callback(ret, task->arg);
}
free(task);
}
return NULL;
}
这个模式的好处是:你可以控制线程数量,避免创建太多线程导致系统过载。而且每个线程独立处理I/O,天然线程安全。
核心思路: 异步I/O的本质是「不阻塞调用者」。用线程池来实现,既达到了异步的效果,又避免了复杂的异步编程模型。说白了,就是用线程的同步来模拟异步。
但要注意,线程池处理文件I/O也有坑。我遇到过一个问题:线程池里的线程都在等文件I/O完成,结果CPU利用率很低,但任务队列却越积越多。为什么?因为文件I/O是阻塞的,线程在等待I/O时不能处理其他任务。
解决方案有两个:
- 使用更大的线程池——让更多线程同时处理I/O
- 使用非阻塞I/O + 轮询——用O_NONBLOCK打开文件,然后轮询
但说实话,文件I/O的非阻塞模式在普通文件上并不好用。因为普通文件总是可读可写的,非阻塞标志对普通文件基本无效。只有管道、socket这些特殊文件才真正支持非阻塞。
所以,我的建议是:对于普通文件,用线程池 + 同步I/O就够了。对于高性能场景,考虑用Linux AIO或者直接内存映射(mmap)。
17.4 实战:多线程日志系统
说了这么多,我们来写一个实际的多线程日志系统。这个系统要支持:
- 多个线程同时写日志
- 日志不会交错
- 性能不能太差
我选择用每个线程独立缓冲区 + 一个专门的写线程的方案。这样每个线程只需要往自己的缓冲区里写,不用加锁。写线程负责把缓冲区的内容刷到文件里。
#define MAX_THREADS 64
#define BUF_SIZE 4096
typedef struct {
char buf[BUF_SIZE];
int len;
pthread_mutex_t lock;
} log_buffer_t;
static log_buffer_t buffers[MAX_THREADS];
static int log_fd;
static pthread_t writer_thread;
void* log_writer(void* arg) {
while (1) {
for (int i = 0; i < MAX_THREADS; i++) {
pthread_mutex_lock(&buffers[i].lock);
if (buffers[i].len > 0) {
write(log_fd, buffers[i].buf, buffers[i].len);
buffers[i].len = 0;
}
pthread_mutex_unlock(&buffers[i].lock);
}
usleep(1000); // 1ms轮询一次
}
return NULL;
}
void log_write(int thread_id, const char* msg, int len) {
pthread_mutex_lock(&buffers[thread_id].lock);
// 如果缓冲区满了,先刷到文件
if (buffers[thread_id].len + len > BUF_SIZE) {
write(log_fd, buffers[thread_id].buf, buffers[thread_id].len);
buffers[thread_id].len = 0;
}
memcpy(buffers[thread_id].buf + buffers[thread_id].len, msg, len);
buffers[thread_id].len += len;
pthread_mutex_unlock(&buffers[thread_id].lock);
}
这个方案的好处是:每个线程写自己的缓冲区,锁的粒度很小。写线程批量刷盘,减少了系统调用次数。我在一个高并发项目里用过类似的设计,性能比直接加文件锁好了10倍以上。
嗯,当然这个方案也有缺点:如果程序崩溃,缓冲区里的日志会丢失。如果你需要严格的数据持久化,那就要用同步写,或者用fsync()强制刷盘。
17.5 本章知识体系
下面这张图展示了多线程文件处理的核心知识结构:
这张图把本章的核心内容串起来了。你想想看,文件读写、文件锁、异步I/O,这三者其实是解决同一个问题的不同角度。文件读写解决的是「怎么读写」,文件锁解决的是「怎么互斥」,异步I/O解决的是「怎么不阻塞」。
在实际项目中,这三者经常要组合使用。比如一个高性能的日志系统,既要用到线程安全的读写(pread/pwrite),也要用到文件锁(防止多个进程同时写),还要用到异步I/O(避免日志写入阻塞业务逻辑)。
好了,这一章的内容就到这里。多线程文件处理,说白了就是要在「数据安全」和「性能」之间找到平衡点。没有银弹,只有根据场景选择合适的技术方案。
公众号:蓝海资料掘金营,微信deep3321