PHP `Mutex` (互斥锁) 与 `Semaphore` (信号量) 在 PHP 并发中的应用

各位听众,早上好/下午好/晚上好!很高兴今天能和大家聊聊PHP并发编程中两个非常重要的武器:Mutex(互斥锁)和 Semaphore(信号量)。这俩哥们儿,听起来好像很高级,但其实理解起来并不难,用起来也挺方便。今天我们就来好好地扒一扒它们,让你的PHP代码也能跑得更快更稳。

并发编程,一个不得不面对的现实

首先,我们得搞清楚为什么要关心并发编程。想象一下,你的网站突然来了好多用户,大家一起抢购商品、发布评论,服务器压力山大啊!如果你的代码是单线程的,那就像一条只有一个车道的马路,再多的车也得排队慢慢过。并发编程就是为了解决这个问题,让多个任务可以同时执行,就像修了多条车道,大大提高了效率。

PHP虽然以单线程为主,但通过一些扩展和技巧,我们仍然可以实现并发,提升性能。而Mutex和Semaphore,就是我们在并发场景下的好帮手。

Mutex:独占资源,谁也别想抢!

Mutex,全称Mutual Exclusion(互斥),顾名思思义,就是互相排斥的意思。它就像一把锁,一次只能有一个线程/进程拿到它,拿到锁的线程/进程就可以访问共享资源,用完之后必须释放锁,其他线程/进程才能有机会获得锁。

你可以把Mutex想象成只有一个钥匙的厕所门,谁拿到钥匙谁才能进去,其他人只能在外面等着。

使用场景:

  • 保护共享资源: 比如,多个进程/线程需要修改同一个文件、更新同一个数据库记录,为了避免数据错乱,就必须使用Mutex来保证同一时刻只有一个进程/线程在操作。
  • 防止竞态条件: 竞态条件是指程序的执行结果取决于多个线程/进程的执行顺序,而这种顺序是不确定的。Mutex可以保证操作的原子性,避免竞态条件。

PHP中使用Mutex的几种方式:

  1. 使用 flock() 函数: 这是PHP内置的文件锁机制,可以模拟Mutex的行为。

    <?php
    
    $file = '/tmp/mutex.lock'; //锁文件
    
    // 获取锁
    $fp = fopen($file, 'w+');
    if (flock($fp, LOCK_EX)) { // LOCK_EX 表示独占锁
    
        // 模拟耗时操作,访问共享资源
        echo "获得了锁,开始执行...n";
        sleep(5); // 模拟处理过程
        echo "执行完毕,释放锁...n";
    
        // 释放锁
        flock($fp, LOCK_UN); // LOCK_UN 表示释放锁
    } else {
        echo "未能获得锁,稍后再试...n";
    }
    
    fclose($fp);
    
    ?>

    代码解释:

    • fopen($file, 'w+'):打开一个文件用于读写。
    • flock($fp, LOCK_EX):尝试获取独占锁。如果成功,返回true,否则返回false
    • flock($fp, LOCK_UN):释放锁。
    • sleep(5):模拟耗时操作,比如修改数据库、写入文件等。

    注意事项:

    • flock() 函数依赖于文件系统,因此只能用于本地文件。
    • 如果进程/线程意外退出,锁不会自动释放,可能会导致死锁。为了避免这种情况,可以设置超时时间或者使用try…finally来确保锁能够被释放。
  2. 使用 Mutex 扩展 (需要安装 pcntl 扩展): 这是更专业的Mutex实现,提供了更丰富的功能。

    <?php
    if (!extension_loaded('pcntl')) {
        die('pcntl extension is required.');
    }
    
    if (!extension_loaded('sysvsem')) {
        die('sysvsem extension is required.');
    }
    $sem_key = ftok(__FILE__, 'm'); //  生成一个唯一的键
    $sem_id = sem_get($sem_key, 1, 0666, 1); // 创建或获取信号量, 1表示初始值为1, 相当于Mutex
    if ($sem_id === false) {
        die("Failed to acquire semaphore");
    }
    
    if (sem_acquire($sem_id)) { //尝试获取信号量 (锁)
        echo "获得了锁,开始执行...n";
        sleep(5); // 模拟处理过程
        echo "执行完毕,释放锁...n";
        sem_release($sem_id); // 释放信号量 (锁)
    } else {
        echo "未能获得锁,稍后再试...n";
    }
    ?>

    代码解释:

    • ftok(__FILE__, 'm'):生成一个唯一的键,用于标识信号量。__FILE__ 表示当前文件路径,'m' 是一个任意字符,只要保证不同文件使用不同的字符即可。
    • sem_get($sem_key, 1, 0666, 1):创建或获取信号量。第一个参数是键,第二个参数是信号量数量(对于Mutex,通常为1),第三个参数是权限,第四个参数是初始值(1表示可用)。
    • sem_acquire($sem_id):尝试获取信号量。如果信号量的值大于0,则获取成功,信号量的值减1;否则,阻塞等待,直到信号量的值大于0。
    • sem_release($sem_id):释放信号量,信号量的值加1。

    注意事项:

    • 需要安装 pcntlsysvsem 扩展。
    • 信号量是系统级别的资源,即使进程/线程退出,信号量仍然存在。如果忘记释放信号量,可能会导致其他进程/线程一直阻塞。为了避免这种情况,可以使用 sem_remove() 函数手动删除信号量。但务必小心使用,确保只在不再需要信号量时才删除。
    • 这种方式可以在不同的PHP进程之间实现互斥。
  3. 使用Redis等外部存储系统: 可以利用Redis的原子操作来实现分布式Mutex。

    <?php
    
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    $lockKey = 'my_mutex_lock';
    $lockValue = uniqid(); //  生成一个唯一的值,用于标识锁的持有者
    $lockTimeout = 10; // 锁的超时时间,单位秒
    
    // 尝试获取锁
    $lockAcquired = $redis->set($lockKey, $lockValue, ['NX', 'EX' => $lockTimeout]);
    
    if ($lockAcquired) {
        echo "获得了锁,开始执行...n";
        sleep(5); // 模拟处理过程
        echo "执行完毕,释放锁...n";
    
        // 释放锁 (必须校验锁的持有者)
        if ($redis->get($lockKey) === $lockValue) {
            $redis->del($lockKey);
        } else {
            echo "警告:锁已被其他进程/线程获取,无法释放!n";
        }
    } else {
        echo "未能获得锁,稍后再试...n";
    }
    
    $redis->close();
    
    ?>

    代码解释:

    • $redis->set($lockKey, $lockValue, ['NX', 'EX' => $lockTimeout]):尝试设置键 $lockKey 的值为 $lockValue,并设置选项 NXEX
      • NX:表示只有当键不存在时才设置成功,相当于 "SET if Not eXists"。
      • EX:表示设置键的过期时间,单位为秒。
    • $redis->get($lockKey):获取键 $lockKey 的值。
    • $redis->del($lockKey):删除键 $lockKey

    注意事项:

    • 需要安装Redis扩展。
    • 使用Redis实现分布式锁的关键是利用Redis的原子操作,保证锁的获取和释放的原子性。
    • 为了避免死锁,需要设置锁的超时时间。即使进程/线程意外退出,锁也会在超时后自动释放。
    • 释放锁时,必须校验锁的持有者,确保只有持有锁的进程/线程才能释放锁。这是为了防止误删其他进程/线程的锁。
    • 可以使用Lua脚本来保证锁的获取和释放的原子性,避免竞态条件。

Mutex的优缺点:

  • 优点: 实现简单,易于理解和使用。
  • 缺点: 只能用于保护单个共享资源。如果需要保护多个共享资源,需要使用多个Mutex,容易导致死锁。

Semaphore:允许多个线程/进程访问资源,但有限制!

Semaphore,中文叫信号量,它比Mutex更强大。Mutex只允许一个线程/进程访问资源,而Semaphore允许指定数量的线程/进程同时访问资源。

你可以把Semaphore想象成有多个隔间的厕所,每个隔间都相当于一个资源。Semaphore的值就相当于可用隔间的数量。当一个线程/进程需要使用厕所时,它会尝试获取一个隔间(信号量的值减1)。如果所有隔间都被占用(信号量的值为0),则该线程/进程需要等待,直到有隔间空闲出来(信号量的值大于0)。当线程/进程使用完厕所后,它会释放隔间(信号量的值加1),让其他线程/进程可以使用。

使用场景:

  • 限制并发连接数: 比如,限制数据库连接数、限制API请求频率等。
  • 实现生产者-消费者模式: 生产者生产数据,消费者消费数据,Semaphore可以用来协调生产者和消费者的速度,避免生产者生产过快导致数据溢出,或者消费者消费过慢导致数据饥饿。

PHP中使用Semaphore:

Semaphore在PHP中主要依赖 sysvsem 扩展。

<?php
if (!extension_loaded('pcntl')) {
    die('pcntl extension is required.');
}

if (!extension_loaded('sysvsem')) {
    die('sysvsem extension is required.');
}
$sem_key = ftok(__FILE__, 's'); // 生成一个唯一的键
$sem_id = sem_get($sem_key, 3, 0666, 1); // 创建或获取信号量,3表示初始值为3,允许3个进程同时访问
if ($sem_id === false) {
    die("Failed to acquire semaphore");
}

if (sem_acquire($sem_id)) { // 尝试获取信号量
    echo "获得了信号量,开始执行...n";
    sleep(5); // 模拟耗时操作,访问共享资源
    echo "执行完毕,释放信号量...n";
    sem_release($sem_id); // 释放信号量
} else {
    echo "未能获得信号量,稍后再试...n";
}
?>

代码解释:

  • sem_get($sem_key, 3, 0666, 1):创建或获取信号量。第二个参数 3 表示信号量的初始值为3,意味着允许3个进程/线程同时访问共享资源。

Semaphore的优缺点:

  • 优点: 可以限制并发访问资源的数量,防止资源耗尽。
  • 缺点: 实现相对复杂,容易出现死锁。

Mutex vs Semaphore:如何选择?

特性 Mutex (互斥锁) Semaphore (信号量)
目的 独占资源访问,一次只允许一个线程/进程访问 限制并发访问资源的数量,允许多个线程/进程同时访问,但有限制
资源数量 1 可以是任何非负整数
用途 保护共享资源,防止竞态条件 限制并发连接数,实现生产者-消费者模式等
实现难度 简单 相对复杂

简单总结:

  • 如果只需要保护单个共享资源,并且只允许一个线程/进程访问,那么Mutex是更好的选择。
  • 如果需要限制并发访问资源的数量,或者实现更复杂的并发控制,那么Semaphore是更好的选择。

死锁问题:一个需要警惕的陷阱

无论是Mutex还是Semaphore,都可能导致死锁。死锁是指两个或多个线程/进程相互等待对方释放资源,导致所有线程/进程都无法继续执行的情况。

常见的死锁场景:

  • 循环等待: 线程A持有资源1,等待资源2;线程B持有资源2,等待资源1。
  • 资源竞争: 多个线程/进程竞争同一个资源,但都没有释放资源。

如何避免死锁:

  • 避免循环等待: 按照固定的顺序获取资源,避免循环等待。
  • 设置超时时间: 在尝试获取锁的时候设置超时时间,如果超时仍然没有获取到锁,则放弃获取,避免一直等待。
  • 死锁检测: 定期检测是否存在死锁,如果发现死锁,则释放一些资源,打破死锁。

总结:并发编程,谨慎前行

Mutex和Semaphore是PHP并发编程中非常有用的工具,可以帮助我们提高程序的性能和稳定性。但是,并发编程也是一个复杂的主题,需要谨慎对待。在使用Mutex和Semaphore时,一定要仔细考虑使用场景,避免出现死锁等问题。希望今天的分享能帮助大家更好地理解和使用这两个利器,写出更高效、更健壮的PHP代码!

感谢大家的聆听!希望对你有所帮助。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注