关于锁的哪些事情

总结内容是真的累人啊, 这周打算逼着自己把锁相关的内容整理一下(本人工作一年半功力尚浅,说的不对的地方还望指出会及时更正),一个内容的阐述无外乎是what,why,how?

1. what? 什么是锁,锁的种类有哪些?

      锁:顾名思义就是用来套住某些东西,供自己使用,别人不能用。

     想必大家对多线程不陌生吧,因为是在一个进程下,进程有一些资源是共享的,所以为了保证线程在修改数据的时候遵循逻辑而不会出现异常情况,这时候需要加锁进行控制,保证数据按照逻辑修改,这就是线程锁。

     多进程之间也会存在需要加锁的控制的一些情况,例如 nginx对负载均衡问题和惊群问题的时候使用了进程锁。

      还有就是目前我们很多的系统都是分布式系统,业务模块很多采用集群的方式实现,这时候由于是在不同机器上进行某些共享资源的逻辑修改,进程锁和线程锁就用不了了,为了保证共享资源数据的准确性,就出现了分布式锁, 分布式锁常见的就是使用 数据库,redis (redis+lua, redlock,redission),zookeeper等(目前位置 就简单使用过redis做分布式锁)。

tips(百度的之前有点混淆 哈哈):

分布式(distributed)是指在多台不同的服务器中部署不同的服务模块,通过远程调用协同工作,对外提供服务。

集群(cluster)是指在多台不同的服务器中部署相同应用或服务模块,构成一个集群,通过负载均衡设备对外提供服务。

2. why?为什么使用锁?

      我觉得 最重要的就是保证使用的共享数据内容被业务准确的处理,避免异常情况的发生,数据的准确性得以保障,程序不会出现异常。具体情况可以想一哈当程序中就一个线程的时候,是不需要加锁的,但是通常实际的代码不会只是单线程,多进程和分布式其实本质上也是一种“多线程”(不在同一个进程或者同一个机器上的线程),当多个线程访问公共资源,所以这个时候就需要用到锁了,那么关于锁的使用场景主要涉及到哪些呢?

1. 多个线程在读相同的数据时 2. 多个线程在写相同的数据时 3. 同一个资源,有读又有写时

3.how?如何使用锁?

线程锁:

      (todo:待补充完善

      常见的就是互斥锁,自旋锁,条件变量等的实现。

      pthread_mutex 是pthread中的互斥锁,具有跨平台性质。pthread是POSIX线程(POSIX threads)的简称,是线程的POSIX标准(可移植操作系统接口 Portable Operation System Interface)。POSIX是unix的api设计标准,兼容各大主流平台。所以pthread_mutex是比较低层的,可以跨平台的互斥锁实现。

static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mutex);
block();
pthread_mutex_unlock(&mutex);

自旋锁 与互斥锁有点类似,只是自旋锁被某线程占用时,其他线程不会进入睡眠(挂起)状态,而是一直运行(自旋/空转)直到锁被释放。由于不涉及用户态与内核态之间的切换,它的效率远远高于互斥锁。    

OSSpinLock lock = OS_SPINLOCK_INIT;
OSSpinLockLock(&lock);
OSSpinLockUnlock(&lock);

条件变量 (Condition Variable) 作为一种同步手段类似于栅栏,允许线程以一种无竞争的方式等待某个条件的发生。当该条件没有发生时,线程会一直处于休眠状态。当被其它线程通知条件已经发生时,线程才会被唤醒从而继续向下执行。条件变量是比较底层的同步原语,直接使用的情况不多,往往用于实现高层之间的线程同步。使用条件变量的经典的例子就是线程池(Thread Pool)和生产者消费者模型了

当条件不满足时候使用wait 导致当前线程阻塞直至条件变量被通知,或虚假唤醒发生,可选地循环直至满足某谓词。
void wait( std::unique_lock<std::mutex>& lock );
//Predicate 谓词函数,可以普通函数或者lambda表达式
template< class Predicate >
void wait( std::unique_lock<std::mutex>& lock, Predicate pred );
当条件不满足时候使用wait_for 导致当前线程阻塞直至条件变量被通知,或虚假唤醒发生,或者超时返回。
template< class Rep, class Period >
std::cv_status wait_for( std::unique_lock<std::mutex>& lock,
                         const std::chrono::duration<Rep, Period>& rel_time);
                  
template< class Rep, class Period, class Predicate >
bool wait_for( std::unique_lock<std::mutex>& lock,
               const std::chrono::duration<Rep, Period>& rel_time,
               Predicate pred);

当条件满足的时候进行唤醒使用notify_all/notify_one
void notify_one() noexcept;
若任何线程在 *this 上等待,则调用 notify_one 会解阻塞(唤醒)等待线程之一。
void notify_all() noexcept;
若任何线程在 *this 上等待,则解阻塞(唤醒)全部等待线程。

  注意:使用while循环进行判读防止虚假唤醒

 虚假唤醒:查了半天给出我觉得比较靠谱的,当线程在某个条件变量下等待时,即使其他线程没有broadcast or signaled 这个条件变量,该线程仍然可能被唤醒,在多核处理器系统下,使条件变量完全可以预测会降低系统的性能,而导致虚假唤醒的几率又很小,在不同的语言,甚至不同的操作系统上,条件锁都会产生虚假唤醒现象,所有语言的条件锁库都推荐用户把wait()放进循环里,参见为什么条件锁会产生虚假唤醒现象(spurious wakeup)。pthread 的条件变量等待 pthread_cond_wait 是使用阻塞的系统调用实现的(比如 Linux 上的 futex),这些阻塞的系统调用在进程被信号中断后,通常会中止阻塞、直接返回 EINTR 错误。同样是阻塞系统调用,你从 read 拿到 EINTR 错误后可以直接决定重试,因为这通常不影响它本身的语义。而条件变量等待则不能,因为本线程拿到 EINTR 错误和重新调用 futex 等待之间,可能别的线程已经通过 pthread_cond_signal 或者 pthread_cond_broadcast发过通知了。所以,虚假唤醒的一个可能性是条件变量的等待被信号中断。不过,把等待放到循环里的另一个原因是还可能有这样的情况(有人觉得它是虚假唤醒的一种,有人觉得不是):明明有对应的唤醒,但条件不成立。这是因为可能由于线程调度的原因,被条件变量唤醒的线程在本线程内真正执行「加锁并返回」前,另一个线程插了进来,完整地进行了一套「拿锁、改条件、还锁」的操作

while (!(xxx条件) )
{
    //虚假唤醒发生,由于while循环,再次检查条件是否满足,
    //否则继续等待,解决虚假唤醒
    wait();  
}
//其他代码
....

进程锁:

(最近想啃一啃nginx的源码,啃不动啊 哈哈!)

一种是支持原子实现的原子锁,另外一种是文件锁。(待补充哈

分布式锁:

        重头戏来啦,分布式锁是我想说的重点

  •   基于数据库 

   1. 乐观锁(唯一索引,版本号控制)

     唯一索引基于数据库的分布式锁, 常用的一种方式是使用表的唯一约束特性。当往数据库中成功插入一条数据时, 代表只获取到锁。将这条数据从数据库中删除,则释放送。

     创建新的字段做唯一索引

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `cust_id` varchar(1024) NOT NULL DEFAULT '客户端唯一编码',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
)
 ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

添加锁:
insert into methodLock(method_name,cust_id) values (‘method_name’,‘cust_id’)
这里cust_id 可以是机器的mac地址+线程编号, 确保一个线程只有唯一的一个编号。通过这个编号, 可以有效的判断是否为锁的创建者,从而进行锁的释放以及重入锁判断
释放锁:
delete from methodLock where method_name ='method_name' and cust_id = 'cust_id'
重入锁判断:
select 1 from methodLock where method_name ='method_name' and cust_id = 'cust_id'

 

    版本号控制:为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `status` smallint(8) 0 '使用状态0代表未使用,1代表使用',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
)
methodLock表中 ,除了id,method_name,status,update_time 再增加 version字段

1.查询出机器信息:
select id,version from methodLock where status=0 limit 1;
2.将该机器分配给该 job:
update methodLock set status=<job_id>, version=<刚查出 version+1>;  where id =<刚查出的> and version=<刚查出 version>;

上面这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
  • 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。

   2. 悲观锁(for update)

     FOR UPDATE 仅适用于InnoDB,且必须在事务区块(start sta/COMMIT)中才能生效 ,InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。

设置MySQL为非autocommit模式:
set autocommit=0;
设置完autocommit后,我们就可以执行我们的正常业务了。具体如下:
//0.开始事务
begin;/begin work;/start transaction; (三者选一就可以,推荐start transaction)
//1.查询
select id from methodLock where status=0 limit 1 for update;
//2.修改
update methodLock set status=<job_id> where id=<id>;
//4.提交事务
commit;/commit work;

       这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

       但是还是无法直接解决数据库单点和可重入问题。这里还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了,还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

  • 基于redis

     1. redis + lua

基于 redis 的 setnx()、expire() 方法做分布式锁, setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

使用步骤: 1. setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。3、执行完业务代码后,可以通过 delete 命令删除 key。

      setnx和expire是分开的两步操作,不具有原子性,如果执行完第一条指令应用异常或者重启了,锁将无法过期,造成死锁现象的发生。解决方法:使用Lua脚本(包含setnx和expire两条指令)保证原子性 或者 使用 set key value [EX seconds][PX milliseconds][NX|XX] 命令 (这个是命令是Redis在 2.6.12 版本开始,为 SET 命令增加一系列选项)。

lua的使用:待补充
SET key value[EX seconds][PX milliseconds][NX|XX]
EX seconds: 设定过期时间,单位为秒
PX milliseconds: 设定过期时间,单位为毫秒
NX: 仅当key不存在时设置值
XX: 仅当key存在时设置值
SET具体实例:待补充

释放锁的时候也要注意:释放锁时需要验证value值,也就是说我们在获取锁的时候需要设置一个value,不能直接用del key这种粗暴的方式,因为直接del key任何客户端都可以进行解锁了,所以解锁时,我们需要判断锁是否是自己的,基于value值来判断  也要使用lua脚本来查询删除保证操作的原子性:

lua的使用查询和释放锁:待补充

  注意的是:set 的 value值 必须要具有唯一性,我们可以用UUID来做,设置随机字符串保证唯一性,至于为什么要保证唯一性?假如value不是随机字符串,而是一个固定值,那么就可能存在下面的问题:

  • 1.客户端1获取锁成功
  • 2.客户端1在某个操作上阻塞了太长时间
  • 3.设置的key过期了,锁自动释放了
  • 4.客户端2获取到了对应同一个资源的锁
  • 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题

总结:set命令要用 setkey value px milliseconds nx 或者lua脚本 ;保证原子性,value要具有唯一性,释放锁时要验证value值,不能误解锁;解锁要使用lua脚本,也是为了保证原子性。

上面改进的redis两种方案其实还是有不完善的地方:实际上在Redis集群的时候会出现问题,比如说A客户端在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,一个slave节点升级为master节点,B客户端也可以获取同个key的锁,但客户端A也已经拿到锁了,这就导致多个客户端都拿到锁。这就是Redis官方指出的有安全隐患就是在主从复制模式下会导致两个线程可能会同时持有一个锁,如果业务允许如此,则推荐使用这种方案,毕竟实现简单,易维护。如果对锁的要求非常高的场景,Redis官方建议使用RedLock算法。2.RedLock算法:

https://www.cnblogs.com/rgcLOVEyaya/p/RGC_LOVE_YAYA_1003days.html 这篇文章进行了详细的描述和分析,感兴趣的同学可以看一哈!我这边没使用过也没怎么研究过就不做过多的说明了。

3.Redisson 实现:

     

     Javaer都知道Jedis,Jedis是Redis的Java实现的客户端,其API提供了比较全面的Redis命令的支持。Redission也是Redis的客户端,相比于Jedis功能简单。Jedis简单使用阻塞的I/O和redis交互,Redission通过Netty支持非阻塞I/O。(本人之前是做python开发目前在做c++后台开发未使用过,这里就不给出了)

 

  • 基于zookeeper

在给出如何实现之前想回顾一下zookeeper的几个基本的概念:

zk 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
zk 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。

有序节点:假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号,也就是说如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推。
永久节点:客户端发送请求节点创建后会被持久化,只有主动调用delete方法的时候才可以删除节点。
临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。
因为节点有两个维度,一个是永久的还是临时的,另一个是否有序。组合成的四种类型如下:
1:PERSISTENT                                //  持久化节点、
2:PERSISTENT_SEQUENTIAL       //  持久化排序节点
3:EPHEMERAL                                 //  临时节点
4:EPHEMERAL_SEQUENTIAL        //  临时排序节点
事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。当前zookeeper有如下四种事件:1)节点创建;2)节点删除;3)节点数据修改;4)子节点变更。

下面描述使用zookeeper实现分布式锁的算法流程,假设锁空间的根节点为/lock:

1.客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
2.客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听/lock的子节点变更消息,获得子节点变更通知后重复此步骤直至获得锁;
3.执行业务代码;
4.完成业务流程后,删除对应的子节点释放锁。

步骤1中创建的临时节点能够保证在故障的情况下锁也能被释放,考虑这么个场景:假如客户端a当前创建的子节点为序号最小的节点,获得锁之后客户端所在机器宕机了,客户端没有主动删除子节点;如果创建的是永久的节点,那么这个锁永远不会释放,导致死锁;由于创建的是临时节点,客户端宕机后,过了一定时间zookeeper没有收到客户端的心跳包判断会话失效,将临时节点删除从而释放锁。

在步骤2中获取子节点列表与设置监听这两步操作的原子性问题,考虑这么个场景:客户端a对应子节点为/lock/lock-0000000000,客户端b对应子节点为/lock/lock-0000000001,客户端b获取子节点列表时发现自己不是序号最小的,但是在设置监听器前客户端a完成业务流程删除了子节点/lock/lock-0000000000,客户端b设置的监听器岂不是丢失了这个事件从而导致永远等待了?这个问题不存在的。因为zookeeper提供的API中设置监听器的操作与读操作是原子执行的,也就是说在读子节点列表时同时设置监听器,保证不会丢失事件。

最后,对于这个算法有个极大的优化点:假如当前有1000个节点在等待锁,如果获得锁的客户端释放锁时,这1000个客户端都会被唤醒,这种情况称为“羊群效应”;在这种羊群效应中,zookeeper需要通知1000个客户端,这会阻塞其他的操作,最好的情况应该只唤醒新的最小节点对应的客户端。应该怎么做呢?在设置事件监听时,每个客户端应该对刚好在它之前的子节点设置事件监听,例如子节点列表为/lock/lock-0000000000、/lock/lock-0000000001、/lock/lock-0000000002,序号为1的客户端监听序号为0的子节点删除消息,序号为2的监听序号为1的子节点删除消息。

所以调整后的分布式锁算法流程如下:

1.客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推;
2.客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;
3.执行业务代码;
4.完成业务流程后,删除对应的子节点释放锁。

本人具体使用zk做分布式锁还未接触到:todo 具体实例待补充

参考文献:

https://juejin.im/post/6844903543527178248#heading-6   线程同步及线程锁

https://blog.csdn.net/c_base_jin/article/details/89741247  C++11条件变量使用详解

https://www.cnblogs.com/5iedu/p/11894925.html  

https://blog.csdn.net/bytxl/article/details/24580801  Nginx---进程锁的实现

https://zhuanlan.zhihu.com/p/42056183  分布式锁看这篇就够了

https://zhuanlan.zhihu.com/p/53974502  mysql事务和锁 SELECT FOR UPDATE

https://juejin.im/post/6844903830442737671 基于Redis的分布式锁实现

https://blog.csdn.net/fuzhongmin05/article/details/95102116  基于Zookeeper实现分布式锁

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值