C语言文件锁与并发控制完全指南

系统编程并发

文件锁基础概念

当多个进程同时访问同一个文件时,如果不加控制,可能导致数据不一致或文件损坏。文件锁就是操作系统提供的用于协调并发文件访问的机制。Linux 系统提供了两种主要的文件锁接口:flock() 和 fcntl(),它们在锁粒度、语义和适用场景上各有不同。

文件锁的分类

从锁类型来看,文件锁分为两种:

  • 共享锁(读锁):多个进程可以同时持有共享锁,用于读取文件,互不干扰
  • 排他锁(写锁):同一时刻只能有一个进程持有排他锁,且此时不能有共享锁存在,用于写入文件

从锁粒度来看,又可分为整文件锁和记录锁(字节范围锁)。整文件锁对整个文件加锁,记录锁可以对文件的任意字节区间加锁。

flock 文件锁

flock() 是 BSD 系统引入的文件锁接口,语义简洁,对整个文件加锁。它只区分共享锁和排他锁,不支持记录级别的锁定。在 Linux 上,flock() 锁与文件描述符和文件表项关联,而非进程。

flock 基本用法

flock() 的操作类型包括:LOCK_SH(共享锁)、LOCK_EX(排他锁)、LOCK_UN(解锁),以及 LOCK_NB(非阻塞标志)。

#include <sys/file.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main(void) {
    int fd = open("data.txt", O_RDWR | O_CREAT, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    /* 获取排他锁 */
    if (flock(fd, LOCK_EX) < 0) {
        perror("flock LOCK_EX");
        close(fd);
        return 1;
    }

    /* 写入数据 */
    const char *data = "Protected write operation\n";
    write(fd, data, strlen(data));

    printf("Write completed, releasing lock...\n");

    /* 释放锁 */
    flock(fd, LOCK_UN);
    close(fd);
    return 0;
}

非阻塞加锁模式

默认情况下 flock() 是阻塞的,如果锁已被其他进程持有,调用进程会一直等待。加上 LOCK_NB 标志后,如果无法立即获取锁,flock() 会立即返回 -1 并设置 errno 为 EWOULDBLOCK。

#include <sys/file.h>
#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
#include <unistd.h>

int main(void) {
    int fd = open("data.txt", O_RDWR);
    if (fd < 0) { perror("open"); return 1; }

    /* 尝试非阻塞获取排他锁 */
    if (flock(fd, LOCK_EX | LOCK_NB) < 0) {
        if (errno == EWOULDBLOCK) {
            printf("File is locked by another process\n");
        } else {
            perror("flock");
        }
        close(fd);
        return 1;
    }

    printf("Lock acquired successfully\n");
    /* ... 临界区操作 ... */
    flock(fd, LOCK_UN);
    close(fd);
    return 0;
}

fcntl 记录锁

fcntl() 记录锁是 POSIX 标准定义的文件锁机制,可以对文件的任意字节范围加锁,因此称为记录锁或字节范围锁。与 flock() 不同,fcntl() 锁与 (进程ID, 文件inode) 关联,而非文件描述符。

fcntl 锁的结构体与操作

fcntl() 记录锁使用 struct flock 描述锁的属性,包括锁类型、起始偏移量、长度和锁的持有进程。

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* 对文件的指定范围加排他锁 */
int lock_region(int fd, off_t offset, off_t len) {
    struct flock fl;
    memset(&fl, 0, sizeof(fl));
    fl.l_type   = F_WRLCK;       /* 排他锁 */
    fl.l_whence = SEEK_SET;      /* 偏移基准 */
    fl.l_start  = offset;        /* 起始偏移 */
    fl.l_len    = len;           /* 锁定长度,0 表示到文件末尾 */

    if (fcntl(fd, F_SETLKW, &fl) < 0) {
        perror("fcntl F_SETLKW");
        return -1;
    }
    return 0;
}

/* 解锁指定范围 */
int unlock_region(int fd, off_t offset, off_t len) {
    struct flock fl;
    memset(&fl, 0, sizeof(fl));
    fl.l_type   = F_UNLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start  = offset;
    fl.l_len    = len;

    if (fcntl(fd, F_SETLKW, &fl) < 0) {
        perror("fcntl unlock");
        return -1;
    }
    return 0;
}

int main(void) {
    int fd = open("records.dat", O_RDWR | O_CREAT, 0644);
    if (fd < 0) { perror("open"); return 1; }

    /* 锁定第 100~199 字节 */
    if (lock_region(fd, 100, 100) == 0) {
        printf("Locked region [100, 200)\n");

        /* 在临界区内执行文件操作 */
        lseek(fd, 100, SEEK_SET);
        const char *data = "Updated record";
        write(fd, data, strlen(data));

        unlock_region(fd, 100, 100);
        printf("Unlocked region\n");
    }

    close(fd);
    return 0;
}

检测锁冲突

使用 F_GETLK 命令可以检测指定的锁是否会被其他进程的锁阻塞。如果存在冲突,fcntl() 会将冲突锁的信息写入 flock 结构体并返回 -1。

#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    int fd = open("records.dat", O_RDWR);
    if (fd < 0) { perror("open"); return 1; }

    struct flock fl;
    memset(&fl, 0, sizeof(fl));
    fl.l_type   = F_WRLCK;
    fl.l_whence = SEEK_SET;
    fl.l_start  = 0;
    fl.l_len    = 0;        /* 整个文件 */

    /* 检测是否能获取写锁 */
    if (fcntl(fd, F_GETLK, &fl) < 0) {
        perror("fcntl F_GETLK");
        close(fd);
        return 1;
    }

    if (fl.l_type == F_UNLCK) {
        printf("No conflicting lock, can proceed\n");
    } else {
        printf("Lock held by pid %d, type %s, "
               "region [%ld, %ld)\n",
               fl.l_pid,
               fl.l_type == F_RDLCK ? "READ" : "WRITE",
               (long)fl.l_start,
               (long)(fl.l_start + fl.l_len));
    }

    close(fd);
    return 0;
}

flock 与 fcntl 的区别

两种文件锁机制在实现细节和行为上存在显著差异,理解这些差异对于正确使用文件锁至关重要。

核心差异对比

  • 锁粒度:flock() 只能对整个文件加锁;fcntl() 可以对文件的任意字节区间加锁
  • 锁关联对象:flock() 锁与文件表项关联,通过 dup 复制的文件描述符共享同一把锁;fcntl() 锁与 (pid, inode) 关联,进程内所有文件描述符共享同一把锁
  • 锁释放时机:flock() 在关闭所有引用同一文件表项的文件描述符时释放;fcntl() 在进程终止或显式解锁时释放
  • 子进程继承:flock() 锁不会被 fork 继承(但 dup 后共享);fcntl() 锁属于进程,fork 后子进程不继承
  • 标准兼容:flock() 来自 BSD,fcntl() 属于 POSIX 标准,可移植性更好

选择建议

如果你的场景只需要对整个文件加锁,flock() 更简单直观;如果需要对文件的部分区域加锁,或者需要跨 NFS 使用,fcntl() 是更合适的选择。在 NFS 环境下,fcntl() 的记录锁通过 lock daemon 可以在网络文件系统上正常工作,而 flock() 通常是本地有效的。

实战:并发安全的日志写入

在实际项目中,多个进程向同一个日志文件写入数据是常见的并发场景。下面展示一个使用 flock() 实现的并发安全日志模块。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>

typedef struct {
    int fd;
    char path[256];
} logger_t;

int logger_open(logger_t *log, const char *path) {
    log->fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0644);
    if (log->fd < 0) {
        perror("logger_open");
        return -1;
    }
    strncpy(log->path, path, sizeof(log->path) - 1);
    return 0;
}

void logger_write(logger_t *log, const char *level, const char *msg) {
    /* 获取排他锁,保证写入的原子性 */
    if (flock(log->fd, LOCK_EX) < 0) {
        perror("flock");
        return;
    }

    time_t now = time(NULL);
    struct tm *t = localtime(&now);
    char timestamp[32];
    strftime(timestamp, sizeof(timestamp),
              "%Y-%m-%d %H:%M:%S", t);

    char line[512];
    int n = snprintf(line, sizeof(line),
                     "[%s] [%s] %s\n", timestamp, level, msg);
    write(log->fd, line, n);

    flock(log->fd, LOCK_UN);
}

void logger_close(logger_t *log) {
    if (log->fd >= 0) {
        close(log->fd);
        log->fd = -1;
    }
}

int main(void) {
    logger_t log;
    if (logger_open(&log, "app.log") < 0)
        return 1;

    logger_write(&log, "INFO", "Application started");
    logger_write(&log, "WARN", "Low memory detected");
    logger_write(&log, "ERROR", "Connection timeout");

    logger_close(&log);
    return 0;
}

总结

本文详细介绍了 C 语言中两种主要的文件锁机制及其实际应用:

  • flock():接口简洁,适用于整文件加锁场景,锁与文件描述符关联,同一文件表项的描述符共享锁
  • fcntl() 记录锁:支持字节范围锁定,POSIX 标准兼容,锁与进程关联,适合需要精细控制的场景
  • 两者差异:锁粒度、关联对象、释放时机、NFS 支持等方面存在本质区别,需根据场景选择
  • 实战应用:并发日志写入是文件锁的典型用例,结合 O_APPEND 标志和文件锁可保证写入原子性

文件锁虽然是一种相对简单的并发控制手段,但在多进程文件操作场景中不可或缺。正确理解 flock() 和 fcntl() 的语义差异,避免混用两种锁(它们互不感知,可能导致死锁),是编写健壮并发程序的关键。在实际开发中,建议根据项目需求选择其中一种并统一使用,确保并发控制逻辑的一致性和可维护性。