什么是互斥锁, 读写锁, 自旋锁, 分布式锁

锁的作用

用来解决"非并发安全"的方案之一就是"锁", 它能保证一些方法在同一时间只能被执行一次, 从而避免并发问题.

如: 每日的登录奖励, 抢购等需要并发安全的业务.

我曾经也傻傻分不清楚听说过的"互斥锁, 读写锁, 自旋锁", 所以现在决定再深入理解一下, 并且重点理解分布式锁的实现方案.

互斥锁

共享资源的使用是互斥的,即一个线程获得资源的使用权后就会将该资源加锁,使用完后会将其解锁,如果在使用过程中有其他线程想要获取该资源的锁,那么它就会被阻塞陷入睡眠状态,直到该资源被解锁才会被唤醒,如果被阻塞的资源不止一个,那么它们都会被唤醒,但是获得资源使用权的是第一个被唤醒的线程,其它线程又陷入沉睡.

互斥锁在各个语言中定义都不太相同

在大多数语言中, 互斥锁使用线程调度来实现的, 假如现在锁被锁住了, 那么后面的线程就会进入”休眠”状态, 直到解锁之后, 又会唤醒线程继续执行. 这也叫空等待(sleep-waiting).

但严格来说, 只要是同一时刻只能被拿到一次的锁都叫互斥锁,自旋锁也是

比如在golang中, sync.Mutex就是一个开箱即用的互斥锁, 它能保证在同一时刻只有一个"协程"能拿到锁, golang中就同时使用了"自旋"和"休眠"两种方式来实现互斥锁.

自旋锁

自旋锁也是广义上的互斥锁, 是互斥锁的实现方式之一, 它不会产生线程的调度, 而是通过"循环"来尝试获取锁, 优点是能很快的获取锁, 缺点是会占用过多的CPU时间, 这被称为忙等待(busy-waiting).

读写锁

在互斥锁中, 只有两个状态: 加锁和未加锁, 而在一些情况下对于"读"可以并发的进行而不用加锁, 对于读则需要加锁, 比如golang中map的操作.

为了让"读"操作更快的进行(不必加锁), 就诞生了"读写锁"的概念, 它有三个状态: 读模式下加锁状态, 写模式加锁状态和未加锁状态.

规则如下

  • 如果有其它线程读数据, 则允许其它线程执行读操作, 但不允许写操作
  • 如果有其它线程写数据, 则其它线程都不允许读和写操作

由于这个特性, 读写锁能在读频率更高的情况下有更好的并发性能.

分布式锁

在单机情况下, 在内存中的一个互斥锁就能控制到一个程序中所有线程的并发.

但由于有集群架构(负载均衡/微服务等场景下), 内存中的锁就没用了. 所以我们需要一个"全局锁"去实现控制多个程序/多个机器上的线程并发. 这个全局锁就叫"分布式锁".

简易实现

锁无非就是一个全局的状态, 如果第一个人拿到了锁, 那么第二个人就只能等待, 知道第一个归还才能拿到锁.

由于redis是并发安全的(单线程), 所以可以用它来实现并发安全的全局状态管理.

按照这个逻辑, 我们其实用redis的GET和SET就能现实锁(当然并不是这么简单, 但是没试过怎么会知道里面的坑与解决思路呢?).

func Lock(key string) {
  // 获取锁, 如果获取到为空, 则是没有加锁状态, 可以获得到锁, 并且标记为加锁状态
  if redis.GET(key) == "" {
    redis.SET(key, "1")
    return
  }
  
  // 如果是加锁状态, 则"自旋"等待获取锁
  // 这只是简陋实现, 在自旋的时候还需要考虑超时时间, 自旋次数等条件来避免死锁.
  for {
    if redis.GET(key) == ""{
      redis.SET(key, "1")
      break
    }
  }
}

func Unlock(key string) {
  // 删除加锁状态
  redis.DELETE(key)
}

仔细想不难发现上面实现方式的问题

  • GET和SET这两步操作不是"原子操作", 所以这段代码本来就有并发问题.
  • 错误的解锁: 在A程序获得的锁, 在一些情况下能够被B程序错误的解锁, 什么情况呢? 下面会说到.

那么如何避免这些问题, redis官方有答案, 就是redlock.

redlock

redlock是redis官方写的基于redis的分布式锁实现算法.

在继续升入之前建议阅读官方文档, 笔者也不能保证我的理解完全正确, 本文只是帮助快速理解官方文档的参考.

算法像这个样子

加锁:
使用NX(如果你不知道NX, 就需要去搜一下 NX 的作用).

SET resource_name my_random_value NX PX 30000

解锁:
使用lua脚本

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这两行代码是如何避免上面所说的问题的呢?

  • SET 和 lua脚本 都是原子操作, 所以不存在并发问题.
  • 添加了"random_value"逻辑, 这个逻辑就是用来避免错误解锁的问题.

随机值 random_value

首先我们来理解下 为什么不加随机值逻辑会有错误解锁的问题.

假设我们给锁设置超时时间为4s, 现在有ABC三个线程在抢占锁, 而A线程的程序需要5s才能处理完, 那么运行流程如下:

[0s] A: lock => A先获得锁
[2s] A: doing => 2s
[4s] B: lock => 4s (在第4s超时, 所以B能获取到锁)
[5s] A: unlock => 5s (任务做完了, 释放锁, 但是 现在释放的是B加的锁, 这就发生了错误)
[5s] C: lock => 5s (还能再次得到锁, 因为B上的锁错误的被A释放了)

可以看到问题: 当一个进程(A)发生错误(超时未归还锁等)之后, 可能会影响到后续的多个进程(B,C)的锁逻辑.

为了解决这个问题, redlock就使用了随机值逻辑: 在解锁的时候判断值是否是上锁时的随机值, 如果是则说明是自己上的锁, 才能正常解锁.

官方文档这样说:

This is important in order to avoid removing a lock that was created by another client. For example a client may acquire the lock, get blocked in some operation for longer than the lock validity time (the time at which the key will expire), and later remove the lock, that was already acquired by some other client. Using just DEL is not safe as a client may remove the lock of another client. With the above script instead every lock is “signed” with a random string, so the lock will be removed only if it is still the one that was set by the client trying to remove it.

高可用

有点复杂, 我选择暂时先使用已经实现好了的库吧.
Implementations

Before describing the algorithm, here are a few links to implementations already available that can be used for reference.

实践

有这样一个业务: 每日登录奖励

伪代码如下

func WhenLogin(uid) {
  if IsRewardedToday(uid){
    return
  }
  DoReward(uid)
}

如果我们不做并发处理, 在同一时刻if IsRewardedToday(uid)会同时成立, 那么DoReward就会运行多次, 这可能会造成BUG.

所以我们就需要用到锁, 代码如下

func WhenLogin(uid) {
  redlock.Lock(uid)
  if IsRewardedToday(uid){
    redlock.Unlock(uid)
    return
  }
  DoReward(uid)
  redlock.Unlock(uid)
}

这样功能就实现了, 但还可以优化

double checked locking

由于分布式锁的效率低下, 为了更好的性能, 我们应最优化代码.

上面的代码就有一个问题, 当任务已经做了之后, IsRewardedToday(uid)会返回true, 那么在第五句就会return, 这时候是不存在并发问题的.

这种情况下加锁就是一个没用的操作.

所以 我们试着将加锁代码移后:

func WhenLogin(uid) {
  if IsRewardedToday(uid){
    return
  }
  redlock.Lock(uid)
  DoReward(uid)
  redlock.Unlock(uid)
}

仔细思考, 是不是还有问题呢?

当没有做过任务的并发情况下, 所有的线程都会走到第五句代码redlock.Lock(uid), 现在不管有没有锁 DoReward(uid)都会被执行多次.

那么怎么同时考虑到 任务没做 和 做过 这两种情况的并发呢?

答案就是double checked locking, 笔者是在java的懒汉模式单例模式解决并发问题时看见的这个词. 同样也能解决这里的问题.

只需要再添加两行代码实现 "double check".

func WhenLogin(uid) {
  if IsRewardedToday(uid){
    return
  }
  redlock.Lock(uid)

  if IsRewardedToday(uid){
    redlock.Unlock(uid)
    return
  }
  DoReward(uid)
  redlock.Unlock(uid)
}

现在 这段代码在大多数情况下都是不会加锁的, 避免了锁的开销.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342