既然都整到半夜了廊勃,那就順便把這段時(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í)到挺多的.......