第十七章 多线程文件处理:多线程文件读写、文件锁与异步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 本章知识体系

下面这张图展示了多线程文件处理的核心知识结构:

多线程文件处理知识体系 多线程文件处理 文件读写 pread() / pwrite() 独立打开文件 缓冲区管理 文件锁 flock() - 整个文件锁 fcntl() - 区域记录锁 共享锁 / 排他锁 异步I/O POSIX AIO (aio_*) Linux AIO (io_*) 线程池 + 同步I/O 核心原则:线程安全 + 性能平衡

这张图把本章的核心内容串起来了。你想想看,文件读写、文件锁、异步I/O,这三者其实是解决同一个问题的不同角度。文件读写解决的是「怎么读写」,文件锁解决的是「怎么互斥」,异步I/O解决的是「怎么不阻塞」。

在实际项目中,这三者经常要组合使用。比如一个高性能的日志系统,既要用到线程安全的读写(pread/pwrite),也要用到文件锁(防止多个进程同时写),还要用到异步I/O(避免日志写入阻塞业务逻辑)。

好了,这一章的内容就到这里。多线程文件处理,说白了就是要在「数据安全」和「性能」之间找到平衡点。没有银弹,只有根据场景选择合适的技术方案。


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