大家好,欢迎来到今天的技术讲座。我是你们的老朋友,一个在代码堆里刨食,比螃蟹还横着走的高级程序员。
今天我们不聊什么“Hello World”,也不讲那些云山雾罩的架构图。今天我们要聊的是 PHP 里最让人心惊肉跳、最让运维抓狂、最让程序员发际线后移的问题——死锁。
特别是,当你的 PHP 程序变成“常驻内存”任务(比如 Swoole、Workerman 或 PHP 8.1+ 的 Fiber)之后,死锁就像房间里的大象,你明明看见了,却不敢敲桌子。
想象一下这样一个场景:你的服务运行得很好,CPU 占用率正常,内存也没爆,但就是——没有反应。用户在疯狂点击按钮,请求进来了,但它们就像是掉进了黑洞,杳无音信。这就是典型的“协程死锁”。今天,我们就来当一回侦探,用监控工具和代码分析,找出这个躲在后台里的“异步逻辑环”。
第一部分:把协程想象成一群懒惰的管家
首先,我们要搞清楚什么是协程。在很多人的印象里,PHP 是脚本语言,跑完就死,死得干干净净。但在常驻内存里,PHP 变成了一种多任务并发模型。
把你的常驻内存进程想象成一个超级大管家(主线程)。他手里有一堆活(任务),但这活儿特别费脑子,得等别人送来信息。
普通的多线程是几个管家同时抢着干活,互不干扰。
而协程呢?更像是同一个管家,他在干活的时候,突然觉得“哎呀,我要去查个数据库”,于是他把手里的事一放,把钥匙一扔(让出控制权),去睡觉(挂起),等数据库送信来了,他又把钥匙捡起来(恢复),接着干刚才的事。
死锁是什么?
死锁,就是这群管家在玩“石头剪刀布”,或者更糟糕的——“二把锁”游戏。
A 管家手里拿了锁1,想去拿锁2,但锁2在 B 管家手里。
B 管家手里拿了锁2,想去拿锁1,但锁1在 A 管家手里。
于是,A 等 B,B 等 A。两人像两尊石狮子一样杵在那儿,一动不动,等着对方释放资源,等着对方去死。
这在 PHP 协程里特别常见,因为协程切换太快了,快到你的大脑根本跟不上去。
第二部分:简单的代码演示——为什么我们容易死锁?
为了让你彻底理解,我们先写一段看起来很正常,实际上非常“作死”的代码。假设我们有一个简单的锁类:
class Lock {
private $locked = false;
private $owner = null; // 谁锁了它
public function acquire(string $who) {
while ($this->locked) {
// 必须等待,释放 CPU 资源
// 这里模拟了锁的等待
yield;
}
$this->locked = true;
$this->owner = $who;
echo "[$who] 拿到了锁!n";
}
public function release() {
echo "[$this->owner] 释放了锁!n";
$this->locked = false;
$this->owner = null;
}
}
现在,我们要模拟一个经典的场景:A 和 B 两个人同时要修改两个共享资源。为了安全,他们必须按顺序拿锁。
$lockA = new Lock();
$lockB = new Lock();
function taskA($lockA, $lockB, $name) {
echo "[$name] 开始干活n";
// 尝试先拿 A,再拿 B
$lockA->acquire($name);
// 假装处理了 1 秒
usleep(1000000);
$lockB->acquire($name);
echo "[$name] 两个锁都拿到了,爽!n";
$lockB->release();
$lockA->release();
}
function taskB($lockA, $lockB, $name) {
echo "[$name] 开始干活n";
// 尝试先拿 B,再拿 A
$lockB->acquire($name);
usleep(1000000);
$lockA->acquire($name);
echo "[$name] 两个锁都拿到了,爽!n";
$lockA->release();
$lockB->release();
}
// 启动两个协程
$fiberA = new Fiber(function() use ($lockA, $lockB) {
taskA($lockA, $lockB, 'A');
});
$fiberB = new Fiber(function() use ($lockA, $lockB) {
taskB($lockA, $lockB, 'B');
});
$fiberA->start();
$fiberB->start();
运行结果:
你会发现,程序卡住了。
[A] 拿到了锁!
[B] 拿到了锁!
[A] 释放了锁! <-- 注意这里,A 在等待 B
[B] 释放了锁! <-- 注意这里,B 在等待 A
(无限循环...)
为什么?
A 拿了 A,等 B。
B 拿了 B,等 A。
这就是死锁。
第三部分:常驻内存中的“隐形杀手”
上面的例子很浅显,稍微有点经验的人一眼就能看出来是顺序错了。但在真实的常驻内存任务中,死锁是异步逻辑环的产物,它通常隐藏得非常深。
假设你的系统有一个复杂的逻辑:
- 订单处理任务 需要更新数据库里的订单状态。
- 库存扣减任务 需要锁定商品库存。
- 异步消息推送 需要发送邮件。
如果这三者之间有依赖,并且处理顺序混乱,就会形成环。
比如:
- 协程 1 持有
Order_Lock,等待Redis_Lock(为了发消息通知)。 - 协程 2 持有
Redis_Lock,等待Order_Lock(为了检查订单有效性)。
这还没完,如果这些锁的持有时间很长(比如网络慢、数据库慢),协程就会一直挂起。在 PHP 的常驻进程中,这些挂起的协程会占用内存,占用调度队列。随着时间的推移,队列里堆满了“死锁等待”,CPU 就在空转,系统就僵了。
第四部分:诊断工具——如何抓出那个“狡猾的环”
怎么知道你的代码里有没有死锁?总不能拿着放大镜一行行看吧?我们要用工具,要用黑科技。
1. 传统的 debug_backtrace 竟然有用!
在 PHP Fiber 之前,协程是不允许你直接打印调用栈的,因为系统栈切换了,你不知道你在哪。
但在 PHP 8.1+ 中,Fiber 提供了 Fiber::getCurrent(),我们可以利用这个方法来手动打印协程的执行现场。
黑科技代码:
写一个简单的 Hook,拦截所有的锁操作。
function monitorLocks() {
// 假设这是你的锁获取函数的封装
// 我们不直接调用锁,而是调用这个监控器
// 获取当前协程 ID
$fiberId = Fiber::getCurrent() ? spl_object_id(Fiber::getCurrent()) : 'Main';
// 获取当前的调用栈
// 注意:这需要开启 Error Reporting
$stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
// 过滤掉 monitorLocks 本身的调用
$caller = [];
foreach ($stack as $i => $frame) {
if ($frame['function'] === 'acquire' && isset($frame['class']) && str_contains($frame['class'], 'Lock')) {
$caller = array_slice($stack, $i + 1);
break;
}
}
return [
'fiber' => $fiberId,
'caller' => $caller,
'time' => microtime(true)
];
}
使用方法:
一旦发生卡顿,你只要往你的代码里插一句打印,看看哪个协程在死等。
// 模拟死锁现场
$lockA->acquire('A'); // A 现在持有锁 A
$lockB->acquire('B'); // B 现在持有锁 B,并且正在等 A
// 假设你在监控日志里发现:
// [B] 正在等待 [A] 持有的 LockA,调用栈是 [OrderController::update, RedisService::lock...]
// [A] 正在等待 [B] 持有的 LockB,调用栈是 [InventoryService::check, OrderController::update...]
// 哎呀!OrderController::update 既在等 RedisService,又在等 InventoryService。
// 这就是那个“环”!
2. 利用 Swoole 或 Workerman 的 Client 监控
如果你用的是 Swoole,这简直是开挂。
Swoole 提供了 SwooleRuntime::enableCoroutine(true)。
我们可以写一个定时器,每隔 1 秒扫描一下所有正在运行的 Fiber。
// 这是一个伪代码,演示思路
Timer::tick(1000, function() {
$running = [];
foreach (SwooleCoroutine::listCoroutines() as $cid) {
// 检查这个协程的状态
// 如果状态是 SUSPENDED (挂起),且挂起时间超过了阈值(比如 10 秒)
if (Coroutine::getState($cid) === SWOOLE_CORO_SUSPEND && Coroutine::getDuration($cid) > 10000000) {
// 开始打印它的“遗言”
$fiber = Coroutine::getFiber($cid);
echo "发现卡死协程: ID $cidn";
echo "调用栈:n";
print_r($fiber->getTraceAsString()); // 或者你的 debug_backtrace
}
}
});
这段代码会自动揪出那些“挂机”太久的协程。一旦发现,你就知道哪个锁死在那儿了。
3. 动态追踪库
现在有很多 PHP 性能分析工具,比如 Blackfire 或者 Xhprof。虽然它们主要用于同步分析,但在协程环境下,如果配合得当,也能发现资源占用异常的情况。不过,针对死锁,我们还是推荐手动栈追踪,因为分析器通常无法看到“为什么”它不继续往下走。
第五部分:实战案例——数据库与 Redis 的双重陷阱
让我们看一个更真实的、高并发环境下的死锁案例。
场景:
- 微服务 A 需要扣减库存。
- 微服务 B 需要创建订单。
它们都连着同一个 Redis 实例和同一个 MySQL 实例。
错误的代码模式(诱导式):
function processOrder($orderId, $userId) {
// 1. 先去 Redis 锁住订单键,防止重复下单
$redisLock = $redis->set("lock:$orderId", 1, ['NX', 'EX' => 30]);
if (!$redisLock) return "抢锁失败";
try {
// 2. 开启数据库事务
$pdo->beginTransaction();
// 3. 检查库存(依赖数据库行锁)
$stmt = $pdo->prepare("SELECT stock FROM products WHERE id = ?");
$stmt->execute([$productId]);
$stock = $stmt->fetchColumn();
if ($stock > 0) {
// 4. 扣减库存(数据库锁)
$pdo->prepare("UPDATE products SET stock = ? WHERE id = ?")->execute([$stock - 1, $productId]);
// 5. 插入订单
$pdo->prepare("INSERT INTO orders ...")->execute([...]);
$pdo->commit();
} else {
$pdo->rollBack();
}
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
} finally {
$redis->del("lock:$orderId");
}
}
死锁是怎么发生的?
如果此时有两个协程,同时处理 order_1 和 order_2,且它们没有遵循严格的顺序,就会发生“分布式死锁”。
- 协程 1 执行到了第 3 步,锁住了 MySQL 表的某一行。
- 协程 2 执行到了第 1 步,锁住了 Redis 的
lock:order_1。 - 协程 1 想要执行第 2 步,去抢数据库锁,被协程 2 阻塞(因为锁还在协程 2 手里)。
- 协程 2 想要执行第 3 步,去查数据库,结果发现 MySQL 里的行被协程 1 锁住了!它也阻塞了。
如何监控?
这时候,单纯的 var_dump 没用了。你需要使用我上面提到的监控手段。
你会在日志里看到这样的画面:
[Fiber-123]: Waiting for MySQL Table Lock (id=1001)
Call Stack: OrderService::create, Database::beginTransaction
[Fiber-456]: Waiting for MySQL Table Lock (id=1001)
Call Stack: InventoryService::check, OrderService::create
看到了吗?OrderService::create 同时在等 Fiber-123 和 Fiber-456。这两个 Fiber 互不相让。这就是一个完美的异步逻辑环。
第六部分:打破逻辑环——最佳实践
发现死锁不是目的,解决问题才是。在 PHP 协程中,打破死锁主要有两个手段:锁超时 和 拓扑排序。
1. 永远不要相信超时,要相信锁的持有时间
很多同学喜欢给锁加个 10 秒超时,认为“锁了 10 秒还没拿到,肯定出事了,就报错吧”。
错!大错特错!
在常驻内存里,如果你设置了锁超时,代码逻辑可能会这样写:
$lock = $redis->set("lock", 1, ['NX', 'EX' => 10]);
if ($lock) {
// ... 业务逻辑 ...
$redis->del("lock");
} else {
// 报错了?或者重试?
// 但是如果死锁了,这里会无限重试,死循环!
}
如果发生死锁,这两个协程都会在超时后释放锁,然后重新去抢锁。于是,A 释放了锁,B 释放了锁,A 抢到了锁,B 抢到了锁,然后死锁再次发生。CPU 火花带闪电。
正确的做法是: 限制重试次数,或者使用令牌桶算法控制并发度,而不是依赖“超时”来打破死锁。
2. 链路追踪
这是解决异步逻辑环的终极武器。
在 PHP 的协程世界里,每个请求进来,我们都要给它打上一个唯一的 TraceID。
TraceID = uuid()。
然后,在所有锁操作、数据库查询、外部 API 调用中,我们都把这个 TraceID 传进去。
function acquireLock($key, $traceId) {
$redis->set("lock:$key", 1, ['NX', 'EX' => 30]);
// 记录日志:谁在等这个锁
// "TraceID-ABC acquired Lock-Key-X at 12:00:01"
}
function useLock($key, $traceId) {
// 检查日志
// "TraceID-XYZ is waiting for Lock-Key-X since 12:00:10"
// ...
}
当你发现死锁时,拿出你的 TraceID,在日志里搜索。
你会看到一条长长的链路:
Trace-123 -> Acquire Lock (Order) -> Wait For DB Lock -> Wait For Redis Lock (User)
Trace-456 -> Acquire Lock (User) -> Wait For Redis Lock (Order)
这就是证据链。一旦有了链路,你就能画出拓扑图,一眼看到哪个环堵住了。
第七部分:总结与心态建设
好了,讲了这么多,我们总结一下在 PHP 常驻内存任务中,如何处理死锁诊断:
- 敬畏全局变量: PHP 协程里,锁、全局状态、静态变量都是共享的。不要觉得自己写的函数是独立的,它可能在跟成千上万个其他函数抢资源。
- 不要写死锁代码: 简单的锁顺序错误(A->B,B->A)很容易犯。记住一个口诀:“谁先到谁先拿,拿完赶紧放,拿到两个锁必须按顺序释放。”
- 善用
debug_backtrace: 这是你的法医报告。在关键等待点打印堆栈。 - 给协程起名字: 在 Fiber/Swoole 中,把你的 Fiber 起个有意义的名字,比如
Order_Create-1001。在监控里看到Fiber-8821卡住,如果名字不对,那就更方便排查了。 - 保持冷静: 死锁不是世界末日。它是逻辑的必然结果。只要你手握 TraceID 和堆栈跟踪,你就能像剥洋葱一样,一层一层剥开这个异步逻辑环。
最后,送大家一句话:“锁是为了安全,不是为了吵架。” 设计好你的锁策略,让资源流动起来,而不是变成两堵墙。祝大家在 PHP 协程的江湖里,独孤求败,再也不用去修服务器的硬盘!