PHP下O_APPEND模式的原子性

既然都整到半夜了廊勃,那就順便把這段時(shí)間碰到的一個(gè)問(wèn)題整理成博客記錄下來(lái)吧,其間也和一位好朋友討論了很久這個(gè)問(wèn)題演顾,最后也終于找到了原因供搀,如果文章中有我理解有誤的地方,歡迎指正钠至。

一次為項(xiàng)目中的內(nèi)部日志庫(kù)改動(dòng)后葛虐,在本地的測(cè)試中發(fā)現(xiàn)了日志文件居然有寫(xiě)亂的情況發(fā)生,從直覺(jué)上來(lái)說(shuō)棉钧,這大概率是由并發(fā)問(wèn)題導(dǎo)致的屿脐,但是這個(gè)日志文件我們是使用O_APPEND模式打開(kāi)的,這與前輩們口口相傳的“ O_APPEND 模式是原子”相違背宪卿。為了方便調(diào)試這個(gè)問(wèn)題的诵,我先把這個(gè)問(wèn)題簡(jiǎn)化成了一個(gè)可穩(wěn)定復(fù)現(xiàn)的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);
}

/**
 * 創(chuàng)建20個(gè)進(jìn)程模擬并發(fā)寫(xiě)
 */
$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得到一個(gè)字母編碼,并重復(fù)BUFF_SIZE次構(gòu)成一行
         */
        $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);
    }
}

/**
 * 等待所有子進(jìn)程完成并發(fā)寫(xiě)
 */
$pidSeq = 1;
$curPid = posix_getpid();
foreach ($pids as $pid) {
    echo "cur_pid:{$curPid} seq:{$pidSeq} wait {$pid}\n";
    pcntl_waitpid($pid, $status);
    $pidSeq++;
}

/**
 * 因?yàn)橐恍械淖帜付际且粯拥挠蛹兀绻粋€(gè)字母在那一行沒(méi)有重復(fù)BUFF_SIZE次西疤,則代表
 * 寫(xiě)入時(shí)出現(xiàn)了寫(xiě)亂的情況
 */
$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);

在我的反復(fù)測(cè)試中,只要大于8192這個(gè)值休溶,就會(huì)穩(wěn)定的出現(xiàn)寫(xiě)亂的情況代赁,而低于或者等于這個(gè)值則沒(méi)有任何問(wèn)題。

我第一個(gè)想到的是兽掰,O_APPEND模式的原子寫(xiě)入的數(shù)據(jù)也許有一個(gè)大小上限芭碍,所以開(kāi)始查詢(xún)write 調(diào)用的文檔,而write調(diào)用的文檔中提到了這么一句話(huà):

(On Linux, PIPE_BUF is 4096 bytes.) So in Linux the size of an atomic write is 4096 bytes.

這個(gè)PIPE_BUF可以通過(guò)ulimit -a看到孽尽,其中 pipe size 一行就是PIPE_BUF的大小窖壕,在我本機(jī)Mac上是512字節(jié),在Linux是4KB(4096字節(jié))杉女,但很明顯的是瞻讽,這與我前面試出來(lái)的8192值,明顯不一樣熏挎。

后面我朋友使用strace 跟蹤了一次PHP寫(xiě)入10240字節(jié)數(shù)據(jù)時(shí)的系統(tǒng)調(diào)用速勇,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";

跟蹤系統(tǒng)調(diào)用的結(jié)果如下:

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分成了兩次調(diào)用write 去寫(xiě)入,而一次寫(xiě)入的上限從第一次write可以看出是8192字節(jié)婆瓜,和我前面試出來(lái)的值剛好一致快集。這個(gè)時(shí)候就需要去PHP內(nèi)核確定一下它具體的流程,通過(guò)斷點(diǎn)調(diào)試定位了PHP的 fwrite最終調(diào)用實(shí)現(xiàn)在 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內(nèi)部做了一次分割个初,一次最大只可以寫(xiě)入stream->chunk_size大小的數(shù)據(jù),這就解釋了前面跟蹤系統(tǒng)調(diào)用時(shí)為什么看到了兩次write系統(tǒng)調(diào)用猴蹂,因?yàn)镻HP內(nèi)部會(huì)按照stream->chunk_size這個(gè)最大值來(lái)切割要寫(xiě)入的數(shù)據(jù)院溺,然后分批寫(xiě)入。

那么stream->chunk_size這個(gè)值又在哪定義的呢磅轻,同樣也是通過(guò)斷點(diǎn)調(diào)試珍逸,賦值是在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內(nèi)核會(huì)把超過(guò)8192字節(jié)的數(shù)據(jù)分批寫(xiě)入后,就明白了為什么O_APPEND也會(huì)出現(xiàn)寫(xiě)亂的情況聋溜,我們可以將追加模式(O_APPEND)下的write的調(diào)用大概簡(jiǎn)化成下面這個(gè)流程:

鎖定文件inode->寫(xiě)入前重新獲取文件大小并設(shè)置為當(dāng)前寫(xiě)入偏移量->開(kāi)始寫(xiě)入數(shù)據(jù)->解鎖文件inode

可以看到谆膳,一次追加寫(xiě)入調(diào)用是原子的,但是如果你將這一次寫(xiě)入的數(shù)據(jù)分為了兩次調(diào)用:

第一個(gè)追加寫(xiě)操作:鎖定文件inode->寫(xiě)入前重新獲取文件大小并設(shè)置為當(dāng)前寫(xiě)入偏移量->開(kāi)始寫(xiě)入數(shù)據(jù)(8192個(gè)字節(jié))->解鎖文件inode

第二個(gè)追加寫(xiě)操作:鎖定文件inode->寫(xiě)入前重新獲取文件大小并設(shè)置為當(dāng)前寫(xiě)入偏移量->開(kāi)始寫(xiě)入數(shù)據(jù)(2048個(gè)字節(jié))->解鎖文件inode

那么在第一個(gè)追加寫(xiě)8192個(gè)字節(jié)后撮躁,第二個(gè)追加寫(xiě)2048個(gè)字節(jié)的操作可能并不會(huì)馬上執(zhí)行漱病,因?yàn)槭躄inux內(nèi)核的調(diào)度,在執(zhí)行第二個(gè)追加寫(xiě)操作的時(shí)候把曼,中間可能會(huì)穿插了別的進(jìn)程的追加寫(xiě)操作杨帽,所以會(huì)出現(xiàn)O_APPEND模式下也出現(xiàn)了寫(xiě)操作錯(cuò)亂的情況。

但是我朋友后面發(fā)現(xiàn)PHP內(nèi)核中的master分支嗤军,已經(jīng)把這個(gè)按照最大8192字節(jié)分批寫(xiě)入的邏輯給移除了注盈,也就是說(shuō)后面新的PHP版本的追加寫(xiě)就不存在這個(gè)因?yàn)槌^(guò)8192字節(jié)會(huì)寫(xiě)亂的情況了,但現(xiàn)有的PHP版本如7.1叙赚、7.2等還是會(huì)有這種情況老客。

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.

移除這個(gè)邏輯的代碼提交:https://github.com/php/php-src/commit/5cbe5a538c92d7d515b0270625e2f705a1c02b18

到最后我也非常好奇,那么一次write調(diào)用能夠原子寫(xiě)入的大小到底有多大呢纠俭,因?yàn)槲野l(fā)現(xiàn)Nginx也是用O_APPEND打開(kāi)后沿量,直接就調(diào)用write進(jìn)行日志寫(xiě)入,并沒(méi)有額外的同步機(jī)制冤荆。我用C也實(shí)現(xiàn)了一遍上面PHP復(fù)現(xiàn)腳本的邏輯朴则,發(fā)現(xiàn)不管寫(xiě)多大都不會(huì)亂:

#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程序沒(méi)有實(shí)現(xiàn)最后的驗(yàn)證邏輯,我是把PHP復(fù)現(xiàn)腳本底下的驗(yàn)證邏輯單獨(dú)寫(xiě)成一個(gè)腳本钓简,來(lái)驗(yàn)證這個(gè)C程序輸出的文件乌妒,你同樣可以這也做(主要是懶沒(méi)寫(xiě)完。

抱著試一試的心態(tài)外邓,我去翻了一下Linux內(nèi)核文件系統(tǒng)的源代碼撤蚊,當(dāng)應(yīng)用層調(diào)用write時(shí)流程如下:

write -> _libc_write -> ksys_write -> vfs_write -> [具體的文件系統(tǒng)實(shí)現(xiàn)] -> write_iter

ext4的寫(xiě)入實(shí)現(xiàn)如下:

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;
}

對(duì)Linux內(nèi)核不是很熟悉,但就這么看的話(huà)损话,似乎在所有寫(xiě)流程(阻塞同步寫(xiě)為例子)開(kāi)始之前侦啸,會(huì)先上鎖文件的inode(“一般情況下槽唾,一個(gè)文件對(duì)應(yīng)一個(gè)inode),那么其實(shí)可以理解為一次write 調(diào)用就是原子的光涂?

但總得來(lái)說(shuō)這一次問(wèn)題追蹤還是學(xué)習(xí)到挺多的.......

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末庞萍,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子忘闻,更是在濱河造成了極大的恐慌钝计,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,755評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件齐佳,死亡現(xiàn)場(chǎng)離奇詭異私恬,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)炼吴,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)本鸣,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人硅蹦,你說(shuō)我怎么就攤上這事永高。” “怎么了提针?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,138評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵命爬,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我辐脖,道長(zhǎng)饲宛,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,791評(píng)論 1 295
  • 正文 為了忘掉前任嗜价,我火速辦了婚禮艇抠,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘久锥。我一直安慰自己家淤,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布瑟由。 她就那樣靜靜地躺著絮重,像睡著了一般。 火紅的嫁衣襯著肌膚如雪歹苦。 梳的紋絲不亂的頭發(fā)上青伤,一...
    開(kāi)封第一講書(shū)人閱讀 51,631評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音殴瘦,去河邊找鬼狠角。 笑死,一個(gè)胖子當(dāng)著我的面吹牛蚪腋,可吹牛的內(nèi)容都是我干的丰歌。 我是一名探鬼主播姨蟋,決...
    沈念sama閱讀 40,362評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼立帖!你這毒婦竟也來(lái)了芬探?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 39,264評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤厘惦,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后哩簿,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年贞绳,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了膨疏。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,040評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡宗苍,死狀恐怖稼稿,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情讳窟,我是刑警寧澤让歼,帶...
    沈念sama閱讀 35,742評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站丽啡,受9級(jí)特大地震影響谋右,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜补箍,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評(píng)論 3 330
  • 文/蒙蒙 一改执、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧坑雅,春花似錦辈挂、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,944評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至遥诉,卻和暖如春后豫,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背突那。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,060評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工挫酿, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人愕难。 一個(gè)月前我還...
    沈念sama閱讀 48,247評(píng)論 3 371
  • 正文 我出身青樓早龟,卻偏偏與公主長(zhǎng)得像惫霸,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子葱弟,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容