极客时间已完结课程限时免费阅读

17 | 如何正确使用锁保护共享数据,协调异步线程?

17 | 如何正确使用锁保护共享数据,协调异步线程?-极客时间

17 | 如何正确使用锁保护共享数据,协调异步线程?

讲述:李玥

时长14:05大小12.89M

你好,我是李玥。
在前几天的加餐文章中我讲到,JMQ 为了提升整个流程的处理性能,使用了一个“近乎无锁”的设计,这里面其实隐含着两个信息点。第一个是,在消息队列中,“锁”是一个必须要使用的技术。第二个是,使用锁其实会降低系统的性能。
那么,如何正确使用锁,又需要注意哪些事项呢?今天我们就来聊一聊这个问题。
我们知道,使用异步和并发的设计可以大幅提升程序的性能,但我们为此付出的代价是,程序比原来更加复杂了,多线程在并行执行的时候,带来了很多不确定性。特别是对于一些需要多个线程并发读写的共享数据,如果处理不好,很可能会产出不可预期的结果,这肯定不是我们想要的。
我给你举个例子来说明一下,大家应该都参与过微信群投票吧?比如,群主说:“今晚儿咱们聚餐,能来的都回消息报一下名,顺便统计一下人数。都按我这个格式来报名。”然后,群主发了一条消息:“群主,1 人”。
这时候小六和无双都要报名,过一会儿,他俩几乎同时各发了一条消息,“小六,2 人”“无双,2 人”,每个人发的消息都只统计了群主和他们自己,一共 2 人,而这时候,其实已经有 3 个人报名了,并且,在最后发消息的无双的名单中,小六的报名被覆盖了。
这就是一个非常典型的由于并发读写导致的数据错误。使用锁可以非常有效地解决这个问题。锁的原理是这样的:任何时间都只能有一个线程持有锁,只有持有锁的线程才能访问被锁保护的资源。
在上面微信群报名的例子中,如果说我们的微信群中有一把锁,想要报名的人必须先拿到锁,然后才能更新报名名单。这样,就避免了多个人同时更新消息,报名名单也就不会出错了。

避免滥用锁

那是不是遇到这种情况都要用锁解决呢?我分享一下我个人使用锁的第一条原则:如果能不用锁,就不用锁;如果你不确定是不是应该用锁,那也不要用锁。为什么这么说呢?因为,虽然说使用锁可以保护共享资源,但是代价还是不小的。
第一,加锁和解锁过程都是需要 CPU 时间的,这是一个性能的损失。另外,使用锁就有可能导致线程等待锁,等待锁过程中线程是阻塞的状态,过多的锁等待会显著降低程序的性能。
第二,如果对锁使用不当,很容易造成死锁,导致整个程序“卡死”,这是非常严重的问题。本来多线程的程序就非常难于调试,如果再加上锁,出现并发问题或者死锁问题,你的程序将更加难调试。
所以,你在使用锁以前,一定要非常清楚明确地知道,这个问题必须要用一把锁来解决。切忌看到一个共享数据,也搞不清它在并发环境中会不会出现争用问题,就“为了保险,给它加个锁吧。”千万不能有这种不负责任的想法,否则你将会付出惨痛的代价!我曾经遇到过的严重线上事故,其中有几次就是由于不当地使用锁导致的。
只有在并发环境中,共享资源不支持并发访问,或者说并发访问共享资源会导致系统错误的情况下,才需要使用锁。

锁的用法

锁的用法一般是这样的:
在访问共享资源之前,先获取锁。
如果获取锁成功,就可以访问共享资源了。
最后,需要释放锁,以便其他线程继续访问共享资源。
在 Java 语言中,使用锁的例子:
private Lock lock = new ReentrantLock();
public void visitShareResWithLock() {
lock.lock();
try {
// 在这里安全的访问共享资源
} finally {
lock.unlock();
}
}
也可以使用 synchronized 关键字,它的效果和锁是一样的:
private Object lock = new Object();
public void visitShareResWithLock() {
synchronized (lock) {
// 在这里安全的访问共享资源
}
}
使用锁的时候,你需要注意几个问题:
第一个,也是最重要的问题就是,使用完锁,一定要释放它。比较容易出现状况的地方是,很多语言都有异常机制,当抛出异常的时候,不再执行后面的代码。如果在访问共享资源时抛出异常,那后面释放锁的代码就不会被执行,这样,锁就一直无法释放,形成死锁。所以,你要考虑到代码可能走到的所有正常和异常的分支,确保所有情况下,锁都能被释放。
有些语言提供了 try-with 的机制,不需要显式地获取和释放锁,可以简化编程,有效避免这种问题,推荐你使用。
比如在 Python 中:
lock = threading.RLock()
def visitShareResWithLock():
with lock:
# 注意缩进
# 在这里安全的访问共享资源
# 锁会在with代码段执行完成后自动释放
接下来我们说一下,使用锁的时候,遇到的最常见的问题:死锁。

如何避免死锁?

死锁是指,由于某种原因,锁一直没有释放,后续需要获取锁的线程都将处于等待锁的状态,这样程序就卡死了。
导致死锁的原因并不多,第一种原因就是我在刚刚讲的,获取了锁之后没有释放,有经验的程序员很少会犯这种错误,即使出现这种错误,也很容易通过查看代码找到 Bug。
还有一种是锁的重入问题,我们来看下面这段代码:
public void visitShareResWithLock() {
lock.lock(); // 获取锁
try {
lock.lock(); // 再次获取锁,会导致死锁吗?
} finally {
lock.unlock();
}
在这段代码中,当前的线程获取到了锁 lock,然后在持有这把锁的情况下,再次去尝试获取这把锁,这样会导致死锁吗?
答案是,不一定。会不会死锁取决于,你获取的这把锁它是不是可重入锁。如果是可重入锁,那就没有问题,否则就会死锁。
大部分编程语言都提供了可重入锁,如果没有特别的要求,你要尽量使用可重入锁。有的同学可能会问,“既然已经获取到锁了,我干嘛还要再次获取同一把锁呢?”
其实,如果你的程序足够复杂,调用栈很深,很多情况下,当你需要获取一把锁的时候,你是不太好判断在 n 层调用之外的某个地方,是不是已经获取过这把锁了,这个时候,获取可重入锁就有意义了。
最后一种死锁的情况是最复杂的,也是最难解决的。如果你的程序中存在多把锁,就有可能出现这些锁互相锁住的情况。我们一起来看下面这段 Python 代码:
import threading
def func1(lockA, lockB):
while True:
print("Thread1: Try to accquire lockA...")
with lockA:
print("Thread1: lockA accquired. Try to accquire lockB...")
with lockB:
print("Thread1: Both lockA and LockB accrquired.")
def func2(lockA, lockB):
while True:
print("Thread2: Try to accquire lockB...")
with lockB:
print("Thread2: lockB accquired. Try to accquire lockA...")
with lockA:
print("Thread2: Both lockA and LockB accrquired.")
if __name__ == '__main__':
lockA = threading.RLock();
lockB = threading.RLock()
t1 = threading.Thread(target=func1, args=(lockA, lockB,))
t2 = threading.Thread(target=func2, args=(lockA, lockB,))
t1.start()
t2.start()
这个代码模拟了一个最简单最典型的死锁情况。在这个程序里面,我们有两把锁:lockA 和 lockB,然后我们定义了两个线程,这两个线程反复地去获取这两把锁,然后释放。我们执行以下这段代码,看看会出现什么情况:
$ python3 DeadLock.py
Thread1: Try to accquire lockA...
Thread1: lockA accquired. Try to accquire lockB...
Thread1: Both lockA and LockB accrquired.
Thread1: Try to accquire lockA...
... ...
Thread1: Try to accquire lockA...
Thread2: Try to accquire lockB...
Thread1: lockA accquired. Try to accquire lockB...
Thread2: lockB accquired. Try to accquire lockA...
可以看到,程序执行一会儿就卡住了,发生了死锁。那死锁的原因是什么呢?请注意看代码,这两个线程,他们获取锁的顺序是不一样的。第一个线程,先获取 lockA,再获取 lockB,而第二个线程正好相反,先获取 lockB,再获取 lockA。
然后,你再看一下死锁前的最后两行日志,线程 1 持有了 lockA,现在尝试获取 lockB,而线程 2 持有了 lockB,尝试获取 lockA。你可以想一下这个场景,两个线程,各持有一把锁,都等着对方手里的另外一把锁,这样就僵持住了。
这是最简单的两把锁两个线程死锁的情况,我们还可以分析清楚,你想想如果你的程序中有十几把锁,几十处加锁解锁,几百的线程,如果出现死锁你还能分析清楚是什么情况吗?
关于避免死锁,我在这里给你几点建议。
再次强调一下,避免滥用锁,程序里用的锁少,写出死锁 Bug 的几率自然就低。
对于同一把锁,加锁和解锁必须要放在同一个方法中,这样一次加锁对应一次解锁,代码清晰简单,便于分析问题。
尽量避免在持有一把锁的情况下,去获取另外一把锁,就是要尽量避免同时持有多把锁。
如果需要持有多把锁,一定要注意加解锁的顺序,解锁的顺序要和加锁顺序相反。比如,获取三把锁的顺序是 A、B、C,释放锁的顺序必须是 C、B、A。
给你程序中所有的锁排一个顺序,在所有需要加锁的地方,按照同样的顺序加解锁。比如我刚刚举的那个例子,如果两个线程都按照先获取 lockA 再获取 lockB 的顺序加锁,就不会产生死锁。
最后,你需要知道,即使你完全遵从我这些建议,我也无法完全保证你写出的程序就没有死锁,只能说,会降低一些犯错误的概率。

使用读写锁要兼顾性能和安全性

对于共享数据来说,如果说某个方法在访问它的时候,只是去读取,并不更新数据,那是不是就不需要加锁呢?还是需要的,因为如果一个线程读数据的同时,另外一个线程同时在更新数据,那么你读到的数据有可能是更新到一半的数据,这肯定是不符合预期的。所以,无论是只读访问,还是读写访问,都是需要加锁的。
如果给数据简单地加一把锁,虽然解决了安全性的问题,但是牺牲了性能,因为,那无论读还是写,都无法并发了,跟单线程的程序性能是一样。
实际上,如果没有线程在更新数据,那即使多个线程都在并发读,也是没有问题的。我在上节课跟你讲过,大部分情况下,数据的读写比是不均衡的,读要远远多于写,所以,我们希望的是:
读访问可以并发执行。
写的同时不能并发读,也不能并发写。
这样就兼顾了性能和安全性。读写锁就是为这一需求设计的。我们来看一下 Java 中提供的读写锁:
ReadWriteLock rwlock = new ReentrantReadWriteLock();
public void read() {
rwlock.readLock().lock();
try {
// 在这儿读取共享数据
} finally {
rwlock.readLock().unlock();
}
}
public void write() {
rwlock.writeLock().lock();
try {
// 在这儿更新共享数据
} finally {
rwlock.writeLock().unlock();
}
}
在这段代码中,需要读数据的时候,我们获取读锁,获取到的读锁不是一个互斥锁,也就是说 read() 方法是可以多个线程并行执行的,这样使得读数据的性能依然很好。写数据的时候,我们获取写锁,当一个线程持有写锁的时候,其他线程既无法获取读锁,也不能获取写锁,达到保护共享数据的目的。
这样,使用读写锁就兼顾了性能和安全。

小结

锁可以保护共享资源,避免并发更新造成的数据错误。只有持有锁的线程才能访问被保护资源。线程在访问资源之前必须获取锁,访问完成后一定要记得释放锁。
一定不要滥用锁,否则容易导致死锁。死锁的原因,主要由于多个线程中多把锁相互争用导致的。一般来说,如果程序中使用的锁比较多,很难分析死锁的原因,所以需要尽量少的使用锁,并且保持程序的结构尽量简单、清晰。
最后,我们介绍了读写锁,在某些场景下,使用读写锁可以兼顾性能和安全性,是非常好的选择。

思考题

我刚刚讲到,Python 中提供了 try-with-lock,不需要显式地获取和释放锁,非常方便。遗憾的是,在 Java 中并没有这样的机制,那你能不能自己在 Java 中实现一个 try-with-lock 呢?
欢迎你把代码上传到 GitHub 上,然后在评论区给出访问链接。如果你有任何问题,也可以在评论区留言与我交流。
感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 7

提建议

上一篇
16 | 缓存策略:如何使用缓存来减少磁盘IO?
下一篇
18 | 如何用硬件同步原语(CAS)替代锁?
 写留言

精选留言(36)

  • 糖醋🏀
    2019-09-01
    java7开始io就有try-with-resource。 可以利用这一个特性,来说实现,自动释放。 代码如下: public class AutoUnlockProxy implements Closeable { private Lock lock; public AutoUnlockProxy(Lock lock) { this.lock = lock; } @Override public void close() throws IOException { lock.unlock(); System.out.println("释放锁"); } public void lock() { lock.lock(); } public void tryLock(long time, TimeUnit unit) throws InterruptedException { lock.tryLock(time, unit); } public static void main(String[] args) { try (AutoUnlockProxy autoUnlockProxy = new AutoUnlockProxy(new ReentrantLock())) { autoUnlockProxy.lock(); System.out.println("加锁了"); } catch (IOException e) { e.printStackTrace(); } } }
    展开

    作者回复: 👍👍👍

    共 4 条评论
    73
  • 菜鸟和谐号
    2019-09-08
    既然是消息队列自然要多讲讲消息队列的知识,一下io,一下缓存,一下多线程,讲的很杂,也不深入。
    共 10 条评论
    43
  • humor
    2019-09-01
    /** *业务调用接口 **/ public interface Invoker{ void invoke(); } /** *try-with-lock **/ public class RLock{ private Lock lock = new ReentrantLock(); public void run(Invoker invoker) { lock.lock(); try{ invoker.invoke(); } finally{ lock.unlock(); } } } public BizInvoker implements Inoker{ private RLock rLock = new RLock(); public void invoke() { //需要加锁的业务逻辑 } public static void main(String[] args) { rLock.run(new BizInvoker()); } }
    展开

    作者回复: 👍👍👍

    13
  • Cast
    2019-09-01
    老师,请问为什么要按逆序去释放锁呢?按照获取的顺序去释放好像也没什么毛病吧?

    作者回复: 如果释放几把锁的过程中,不再有其它加锁的代码,正序释放也是没问题的。 逆序释放只是一种习惯,让代码的结构更清晰。

    共 3 条评论
    10
  • 张三
    2019-08-31
    幸亏学过极客时间的并发编程专栏,看懂了。我觉得并发容器的选择比较复杂。
    共 1 条评论
    7
  • 喜欢地球的阿培同学
    2020-04-17
    老师突然想到一个问题,假设现在100个线程,一个线程正在运行,99个线程正在阻塞(等待锁释放),那么会导致CPU上下文频繁切换吗?

    作者回复: 不会。

    6
  • long
    2021-03-24
    要是能讲一下, 设计消息队列, 哪里需要用到锁, 就完美了.
    3
  • geek_time
    2020-10-19
    可以通过函数式接口实现: @FunctionalInterface // 标明为函数式接口 public interface LockFunction { void lock(); //抽象方法 } public class MyLock { ReentrantLock lock = new ReentrantLock(); void lock(LockFunction function) { lock.lock(); try { function.lock(); } catch (Exception e) { throw e; } finally { lock.unlock(); } } public static void main(String[] args) { MyLock myLock = new MyLock(); myLock.lock(() -> { System.out.println("test"); }); } }
    展开
    3
  • 长期规划
    2019-10-08
    避免死锁 1。锁最好不要嵌套,如果实在需要嵌套,要按2的顺序 2。所有锁的地方都按相同的顺序加锁。比如有A,B,C,D四把锁,所有加锁的地方的顺序都是A→B→C→D,或者是其切片,比如线程1使用B→C,线程2使用A→B,线程3使用C→D 3。解锁顺序最好和加锁顺序相反,这样逻辑清晰,锁不用后一定要释放
    展开
    3
  • L!en6o
    2019-08-31
    加一个锁回调 封装起来 实现 try-with-lock
    3
  • 2020-02-24
    类似 MySql 中的 排它锁(X)和共享锁(S), 共享锁之间不互斥,排它锁和共享锁互斥,排它锁和排它锁互斥
    2
  • i_chase
    2022-03-19
    变成java大讲堂了。。
    1
  • Switch
    2019-10-24
    try with resource实现 传入参数实现 https://github.com/Switch-vov/mq-learing/tree/master/src/main/java/com/switchvov/lock
    1
  • 千锤百炼领悟之极限
    2023-02-01 来自广东
    关于锁使用的建议: 1.能不使用锁就不要使用锁,或者不清楚是否需要锁的情况下也不要使用锁。 2.加锁与解锁都必须在同一个方法内,避免加锁后忘记解锁。 3.尽量不要在已经持有一把锁的情况下,再去持有多把锁。 4.如果必须持有多把锁,加锁与解锁的顺序要相反,例如加锁顺序是A->B->C,那么解锁的顺序就是C->B->A。 5.给你系统中的锁定一个顺序,在任何情况都按这个顺序加锁。
    展开
  • syz
    2022-07-14
    死锁:忘记释放、锁重入问题、多个锁多处调用相互竞争 fun lockAndUnlock(doSth :()->Unit,onError:(e:Exception)->Unit){ try { lock.lock() doSth() }catch (e:Exception){ onError(e) }finally { lock.unlock() } } }
    展开
  • 陈斌
    2022-04-21
    读写锁,在其他线程持有读锁时,另一个线程可以过去到写锁吗?
  • 再见理想
    2022-04-07
    关于锁的注意事项: 1,如无必要,尽量不要用锁,加/解锁会占用cpu时间,对系统性能影响很大。 2,加锁后一定要记得释放锁,否则会导致其他需要用锁的程序阻塞。 3,必要的场景,锁要设计可重入或超时时间,防止程序死锁。 4,使用多个锁的情况,释放锁的顺序要和加锁的顺序相反。 5,使用多个锁的情况,需要将锁进行排序,用到用锁的地方都按照锁顺序加锁,防止锁循环等待。 6,可以用读写锁提升性能,支持并发读,不支持并发读写。 7,尽量避免使用多个锁。 8,加锁,释放锁尽量放在同一个方法内。
    展开
  • 菜鸟
    2021-08-24
    线程的切换的代价有多大?以及线程切换会引起缓存失效吗?
  • Heaven
    2021-02-07
    Java7 提供了对应的try-with-resource,我们可以实现一个实现了Closeable接口,利用装饰器模式包裹了对应的Reentrantlock的实体类来达成我们的目标
  • 贺宇
    2020-05-19
    遇到过数据库死锁的情况,加锁顺序还是很重要的