既然都整到半夜了,那就顺便把这段时间碰到的一个问题整理成博客记录下来吧,其间也和一位好朋友讨论了很久这个问题,最后也终于找到了原因,如果文章中有我理解有误的地方,欢迎指正。
一次为项目中的内部日志库改动后,在本地的测试中发现了日志文件居然有写乱的情况发生,从直觉上来说,这大概率是由并发问题导致的,但是这个日志文件我们是使用O_APPEND
模式打开的,这与前辈们口口相传的“ O_APPEND
模式是原子”相违背。为了方便调试这个问题,我先把这个问题简化成了一个可稳定复现的PHP脚本:
<?php
/**
* author: LiZhiYang
* email: zhiyanglee@foxmail.com
*/
define('BUFF_SIZE', 8193 - 1);
define('WORKER_NUMS', 20);
define('WORKER_PER_LINES', 50);
$fileName = "sample.txt";
if (file_exists($fileName)) {
unlink($fileName);
}
/**
* 创建20个进程模拟并发写
*/
$pids = [];
for ($i = 0; $i < WORKER_NUMS; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
echo "pcntl_fork error.\n";
} else if ($pid) {
$pids[] = $pid;
} else {
$appendFd = fopen($fileName, "a+");
/**
* $i + 65得到一个字母编码,并重复BUFF_SIZE次构成一行
*/
$buf = '';
$ch = pack("C", $i + 65);
$buf = str_repeat($ch, BUFF_SIZE);
$buf .= "\n";
for ($i = 0; $i < WORKER_PER_LINES; $i++) {
fwrite($appendFd, $buf);
}
fclose($appendFd);
exit(0);
}
}
/**
* 等待所有子进程完成并发写
*/
$pidSeq = 1;
$curPid = posix_getpid();
foreach ($pids as $pid) {
echo "cur_pid:{$curPid} seq:{$pidSeq} wait {$pid}\n";
pcntl_waitpid($pid, $status);
$pidSeq++;
}
/**
* 因为一行的字母都是一样的,如果一个字母在那一行没有重复BUFF_SIZE次,则代表
* 写入时出现了写乱的情况
*/
$line = 1;
$fd = fopen($fileName, "r");
while (($row = fgets($fd)) !== false) {
$firstChar = $row[0];
if (!preg_match('/^' . $firstChar . '{' . BUFF_SIZE. '}$/', $row)) {
echo "line:{$line} concurrent error.\n";
exit(-1);
} else {
echo "line:{$line} pass.\n";
}
$line++;
}
fclose($fd);
在我的反复测试中,只要大于8192这个值,就会稳定的出现写乱的情况,而低于或者等于这个值则没有任何问题。
我第一个想到的是,O_APPEND模式的原子写入的数据也许有一个大小上限,所以开始查询write
调用的文档,而write调用的文档中提到了这么一句话:
(On Linux, PIPE_BUF is 4096 bytes.) So in Linux the size of an atomic write is 4096 bytes.
这个PIPE_BUF可以通过ulimit -a
看到,其中 pipe size 一行就是PIPE_BUF的大小,在我本机Mac上是512字节,在Linux是4KB(4096字节),但很明显的是,这与我前面试出来的8192值,明显不一样。
后面我朋友使用strace
跟踪了一次PHP写入10240字节数据时的系统调用,PHP脚本:
<?php
$fileName = "test_fwrite.log";
if (file_exists($fileName)) {
unlink($fileName);
}
$str = str_repeat('a', 10240);
$fd = fopen($fileName,"a+");
fwrite($fd, $str);
echo "ok\n";
跟踪系统调用的结果如下:
fstat(4, {st_mode=S_IFREG|0666, st_size=0, ...}) = 0
lseek(4, 0, SEEK_CUR) = 0
lseek(4, 0, SEEK_CUR) = 0
write(4, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 8192) = 8192
write(4, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 2048) = 2048
write(1, "ok\n", 3) = 3
close(4) = 0
close(2) = 0
close(1) = 0
可以很明显的看到PHP分成了两次调用write
去写入,而一次写入的上限从第一次write
可以看出是8192字节,和我前面试出来的值刚好一致。这个时候就需要去PHP内核确定一下它具体的流程,通过断点调试定位了PHP的 fwrite
最终调用实现在 main/streams/streams.c(1122行)
中:
/* Writes a buffer directly to a stream, using multiple of the chunk size */
static ssize_t _php_stream_write_buffer(php_stream *stream, const char *buf, size_t count)
{
.........
while (count > 0) {
size_t towrite = count;
if (towrite > stream->chunk_size)
towrite = stream->chunk_size;
justwrote = stream->ops->write(stream, buf, towrite);
..........
}
return didwrite;
}
可以看到,PHP内部做了一次分割,一次最大只可以写入stream->chunk_size
大小的数据,这就解释了前面跟踪系统调用时为什么看到了两次write
系统调用,因为PHP内部会按照stream->chunk_size
这个最大值来切割要写入的数据,然后分批写入。
那么stream->chunk_size
这个值又在哪定义的呢,同样也是通过断点调试,赋值是在ext/standard/file.c(148行)
:
static void file_globals_ctor(php_file_globals *file_globals_p)
{
memset(file_globals_p, 0, sizeof(php_file_globals));
file_globals_p->def_chunk_size = PHP_SOCK_CHUNK_SIZE;
}
而PHP_SOCK_CHUNK_SIZE
则定义在 main/php_network.h(222行)
#define PHP_SOCK_CHUNK_SIZE 8192
看到PHP内核会把超过8192字节的数据分批写入后,就明白了为什么O_APPEND
也会出现写乱的情况,我们可以将追加模式(O_APPEND
)下的write
的调用大概简化成下面这个流程:
锁定文件inode->写入前重新获取文件大小并设置为当前写入偏移量->开始写入数据->解锁文件inode
可以看到,一次追加写入调用是原子的,但是如果你将这一次写入的数据分为了两次调用:
第一个追加写操作:锁定文件inode->写入前重新获取文件大小并设置为当前写入偏移量->开始写入数据(8192个字节)->解锁文件inode
第二个追加写操作:锁定文件inode->写入前重新获取文件大小并设置为当前写入偏移量->开始写入数据(2048个字节)->解锁文件inode
那么在第一个追加写8192个字节后,第二个追加写2048个字节的操作可能并不会马上执行,因为受Linux内核的调度,在执行第二个追加写操作的时候,中间可能会穿插了别的进程的追加写操作,所以会出现O_APPEND
模式下也出现了写操作错乱的情况。
但是我朋友后面发现PHP内核中的master分支,已经把这个按照最大8192字节分批写入的逻辑给移除了,也就是说后面新的PHP版本的追加写就不存在这个因为超过8192字节会写乱的情况了,但现有的PHP版本如7.1、7.2等还是会有这种情况。
Don't use chunking for stream writes
We're currently splitting up large writes into 8K size chunks, which
adversely affects I/O performance in some cases. Splitting up writes
doesn't make a lot of sense, as we already must have a backing buffer,
so there is no memory/performance tradeoff to be made here.This change disables the write chunking at the stream layer, but
retains the current retry loop for partial writes. In particular
network writes will typically only write part of the data for large
writes, so we need to keep the retry loop to preserve backwards
compatibility.If issues due to this change turn up, chunking should be reintroduced
at lower levels where it is needed to avoid issues for specific streams,
rather than unnecessarily enforcing it for all streams.
移除这个逻辑的代码提交:https://github.com/php/php-src/commit/5cbe5a538c92d7d515b0270625e2f705a1c02b18
到最后我也非常好奇,那么一次write
调用能够原子写入的大小到底有多大呢,因为我发现Nginx也是用O_APPEND
打开后,直接就调用write
进行日志写入,并没有额外的同步机制。我用C也实现了一遍上面PHP复现脚本的逻辑,发现不管写多大都不会乱:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <errno.h>
void print_error_and_exit(int exit_code)
{
fprintf(stderr, "%s\n", strerror( errno ));
exit(exit_code);
}
void check_rt(int rt)
{
if (rt < 0) {
print_error_and_exit(-1);
}
}
int main(int argc,char **argv) {
size_t num_workers = 20;
size_t lines_per_worker = 50;
size_t buf_len = 0;
if (argc > 1) {
size_t input_buf_len = atoi(argv[1]);
if (input_buf_len > 0) {
buf_len = input_buf_len;
printf("set buf length to input value:%zu\n", input_buf_len);
}
}
if (buf_len <= 0) {
printf("set buf length to default value:4096.\n");
buf_len = 4096;
}
pid_t pids[num_workers];
for (size_t i = 0; i < num_workers; i++)
{
pid_t pid = fork();
if (pid == -1) {
printf("fork error.\n");
} else if (pid) {
pids[i] = pid;
} else {
int fd = open("./sample.txt", O_WRONLY|O_CREAT|O_APPEND);
check_rt(fd);
char c = i + 65;
char buf[buf_len];
for (size_t i = 0; i < (buf_len - 1); i++)
{
buf[i] = c;
}
buf[buf_len - 1] = '\n';
for (size_t i = 0; i < lines_per_worker; i++) {
int r = write(fd, &buf, buf_len);
check_rt(r);
}
exit(0);
}
}
for (size_t i = 0; i < num_workers; i++)
{
pid_t pid = pids[i];
int status;
printf("wating process[%d]\n", pid);
waitpid(pid, &status, WUNTRACED|WCONTINUED);
}
}
我这里的C程序没有实现最后的验证逻辑,我是把PHP复现脚本底下的验证逻辑单独写成一个脚本,来验证这个C程序输出的文件,你同样可以这也做(主要是懒没写完。
抱着试一试的心态,我去翻了一下Linux内核文件系统的源代码,当应用层调用write
时流程如下:
write -> _libc_write -> ksys_write -> vfs_write -> [具体的文件系统实现] -> write_iter
ext4的写入实现如下:
static ssize_t
ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
.........
if (!inode_trylock(inode)) {
if (iocb->ki_flags & IOCB_NOWAIT)
return -EAGAIN;
inode_lock(inode);
}
.........
out:
inode_unlock(inode);
return ret;
}
对Linux内核不是很熟悉,但就这么看的话,似乎在所有写流程(阻塞同步写为例子)开始之前,会先上锁文件的inode(“一般情况下,一个文件对应一个inode),那么其实可以理解为一次write
调用就是原子的?
但总得来说这一次问题追踪还是学习到挺多的.......
网友评论