关于O_APPEND模式write的原子性

简介:

上上周的事情了,端午小长假将近,还是按往常一样,最后一天一定要搞一个“课题”,场面不大,一天就能搞定的东西,如果说系统学习vim或者Emacs之 类的,那就算了...还好,问题呼之即来,那就是write系统调用是不是原子的,答案很显然,不是!但大师说带有APPEND标志的write是原子 的,很多软件的日志都是O_APPEND打开,然后在不加锁的情况下直接write的,不会出现问题,此事如何证实?本文给出答案。
       曾经纠结于Linux的write系统调用是不是原子的,答案是显然的,不是!为什么不是呢?这个问题可不是那么好回答,本文试图用一种简单的方式解释一 下。另外,本文也将说明一下O_APPEND方式的write为什么是原子的,同样是简单的方式,只做实验或者思想试验,不讲代码。但是作为基础,我给出 重要结构体的伪实现:
1.inode结构
表示一个文件实体,每一个磁盘中的文件只有一个inode对象与之对应。
2.file结构
表示一个文件实体在进程中的代表,需要操作某个文件(即某个inode)并独立打开它的每一个进程都有一份独立的对应该inode的file对象。该对象拥有一个pos指针,表示一个file的当前位置,不管是read还是write均从这里开始。
3.task结构
操作file的主体。
       提到write操作,最基本的就是从哪里开始写的问题,即文件当前的position。一个write系统调用的语义就是,从position开始,写入 长度为len的参数buff,仅此而已,具体的写入很简单,就是内存拷贝,缓存管理,最后交给块设备即可,所以关键就是,position的定位。定位方 式分为3种:
1.调用lseek手工定位;
2.根据历史write操作自动定位;
3.根据O_APPEND标志自动定位;
lseek 手工定位很简单,即设置file的pos指针,根据历史write操作自动定位最好理解,比如你写入了n个字节,那么file的pos就向前推进n,在 write操作的最开始处得到file的pos,然后开始write,write完毕后根据实际写入的数量重新设置file的pos。O_APPEND方 式是完全和pos无关的,因为它根本就不用file的pos来定位写入开始的位置,而是根据inode的大小来定位,也就是将write的开始位置设置到 文件的末尾。
       好了,到此为止,我们完成了当前位置的定位,接下来就开始write了,现在的问题是,一次write是不是可以被另一次的write影响,为了更简单的 分析问题,我假设每次都将buffer一次性写完(因为一个buffer分多次写在多进程环境下肯定是会出现交叉的,毫无疑问!),即write的 count参数是多少,write的返回值就是多少。首先我将一个write操作流程化,假设每次写入的数据长度均为100,线程A写100个A,线程B 写100个B:
L1.get_pos
L2.write_buffer
L3.update_pos
以下分几个场景来讨论。

场景1:


线程A处在L2,线程B进入L1,无疑两个线程将获得相同的pos,当线程B紧随线程A其后进入L2的时候,线程B很有很能会将线程A的刚刚写入的数据抹掉。

场景1-1:

我在L2按照时间流逝的方向定义三个时间点,L2刚刚开始的时间(马上就要写第一个字节的那个点),中间的某个时间,L2结束的时间(写完第100字节的那个点,100是我们的假设),分别为,t1,t2,t3。
       线程A在时间t2被从CPU调度出去,不再运行,原因可能是有RT进程来袭,也可能时间片用尽...不管怎样,它不再运行了,线程B进入t1,此时线程A 已经写入了若干个A,假设是40个,然后线程B一口气跑到了t3,此时写入的100字节全部都是B。线程B脱离L2,此时线程A被重新拉回CPU,从第 41个字节开始,写入了60字节的A结束L2,此时文件的内容是前面40个B,后面60个A。

分析:

毫无疑问,上面的场景得到 的结论就是,在一次性的write中,不会出现交叉,而只能出现覆盖,而具体如何覆盖是不确定的,有完全覆盖,也有上述场景1-1中描述的不完全覆盖,但 是一般而言是不会出现不完整覆盖的情况的,甚至说在多个线程每次写入文件的字节数量相等的情况下,是100%不会出现!为什么呢?这是一个很关键的设计, 即L2的过程是不会被打断的,即它是原子的。不管什么模式的write,write本身都是原子的,比如你要写X字节的数据,但是由于某种原因只写了X- y个字节,那么写X-y字节数据的过程是原子的,所谓的write非原子性场景指的是pos定位和write之间的那段,单独的pos定位和write随 便一个,都是原子的。
       为了下面论述的方便,我重新流程化了write操作:
L1.get_pos
L2-0.lock_inode
L2-1.write_buffer
L2-2.unlock_inode
L3.update_pos

因此,所谓的非原子性write导致的事故只会发生在L1和L2以及L2和L3之间!

场景2:

线程A比线程B先进入L2,但是在L2和L3之间中让出CPU,导致线程B覆盖了线程A的数据,进而线程B先走出L3,按照自己的写入长度设置了pos,导致线程A被重新拉回CPU后,pos又被设置了回去。
       端午节假期前的最后一个工作日,同事在纠结于一个问题,为何ngx或者apache写日志的时候都是直接写的,为何不lock,write既然是非原子 的,难道就不怕乱掉吗?确实没有乱掉,也真的没有lock,到底原因何在?按照上面的分析,频繁写的时候,应该会乱才对!由于我对ngx的代码不熟,也就 没有去细看,我觉得它好像用了O_APPENDB标志打开的文件。O_APPEND是何方神圣?为了揭示它,我为O_APPEND模式进一步扩充上面 write的流程:
L1.get_pos
L2-0.lock_inode
L2-1.change_pos_to_inode->size
L2-2.write_buffer
L2-3.update_inode->size
L2-4.unlock_inode
L3.update_pos
我 想到此为止,不用多说,也应该知道为何O_APPEND模式打开的文件会是原子操作了,多个线程或者进程随便写入,不会交叉,不会覆盖。不过要再次重申, 如果一次write没有写完一个buffer,分了好几次写,那么即便是O_APPEND模式的文件write,也会出现交叉,因为两次write之间是 没有任何机制保护的。
       通过上述的分析,我们可以看出,真正写的过程是绝对lock的,但是write系统调用除了真正的写,还包括pos的定位,这个定位发生在lock之后还是之前决定了本次调用的write是原子的还是非原子的。
注解:场景2模拟代码
说 实话,在现代CPU上重现场景2造成的现象特别难,几十行的代码你看得很累,对于CPU而言,弹指一挥间就执行完了,因此必须模拟实现,在 mm/filemap.c的generic_file_aio_write函数中的mutex_unlock后面加入以下的代码即可(你也可以用 jprobe在里面耽搁一下):

if (!strcmp(current->comm, "child")) {
#include <linux/sched.h>
    struct task_struct *pp = current->real_parent;   
    while(pp && !strcmp(pp->comm, "parent")) {
        schedule_timeout(1);
    }
}


加入这些代码是为了模拟线程A被调度出去的情景,既然我知道调度出去并且线程B赶超线程A之后肯定会有问题,并且这确实会发生,我只是不知道它什么时候发生而已,因此我就制造一个它发生的假象。
       至于怎么设计对应的应用程序,唉...fork+exec。
Linus的应付之道
就事论事的Linus解决原子write的方式超级优美,看一下他的风格:
重新定义两个带有lock机制的pos_read/write,总的来讲就是为pos设置一把锁:

+static inline loff_t file_pos_read_lock(struct file *file)
 {
+	if (file->f_mode & FMODE_LSEEK)
+		mutex_lock(&file->f_pos_lock);
 	return file->f_pos;
 }
+static inline void file_pos_write_unlock(struct file *file, loff_t pos)
 {
 	file->f_pos = pos;
+	if (file->f_mode & FMODE_LSEEK)
+		mutex_unlock(&file->f_pos_lock);
 }

修改sys_write系统调用:

 	file = fget_light(fd, &fput_needed);
 	if (file) {
-		loff_t pos = file_pos_read(file);
+		loff_t pos = file_pos_read_lock(file);
 		ret = vfs_write(file, buf, count, &pos);
-		file_pos_write(file, pos);
+		file_pos_write_unlock(file, pos);
 		fput_light(file, fput_needed);
 	}

这种短平快的风格一针见血指出了问题的解决之道,事实上,大多数的复杂性都是优化的副产品!



 本文转自 dog250 51CTO博客,原文链接:http://blog.51cto.com/dog250/1423346
相关文章
|
5月前
|
存储 C++ iOS开发
采用read()和write()读写二进制文件
C++ 中文本与二进制文件读写的区别在于数据存储格式和效率。文本文件以可读字符存储,浪费空间且不利于高效查找。二进制文件紧凑且高效,适合存储结构化数据如CStudent对象。`&gt;&gt;`和`&lt;&lt;`运算符适用于文本文件,而二进制文件需用`read()`和`write()`方法。`write()`从文件写指针位置写入数据,`read()`从文件读指针位置读取,两者都会移动指针。示例代码展示了如何使用这些方法处理学生信息。
61 12
|
7月前
|
缓存 数据库 NoSQL
【后端面经】【缓存】33|缓存模式:缓存模式能不能解决缓存一致性问题?-02 Write Through + Write Back
【5月更文挑战第10天】`Write Through`是一种缓存策略,写操作仅需写入缓存,缓存负责更新数据库。异步版本可能丢失数据,而同步变种先写数据库再异步刷新缓存,减少丢数据风险。`Write Back`模式数据先写入缓存,过期时才写入数据库,可能导致数据丢失,但若使用Redis并确保高可用,可部分解决一致性问题。在特定条件下,如使用SETNX命令,能缓解一致性挑战。
96 0
【后端面经】【缓存】33|缓存模式:缓存模式能不能解决缓存一致性问题?-02 Write Through + Write Back
|
7月前
append/prepend与after/before区别
append/prepend与after/before区别
|
7月前
|
存储 安全 Java
简单聊聊copy on write(写时复制)技术
简单聊聊copy on write(写时复制)技术
|
存储 缓存
【什么是Read Write Through机制】
【什么是Read Write Through机制】
165 0
|
存储 C++ iOS开发
C++ 采用read()和write()读写二进制文件
以文本形式读写文件和以二进制形式读写文件的区别,并掌握了用重载的 >> 和 << 运算符实现以文本形式读写文件。在此基础上,本节继续讲解如何以二进制形式读写文件。 举个例子,现在要做一个学籍管理程序,其中一个重要的工作就是记录学生的学号、姓名、年龄等信息。这意味着,我们需要用一个类来表示学生,如下所示: class CStudent { char szName[20]; //假设学生姓名不超过19个字符,以 '\0' 结尾 char szId[l0]; //假设学号为9位,以 '\0' 结尾 int age; //年龄
133 0
|
存储 算法 Java
并发和Read-copy update(RCU)
并发和Read-copy update(RCU)
并发和Read-copy update(RCU)
|
SQL 缓存 Oracle
一致性读(Read Consistency)的深入解析
一致性读在Oracle中是一个非常重要的概念, 大家一起跟着我先来做下面的一个实验:
一致性读(Read Consistency)的深入解析