多线程好玩嘿,以前都不怎么写。。
关于实现
其实并没有太多看过Redisson,但是推荐还是用这个框架,简单粗暴。并且多线程写的是真的少。。。代码目前我觉得没啥大问题,但是怕有对多线程不熟导致的一些坑,求大佬发现问题一定告诉一下哈。。
分布式下的情况
分布式与单机情况下最大的不同在于其不是多线程而是多进程。
多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
分布式锁
当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。
与单机模式下的锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题。
分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如Redis、Memcache。至于利用数据库、文件等做锁与单机的实现是一样的,只要保证标记能互斥就行。
Redis实现分布式锁的思想
简单的想法
分布式与单机的区别,无非在于锁大概率不是在一台机器上加的,或者说,多台服务器开启了多个服务,根本无法确定同时进行操作的两个线程是在一个服务上。那么对于锁而言,单机情况是同进程下多线程,等于说锁是对所有线程可见的,那么在分布式下,如果将锁放到整个分布式系统都可见的地方,自然能够完成锁的操作。只是不会是说利用Java语言的锁,例如synchronized关键字等。
这个解决方案有很多,Redis是其中一种。即将锁的数据放在Redis中。等于说,获取锁就是设置一个key,然后放到Redis上,而线程进行操作的时候先去Redis上查看是否有这个key。完成了整个锁的思想。
等于说对于锁,还是有抢的过程:使用
setnx
,若返回1说明抢锁成功,同时完成了加锁操作,根本没有这个值,否则就是失败。反之,使用
del
删除key,就是解锁。
但是这样会非常容易引起死锁,因为如果线程在执行过程中挂掉了,没有释放锁,那么就会造成死锁。这个解决方法非常简单,考虑在加锁后,使用
expire
设定超时时间,超时自动释放。
原子性的问题
上述的方法有一个比较重要的问题,就是执行了两个命令,等于说是非原子性的操作。这样有可能引起非常致命的错误,所以解决方法上需要考虑用
set
命令代替。但是这个是在Redis 2.6之后才有的,可以:
set key value EX time
这样的格式,若key存在,则返回0,若key不存在,设置key同时设置超时时间。
当然其实这个没有用上。。后面会使用set,以及考虑重用锁,所以还是用了其他方法。
误删锁的问题
虽然设置了超时时间,但是超时时间的设定却有待商榷。因为无法确定这个方法会不会出现问题,假设方法出现了问题,设定的超时时间是20s,方法来个超级大的慢查询,20s还没有执行成功。此时key自动失效,等于说方法没有执行成功却释放了锁。
此时其他线程的请求会获得锁,然后执行方法。如果恰好该线程也出问题了,而之前的方法执行完毕,执行了
del
等于说提前将别的锁给解了。
解决方法就是确定,锁只有当前线程才能解,那么就是在设置key的时候,value还什么都没设置呢,将线程id设置进去就可以了。
而这样解决了误删锁的问题,还有自动释放锁的问题。或者说是超时时间的设定问题。一个解决方法是,设定一个时间,时间无所谓,但是给线程开启一个守护进程。当守护进程发现时间快到了,不管方法执行情况,直接续时。这样可能会消费很长的时间,但是保证线程必然会保持锁直到完成。如果碰见挂掉的情况,此时守护进程会跟随挂掉,所以自然不能续时,保证了不会死锁。
也可以看到,上述条件可能会引起饥饿,但是这个就不是分布式锁的问题了,而是程序的问题。
具体的实现下面会说。
再次回到原子性的问题
此时,因为额外考虑了验证value是否是自己线程,所以又出现了不是原子性操作的问题。而解决的方法一般是使用脚本。这也是Redis分布式锁一般会使用到Lua脚本的情况:
if (redis.call('get', KEYS[1]) == ARGV[1])
then
return redis.call('del', KEYS[1])
else
return 0
end
看不懂需要先简单了解Lua以及Redis的
eval
执行Lua脚本。而在传参上:
jedis.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
等于说就是判定,key的值只有是当前线程id时,才删除该key,否则不管直接返回。
值得注意的是,在写具体代码的时候,一定不要分开写,否则就不是原子性了。所以获取、释放锁的全部逻辑都写在Lua脚本,然后直接一行代码写
eval()
方法,如果想要好看,像上面一样写一个luaScript变量放脚本,这样就无法保证方法的原子性。
可重入锁
可重入锁还是比较简单的。我们之前对value设置线程id,而key是一个自定义的数。那么反过来,将key设定为线程id,而value是重入次数,就能实现可重入锁了。而在释放的时候也不是单纯的
del
了,而是将value-1,当为0的时候再
del
。
但是数据结构就需要换一换了,不能采用默认的string,最好是换成hash,hash的名字是锁名,然后key来限定线程名。
续时-watch dog机制
这个可能跟Redisson的Watch Dog不太一样的实现,也不是Redis 2.6新出的特性。主要思路还是创建一个守护线程,守护线程持续监听该锁的剩余时间,如果剩余1/3则重新重置时间,直到主线程完成操作。而主要就是在获取锁后跑逻辑的时候,开启线程,逻辑完成一定要将守护线程关闭。但是这里使用了
stop()
方法关闭,IDE显示说已过时,确实不知道新的关闭的方法。。。
利用Jedis实现
算是手搓的吧,有一些代码上的问题,首先忽略代码风格,理论上这些大量的常量都应该单独拿出来的,并且设置的各种时间也都应该作为参数。等于说是手写了一个简单的Redisson框架了,但是watch dog机制应该是和Redisson不一样的。Redis的地址和端口请自己添加。
两个Lua脚本,传参key为锁名,value为线程id。逻辑基本如下:
class WatchDog implements Runnable {
@Override
public void run() {
System.out.println("守护线程启动");
Jedis jedis = new Jedis(HOST, PORT);
while (true) {
long ttl = jedis.ttl("redisLock");
if (ttl > 10) {
try {
System.out.println("时间未重置,进入睡眠");
Thread.sleep(5000);
} catch (Exception ex) {
ex.printStackTrace();
}
} else {
System.out.println("时间重置");
jedis.expire("redisLock", 30);
}
}
}
}
class Person implements Runnable {
private static int num = 10;
private Jedis jedis;
public boolean getLock(String threadId) {
return ((long) jedis.eval("if (redis.call('exists', KEYS[1]) == 0) then\n" +
"\tredis.call('hset', KEYS[1], ARGV[1], 1);\n" +
"\tredis.call('expire', KEYS[1], 30);\n" +
"\treturn 1;\n" +
"elseif (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then\n" +
"\tredis.call('hincrby', KEYS[1], ARGV[1], 1);\n" +
"\tredis.call('expire', KEYS[1], 30);\n" +
"\treturn 1;\n" +
"end\n" +
"return 0;", Collections.singletonList("redisLock"), Collections.singletonList(threadId)) == 1);
}
public void unLock(String threadId) {
jedis.eval("if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then\n" +
"\treturn nil;\n" +
"else\n" +
"\tlocal count = redis.call('hincrby', KEYS[1], ARGV[1], -1);\n" +
"\tif (count > 0) then\n" +
"\t\tredis.call('expire', KEYS[1], 30);\n" +
"\telse\n" +
"\t\tredis.call('del', KEYS[1]);\n" +
"\tend\n" +
"end", Collections.singletonList("redisLock"), Collections.singletonList(threadId));
}
@Override
public void run() {
jedis = new Jedis(HOST, PORT);
String threadName = Thread.currentThread().getName();
String threadId = String.valueOf(Thread.currentThread().getId());
System.out.println(threadName + ": 线程开始执行");
while (num > 0) {
if (getLock(threadId)) {
try {
Thread daemonThread = new Thread(new WatchDog());
daemonThread.setDaemon(true);
daemonThread.start();
if (num > 0) {
num--;
System.out.println(threadName + ": 线程已经获得锁,此时num处理后为:"+num);
} else {
System.out.println(threadName + ": 线程已经获得锁,此时num已经不能操作:"+num);
}
System.out.println(threadName + ": 线程已释放锁");
unLock(threadId);
daemonThread.stop();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
}
单独拿出来的Lua代码
Lua脚本基本参考Redisson的Lua脚本,其实这个逻辑比较简单粗暴。。。大家写的都一样。
加锁的:
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[1], 1);
redis.call('expire', KEYS[1], 30);
return 1;
elseif (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[1], 1);
redis.call('expire', KEYS[1], 30);
return 1;
end
return 0;
解锁的:
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil;
else
local count = redis.call('hincrby', KEYS[1], ARGV[1], -1);
if (count > 0) then
redis.call('expire', KEYS[1], 30);
else
redis.call('del', KEYS[1]);
end
end
Redisson
使用这个框架,就比较简单了,Redis分布式锁相比Zookeeper等,就是需要考虑大量条件,比较恶心,而使用Redisson就比较简单了:
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("key");
lock.lock();
// 逻辑
lock.unlock();
而实际上,这个机制基本和上面的一样了。而默认的超时是30s。
在续期上有些不同,Redisson有watch dog机制。也就是说,在获取锁成功后,会开启一个定时任务,在时间还剩1/3的时候进行一次续时。这个定时任务利用了netty-common包中的HashedWheelTimer。上面的写法等于说是用守护线程。