但大师说带有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,仅此而已。详细的写入非常easy,就是内存拷贝,缓存管理,最后交给块设备就可以,所以关键就是,position的定位。定位方式分为3种:
1.调用lseek手工定位;
2.依据历史write操作自己主动定位;
3.依据O_APPEND标志自己主动定位;
lseek手工定位非常easy,即设置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); }
这样的短平快的风格一针见血指出了问题的解决之道,其实,大多数的复杂性都是优化的副产品!