日志

我靠!Semaphore里面居然有这么一个大坑!

 来源    2020-08-02    0  

这是why的第 59 篇原创文章

荒腔走板

大家好,我是why哥 ,欢迎来到我连续周更优质原创文章的第 59 篇。

上周写了一篇文章,一不小心戳到了大家的爽点,其中一个转载我文章的大号,阅读量居然突破了 10w+,我也是受宠若惊。

但是其实我是一个技术博主来的,偶尔写点生活相关的。所以这篇还是回到技术上。

但是我的技术文章有个特点是第一张图片都是我自己拍的。然后我会围绕这个图片进行一个简短的描述,我称之为荒腔走板环节。

目的是给冰冷的技术文注入一丝色彩。

我这样做已经坚持了很多篇 ,有的读者给我说:看完荒腔走板部分就退出去了。

那你们是真的棒哦,至少退出去之前,拉到文末,来个一键三连吧,给我来点正反馈。

好了,先说说这期的荒腔走板。

上面这个图片是我上周末看《乐队的夏天》的时候拍的。

这个乐队的名字叫做水木年华,我喜欢这个乐队。

我听他们的歌的时候,应该是初中,那个时候磁带已经差不多快过气了,进入了光碟的时代,我记得一张光碟里面有好几十首歌,第一次在 DVD 里面听到他们的歌是《一生有你》,听到这首歌的时候就感觉很干净,很惊艳。

然后一字一句抄在自己的歌词本上。

听到这首歌的那个周末,我就看着那个 MV 反复学,那时的 DVD 有个功能是可以 A-B 反复播放某个片段,我就一句一句的学,学会了这首歌。

那时候的李健,一双清澈明亮的大眼睛,就像一汪湖水,我一个小男孩,都好想在他的眼睛里扎个猛子。

这首歌,我愿称之为校园民谣的巅峰之一。

十多年后的今天,这个乐队重新出现在我的视野中,只是李健已经不再其中。

他们在乐夏的舞台上唱了一首《青春再见》,结果被一个自称 23 岁的胖小伙说“中年人的油腻”,被另个专业乐迷说:“四十多岁的人怎么还在唱青春再见?”。第一期就被淘汰出局。

这操作,看的我一愣一愣的。

这个怎么就油腻了?四十多岁的人怎么就不能唱青春再见了?男人至死都是少年你们不知道吗?小子,他们玩音乐的时候你还不会说话呢。

他们离开舞台的画面,我感觉到一丝辛酸,一丝真的青春再见的辛酸。

水木年华没有错,错的是这个舞台,这个舞台不适合他们的歌曲。

好了,说回文章。

一起看个问题

前几天有个读者给我发了一个链接,说这个链接里面的代码,为什么会这样运行,实在是没有搞懂是怎么回事,链接如下:

https://springboot.io/t/topic/1139

代码是这样的,给大家上个图:

注意第 10 行,permits 参数,根据他的描述应该是 3:

不知道为什么代码里面给了一个 2。但是为了保证真实,我直接拿过来了,没有进行改动。一会我会根据这个代码进行简单的修改。

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

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

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

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

如果不懂 semaphore ,那上面代码你也看不懂了,我按照代码的逻辑给你举个例子。

比如一个高端停车场,只有 3 个车位。(这就是“指定资源”)

现在里面没有停车,那么它最多可以停几辆车呢?

是的,门口的剩余车辆指示牌显示:剩余停车位 3 辆。

这个时候,有三路人想要过来停车。

三条路分别是:转发路、点赞路、赞赏路。

路上的车分别是 why 哥的劳斯莱斯、赵四的布加迪、刘能、谢广坤这对好基友开的法拉利:

这个时候从“点赞路”过来的赵四先开到了,于是停了进去。

门口的停车位显示:剩余停车位 2 辆。

刘能、谢广坤到了后发现,刚好还剩下 2 个车位,于是好基友手拉手,一起停了进去。

门口的停车位显示:余下车位 0 辆。

没多久,我也到了,发现没有停车位了,怎么办呢?我只有在门口等一下了。

没一会,赵四办完事了,开着他的布加迪走了。

门口的停车位显示:余下车位 1 辆。

我赶紧停进去。

门口的停车位显示:余下车位 0 辆。

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

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

在上面这个场景中就是:赵四的布加迪开进去停车后,后面刘能、谢广坤的法拉利和我的劳斯莱斯都停不进去了。

就是这样式儿的:

为什么停不进去呢?他怀疑是死锁了,这个怀疑有点无厘头啊。

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

  • 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。(不满足,还有两个停车位没有用呢。)

  • 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。(不满足,张三占了一个停车位了,没有提出还要一个停车位的要求,另外的停车位也没有被占用)

  • 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放。(满足,张三的车不开出来,这个停车位理论上是不会被夺走的)

  • 循环等待条件: 若干进程间形成首尾相接循环等待资源的关系。(不满足,只有我和刘能、谢广坤两拨人在等资源,但没有循环等待的情况。)

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

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

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

自己撸代码

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

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("这里有" + parkSpace + "个停车位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "赵四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "刘能、谢广坤");
        Thread threadC = new Thread(new ParkCar(1, "劳斯莱斯", semaphore), "why哥");

        threadA.start();
        threadB.start();
        threadC.start();
    }
}

class ParkCar implements Runnable {
    
    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        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() + "把自己的" + carName + "停进来了,剩余停车位:" + semaphore.availablePermits() + "辆");
            //模拟停车时长
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "开走了,停了" + parkTime + "小时");
        } 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 线程就不会执行。

我人都懵逼了,反复分析,发现这和我认知不一样啊!于是我陷入了沉思:

过了一会,保洁大爷过来收垃圾,问我:“hi,小帅哥,你这瓶红牛喝完了吧?我把瓶子收走了啊。”然后瞟了一眼屏幕,指着获取剩余许可证的那行代码对我说:“你这个地方方法调用错了哈,你再好好看看方法说明。”

System.out.println("剩余可用许可证: " + semaphore.drainPermits());

说完之后,拍了拍我的肩膀,转身离去。得到大师点化,我才恍然大悟。

由于获取剩余可用许可证的方法是 drainPermits,所以线程 A 调用完成之后,剩下的许可证为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使用不当的大坑

我们还是带入之前停车的场景。假设赵四和我先把车停进去了,这个时候刘能、谢广坤他们来了,发现车位不够了,两个好基友嘛,就等着,非要停在一起

等了一会,我们一直没出来,门口看车的大爷出来对他们说:“我估摸着你们还得等很长时间,别等了,快走吧。”

于是,他们开车离去。

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

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("这里有" + parkSpace + "个停车位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "赵四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "刘能、谢广坤");
        Thread threadC = new Thread(new ParkCar(1, "劳斯莱斯", semaphore), "why哥");

        threadA.start();
        threadC.start();
        threadB.start();
        //模拟大爷劝退
        threadB.interrupt();
    }
}

class ParkCar implements Runnable {

    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        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() + "把自己的" + carName + "停进来了," + "剩余停车位:" + semaphore.availablePermits() + "辆");
            //模拟停车时长
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把自己的" + carName + "开走了,停了" + parkTime + "小时");
        } catch (InterruptedException e) {
            System.err.println(Thread.currentThread().getName() + "被门口大爷劝走了。");
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName() + "走后,剩余停车位:" + semaphore.availablePermits() + "辆");
        }
    }
}

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

why哥走后,剩余停车位变成了 5 辆?我是开着劳斯莱斯去给他们开发停车位去了吗?

在往前看日志发现,原来是刘能、谢广坤走后,显示了剩余停车位 3 辆。

问题就出在这个地方。

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

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

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

在我们的例子里面就是刘能、谢广坤的车都还没停进去,走的时候门口的显示屏就增加了两个停车位。

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

那么怎么修复呢?

答案已经呼之欲出了,这个地方需要 catch 起来,如果出现中断异常,直接返回:

跑起来,结果也正确,所有车都走了后,停车位还是只有 3 辆:

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

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

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

增强release

你分析上面的问题会发现,导致问题的原因是没有获取到许可证的线程,调用了 release 方法。

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

我们可以就这个问题,对 release 方法进行增强,只有获取后的线程,才能调用 release 方法。

这一招我是在《Java高并发编程详解-深入理解并发核心库》里面学到的:

其中的 3.4.4 小节《扩展 Semaphore 增强 release》:

获取许可证的方法被修改成这样了(我只截取其中一个方法),获取成功后放入到队列里面:

里面的 release 方法修改成这样了,执行之前先看看当前线程是否是在队列里面:

还有一段温馨提示:

这本书写的还是不错的,推荐给大家。

最后说一句(求关注)

才疏学浅,难免会有纰漏,如果你发现了错误的地方,还请你留言指出来,我对其加以修改。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

我是 why,一个被代码耽误的文学创作者,不是大佬,但是喜欢分享,是一个又暖又有料的四川好男人

记druid 在配置中心下的一个大坑: cpu 达到 100%
日志把我们的dubbo 应用移步到配置中心上去之后,发现我们的应用过一段时间就会出现cpu 100%的情况 (大概是12个小时),一开始cpu占用是2-5% 的样子,什么都没做,后面竟然用尽了cpu.. ...
nginx配置遇到的一个大坑
日志鄙人负责的项目即将上线,今天团队伙伴反应网站上的图片,有的可以显示有的不可以显示报404,找我看看问题. 我心想啊,404,应该是没有文件才出的,于是,我直接上nginx服务器上查看,检查路径下是否有 ...
OpenCV imread读取jpg图像的一个大坑
日志长话短说 版本区间[OpenCV3.0.0, OpenCV3.4.1]内的OpenCV,(至少在windows下,使用官方提供的预编译版本),imread读取jpg图片后的像素值,和版本区间[Open ...
1
Android补间动画设置后不执行(我踩的一个大坑)
日志问题描述 写了一个透明动画(AlphaAnimation),很简单,就是让一个图片从不透明到透明循环两次. 点击按钮,执行动画,动画却没有执行.但是使用Debug发现,代码确实执行了,只是没有显示出效 ...
通信服务器编程中的一个大坑
日志关于DAServer的延迟队列,这些年没少坑过大家!一直都是这样或那样的“小毛病”,说是小毛病,实际上都不小,引起的问题都属于那种“顽固性老毛病”,每次引起问题都觉得想不通哪里会有问题. 比如说: 曾 ...
1
Qt5 UI信号、槽自动连接的控件重名大坑(UI生成的槽函数存在一个隐患,即控件重名。对很复杂的控件,不要在 designer 里做提升,而是等到程序启动后,再动态创建,可以避免很多问题)
日志对Qt5稍有熟悉的童鞋都知道信号.槽的自动连接机制.该机制使得qt designer 设计的UI中包含的控件,可以不通过显式connect,直接和cpp中的相应槽相关联.该机制的详细文章见 http: ...
1
记录Access数据库更新操作大坑一个
日志对于更新Access数据库的操作,必须保持参数数组与sql语句中参数顺序一致,如下: public bool Update(MyModel model) { StringBuilder strSql ...
1
靠!老师居然叫我们去写博弈论!!!结果写了一个晚上的博弈论,简直要死QAQ。。。发发博客休息一下。。。TAT。。。
日志萌萌的糖果博弈 题目描述: 用糖果来引诱小朋友学习是最常用的手法,绵羊爸爸就是用糖果来引诱萌萌学习博弈的.他把糖果分成了两堆,一堆有A粒,另一堆有B粒.他让萌萌和他一起按照下面的规则取糖果:每次可以任 ...
1
哇,今天做到一个十分666的题目,最后居然化成了背包,而其中的证明真是太妙了!!!
日志一眼看去,完全看不出来这TM是背包.知道用背包可以借之后却还是一脸蒙蔽,后来经过LZ学长的点拨,终于顿悟了!!! 我们设f[i] 表示用取i个(也可以没有i个)数所可以得到的最大值,那么有方程式:f[ ...
使用AOP和Semaphore对项目中具体的某一个接口进行限流
日志整体思路: 一 具体接口,可以自定义一个注解,配置限流量,然后对需要限流的方法加上注解即可! 二 容器初始化的时候扫描所有所有controller,并找出需要限流的接口方法,获取对应的限流量 三 使用 ...
1
可以在c编程中将一个fifo重定向到stdout吗?
问答我想将一个fifo重定向到stdout和 我读了http://man7.org/linux/man-pages/man2/tee.2.html文档 它说tee(int fd_in,int fd_out ...
7
如果pandas系列的值是一个列表,如何获取每个元素的子列表?
问答使用两个Pandas系列:series1和series2,我愿意制作series3. series1的每个值都是一个列表,series2的每个值都是series1的对应索引. >>> ...
1
python – 是否可以将django用户帐户绑定到一个特定的工作站?
问答我的目标是每个用户只能从特定的工作站登录我的Django Web应用程序. 如果用户尝试从其他工作站登录,则应拒绝登录. 是否可以通过识别工作站的主机名在Django中实现这种许可模式? 我正在使用D ...
2
如何从另一个线程取消发出http请求的线程
问答关于我们如何杀死已发出http post请求并等待获得响应的线程的任何建议.我希望从另一个并行运行的线程中杀死这个线程,然后才能收到请求的任何响应.我建议关闭或中止http post请求但没有锻炼,有 ...
1
Mysql按条件选择,除非满足另一个条件
问答我有一个记录交易的表.我想选择具有值为2000的4个事务的所有ID,但是然后排除那些具有2500个事务的事务. SELECT t.tuserid, COUNT(*) AS CNT FROM trans ...
1
php – Laravel – 从附带where子句的另一个表中不存在的记录中获取记录
问答我有以下SQL表(在MySQL中): students +-----+------------+ | id | first_name | +-----+------------+ | 01 | Joh ...
1
一个简单的文件目录选择器 – FileListerDialog
日志FileListerDialog FileListerDialog helps you to list and pick file/directory. Library is built for An ...
1
java – 创建一个JAR文件并在Raspberry PI上运行
问答这是一个非常常见的问题,并且有可能将其标记为重复,但即使在审查了stackoverflow和其他社区的大量答案和帖子后,问题也无法解决. 我在NetBeans IDE 7.3中创建了一个项目,并通过按 ...
1
我可以获得一个C#Visual studio程序来控制树莓派吗?
问答我正在开发一个结合了visual studio应用程序和raspberry pi的项目. 有没有一种简单的方法可以通过C#Visual Studio程序在树莓派上运行终端程序(如"pytho ...
2
将两个进程的stdout重定向到Linux C中的另一个进程的stdin
问答我遇到了关于多进程重定向stdout的问题. 假设我有进程A,我在A中使用fork(),然后我得到进程A和B.我在B中使用fork(),最后我得到进程A,B和C.B和C都在实现其他程序EXEC(). ...
1