主要介绍乐观锁以及悲观锁的概念以及实现方式和适用场景。
1 基本概念
-
乐观锁 临界区资源资源被修改的概率低。因此在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
-
悲观锁 临界区资源资源被修改的概率非常大。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
2 实现方式
悲观锁的实现方式是加锁; 乐观锁的实现方式有两种,CAS机制和版本号机制;
2.1 CAS(Compare And Swap)
CAS操作包括了3个操作数:
- 需要读写的内存位置(V)
- 进行比较的预期值(A)
- 拟写入的新值(B)
CAS操作逻辑如下:如果内存位置V的值等于预期的A值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
2.2 版本号机制
版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。当某个线程查询数据时,将该数据的版本号一起查出来;当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。 需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。
3 优缺点和适用场景
3.1 功能限制
与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
3.2 竞争的激烈程度
如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:
- 当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
- 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
4 CAS有哪些缺点
4.1 ABA问题
4.2 高竞争下的开销问题
在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。当然,更重要的是避免在高竞争环境下使用乐观锁。
4.3 功能限制
CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着: (1)原子性不一定能保证线程安全,例如在Java中需要与volatile配合来保证线程安全; (2)当涉及到多个变量(内存值)时,CAS也无能为力。除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。
5 两个互斥锁实现读写锁
下面代码说明了一个问题,即一个线程加锁,而另一个线程解锁,是可行的,只是这种情况容易导致死锁。必须保证第二次加锁时,已经调用了解锁了。
pthread_mutex_t r_mutex;
pthread_mutex_t w_mutex;
int reader = 0;
pthread_mutex_init(&w_mutex, NULL);
pthread_mutex_init(&r_mutex, NULL);
//加写锁
pthread_mutex_lock(&w_mutex);
//解写锁
pthread_mutex_unlock(&w_mutex);
//加读锁
pthread_mutex_lock(&r_mutex);
if (reader == 0)
pthread_mutex_lock(&w_mutex);
reader++;
pthread_mutex_unlock(&r_mutex);
//解读锁
pthread_mutex_lock(&r_mutex);
reader--;
if (reader == 0)
pthread_mutex_unlock(&w_mutex);
pthread_mutex_unlock(&r_mutex);