关于文件写入的原子性讨论

简介: ​   文件的写入是否是原子的?多个线程写入同一个文件是否会写错乱?多个进程写入同一个文件是否会写错乱?想必这些问题多多少少会对我们产生一定的困扰,即使知道结果,很多时候也很难将这其中的原理清晰的表达给提问者,侯捷曾说过,**源码面前,了无秘密**,那么本文也希望从源代码的角度分析上述问题。在开始之前我们需要补充一下Linux 文件相关的一些基础原理,便于更好的看懂Linux源

​   文件的写入是否是原子的?多个线程写入同一个文件是否会写错乱?多个进程写入同一个文件是否会写错乱?想必这些问题多多少少会对我们产生一定的困扰,即使知道结果,很多时候也很难将这其中的原理清晰的表达给提问者,侯捷曾说过,源码面前,了无秘密,那么本文也希望从源代码的角度分析上述问题。在开始之前我们需要补充一下Linux 文件相关的一些基础原理,便于更好的看懂Linux源代码。

​   学过Linux的读者想必都应该知道文件的数据分为两个部分,一个部分就是文件数据本身,另外一个部分则是文件的元数据,也就是inode、权限、扩展属性、mtime、ctime、atime等等,inode对于一个文件来说及其的重要,可以唯一的标识一个文件(实际应该是inode + dev号,唯一标识一个文件,更准确来说应该是在同一个文件系统的前提下才成立,不同的文件系统inode是会重复的,不过这不是重点,姑且这里不严谨的认为inode就是用来唯一标识一个文件的吧),内核中将inode号和文件的元数据构建为一个struct inode对象,该对象结构如下:

struct inode {
    umode_t            i_mode;
    uid_t            i_uid;
    gid_t            i_gid;
    unsigned long        i_ino;
    atomic_t        i_count;
    dev_t            i_rdev;
    loff_t            i_size;
    struct timespec        i_atime;
    struct timespec        i_mtime;
    struct timespec        i_ctime;
    .......// 省略
};

​   通过这个inode对象就可以关联一个文件,然后对这个文件进行读写操作,Linux内核对于文件同样也有一个struct file对象来表示,该对象结构如下:

struct file {
      .....
    const struct file_operations    *f_op;
    loff_t            f_pos;
    struct address_space    *f_mapping;
     ....// 省略
};

​   有几个成员比较关键,一个是f_op,文件操作的方法集合,文件操作不用关心其底层的文件系统是什么,直接通过f_op成员找到对应的方法即可。另外一个则是f_pos,也就是这个文件读到哪里了,或者说是写到哪里了,是一个偏移量。一个进程打开一个文件的时候就会在内核中创建一个struct file对象,读取文件的时候则分为以下几步:

  1. 通过fd找到对应对应的struct file对象
  2. 通过struct file对象获取当前的offset,也就是读取f_pos成员
  3. 通过f_op找到对应的操作方法,并传入要读取的偏移量进行数据的读取
  4. 读取完成后,重新设置新的offset

一次读文件的过程便是如此,对应到代码也是非常的清晰,如下:

// vfs_read -> do_sync_read
ssize_t do_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = buf, .iov_len = len };
    struct kiocb kiocb;
    ssize_t ret;
    // 设置要读取的长度和开始的偏移量
    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = *ppos;
    kiocb.ki_left = len;
    kiocb.ki_nbytes = len;

    for (;;) {
        // 实际开始进行读取操作
        ret = filp->f_op->aio_read(&kiocb, &iov, 1, kiocb.ki_pos);
        if (ret != -EIOCBRETRY)
            break;
        wait_on_retry_sync_kiocb(&kiocb);
    }

    if (-EIOCBQUEUED == ret)
        ret = wait_on_sync_kiocb(&kiocb);
    // 读完后更新最后的offset
    *ppos = kiocb.ki_pos;
    return ret;
}

​   文件的写入也是如此,拿到offet,调用实际的写入方法,最后更新offset。到此为止一个文件的读和写的大体过程我们是清楚了,很显然上述的过程并不是原子的,无论是文件的读还是写,都至少有两个步骤,一个是拿offset,另外一个则是实际的读和写。并且在整个过程中并没有看到加锁的动作,那么第一个问题就得到了解决。对于第二个问题我们可以简要的分析下,假如有两个线程,第一个线程拿到offset是1,然后开始写入,在写入的过程中,第二个线程也去拿offset,因为对于一个文件来说多个线程是共享同一个struct file结构,因此拿到的offset仍然是1,这个时候线程1写结束,更新offset,然后线程2开始写。最后的结果显而易见,线程2覆盖了线程1的数据,通过分析可知,多线程写文件不是原子的,会产生数据覆盖。但是否会产生数据错乱,也就是数据交叉写入了?其实这种情况是不会发生的,至于为什么请看下面这段代码:

ssize_t generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,
        unsigned long nr_segs, loff_t pos)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    struct blk_plug plug;
    ssize_t ret;

    BUG_ON(iocb->ki_pos != pos);
    // 文件的写入其实是加锁的
    mutex_lock(&inode->i_mutex);
    blk_start_plug(&plug);
    ret = __generic_file_aio_write(iocb, iov, nr_segs, &iocb->ki_pos);
    mutex_unlock(&inode->i_mutex);

    if (ret > 0 || ret == -EIOCBQUEUED) {
        ssize_t err;

        err = generic_write_sync(file, pos, ret);
        if (err < 0 && ret > 0)
            ret = err;
    }
    blk_finish_plug(&plug);
    return ret;
}
EXPORT_SYMBOL(generic_file_aio_write);

​   所以并不会产生数据错乱,只会存在数据覆盖的问题,既然如此我们在实际的进行文件读写的时候是否需要进行加锁呢? 加锁的确是可以解决问题的,但是在这里未免有点牛刀杀鸡的感觉,好在OS给我们提供了原子写入的方法,第一种就是在打开文件的时候添加O_APPEND标志,通过O_APPEND标志将获取文件的offset和文件写入放在一起用锁进行了保护,使得这两步是原子的,具体代码可以看上面代码中的__generic_file_aio_write函数。


ssize_t __generic_file_aio_write(struct kiocb *iocb, const struct iovec *iov,
                 unsigned long nr_segs, loff_t *ppos)
{
    struct file *file = iocb->ki_filp;
    struct address_space * mapping = file->f_mapping;
    size_t ocount;        /* original count */
    size_t count;        /* after file limit checks */
    struct inode     *inode = mapping->host;
    loff_t        pos;
    ssize_t        written;
    ssize_t        err;

    ocount = 0;
    err = generic_segment_checks(iov, &nr_segs, &ocount, VERIFY_READ);
    if (err)
        return err;

    count = ocount;
    pos = *ppos;

    vfs_check_frozen(inode->i_sb, SB_FREEZE_WRITE);

    /* We can write back this queue in page reclaim */
    current->backing_dev_info = mapping->backing_dev_info;
    written = 0;
    // 重点就在这个函数
    err = generic_write_checks(file, &pos, &count, S_ISBLK(inode->i_mode));
    if (err)
        goto out;
    ......// 省略
}

inline int generic_write_checks(struct file *file, loff_t *pos, size_t *count, int isblk)
{
    struct inode *inode = file->f_mapping->host;
    unsigned long limit = rlimit(RLIMIT_FSIZE);

        if (unlikely(*pos < 0))
                return -EINVAL;

    if (!isblk) {
        /* FIXME: this is for backwards compatibility with 2.4 */
          // 如果带有O_APPEND标志,会直接拿到文件的大小,设置为新的offset
        if (file->f_flags & O_APPEND)
                        *pos = i_size_read(inode);

        if (limit != RLIM_INFINITY) {
            if (*pos >= limit) {
                send_sig(SIGXFSZ, current, 0);
                return -EFBIG;
            }
            if (*count > limit - (typeof(limit))*pos) {
                *count = limit - (typeof(limit))*pos;
            }
        }
    }
    ......// 省略
}

​   通过上面的代码可知,如果带有O_APPEND标志的情况,在文件真正写入之前会调用generic_write_checks进行一些检查,在检查的时候如果发现带有O_APPEND标志就将offset设置为文件的大小。而这整个过程都是在加锁的情况下完成的,所以带有O_APPEND标志的情况下,文件的写入是原子的,多线程写文件是不会导致数据错乱的。另外一种情况就是pwrite系统调用,pwrite系统调用通过让用户指定写入的offset,值得整个写入的过程天然的变成原子的了,在上文说到,整个写入的过程是因为获取offset和文件写入是两个独立的步骤,并没有加锁,通过pwrite省去了获取offset这一步,最终整个文件写入只有一步加锁的文件写入过程了。pwrite的代码如下:

SYSCALL_DEFINE(pwrite64)(unsigned int fd, const char __user *buf,
             size_t count, loff_t pos)
{
    struct file *file;
    ssize_t ret = -EBADF;
    int fput_needed;

    if (pos < 0)
        return -EINVAL;

    file = fget_light(fd, &fput_needed);
    if (file) {
        ret = -ESPIPE;
        if (file->f_mode & FMODE_PWRITE)  
            // 直接把offset也就是pos传递进去,而普通的write需要
            // 需要先从struct file中拿到offset,然后传递进去
            ret = vfs_write(file, buf, count, &pos);
        fput_light(file, fput_needed);
    }

    return ret;
}

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
        size_t, count)
{
    struct file *file;
    ssize_t ret = -EBADF;
    int fput_needed;

    file = fget_light(fd, &fput_needed);
    if (file) {
        // 第一步拿offset
        loff_t pos = file_pos_read(file);
        // 第二步实际的写入
        ret = vfs_write(file, buf, count, &pos);
        // 第三步写回offset
        file_pos_write(file, pos);
        fput_light(file, fput_needed);
    }

    return ret;
}

​   最后一个问题是多个进程写同一个文件是否会造成文件写错乱,直观来说是多进程写文件不是原子的,这是很显而易见的,因为每个进程都拥有一个struct file对象,是独立的,并且都拥有独立的文件offset,所以很显然这会导致上文中说到的数据覆盖的情况,但是否会导致数据错乱呢?,答案是不会,虽然struct file对象是独立的,但是struct inode是共享的(相同的文件无论打开多少次都只有一个struct inode对象),文件的最后写入其实是先要写入到页缓存中,而页缓存和struct inode是一一对应的关系,在实际文件写入之前会加锁,而这个锁就是属于struct inode对象(见上文中的mutex_lock(&inode->i_mutex))的,所有无论有多少个进程或者线程,只要是对同一个文件写数据,拿到的都是同一把锁,是线程安全的,所以也不会出现数据写错乱的情况。

目录
相关文章
|
数据中心 Anolis
性能优化特性之:LSE指令集编译优化
本文介绍了倚天实例上的编译优化特性:LSE,并从优化原理、使用方法进行了详细阐述。
|
监控 调度 开发工具
IO神器blktrace使用介绍
## 前言 1. blktrace的作者正是block io的maintainer,开发此工具,可以更好的追踪IO的过程。 2. blktrace 结合btt可以统计一个IO是在调度队列停留的时间长,还是在硬件上消耗的时间长,利用这个工具可以协助分析和优化问题。 ## blktrace的原理 一个I/O请求的处理过程,可以梳理为这样一张简单的图: ![](http://image
17302 0
|
7月前
|
机器学习/深度学习 人工智能 算法
基于DCT和扩频的音频水印嵌入提取算法matlab仿真
本文介绍了结合DCT和扩频技术的音频水印算法,用于在不降低音质的情况下嵌入版权信息。在matlab2022a中实现,算法利用DCT进行频域处理,通过扩频增强水印的隐蔽性和抗攻击性。核心程序展示了水印的嵌入与提取过程,包括DCT变换、水印扩频及反变换步骤。该方法有效且专业,未来研究将侧重于提高实用性和安全性。
|
7月前
|
编解码 网络协议 程序员
【RTP 传输协议】实时视频传输的艺术:深入探索 RTP 协议及其在 C++ 中的实现
【RTP 传输协议】实时视频传输的艺术:深入探索 RTP 协议及其在 C++ 中的实现
1372 0
|
设计模式 数据采集 IDE
我们一直谈论“写代码”,但你会“读代码”吗?
编程,又被称作“写代码”。这个说法有可能会带来一点点误解,让人觉得如何“写”是学习编程要解决的主要问题。但事实并非如此。尽管最终代码要在键盘上敲出来,但这个过程在开发中的实际时间占比可能要远远小于你的预期。编写之前的设计,编写之后的调试,以及阅读他人的代码,这些会花费比“写”更多的时间。
|
SQL 存储 缓存
【ACID底层实现原理、一致性非锁定读(MVCC的原理)、BufferPool缓存机制、重做日志刷盘策略、隔离级别】
【ACID底层实现原理、一致性非锁定读(MVCC的原理)、BufferPool缓存机制、重做日志刷盘策略、隔离级别】
51536 1
【ACID底层实现原理、一致性非锁定读(MVCC的原理)、BufferPool缓存机制、重做日志刷盘策略、隔离级别】
|
SQL 存储 Oracle
精通Java事务编程(3)-弱隔离级别之快照隔离和可重复读
表面看,RC已满足事务所需的一切特征:支持中止(原子性),防止读取不完整的事务结果,并防止并发写的混乱。这点很关键!为我们的开发省去一大堆麻烦。
124 0
|
SQL 安全 Oracle
精通Java事务编程(2)-弱隔离级别之已提交读
若两个事务不触及相同数据,即无数据依赖关系,则它们能安全并行运行。只有当: 某事务读取由另一个事务同时修改的数据时 或两个事务同时修改相同数据
114 0
|
5月前
|
Prometheus 监控 Cloud Native
Java中的日志管理与监控技术选型
Java中的日志管理与监控技术选型
volatile的读写过程(四)
volatile的读写过程
107 0
volatile的读写过程(四)