当 Semaphore 遇到 finally,有大坑,要注意!

2024-08-13 17:47:33 浏览数 (2)

你好呀,我是歪歪。

前几天有个读者给我发来一段代码,他说这段代码会照成死锁,但是实在不知道是什么原因。

他给的代码很长,幸好同时也给了我一个相应的代码解释:

Semaphore 类指定了 3 个许可证,且是指定公平锁方式。然后开了 3 个线程,线程 A,C 只能获取一个许可证,线程 B 可以获取 2 个许可证。

但是在运行时,有时只会执行完线程 A,线程 B 和线程 C 都不会输出。

感觉是死锁了,想不明白,想探讨一下。

代码如下,你粘过去就能跑:

代码语言:javascript复制
public class SemaphoreDemo {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(3, true);
        ReentrantLock reentrantLock = new ReentrantLock();
        Thread threadA = new Thread(new MyRunnable(1, semaphore, reentrantLock), "thread-A");
        Thread threadB = new Thread(new MyRunnable(2, semaphore, reentrantLock), "thread-B");
        Thread threadC = new Thread(new MyRunnable(1, semaphore, reentrantLock), "thread-C");
        threadA.start();
        threadB.start();
        threadC.start();
    }
}

class MyRunnable implements Runnable {
    private int n;
    private Semaphore semaphore;
    private ReentrantLock lock;

    public MyRunnable(int n, Semaphore semaphore, ReentrantLock lock) {
        this.n = n;
        this.semaphore = semaphore;
        this.lock = lock;
    }

    @Override
    public void run() {
        try {
            semaphore.acquire(n);
            System.out.println("剩余可用许可证: "   semaphore.drainPermits());
            System.out.println(Thread.currentThread().getName()   "执行完成。。。。");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            semaphore.release(n);
        }
    }
}

歪师傅在本地运行了一下,发现确实如他所说。

有时候会正常执行结束,但是有时候,执行完线程 A,线程 B 和线程 C 都不会输出:

从表象上看,确实像是 B,C 线程“死”了。

现象就是这个现象,知道 semaphore 是干啥的同学可以先看看上面的代码,为什么造成了“死锁”。

最终定位起来是一个非常无语的低级错误,但是我反复看了几遍居然没有看出来。

不知道 semaphore 是干啥的同学,我先给你科普一下。

semaphore 我们一般叫它信号量,用来控制同时访问指定资源的线程数量。

如果不懂 semaphore ,那上面代码你也看不懂了,所以歪师傅按照代码的逻辑给你举个例子,来一波手摸手教学。

举个例子

比如,有一个网吧,这个网吧只有三个机子,所以放学的时候我们要去抢位置。

抢位置的同学,分别是学渣歪歪,学霸张伟,以及刘波儿、龙傲天这一对好基友。

假设,学渣歪歪是最先抢到一个机位的。

那么请问网吧里面还有几个位置呢?

对咯~

还有 2 个位置。

刘波儿、龙傲天到了后发现,刚好还剩下 2 台机子,于是好基友手拉手,一起愉快的占用了这 2 个位置。

那么问题又来了,请问网吧这时候还有几个位置呢?

又对咯~

满员了,没有位置了。

没多久,学霸张伟到了,发现没有机子了,怎么办呢?

只有在网吧门口等一下了。

没一会,歪歪的网费就用完了,下机走人。

张伟一屁股就坐在了位置上,开始愉快的网上冲浪了。

这个时候网吧还是没有空位的。

上面的代码想要描述的就是这样的一个事情。

但是根据提问者的描述,“在运行时,有时只会执行完线程 A,线程 B 和线程 C 都不会输出。”

在上面这个场景中就是:歪歪在网吧开了台机器后,后面来的张伟、刘波儿、龙傲天都没有位置玩儿了。

但是明明还有两个位置啊,为什么玩不了呢?

他怀疑是死锁了,这个怀疑有点无厘头啊。

我们先回忆一下死锁的四个必要条件:

  • 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。(不满足,还有两台电脑没有用呢。)
  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。(不满足,歪歪占了一台机器了,没有提出还要再开一台机器的要求,另外的机器也没有被占用)
  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放。(满足,歪歪正在使用的这台电脑,如果歪歪不让出来,这个机器理论上是不会被夺走的)
  • 循环等待条件: 若干进程间形成首尾相接循环等待资源的关系。(不满足,只有张伟、刘波儿和龙傲天两拨人在等资源,但没有循环等待的情况。)

这四个条件是死锁的必要条件,必要条件就是说只要有死锁了,这些条件必然全部成立。

而经过分析,我们发现没有满足死锁的必要条件。那为什么会出现这样的现象呢?

我们先根据上面的场景,自己写一段代码。

Demo

下面的程序基本上是按照上面截图中的示例代码接合上面的故事改的,可以直接复制粘贴:

代码语言:javascript复制
public class InternetBarDemo {
    public static void main(String[] args) {
        int parkSpace = 3;
        System.out.println("网吧里有"   parkSpace   "台机器,先到先得哦!");
        Semaphore semaphore = new Semaphore(parkSpace, true);
        Thread threadA = new Thread(new InternetBar(1, semaphore), "歪歪");
        Thread threadB = new Thread(new InternetBar(2, semaphore), "刘波儿、龙傲天");
        Thread threadC = new Thread(new InternetBar(1, semaphore), "张伟");
        threadA.start();
        threadB.start();
        threadC.start();
    }
}

class InternetBar implements Runnable {

    private final int n;
    private final Semaphore semaphore;

    public InternetBar(int n, Semaphore semaphore) {
        this.n = n;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName()   "来上网了,但是网吧位置不够了,等着吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName()   "占到位置咯,剩余位置:"   semaphore.availablePermits()   "台");
            //模拟上网时长
            int internetBarTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(internetBarTime);
            System.out.println(Thread.currentThread().getName()   "的网费没了,上了"   internetBarTime   "小时网");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName()   "走后,剩余位置:"   semaphore.availablePermits()   "台");
        }
    }
}

运行后的结果如下:

这个运行结果和我们预期的是一致的,并没有出现线程阻塞的现象。

另外,需要额外多说一句:由于是多线程环境,所以你本地的运行结果可能不尽相同,多跑几次也许能跑出我的同款输出。

那为什么之前的代码就会出现“在运行时,有时只会执行完线程 A,线程 B 和线程 C 都不会输出”这种现象呢?

其实差异就在这一行代码上,获取剩余通行证的方法:

上面是链接里面的代码,下面是我自己写的代码。

说真的,读者给我的代码,我最开始硬是眼神编译了一分钟,没有看出问题来。

当我真正把代码粘到 IDEA 里面,跑起来后发现有时候先执行了 B 线程后,A、C 线程都可以执行。当最先执行 A 线程的时候,B、C 线程就不会执行。

我人都懵逼了,反复分析,发现这和我认知不一样啊!

于是我陷入了沉思:

通过 Dump 文件,可以看到 B 和 C 都卡在 33 行,等着获取许可:

但是这个时候明明还有 2 个许可证啊,完全能满足 B 或者 C 中的任何一个啊?

想不通,实在想不通。

最后还是在 Debug 的时候,在明确输出“剩余可用许可证:2”之后:

我又鬼使神差的想要看看“剩余可用许可证”到底是不是 2。

于是我又执行了一遍这个代码:

第一次执行 semaphore.drainPermits() 返回的是 2,再次执行返回的居然是 0 ?!

所以,问题就出在 drainPermits 方法。

线程 A 调用完成之后,drainPermits 方法把剩下的许可证变成了 0,然后执行 release 之后,许可证又变为 1。

这时又是一个公平锁,所以,如果线程 B 先进去排队了,剩下的许可证不足以让 B 线程运行,它就一直等着。C 线程也就没有机会执行。

把获取剩余可用许可证的方法换为 availablePermits 方法后,正常输出:

这真的是一个很小的点,但是当局者迷,旁观者清,就是这个道理。

方法解释

我估计很多不太了解 semaphore 的朋友看完前面这两部分也还是略微有点懵逼。

没事,所有的疑惑将在这一小节解开。

在上面的测试案例中,我们只用到了 semaphore 的四个方法:

  • availablePermits:获取剩余可用许可证。
  • drainPermits :获取剩余可用许可证。(是的,没写错,就是和上面一模一样)
  • release(int n):释放指定数量的许可证。
  • acquire(int n):申请指定数量的许可证。

首先看 availablePermits 和 drainPermits 这个两个方法的差异:

这两个地方的文档描述,有点玩文字游戏的意思了。稍不留神就被带进去了。

你仔细看:availablePermits 只是 return 当前可用的许可证数量。而 drainPermits 是 acquires and return,它先全部获取后再返回。

availablePermits 只是看看还有多少许可证,drainPermits 是拿走所有剩下的许可证。

所以在上面的场景下,这两个方法的返回值是一样的,但是内部处理完全内部不一样:

另外,其实你只要知道 drainPermits 前面的 drain 的意思,其实可能也能避免踩到这个坑:

见名知意啊,朋友们,可见英语对编程还是非常重要的。

接下来先看看释放的方法:release。

该方法就是释放指定数量许可证。释放,就意味着许可证的增加。就类似于刘波儿、龙傲天在网上冲完浪之后,下机,网吧就会多两台空闲机器出来。

上面红框框起来的部分是它的主要逻辑。大家自己看一下,我就不翻译了,大概意思就是释放许可证之后,其他等着用许可证的线程就可以看一下释放之后的许可证数量是否够用,如果够就可以获取许可证,然后运行了。

该方法的精华在 599 到 602 行的说明中:

这句话非常非常非常关键:

说的是执行 release 操作的线程不一定非得是执行了 acquire 方法的线程。

开发人员,需要根据实际场景来保证 semaphore 的正确使用。

release 操作这里,大家都知道需要放到 finally 代码块里面去执行。但是正是这个认知,是最容易踩坑的地方,而且出了问题还非常不好排查的那种。

放肯定是要放在 finally 代码块里面的,只是怎么放,这里有点讲究。

我结合下一节的例子和 acquire 方法一起说明:

acquire 方法主要先关注我红框框起来的部分。

从该方法的源码可以看出,会抛出 InterruptException 异常。记住这点,我们在下一节,带入场景讨论。

release 使用不当的大坑

我们还是带入之前网吧的场景。

假设歪歪和张伟先到网吧占到了位置,这个时候刘波儿、龙傲天他们来了,发现只有一个位置,两个好基友嘛,就等着,非要一起上网开黑。

等了一会,歪歪和张伟一直不下机,网管对他们说:“如果你们非得一起上网的话,我估摸着你们还得等很长时间,别等了,快走吧。”

于是,他们走了。

来,就这个场景,整一段代码:

代码语言:javascript复制
public class InternetBarDemo {
    public static void main(String[] args) {
        int parkSpace = 3;
        System.out.println("网吧里有"   parkSpace   "台机器,先到先得哦!");
        Semaphore semaphore = new Semaphore(parkSpace, true);
        Thread threadA = new Thread(new InternetBar(1, semaphore), "歪歪");
        Thread threadB = new Thread(new InternetBar(2, semaphore), "刘波儿、龙傲天");
        Thread threadC = new Thread(new InternetBar(1, semaphore), "张伟");
        threadA.start();
        threadB.start();
        threadC.start();
        //模拟网管劝退
        threadB.interrupt();
    }
}

class InternetBar implements Runnable {

    private final int n;
    private final Semaphore semaphore;

    public InternetBar(int n, Semaphore semaphore) {
        this.n = n;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName()   "来上网了,但是网吧位置不够了,等着吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName()   "占到位置咯,剩余位置:"   semaphore.availablePermits()   "台");
            //模拟上网时长
            int internetBarTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(internetBarTime);
            System.out.println(Thread.currentThread().getName()   "的网费没了,上了"   internetBarTime   "小时网");
        } catch (InterruptedException e) {
            System.err.println(Thread.currentThread().getName()   "被网管劝走了。");
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName()   "走后,剩余位置:"   semaphore.availablePermits()   "台");
        }
    }
}

看着代码是没有毛病,但是运行起来你会发现,有可能出现这样的情况:

歪歪走后,网吧的机器变成了 5 台?

所以,我是去给他们配机器了吗?

在往前看日志发现,原来是刘波儿、龙傲天走后,显示了剩余位置 3 台。

问题就出在这个地方。

而这个地方对应的代码是这样的:

有没有一点恍然大悟的感觉。

40 行抛出了 InterruptedException,导致明明没有获取到许可证的线程,执行了 release 方法,而该方法导致许可证增加。

在我们的例子里面就是刘波儿、龙傲天还没开机上网呢,走的时候就凭空增加了两台电脑。

这就是坑,就是你代码中的 BUG 潜伏地带。

而且还非常的危险,你想你代码里面莫名其妙的多了几个“许可证”。就意味着可能又多于你预期的线程在运行。很危险。

那么怎么修复呢?

答案已经呼之欲出了,把这坨代码拿到 finally 外面去,然后 catch 起来,如果出现中断异常,直接返回:

跑起来,结果也正确,所有人都走了后,网吧还是只有 3 台机器:

上面的写法还有一个疑问,如果我刚刚拿到许可证,就被中断了,怎么办?

看源码啊,源码里面有答案的:

抛出 InterruptedException 后,分配给这个线程的所有许可证都会被分配给其他想要获取许可证的线程,就像通过调用 release 方法一样。

最后,一句话总结这小节问题的原因就是:

在使用 Semaphore 时,没有获取到许可证的线程,调用了 release 方法,导致许可证凭空增加。

我觉得这个设定,就是非常容易踩坑的地方。简直就是一个大坑!

好了,本文就讲到这里,如果觉得本文对你有所帮助,欢迎点赞、在看、收藏、转发分享给其他需要的人。

下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。

你要不喜欢,退出之前记得文末点个“在看”哦。

0 人点赞