文件的多进程读写

时间:2021-07-23 15:04:20

 一直以为多进程读写文件是安全的,只有fflush和fclose才会真正修改文件内容,诸多开源项目的日志系统也基本都是多线程的,asterisk同样没有为ast_log建立一个专门的日志线程,而是各自调用,通过fflush直接写缓存。但是最近在8032上面发现的一个问题改变了我的看法。

 

问题描述:
    跟踪用户设备时发现网管配置文件snmp.conf出现过几次文件内容丢失的情况,实验室环境也同样出现过,多出现在恢复配置,重启时,难以重现,没有规律。

 

重现模型:
    在实际使用中较难重现,因此考虑通过实验来放大问题,找出原因。
1.  编写test可执行程序,功能是修改test.txt中的一个字段,并保存。
2.  编写test.sh脚本,无限循环执行test程序。
3.  分别开两个命令行窗口,执行test.sh,模拟文件的多进程读写模型。

    拷机半分钟到一分钟后,就会出现test.txt文件内容被清空的情况,问题重现。

 

问题分析:
    文件内容的修改,常用算法是先r只读模式读出文件内容,在字符串中修改文件内容,然后再通过w写模式打开文件,写入修改后的字符串,保存退出。

    虽然做了实验重现了现象,还是觉得难以理解,fclose是如此的安全,甚至让我觉得对它有任何的怀疑都是一种*。于是在如图A,B,C,D,E处分别做了sleep延时处理,然后另一进程进行文件修改操作。
    实验后发现,问题出在D处。fclose的确是安全的,但是w模式不是。w模式打开文件后,文件长度被立即截断为0,而不是在fclose后才修改,此时另一进程读此文件就会发生读到空数据的问题。

 

进一步分析:
    w模式是不安全的,那么同样常用的a模式呢。APUE 5.5 打开流 中提到

当用添加类型打开一文件后,则每次写都将数据写到文件的当前尾端处。如若有多个进程用标准I / O添加方式打开了同一文件,那么来自每个进程的数据都将正确地写到文件中。

 

    那么Linux是如何保证a模式的多进程安全性呢。APUE 中提到

3.10 文件共享

如果用O_APPEND标志打开了一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有添写标志的文件执行写操作时,在文件表项中的当前文件位移量首先被设置为i节点表项中的文件长度。这就使得每次写的数据都添加到文件的当前尾端处。

3.11 原子操作
UNIX提供了一种方法是这种操作(文件写入)成为原子操做, 其方法就是在打开文件时设置O_APPEND标志。

 

    上述内容让人对a模式的安全性有了一点信心,但是注意到文件写入最终是通过系统调用write实现的,系统调用本身是可能发生CPU进程调度的,那么Linux如何保证一次文件写入过程中不会被另一个写入进程插入呢。比如一个进程写入10个a,另一个进程写入10个b,如何保证这10个a和10个b之间不会互相交错呢。

    APUE上没有找到对write原子性的明确描述,但是在 15.2 管道 中提到

在写管道时,常数PIPE_BUF规定了内核中管道缓存器的大小。如果对管道进行write调用,而且要求写的字节数小于等于PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的write操作穿插进行。但是,若有多个进程同时写一个管道(或FIFO),而且某个或某些进程要求写的字节数超过PIPE_BUF字节数,则数据可能会与其他写操作的数据相穿插。

 

    可见,write的原子性其实还是有限制的,管道有PIPE_BUF的限制(PIPE_BUF值一般为512),可以想见,普通文件应该也是有一个限制值,很难想象如果write个几G的数据都是原子操作的话系统会变成一个什么模样,因此,可以认为,在write较少的数据时,linux内核保证了它的原子性,但是一次写入数据较多时,write是可能被其他操作中途插入的。

 

结论:
    文件的多进程读写并不是绝对安全的,因此,如果要保证一个文件的绝对安全,应该保证同一时刻仅有一个进程在打开它修改它,比较好的方法应该是syslog的做法,建立一个监听线程专门负责该文件写入,其他线程通过向该线程发送消息队列来写入数据,次一些的方法可以使用文件锁等。

    对于不是非常关心文件安全的模块来说(如log模块),则应该采用a模式的方法写入数据。

    对于不适用于a模式的模块来说(如配置文件,需要修改配置文件,而不是从尾部添加),则应尽量避免多进程读写同一文件,从这点意义上说,asterisk的ast_config模块同样存在该问题,一旦发生多进程读写,就有文件内容丢失的风险。

    注意到文件丢失实际上是由w模式引起的,那么如果非要多进程修改文件,又不愿意专门新建线程写文件的话,可以通过以下方法折中处理规避文件内容丢失问题。

1. r+读写模式打开文件。
2. 读出文件内容到内存中。
3. 修改内存中内容。
4. 重定向到文件头部。
5. 写入修改后的文件内容。
6. 关闭文件。
7. 截断文件。

    采用r+的文件打开方式,规避了w模式的弊端,另外结合截断函数实现对文件尾部的正确修改,截断函数原型如下:

NAME
       truncate, ftruncate - truncate a file to a specified length

SYNOPSIS
       #include <unistd.h>
       #include <sys/types.h>

       int truncate(const char *path, off_t length);
       int ftruncate(int fd, off_t length);

    通过该方法修改上述模型,多进程执行test.sh后文件内容没有丢失。但是注意到,关闭文件和截断文件毕竟是两个函数,不是原子操作,因此中间是可能被其他操作中断或插入,但可以肯定的是,不会出现文件内容丢失的情况。