linux下多进程写入文件的原子性

时间:2022-07-28 15:04:25

【原文链接】 http://tsecer.blog.163.com/blog/static/1501817201311284223689/


一、文件写入的原子性

管道在整个unix系统中有重要的基础设施意义,它使unix工具设计的“职能简单”原则得以实现的基础,不同的工具使用管道协调完成自己的功能,并把一个功能做好。一个想法的提出通常具有明确的场景和简洁的原理,后来需求的不断发展导致问题看起来极为复杂,就像我们现在社会的进化,可能原始社会中大家都是饿了吃,困了睡,两者都找不到就去死的节奏。
shell通过管道,让各个工具协调工作,基本的方法也是通过管道,看shell的语法文件中,单单是重定向着一个语法就占用了十几条。匿名管道之后,又有了命名管道,当命名管道创建之后,多个进程写入同一个管道的可能就会变大。在这种情况下,一些钻牛角尖的人就想确定下多进程写入一个管道时,读出方读到的数据和写入方的写入是否一致,更糟糕的是,不同的写入者一次写入的内容是否会与其他写入者一次性写入的数据交错在一起?
二、glibc中对于fprintf函数的实现
1、glibc的FILE锁
其实想想printf的实现,也有些细节。比方说,用户提供的格式化字符串没有任何限制,例如,对于简单的
printf("%s%s", str1, str2)
这样的命令,用户态提供了两个任意起始地址的字符串,写入时需要将两这个字符串放在一个连续的内存空间中写入内核,此时printf应该如何连接这个字符串。最为直观的办法就是分配一个足以包含两个字符串长度的连续空间,把它们连接在一起,然后传递给系统调用。
这里只是最为简单的情况,单单是打印一个hello world,没有问题,考虑到格式化的任意性,这种把所有的结果字符串整理完成之后再写入,明显是不切合实际的。这相当于要求任意的字符串都要有两份相同的备份,并且对于连续空间的要求也过于苛刻。
glibc对于格式化文件输出的代码位于glibc-2.6\stdio-common\vfprintf.c,这个文件中使用了较多的宏,看起来有些诡异,但是并不影响我们对于此处实现的理解。从该文件的实现可以看到
   _IO_flockfile (s);
……
  f = lead_str_end = __find_specwc ((const UCHAR_T *) format);
……
  /* Process whole format string.  */
  do
    {
……
/* Write the following constant string.  */
outstring (specs[nspecs_done].end_of_fmt,
   specs[nspecs_done].next_fmt
   - specs[nspecs_done].end_of_fmt);
  }
all_done:
  if (__builtin_expect (workstart != NULL, 0))
    free (workstart);
  /* Unlock the stream.  */
   _IO_funlockfile (s);
  _IO_cleanup_region_end (0);
glibc的做法就是化整为零,在处理一个格式化输出的时候首先加锁,这里的“锁”位于FILE结构中,通过相同的FILE结构访问到相同的锁,共享相同的互斥。反过来说,就是FILE中使用的fd相同,只要它们封装在不同的FILE结构中,它们之间的写入也并不互斥。加上锁之后,glibc不再一次性把所有的字符格式化完成之后传递给write函数执行,而是只处理自己“基本职责”功能,就是扫描一个格式化字符串中的描述并逐个处理。对于上面的printf("%s%s", str1, str2)例子,vfpintf函数根据%s来讲str1的内容通过outstring传递给更底层的FILE结构缓冲管理层。这个缓冲的大小默认情况下在文件打开时通过stat函数获得文件所在的文件系统的block大小,FILE缓冲一个block作为自己的缓冲区大小
tsecer@harry #touch hehe
tsecer@harry #stat hehe
  File: `hehe'
  Size: 0               Blocks: 0          IO Block:  4096   regular empty file
2、FILE结构的缓存机制
执行outstring之后,该输出内容被放到缓冲层进行排队及溢出处理,这部分代码主要位于genops.c文件中。当一个缓冲区慢之后,执行冲刷。也预读缓存数据,减少不必要的读取操作。
迄今为止,可以看到进程内调用fprintf可以保证不同线程调用同一个FILE结构的printf具有原子性。但是由于这个锁只是进程范围内有效的一个粒度,所以不同进程之间对于同一个底层文件的写入不能保证原子性。甚至对于同一个文件,如果通过不同fopen调用返回的FILE结构写入,也不能保证写入的互斥型。
整个缓存的管理代码比较繁琐,而且当前没有遇到需要理解这部分代码才能解释的问题,所以这部分先掠过。
三、常规文件(ext2)对write系统调用的实现
sys_write-->>vfs_write--->>>do_sync_write-->>generic_file_aio_write
 mutex_lock(&inode->i_mutex); 
 ret = __generic_file_aio_write_nolock(iocb, iov, nr_segs, &iocb->ki_pos); 
 mutex_unlock(&inode->i_mutex);
从这个调用关系中可以看到,整个文件的写入经过了系统级的mutex锁,所以这个写入是原子性的。也就是说,对于一次write系统调用写入的内容不会与系统中任意一个系统调用写入的内容重叠。
四、pipe文件系统对write的实现
1、真正write的实现
sys_write-->>vfs_write--->>>do_sync_write-->>generic_file_aio_write
ret = 0;
mutex_lock(&inode->i_mutex);
pipe = inode->i_pipe;

if (!pipe->readers) {
send_sig(SIGPIPE, current, 0);
ret = -EPIPE;
goto out;
}

/* We try to merge small writes */
chars = total_len & (PAGE_SIZE-1); /* size of the last buffer */
if (pipe->nrbufs && chars != 0) {
……
}

for (;;) {
int bufs;

if (!pipe->readers) {
send_sig(SIGPIPE, current, 0);
if (!ret)
ret = -EPIPE;
break;
}
bufs = pipe->nrbufs;
if (bufs < PIPE_BUFFERS) {
……
if (do_wakeup) {
wake_up_interruptible_sync(&pipe->wait);
kill_fasync(&pipe->fasync_readers, SIGIO, POLL_IN);
do_wakeup = 0;
}
pipe->waiting_writers++;
pipe_wait(pipe);
pipe->waiting_writers--;
}
out:
mutex_unlock(&inode->i_mutex);
2、互斥锁操作
和其它函数一样,在函数的最开始也煞有介事的加上了一把互斥锁,所以很容易让人以为每次写入的操作是一个原子性操作。但是在写入的循环中调用了一个特殊的pipe_wait操作,
void pipe_wait(struct pipe_inode_info *pipe)
{
DEFINE_WAIT(wait);

/*
 * Pipes are system-local resources, so sleeping on them
 * is considered a noninteractive wait:
 */
prepare_to_wait(&pipe->wait, &wait,
TASK_INTERRUPTIBLE | TASK_NONINTERACTIVE);
if (pipe->inode)
mutex_unlock(&pipe->inode->i_mutex);
schedule();
finish_wait(&pipe->wait, &wait);
if (pipe->inode)
mutex_lock(&pipe->inode->i_mutex);
}
在写入过程中,如果写入缓冲区已满,write调用通过pipe_wait释放互斥锁,相当于“秦人失其鹿,天下共逐之”,这个锁就这样从指缝间溜走。在pipe_read系统调用中,也会获得这把锁,由于管道内核中是通过共享的环形缓冲区实现的,所以这一点不足为奇。
3、如果保证PIPE_BUFF以内写操作的原子性
在pipe_write函数中,首先尝试将对页面取模剩余部分和上次写操作进行合并
/* We try to merge small writes */
chars = total_len & (PAGE_SIZE-1); /* size of the last buffer */
if (pipe->nrbufs && chars != 0) 
if (ops->can_merge && offset + chars <= PAGE_SIZE)
这里的合并操作检测其实非常严格,只有当本次小于页面部分和上个页面剩余空间的和小于一个页面的时候才可以进行写操作,这个地方是保证小于页面大小写入原子性的一个保障。举个例子,当前最后一个缓冲页面剩余2K,此时有3K数据写入,由于两者之和大于一个页面,所以不会将3K拆分到该页面中,而是直接分配一个新的页面,从而保证小于页面大小写入的原子性。这也说明了内核中虽然为一个pipe预留了16个页面缓冲,但是并不一定能缓存16个页面的内容。
在pipe_read侧,这个地方也进行了优化
if (!buf->len) {
buf->ops = NULL;
ops->release(pipe, buf);
curbuf = (curbuf + 1) & (PIPE_BUFFERS-1);
pipe->curbuf = curbuf;
pipe->nrbufs = --bufs;
do_wakeup = 1;
}
只有读空了一个页面之后才进行wakeup操作,这只是一个优化,并不是用来保证写操作的原子性。
posix中相关规定
POSIX.1-2001 says  that write(2)s of less than PIPE_BUF bytes must be atomic: the output data is written to the pipe as a contiguous sequence. Writes of more than  PIPE_BUF bytes may be nonatomic: the kernel may interleave the data with data written by other processes. POSIX.1-2001 requires PIPE_BUF to be at least 512 bytes. (On Linux, PIPE_BUF is 4096 bytes.) The precise semantics depend on whether the file descriptor is nonblocking (O_NONBLOCK), whether there are multiple writers to the pipe, and on n, the number of bytes to be written:
这里也注意内核中的
#define PIPE_BUFFERS (16)
#define PIPE_BUF PAGE_SIZE
并不相同,PIPE_BUFFERS定义为16并不是表示内核保证16页面写入的原子性,这个只是为了减少读写操作的阻塞。
五、writev系统调用的原子性
sys_writev--->>>vfs_writev--->>do_readv_writev
{
fn = (io_fn_t)file->f_op->write;
fnv = file->f_op->aio_write;
}
if (fnv)
ret = do_sync_readv_writev(file, iov, nr_segs, tot_len,
pos, fnv);
else
ret = do_loop_readv_writev(file, iov, nr_segs, pos, fn);
如果一个文件系统没有提供aio_write操作,则执行do_loop_readv_writev,这个操作循环调用文件的write接口并且循环中没有加解锁操作,明显地,这个地方可能存在非原子性的地方。但是对于我们常见的ext2文件系统,这点不用担心,因为它们都提供了aio_write操作。
关于这一点,Google搜索验证下可以看到有人也有这种疑虑, 但是没有确切回复,搜索函数名do_loop_readv_writev,第一个结果就是关于这个函数写入原子性的质疑。