背景/ write + O_APPEND 可以实现多进程写相同日志不交叉
我们有多个进程,往同一个文件写日志。当然希望能每条日志边界清晰,既不与其他日志重叠,也不与其他日志交叉。为了达到这个目的,最直观的想法就是加锁。但其实不必这么麻烦,只需在打开文件时使用O_APPEND标记,并使用无用户态缓冲的写文件接口(以C来讲也就是write),就可以确保日志不重叠。
O_APPEND的作用
在使用open打开文件时,第二个参数flags可以指定各种标记,其中就包括O_APPEND.
O_APPEND标记的作用是,每次调用write写文件,都会自动将文件偏移设置到文件末尾。
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
O_APPEND
The file is opened in append mode. Before each write(2), the file offset is positioned at the end of the file
O_APPEND的原理
O_APPEND的功能由内核保证。
以 linux 4.18.9 内核源码中的ext4文件系统举例(其他文件系统应该也是类似的实现)。
// 路径是 linux-4.18
.9/fs/ext4/file.c
static ssize_t
ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
struct inode *inode = file_inode(iocb->ki_filp);
int o_direct = iocb->ki_flags & IOCB_DIRECT;
int unaligned_aio = 0;
int overwrite = 0;
ssize_t ret;
...
// 下面这行对inode进行了加锁,因为一个文件在一个文件系统中只有一个inode,所以可以多进程互斥
if (!inode_trylock(inode)) {
...
inode_lock(inode);
}
// 下面这行里面如果检查到有开启 O_APPEND 标记,就会修正要写的 offset 到文件末尾
ret = ext4_write_checks(iocb, from);
if (ret <= 0)
goto out;
...
ret = __generic_file_write_iter(iocb, from); // 把from中的数据写到iocb的file成员的页缓存中
inode_unlock(inode); // 已经写完了,释放锁
if (ret > 0)
ret = generic_write_sync(iocb, ret); // 将数据从页缓存同步到磁盘
return ret;
out:
inode_unlock(inode);
return ret;
}
static ssize_t ext4_write_checks(struct kiocb *iocb, struct iov_iter *from)
{
...
ret = generic_write_checks(iocb, from); // 对O_APPEND的检查在这个函数里进行
...
}
inline ssize_t generic_write_checks(struct kiocb *iocb, struct iov_iter *from)
{
struct inode *inode = file->f_mapping->host;
/* FIXME: this is for backwards compatibility with 2.4 */
if (iocb->ki_flags & IOCB_APPEND)
iocb->ki_pos = i_size_read(inode); // O_APPEND 牛逼,这里自动将offset自动修正到文件末尾。
// 而且ext4_file_write_iter在调用本函数时,有通过inode加锁,所以多进程同时写一个 O_APPEND 标记打开的文件也不会写乱。
...
}
通过下面的代码我们可以看到 ext4_file_write_iter() 是被赋值给了 ext4_file_operations 的 .write_iter 函数指针上。注意 ext4_file_operations 没有设置 .write 函数指针,而是只设置了 .write_iter 函数指针,在后面的__vfs_write()中可以看到为什么这样做。
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
.unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = ext4_compat_ioctl,
#endif
.mmap = ext4_file_mmap,
.mmap_supported_flags = MAP_SYNC,
.open = ext4_file_open,
.release = ext4_release_file,
.fsync = ext4_sync_file,
.get_unmapped_area = thp_get_unmapped_area,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
.fallocate = ext4_fallocate,
};
系统调用write进到内核后的符号是 kernel_write,然后会按照kernel_write -> vfs_write-> __vfs_write 的顺序调用到 ext4_file_write_iter() 。
// fs/read_write.c
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
return ksys_write(fd, buf, count);
}
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
loff_t *pos)
{
if (file->f_op->write) // ext4_file_operations 的 write 指针是空的
return file->f_op->write(file, p, count, pos);
else if (file->f_op->write_iter) // 这个指针就是设置的 ext4_file_write_iter()
return new_sync_write(file, p, count, pos); // 这里面会调用 ext4_file_write_iter()
else
return -EINVAL;
}
上面就是O_APPEND的实现原理。和我们的主体比较相关的是,每个文件在一个文件系统里面只有一个inode,而inode结构体里面有锁,这样内核就可以通过inode加锁保证多进程访问同一个文件是有序的。也就是说,在使用write写文件时,内核会先把文件锁住后,再去检查有没有O_APPEND标记(有的话就更新offset到文件末尾),然后再把数据写到文件的页缓存,最后才会释放锁。所以多个进程打开同一个文件时有设置O_APPEND标记,那么就可以放心地同时使用write来写文件,而不必担心内容重叠了。
最后注意,多进程写同一文件,不要使用带缓存的写函数
前面提到,在多进程环境,我们可以放心地使用write搭配O_APPEND标记来写文件,这并不会让内容出现重叠或交叉。
那么我不用write,而用fprintf、fwrite等带用户态缓存的接口来写文件,能不能保证多进程环境下不重叠/不交叉呢?答案是不能。
原因是,像fprintf、fwrite等函数都是有用户态缓存的,每次调用这些函数写入的值,都会被放到一块缓存中,只有缓存满或者触发刷新才会通过系统调用write进入到内核去写文件。假设这块缓存的大小是3个字节,现在进程A调用fwrite往里面写"12345",但其实写到"123"的时候缓存就已经满了,于是通过系统调用write将用户态缓存中的”123"写到文件页缓存中。如果在回到进程A的用户态继续执行前,内核对进程进行了调度,另一个进程B获得执行的时间片,并对同一个文件写入"abc",那么交叉就发生了。等进程A回来继续执行完写操作,我们会发现最终文件的内容变成了“123abc45"。这就是为什么我们不能在多进程写同一个文件时使用带缓存的写函数。
验证程序
下面是一个简单的验证程序。代码是在其他人博文里拿过来稍作修改的。
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/wait.h>
#define USE_BUF 1 // 把这行注释掉的话,就是使用write来写。可以发现不会有交叉。
int main(int argc,char * argv[])
{
struct timeval start,end;
int times = argc > 1 ? atoi(argv[1]):10000;
int stat;
int childpid;
int i;
#ifdef USE_BUF
printf("use fwrite\n");
#else
printf("use write\n");
#endif
for(i=0 ;i<1; i++){
if(childpid = fork())
break;
}
if(childpid == -1){
perror("failed to fork\n");
return 1;
}
FILE *fp = NULL;
fp = fopen("tmpfp.dat","a+"); // 注意这里的 "a",其实就是O_APPEND的意思
gettimeofday(&start,NULL);
if(childpid > 0){
char *buf = (char*)malloc(times);
for(int i = 0;i < times;++i) {
buf[i] = 'a';
}
strcat(buf,"\n");
for(i=0; i<10; i++){ //每个进程写10行
usleep(1000);
#ifdef USE_BUF
fwrite(buf,strlen(buf),1,fp);
#else
write(fileno(fp), buf, strlen(buf));
#endif
}
wait(&stat);
}else{
char *buf = (char*)malloc(times);
for(int i = 0;i < times;++i) {
buf[i] = 'b';
}
strcat(buf,"\n");
for(i=0; i<10; i++){
usleep(1000);
#ifdef USE_BUF
fwrite(buf,strlen(buf),1,fp);
// fflush(fp); //这里即使手动刷新也是徒劳,因为可能单词写入的内容长度就大于用户缓存,所以fwrite里面可能已经将部分内容写到文件中了。
#else
write(fileno(fp), buf, strlen(buf));
#endif
}
}
fclose(fp);
return 0;
}
示例程序可以用下面的脚本进行编译:
# run.sh
rm tmpfp.dat;
gcc -std=c99 main.c;
./a.out $1;
sed -n '/^a.*$/p' tmpfp.dat | grep b | wc -l
执行:
[jasondeng@localhost multiProcessFwrite_test]$ ./run.sh 5001 # 每次打印5001个字符
main.c: In function ‘main’:
main.c:56: warning: implicit declaration of function ‘usleep’
use fwrite
7 #表明有7行出现了交叉。一共才打印了10行。。。
# 如果把示例程序中的 #define USE_BUF 1 注释掉,就可以使用write代替fwrite。
[jasondeng@localhost multiProcessFwrite_test]$ ./run.sh 10000
use write
0 # 使用write一行交叉都没有出现
我偏要在用户态缓存,行不行?
也可以,只要你自己实现用户态缓存,并且知道自己的缓存有多大。当你每次往用户态缓存写数据前,先检查下缓存的剩余空间够不够完整地放下当前消息,如果够的话,那就往缓存里写数据;如果缓存剩余空间不够的话,就先调用write将缓存里面的数据刷新到内核中,然后清空缓存,再把当前消息写到缓存。
太简单就不放代码了。