在 PHP 多进程开发中,并发操作共享资源(如文件、目录、数据库记录)时,很容易因“竞态条件”引发意想不到的错误。例如多个进程同时判断并创建同一目录,常会出现 PHP Warning: mkdir(): File exists
警告——这看似简单的问题,实则暴露了多进程并发控制的核心痛点。本文将以“多进程创建目录”为案例,深入分析竞态条件的产生原因,提供两种可靠的解决方案,并延伸多进程共享资源处理的通用思路。
先看一段看似“没问题”的多进程代码:通过 pcntl_fork()
创建 10 个子进程,每个进程先判断目录是否存在,不存在则创建。但实际运行时,大概率会出现“目录已存在”的警告。
<?php
$numberOfProcesses = 10; // 创建10个子进程
// 循环创建子进程
for ($i = 0; $i < $numberOfProcesses; $i++) {
$pid = pcntl_fork(); // 创建子进程
if ($pid == -1) {
// 进程创建失败处理
exit("ERROR: 无法创建子进程\n");
} elseif ($pid == 0) {
// 子进程逻辑
$childPid = getmypid();
echo "子进程 {$childPid} 启动\n";
// 核心逻辑:判断目录不存在则创建
if (!is_dir('./test_dir')) {
// 这里存在并发风险!
mkdir('./test_dir', 0777);
echo "子进程 {$childPid}:目录创建成功\n";
} else {
echo "子进程 {$childPid}:目录已存在\n";
}
echo "子进程 {$childPid} 结束\n";
exit(); // 子进程必须退出,避免继续fork
}
// 父进程:继续循环创建下一个子进程
}
// 父进程等待所有子进程结束(回收僵尸进程)
while (pcntl_waitpid(0, $status) != -1) {
$exitCode = pcntl_wexitstatus($status);
echo "父进程:子进程退出,退出码 {$exitCode}\n";
}
?>
执行代码后,可能出现类似如下输出,伴随警告:
子进程 1234 启动
子进程 5678 启动
子进程 1234:目录创建成功
PHP Warning: mkdir(): File exists in /path/to/your/code.php on line 15
子进程 5678:目录创建成功
子进程 1234 结束
子进程 5678 结束
为什么明明加了 !is_dir()
判断,还会报“目录已存在”?核心原因是 “判断-创建”不是原子操作。
多进程并发时,进程的执行顺序由操作系统调度,具有不确定性。在上述代码中,两个进程可能出现如下“时间差”:
!is_dir('./test_dir')
,判断目录不存在,准备执行 mkdir()
;!is_dir('./test_dir')
,此时目录仍未被创建,判断结果也为“不存在”;mkdir()
,成功创建目录;mkdir()
,但此时目录已存在,因此抛出“File exists”警告。这种“多个进程竞争访问共享资源,最终结果依赖于进程执行顺序”的情况,就是 竞态条件。在多进程操作文件、目录、数据库时,若不做控制,类似问题会频繁出现。
针对“多进程创建目录”的竞态问题,有两种常用解决方案:文件锁(独占锁) 和 原子操作。前者是多进程共享资源控制的通用方案,后者是针对“判断-创建”场景的简化方案。
文件锁的核心思路是:在操作共享资源(此处为“创建目录”)前,先获取一个“独占锁”,确保同一时间只有一个进程能执行关键逻辑;其他进程必须等待锁释放后,才能继续执行。
在 PHP 中,可通过 flock()
函数实现文件锁,LOCK_EX
表示“独占锁”(排他锁),同一时间只允许一个进程持有该锁。
<?php
$numberOfProcesses = 10;
$targetDir = './test_dir_lock'; // 目标目录
$lockFile = './dir_create.lock'; // 锁文件(用于实现独占锁)
for ($i = 0; $i < $numberOfProcesses; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
exit("ERROR: 无法创建子进程\n");
} elseif ($pid == 0) {
$childPid = getmypid();
echo "子进程 {$childPid} 启动\n";
// 1. 打开锁文件(不存在则创建)
$lockHandle = fopen($lockFile, 'w');
if (!$lockHandle) {
echo "子进程 {$childPid}:无法打开锁文件\n";
exit(1);
}
// 2. 获取独占锁(LOCK_EX),未获取到则阻塞等待
if (flock($lockHandle, LOCK_EX)) {
try {
// 3. 关键逻辑:判断并创建目录(此时只有当前进程能执行)
if (!is_dir($targetDir)) {
mkdir($targetDir, 0777, true); // 第三个参数true支持创建多级目录
echo "子进程 {$childPid}:目录创建成功\n";
} else {
echo "子进程 {$childPid}:目录已存在\n";
}
} finally {
// 4. 释放锁(必须执行,避免死锁)
flock($lockHandle, LOCK_UN);
echo "子进程 {$childPid}:锁已释放\n";
}
} else {
echo "子进程 {$childPid}:无法获取锁\n";
}
// 5. 关闭文件句柄
fclose($lockHandle);
echo "子进程 {$childPid} 结束\n";
exit();
}
}
// 父进程等待子进程
while (pcntl_waitpid(0, $status) != -1) {
$exitCode = pcntl_wexitstatus($status);
echo "父进程:子进程退出,退出码 {$exitCode}\n";
}
// 可选:执行完成后删除锁文件(非必须,下次执行可覆盖)
if (file_exists($lockFile)) {
unlink($lockFile);
}
?>
$lockFile
是一个“占位文件”,flock()
通过该文件的句柄实现锁机制,锁的本质是对文件句柄的控制,而非文件内容;LOCK_EX
特性:未获取到锁的进程会阻塞等待(而非直接失败),直到持有锁的进程释放锁(LOCK_UN
),确保所有进程最终能有序执行;try...finally
:确保无论目录创建是否成功,锁都会被释放,避免“死锁”(即一个进程持有锁后异常退出,导致其他进程永远等待)。对于“判断目录是否存在并创建”这一特定场景,PHP 提供了更简洁的方式:将 is_dir()
和 mkdir()
合并为一个原子操作。
mkdir()
函数本身支持“目录已存在时不报错”的特性——通过设置第三个参数 $recursive
为 true
(支持多级目录创建),并配合 @
抑制警告(或通过错误处理判断),可实现“存在则跳过,不存在则创建”的原子逻辑。
<?php
$numberOfProcesses = 10;
$targetDir = './test_dir_atomic';
for ($i = 0; $i < $numberOfProcesses; $i++) {
$pid = pcntl_fork();
if ($pid == -1) {
exit("ERROR: 无法创建子进程\n");
} elseif ($pid == 0) {
$childPid = getmypid();
echo "子进程 {$childPid} 启动\n";
// 核心:原子操作创建目录(存在则不报错,不存在则创建)
// @ 用于抑制“目录已存在”的警告(也可通过 error_reporting 控制)
$isCreated = @mkdir($targetDir, 0777, true);
if ($isCreated || is_dir($targetDir)) {
// 两种情况:1. 成功创建;2. 目录已存在
echo "子进程 {$childPid}:目录可用(创建成功或已存在)\n";
} else {
echo "子进程 {$childPid}:目录创建失败(权限不足等)\n";
}
echo "子进程 {$childPid} 结束\n";
exit();
}
}
// 父进程等待子进程
while (pcntl_waitpid(0, $status) != -1) {
$exitCode = pcntl_wexitstatus($status);
echo "父进程:子进程退出,退出码 {$exitCode}\n";
}
?>
@
抑制警告需谨慎:若需区分“目录已存在”和“真错误”(如权限不足),可通过 error_get_last()
判断错误类型,而非直接用 @
;“多进程创建目录”是共享资源竞态的一个缩影,在处理其他共享资源(如文件写入、数据库记录更新)时,需遵循以下通用原则:
这是最根本的解决方案。例如:
file_1.txt
、file_2.txt
),而非所有进程写同一个文件;flock()
实现,简单易用;SET NX
)、MySQL(唯一索引)实现;对于支持原子操作的场景,优先使用原子操作:
file_put_contents($file, $content, FILE_APPEND | LOCK_EX)
(写入时自动加独占锁);INSERT ... ON DUPLICATE KEY UPDATE
(避免重复插入)、UPDATE ... WHERE
(带条件更新,确保符合预期);SET NX
(不存在则设置,原子操作,用于实现分布式锁)。多进程编程的核心挑战之一是“共享资源的并发控制”,本文通过“多进程创建目录”的案例,揭示了竞态条件的产生原因,并提供了“文件锁”和“原子操作”两种解决方案。关键在于理解:
在实际开发中,需根据具体场景选择方案:简单场景用原子操作提升效率,复杂共享场景用锁保证安全,能避免共享则尽量避免——这才是多进程并发控制的最优思路。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。