PHP下O_APPEND模式的原子性

既然都整到半夜了,那就顺便把这段时间碰到的一个问题整理成博客记录下来吧,其间也和一位好朋友讨论了很久这个问题,最后也终于找到了原因,如果文章中有我理解有误的地方,欢迎指正。

一次为项目中的内部日志库改动后,在本地的测试中发现了日志文件居然有写乱的情况发生,从直觉上来说,这大概率是由并发问题导致的,但是这个日志文件我们是使用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 调用就是原子的?

但总得来说这一次问题追踪还是学习到挺多的.......

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容