简单的并发编程中犯2的一个小例子--CAS使用时一定要考虑下是否有必要做轮询

并发编程我自己写过不少文章,不过我由于其相对需要理解更多的东西,我自己写代码也有时长犯2的时候,对于这些犯2的问题,我们只能将它作为自己宝贵的经历和财富,本文是很简单Java并发方面的小文章,为啥?因为是一个犯2的例子,这里给大家做个简单分享。


先简单描述下场景:

在一个app中,我需要为访问者提供某种信息的存储,由于架构上已经确定的方式,所以可以确保每一个app上存储的用户不会太多,于是就放在了内存中,而不是缓存。


这些信息需要定期清理掉,就像会话一样,每个用户都会有一个唯一的key标识符,用一个ConcurrentHashMap存放,长时间不使用就需要删除掉了。


但是它与会话不同的是,在清空的同时会清空掉许多用户级别的网络通信对象,例如Socket或数据库连接对象等。因此它的清理将与传统的清理方法有一些区别,为何?


因为当清理程序发现需要清理该对象的时候,这个对象正好被一个有效请求所使用,在清理对象的时候,需要将内部的Socket等资源关闭,就会导致问题。


因此我不得不在这个用户级别的对象上去做一个状态:

简单来说有一个FREE、USE、DELETE三种状态,FREE是可以修改为任意状态的,USE是使用状态的,DELETE是删除状态的。USE状态的不能被删除,DELETE状态的不能再被使用。

简单逻辑是:

1、如果通过ConcurrentHashMap获取到相应的对象后,需要判定状态不能是DELETE,再尝试在对象上修改状态为USE才能使用,如果修改失败则不能被使用,当然是用后会更新下最新的时间,这个时间将用volatile来保证可见性,以便于最近不会被清理掉,使用完后会讲对象的状态重新修改为FREE。伪代码如下所示:

           int old = status.get();
           if(old != DELETE && status.compareAndSet(old , USED)) {
                return this.userXXXDO;
           }
           return null;

2、在删除操作前也必须先获通过ConcurrentHashMap取到对象,需要判定状态不能是USE,然后尝试将状态修改为DELELE才能真正开始做删除操作。代码与上面类似。


这个逻辑似乎看似完美,我当时晕头转向的也认为CAS就可以简单搞定这个问题,做几个状态嘛,简单事情,呵呵。

结果以外发生了,外部程序偶然情况下获取不到这个对象,但是在获取不到这个对象的断点中,我使用表达式再执行一次又能获取到,这尼玛是什么问题发生了呢?


刚开始我也跑偏了,因为外层有一个ConcurrentHashMap,思维凝固在是不是这有并发可见性问题,不过这样的猜测连我自己都没有相信,因为我对这个组件的内在的源码是比较了解的,如果它有问题,就彻底颠覆可见性的问题了。


在不断加班到半夜的迷糊中,迷迷糊糊地跟踪代码,发现里头还有一层,就看到点希望,看到了刚才的代码。咋一看,代码没有啥问题,因为这个就是状态转换,而且这个是在一个用户下的操作,一个用户并发的概率本来就很低,而且有CAS来保证原子性,能有什么问题呢?


后来一个哥们问我可不可以用synchronzied一下子提醒了我,我的第一反应是不到万不得已不用这个,这个如果放在内部做就是所有的状态转换全部要加上,悲观锁就不好了,放在外面更不靠谱,那就是一个全局的ConcurrentHashMap,那用它来控制个毛的并发啊,我就是要把锁打散。

但是这个提示让我在迷迷糊糊中醒了一下,我发现可能真的有并发问题,或者说假设一个用户的客户端同时发送多个请求上来,此时由于是同一个用户的请求是同一个,所以KEY肯定是一样的,缓存用户对象也应该是一样的,此时如果两个请求都运行到代码:

int old = status.get();

那么两个请求在此时获取到的状态值就是一样的,当发生CAS的时候,只有一个会成功,另一个不成功的就返回null了,代码看了很久,虽然很简单,但是只可能这里有问题。


考虑实际场景,还真的可能有一个客户端的浏览器同时发起多个请求的情况,因为客户端并不是简单的页面跳转(简单页面用户手点击再快也有时差),而是与服务器端很多ajax交互,当一个选项发生变化的时候,确实有可能同时发起多个ajax请求。


不过怎么改呢?用syncrhonezized,显示我不是那么容易放弃自己的人,哈哈,迷迷糊糊中终于才想起来,CAS也需要考虑下尝试,确实是这样,那么就改为循环来做。

但是一旦改为循环大伙第一个担心的问题就是能否退出循环,Java的里面有许多死循环方式,但是这种代码不退出就是一个大问题,但是限制次数的话,多少为好?这不好说,因为乐观锁在这个阶段是不好讲清楚具体的次数的,或许在许多人眼中这算是小问题,但是我认为在这些问题上是关键的关键,如果不注重就会出大问题。

后来考虑来考虑去发现这样写没问题:

            int old = status.get();
            while(old != DELETED) {
                if(old == USED && status.compareAndSet(old , USED)) {
                    return this.loginDO;
                }
                old = use.get();
            }
            return null;


这个while循环的条件是状态没有被删除,状态只要有被删除,这个请求就应该有机会去获取使用机会,只要有机会就应该去尝试,大家会想会不会一直不成功呢?那不会,乐观锁的道理就是我们足够乐观,因为我们发生到这个点上的问题都是偶然,而且是用户级内部发生,所以它尝试的概率非常低,在这样做的方式下,我们采用乐观机制避开了悲观锁带来的巨大开销,同时又能保证原子性。

而对于删除就没有必要循环了,删除操作发现状态是USE就不能删除,状态为FREE在做CAS的时候如果CAS征用失败也没有必要再去征用,为何?假如有两个线程在征用DELETE,另一个成功了就OK了,如果有一个USE在与之征用,它本身就没有再征用的必要。


到这里问题基本解决,但是这个程序是不是就没有问题了呢?

未必然也,因为最初我们写代码的时候没有考虑到多个请求同时发起的过程,所以也自然不会考虑到多个请求将状态改为FREE的过程,假如有2个请求,其中1个请求释放掉了将状态修改为FREE,而另一个还在使用中,此时有线程想将它DELETE掉,发现FREE状态,是可以删除的,于是将相应的Socket关闭掉,就出大事了。


如果要完全解决这种问题,还需要一个条件变量来使用和释放的次数,使用时加1,释放时候减掉1,这就有点像Lock机制了,只是可控性上更强,但是对于代码复杂性更大,你自己也需要承担更大的责任。


如果在应用中,出现这种问题的概率极低,那么可以暂时用状态也可以,或者为了简单处理也可以直接换成Lock。为何说概率低呢?因为这种数据的清理理论上不会到秒级别,例如10分钟,一个请求来的时候,会刷新最近的操作时间,后台操作即使一长一短,只要偏差不是10分钟以上,在理论上就不会有问题。


大家可能一想,一般要求系统响应3s,不会有那种情况发生。真的是这样嘛?我认为未必,所谓3s只是常规系统,有的系统就未必了,例如WEB版本的数据库软件,通过UI上输入SQL获取结果,WEB版本的安装系统给上千的服务器安装相应的软件等等,这些操作的响应都是可以很长的,这个值是有可能超过我们的清理时间的,所以一切皆有可能,当你真正遇到的时候,希望这些小思路能帮助到你。

简单的并发编程中犯2的一个小例子--CAS使用时一定要考虑下是否有必要做轮询

上一篇:同步锁的一个应用


下一篇:分段录制的实现