计数信号量是一种锁,它可以让用户限制一项资源最多能够同时被多少个进程访问,通常用于限定能够同时使用的资源数量。之前所了解到的分布式锁可以看做是只能被一个进程访问的信号量。 计数信号量和其他种类的锁一样,都需要被获取和释放。客户端首先需要获取信号量,然后执行操作,最后释放信号量。计数信号量和其他锁的区别在于,当客户端获取锁失败的时候,客户端通常会选择进行等待;而当客户端获取计数信号量失败的时候,客户端通常会选择立即返回失败结果。

基本的计数信号量

将多个信号量的持有者的信息都存储到同一个有序集合里面,程序将为每个尝试获取信号量的进程生成一个唯一标识符(比如UUID),并将这个标识符用作有序集合的成员,而成员对应的分值则是进程尝试获取信号量时的Unix时间戳。接着进程会检查自己的标识符在有序集合中的排名,如果排名低于可获取的信号量总数(成员的排名从0开始计算),那么表示进程成功地取得了信号量。反之,则表示进程未能取得信号量,它必须从有序集合里面移除自己的标识符。为了处理过期的信号量,程序在将标识符添加到有序集合之前,会先清理有序集合中所有时间戳大于超时数值(timeout number value)的标识符。

获取信号量的步骤:

  1. 清理有序集合中所有已过期的UUID(时间戳 <= 当前时间戳 - 过期时间);
  2. 生成UUID,使用当前时间戳作为分值,将UUID添加到有序集合里面;
  3. 检查刚刚的UUID的排名,若排名低于可获取的信号量总数(成员排名从0开始计算),那么表示成功获取了信号量;若排名等于或高于可获取的信号量总数,那么未获取成功,需要将刚刚的UUID移除。释放信号量时直接从有序集合中删除UUID即可。若返回值为1 ,则表明成功手动释放;若返回值为0 ,则表明已经由于过期而自动释放。

缺点:

  1. 所有信号量的过期时间都需要一样(为了方便删除过期的UUID);
  2. 不公平,依赖系统时间,当多机环境下,A 的系统时间比B的系统时间快10ms ,那么当 A 取得了最后一个信号量的时候,B只要在10ms内尝试获取信号量,那么就会造成B获取了不存在的信号量,导致获取的信号量超过了信号量的总数;还可能造成信号量提早被其他系统的获取请求释放。

每当锁或者信号量因为系统时钟的细微不同而导致锁的获取结果出现剧烈变化是,这个锁或者信号量就是不公平的(unfair),不公平的锁和信号量可能会导致客户端永远也无法取得它原本应该得到的锁或者信号量。

公平信号量

当各个系统的系统时间并不完全相同的时候,基本信号量就会出现问题:系统时钟较慢的系统上运行的客户端,将能够偷走系统时钟较快的系统上运行的客户端已经取得的信号量,导致信号量变得不公平。我们需要减少不正确的系统时间对信号量获取操作带来的影响,是的只要各个系统的系统时间相差不超过1秒,就不会引起信号量被偷或者信号量提早过期。
为了尽可能地减少系统时间不一致带来的问题,我们需要给信号量实现添加一个计数器以及一个新的有序集合。其中,计数器通过持续地执行自增操作,每次发出获取请求前先对其自增,创建出一种类似于计时器(timer)的机制,确保最先对计数器执行自增操作的客户端能过获得信号量。另外,为了满足“最先对计数器执行自增操作的客户端能够获得信号量”这一要求,程序会将计数器生成的值用作分值,将对应的UUID存储到一个“信号量拥有者”的有序集合里面,然后检查客户端生成的标识符在有序集合中的排名来判断客户端是否取得了信号量。
即原本的有序集合仅用来查找并删除过期的UUID ,新的有序集合用来获取排名判断请求是否成功获取到信号量。同时为了保持新的有序集合能及时删过期的UUID ,在原本的有序集合执行完删除操作后,还要使用 ZINTERSTORE命令,保留仅在原本有序集合中出现的UUID (ZINTERSTORE count_set 2 count_set time_set WEIGHTS 1 0)。
注意:若信号量获取失败,则需要及时删除本次插入的无用数据。
上述方法能在一定程度上解决信号量获取数超过信号量总数的问题,但删除过期UUID的地方还是依赖本地时间,所以尽量保证各个主机的系统时间差距要足够小,仍然需要控制在一两秒之内,从而避免信号量过早释放或者太晚释放。

做到与系统时间无关
去除原本的有序集合,仅留下计数器和计数值作为分值的有序集合,并对于每个UUID都设置一个有过期时间的键,每次移除前,遍历有序集合,并查询其是否过期,并从有序集合中删除所有已过期的UUID 。
这样做不仅能完全达到与系统时间无关,还不会存在信号量获取数超过信号量总数的问题,且能够实现单个获取的信号量能有不同的过期时间,也一定程度上降低了时间复杂度,不过会增加客户端与 Redis 服务器之间的交互次数。

刷新信号量

信号量使用者可能在过期时间内无法处理完请求,此时就需要续约,延长过期时间。由于公平的计数信号量已将时间有序集合和计数有序集合分开,所以只需要在时间有序集合中对UUID执行ZADD即可,若执行失败,则已过期自动释放。
而对于上述“做到与系统时间无关”的方法,有两种方法可以进行续约:

  1. 使用Lua脚本保证原子性;
  2. 先读取过期时间,未过期,再使用带XX选项的SET命令设置新的过期时间(需要加上原有的过期时间),返回成功则续约成功,否则续约失败;已过期则续约失败。

消除竞争条件

当两个进程A和B都在尝试获取剩余的一个信号量时,即使A首先对计数器执行了自增操作,但只要B能够抢先将自己的标识符(UUID)添加到计数有序集合中,并检查标识符(UUID)的排名,那么B就可以成功获取信号量。之后A再将自己的标识符(UUID)添加到有序集合里,并检查标识符(UUID)排名,那么A也可以成功获取信号量,最终导致获取的信号量多于信号量总数,而B只有在尝试释放信号量或者尝试刷新信号量的时候才会觉察到这一点。
将系统时钟用作获取锁的手段提高了这类竞争条件出现的可能性,导致信号量持有者的数量与预期的还要多,多出的信号量数量与各个系统时钟之间的差异有关——差异越大,出现额外信号量持有者的可能性也就越大。虽然引入计数器和信号量持有者有序集合可以移除系统时钟这一不确定因素,并降低竞争条件出现的几率,但由于执行信号量获取操作需要客户端和服务端进行多次通信,所以竞争条件还是有可能会发生。
为了消除获取信号量时所有可能出现的竞争条件,构建一个正确的计数信号量,我们需要用到前面完成的带有超时功能的分布式锁。在想要获取信号量时,首先尝试获取分布式锁,若获取锁成功,则继续执行获取信号量的操作;若获取锁失败,那么获取信号量也失败。

不同计数信号量的使用场景:

  1. 基本的计数信号量:对于多机系统时间的差异不关心,也不需要对信号量进行刷新,并且能够接受信号量的数量偶尔超过限制;
  2. 公平的计数信号量:对于多机系统时间的差异不是非常敏感,但仍然能够接受信号量但数量偶尔超过限制;
  3. 正确的计数信号量:希望信号量一直都具有正确的行为,那么可以使用带锁的信号量实现来保证正确性。
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐