关于文件写入的原子性讨论
文件的写入是否是原子的?多个线程写入同一个文件是否会写错乱?多个进程写入同一个文件是否会写错乱?想必这些问题多多少少会对我们产生一定的困扰,即使知道结果,很多时候也很难将这其中的原理清晰的表达给提问者,侯捷曾说过,源码面前,了无秘密 ,那么本文也希望从源代码的角度分析上述问题。在开始之前我们需要补充一下Linux 文件相关的一些基础原理,便于更好的看懂Linux源代码。
学过Linux的读者想必都应该知道文件的数据分为两个部分,一个部分就是文件数据本身,另外一个部分则是文件的元数据,也就是inode、权限、扩展属性、mtime、ctime、atime等等,inode对于一个文件来说及其的重要,可以唯一的标识一个文件(实际应该是inode + dev号,唯一标识一个文件,更准确来说应该是在同一个文件系统的前提下才成立,不同的文件系统inode是会重复的,不过这不是重点,姑且这里不严谨的认为inode就是用来唯一标识一个文件的吧),内核中将inode号和文件的元数据构建为一个struct inode
对象,该对象结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13 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
对象,读取文件的时候则分为以下几步:
通过fd找到对应对应的struct file
对象
通过struct file
对象获取当前的offset,也就是读取f_pos成员
通过f_op找到对应的操作方法,并传入要读取的偏移量进行数据的读取
读取完成后,重新设置新的offset
一次读文件的过程便是如此,对应到代码也是非常的清晰,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 // 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的数据,通过分析可知,多线程写文件不是原子的,会产生数据覆盖。但是否会产生数据错乱,也就是数据交叉写入了?其实这种情况是不会发生的,至于为什么请看下面这段代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 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
函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58 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的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 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)
)的,所有无论有多少个进程或者线程,只要是对同一个文件写数据,拿到的都是同一把锁,是线程安全的,所以也不会出现数据写错乱的情况。