浅谈PHP之线程锁

一、基本介绍

PHP 本身是面向 Web 开发的脚本语言,它主要是单线程的。不过在一些特定场景下,比如使用 PHP 进行命令行脚本开发或者在多进程环境下,我们可能需要使用线程锁来保证数据的一致性和完整性。

二、实现方式

一、基于文件的锁

  • 原理
    • 利用文件系统的锁机制来实现。当一个进程需要访问共享资源时,它会尝试对一个特定的文件进行加锁。如果文件已经被其他进程锁定,当前进程就会阻塞等待,直到文件锁被释放。

示例代码

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 为例)

php 复制代码
$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 缓存中添加一个键值对,如果添加成功,说明获取到锁;如果添加失败(因为键已经存在),说明锁已经被其他进程持有。

示例代码

php 复制代码
// 尝试获取锁
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 的锁。
      • 避免方法:可以采用资源分级的方法。给每个资源分配一个等级,进程在请求锁时,必须按照资源等级从低到高的顺序来获取锁。这样可以避免形成等待环路,从而防止死锁。

二、锁的释放

  • 重要性
    • 锁的释放是避免资源饥饿和死锁的关键步骤。如果一个进程获取了锁后,没有正确释放锁,其他进程将永远无法获取该锁,导致资源无法被有效利用。
  • 注意事项
    • 确保释放锁 :在使用锁的代码块中,一定要确保锁能够在所有可能的执行路径上被释放。可以使用 try...finally 语句来保证锁的释放。例如,在基于文件的锁示例中:
php 复制代码
$file = 'lock.txt';
$fp = fopen($file, 'w');

if (flock($fp, LOCK_EX)) {
    try {
        // 执行需要同步的代码
    } finally {
        // 释放锁
        flock($fp, LOCK_UN);
    }
}

fclose($fp);
  • 这样即使在执行同步代码块时发生异常,锁也能在 finally 代码块中被释放。
  • 避免提前释放锁:在某些情况下,可能会由于逻辑错误导致锁被提前释放。比如,在一个复杂的业务流程中,某个条件分支错误地执行了锁释放操作,而后续代码还需要使用该锁。这可能会导致数据不一致的问题。因此,要仔细审查代码逻辑,确保锁只在合适的时机被释放。

三、锁的粒度

  • 定义
    • 锁的粒度是指锁所控制的资源范围的大小。粒度可以很粗,比如锁定整个文件或数据库表;也可以很细,比如锁定文件中的某一行或数据库表中的某一条记录。
  • 注意事项
    • 粗粒度锁 :虽然实现起来相对简单,但可能会导致资源利用率低下。因为当一个进程锁定整个资源时,其他进程即使只需要访问资源的一部分也会被阻塞。例如,锁定整个数据库表进行更新操作,可能会使其他只需要查询表中部分数据的进程等待。
      • 适用场景:当业务逻辑简单,对性能要求不是特别高,且资源之间的关联性很强时,可以考虑使用粗粒度锁。比如,一个小型的脚本只需要对一个配置文件进行读写操作,使用文件锁来锁定整个配置文件是合适的。
    • 细粒度锁 :可以提高资源的并发访问能力,但实现起来相对复杂,且可能会增加锁管理的开销。例如,对数据库表中的每一条记录都加锁,可以允许多个进程同时更新不同的记录,但需要更复杂的锁管理机制来协调这些锁。
      • 适用场景:在高并发的场景下,且资源之间的关联性较弱时,细粒度锁是更好的选择。比如,一个大型的电商平台,需要同时处理多个用户的订单更新操作,对数据库中的订单表使用行级锁可以提高系统的并发处理能力。

四、锁的超时机制

  • 定义
    • 锁的超时机制是指在尝试获取锁时,如果在指定的时间内无法获取到锁,则放弃获取锁的操作。这可以避免进程无限期地等待锁,从而提高系统的响应性和稳定性。
  • 实现方法及注意事项
    • 基于文件的锁超时示例
php 复制代码
$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 函数以非阻塞方式获取锁。如果获取锁失败,就在循环中稍作等待后再次尝试,同时检查是否超时。
  • 注意事项:设置合理的超时时间很重要。超时时间过短可能导致进程频繁地尝试获取锁,增加系统开销;超时时间过长又可能使进程等待过久,影响系统的响应性。需要根据具体业务场景和系统性能来调整超时时间。
相关推荐
ServBay6 小时前
告别面条代码,PSL 5.0 重构 PHP 性能与安全天花板
后端·php
JaguarJack3 天前
FrankenPHP 原生支持 Windows 了
后端·php·服务端
BingoGo3 天前
FrankenPHP 原生支持 Windows 了
后端·php
JaguarJack4 天前
PHP 的异步编程 该怎么选择
后端·php·服务端
BingoGo4 天前
PHP 的异步编程 该怎么选择
后端·php
JaguarJack4 天前
为什么 PHP 闭包要加 static?
后端·php·服务端
ServBay5 天前
垃圾堆里编码?真的不要怪 PHP 不行
后端·php
用户962377954485 天前
CTF 伪协议
php
BingoGo8 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack8 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端