浅谈PHP之线程锁
一、基本介绍
PHP 本身是面向 Web 开发的脚本语言,它主要是单线程的。不过在一些特定场景下,比如使用 PHP 进行命令行脚本开发或者在多进程环境下,我们可能需要使用线程锁来保证数据的一致性和完整性。
二、实现方式
一、基于文件的锁
- 原理
- 利用文件系统的锁机制来实现。当一个进程需要访问共享资源时,它会尝试对一个特定的文件进行加锁。如果文件已经被其他进程锁定,当前进程就会阻塞等待,直到文件锁被释放。
示例代码
$file = 'lock.txt';
// 获取文件指针
$fp = fopen($file, 'w');
// 加锁
if (flock($fp, LOCK_EX)) { // LOCK_EX 表示排他锁,即同一时间只有一个进程能获取到锁
// 执行需要同步的代码
echo "执行同步代码块中的内容" . PHP_EOL;
// 释放锁
flock($fp, LOCK_UN);
} else {
echo "无法获取锁" . PHP_EOL;
}
// 关闭文件指针
fclose($fp);
在这个例子中,当一个进程执行到 flock($fp, LOCK_EX)
时,它会尝试对 lock.txt
文件加锁。如果文件已经被其他进程锁定,当前进程会阻塞在这里。当文件锁被释放后(通过 flock($fp, LOCK_UN)
),当前进程才能继续执行后续代码。
二、基于数据库的锁
- 原理
- 通过在数据库中设置锁记录来实现。可以创建一个专门的锁表,当进程需要访问共享资源时,它会尝试在锁表中插入一条记录或者更新某条记录来获取锁。如果插入或更新操作成功,说明获取到锁;如果失败,说明锁已经被其他进程持有。
示例代码(以 MySQL 为例)
$mysqli = new mysqli("localhost", "my_user", "my_password", "my_db");
// 尝试获取锁
$result = $mysqli->query("INSERT INTO locks (resource_name) VALUES ('my_resource') ON DUPLICATE KEY UPDATE process_id = LAST_INSERT_ID(process_id)");
$id = $mysqli->insert_id;
if ($id > 0) {
// 获取到锁,执行同步代码
echo "获取到锁,执行同步代码块中的内容" . PHP_EOL;
// 释放锁
$mysqli->query("DELETE FROM locks WHERE id = $id");
} else {
echo "无法获取锁" . PHP_EOL;
}
$mysqli->close();
这里假设有一个 locks
表,其中 resource_name
字段用于标识需要锁定的资源,process_id
字段用于记录持有锁的进程 ID。通过 INSERT ... ON DUPLICATE KEY UPDATE
语句尝试获取锁,如果插入成功(即 insert_id
大于 0),说明获取到锁;如果插入失败(即存在重复的 resource_name
),则无法获取锁。
三、基于内存共享的锁(如使用 APCu 扩展)
- 原理
- APCu(Alternative PHP Cache user cache)是一个 PHP 扩展,它提供了内存缓存功能。可以利用 APCu 的原子操作来实现锁。当进程需要获取锁时,它会尝试使用
apcu_add
函数在 APCu 缓存中添加一个键值对,如果添加成功,说明获取到锁;如果添加失败(因为键已经存在),说明锁已经被其他进程持有。
- APCu(Alternative PHP Cache user cache)是一个 PHP 扩展,它提供了内存缓存功能。可以利用 APCu 的原子操作来实现锁。当进程需要获取锁时,它会尝试使用
示例代码
// 尝试获取锁
if (apcu_add('my_lock', true)) {
// 获取到锁,执行同步代码
echo "获取到锁,执行同步代码块中的内容" . PHP_EOL;
// 释放锁
apcu_delete('my_lock');
} else {
echo "无法获取锁" . PHP_EOL;
}
在这个例子中,my_lock
是锁的键名。当一个进程调用 apcu_add('my_lock', true)
时,如果 APCu 缓存中不存在 my_lock
键,它会添加这个键并返回 true
,表示获取到锁;如果 my_lock
键已经存在,apcu_add
函数会返回 false
,表示无法获取锁。
三、重要事项
一、死锁问题
- 定义
- 死锁是指两个或多个进程在执行过程中,因争夺资源而造成的一种僵局,当进程处于这种僵局时,它们既无法继续执行,也无法终止,只能等待。
- 常见场景及避免方法
- 嵌套锁:如果在持有某个锁的情况下又尝试获取另一个锁,而另一个锁已经被其他进程持有,且该进程也在等待当前进程持有的锁释放,就会发生死锁。例如,进程 A 持有锁 1 并尝试获取锁 2,同时进程 B 持有锁 2 并尝试获取锁 1。
- 避免方法:尽量避免嵌套锁。如果确实需要嵌套锁,可以采用锁顺序的方式来避免死锁。即规定所有进程获取锁的顺序必须一致。比如,规定先获取锁 1,再获取锁 2,所有进程都按照这个顺序来操作锁,就可以避免死锁的发生。
- 多个资源锁:当多个进程同时竞争多个资源的锁时,也可能出现死锁。例如,有资源 A 和资源 B,进程 A 持有资源 A 的锁并请求资源 B 的锁,进程 B 持有资源 B 的锁并请求资源 A 的锁。
- 避免方法:可以采用资源分级的方法。给每个资源分配一个等级,进程在请求锁时,必须按照资源等级从低到高的顺序来获取锁。这样可以避免形成等待环路,从而防止死锁。
- 嵌套锁:如果在持有某个锁的情况下又尝试获取另一个锁,而另一个锁已经被其他进程持有,且该进程也在等待当前进程持有的锁释放,就会发生死锁。例如,进程 A 持有锁 1 并尝试获取锁 2,同时进程 B 持有锁 2 并尝试获取锁 1。
二、锁的释放
- 重要性
- 锁的释放是避免资源饥饿和死锁的关键步骤。如果一个进程获取了锁后,没有正确释放锁,其他进程将永远无法获取该锁,导致资源无法被有效利用。
- 注意事项
- 确保释放锁:在使用锁的代码块中,一定要确保锁能够在所有可能的执行路径上被释放。可以使用
try...finally
语句来保证锁的释放。例如,在基于文件的锁示例中:
- 确保释放锁:在使用锁的代码块中,一定要确保锁能够在所有可能的执行路径上被释放。可以使用
$file = 'lock.txt';
$fp = fopen($file, 'w');
if (flock($fp, LOCK_EX)) {
try {
// 执行需要同步的代码
} finally {
// 释放锁
flock($fp, LOCK_UN);
}
}
fclose($fp);
- 这样即使在执行同步代码块时发生异常,锁也能在
finally
代码块中被释放。 - 避免提前释放锁:在某些情况下,可能会由于逻辑错误导致锁被提前释放。比如,在一个复杂的业务流程中,某个条件分支错误地执行了锁释放操作,而后续代码还需要使用该锁。这可能会导致数据不一致的问题。因此,要仔细审查代码逻辑,确保锁只在合适的时机被释放。
三、锁的粒度
- 定义
- 锁的粒度是指锁所控制的资源范围的大小。粒度可以很粗,比如锁定整个文件或数据库表;也可以很细,比如锁定文件中的某一行或数据库表中的某一条记录。
- 注意事项
- 粗粒度锁:虽然实现起来相对简单,但可能会导致资源利用率低下。因为当一个进程锁定整个资源时,其他进程即使只需要访问资源的一部分也会被阻塞。例如,锁定整个数据库表进行更新操作,可能会使其他只需要查询表中部分数据的进程等待。
- 适用场景:当业务逻辑简单,对性能要求不是特别高,且资源之间的关联性很强时,可以考虑使用粗粒度锁。比如,一个小型的脚本只需要对一个配置文件进行读写操作,使用文件锁来锁定整个配置文件是合适的。
- 细粒度锁:可以提高资源的并发访问能力,但实现起来相对复杂,且可能会增加锁管理的开销。例如,对数据库表中的每一条记录都加锁,可以允许多个进程同时更新不同的记录,但需要更复杂的锁管理机制来协调这些锁。
- 适用场景:在高并发的场景下,且资源之间的关联性较弱时,细粒度锁是更好的选择。比如,一个大型的电商平台,需要同时处理多个用户的订单更新操作,对数据库中的订单表使用行级锁可以提高系统的并发处理能力。
- 粗粒度锁:虽然实现起来相对简单,但可能会导致资源利用率低下。因为当一个进程锁定整个资源时,其他进程即使只需要访问资源的一部分也会被阻塞。例如,锁定整个数据库表进行更新操作,可能会使其他只需要查询表中部分数据的进程等待。
四、锁的超时机制
- 定义
- 锁的超时机制是指在尝试获取锁时,如果在指定的时间内无法获取到锁,则放弃获取锁的操作。这可以避免进程无限期地等待锁,从而提高系统的响应性和稳定性。
- 实现方法及注意事项
- 基于文件的锁超时示例:
$file = 'lock.txt';
$fp = fopen($file, 'w');
// 设置超时时间
$timeout = 5; // 超时时间为 5 秒
$startTime = time();
while (true) {
if (flock($fp, LOCK_EX | LOCK_NB)) { // LOCK_NB 表示非阻塞方式获取锁
// 获取到锁,执行同步代码
break;
} else {
// 检查是否超时
if (time() - $startTime > $timeout) {
echo "获取锁超时" . PHP_EOL;
break;
}
// 稍作等待后再次尝试
usleep(100000); // 等待 0.1 秒
}
}
// 释放锁
flock($fp, LOCK_UN);
fclose($fp);
- 在这个例子中,通过
LOCK_NB
选项使flock
函数以非阻塞方式获取锁。如果获取锁失败,就在循环中稍作等待后再次尝试,同时检查是否超时。 - 注意事项:设置合理的超时时间很重要。超时时间过短可能导致进程频繁地尝试获取锁,增加系统开销;超时时间过长又可能使进程等待过久,影响系统的响应性。需要根据具体业务场景和系统性能来调整超时时间。