libevent c++高并发网络编程_高并发编程【无锁篇】

a687b0619651525f1a25bdf31a27e4c0.png

摘要:本文首先通过讲解无锁的原理,以及为何选用无锁实现原子操作,在此基础上,介绍几个无锁原子操作代表类:AtomicInteger、AtomicReference、AtomicStampedReference、AtomicIntegerArray、AtomicIntegerFieldUpdater。

1. 无锁类的原理

1.1. 概述

根据【概念理解篇】可以知道,无锁是基于无障碍的,无障碍指的是所有线程都可以同时进入临界区,但是对于临界区的修改,有且只有一个成功,是一个宽进严出的想法。但在无障碍的情况下,可能会出现线程相互干扰的问题,导致所有线程都卡死在了临界区,此时为了解决这个问题,无锁就出现了,在无障碍的基础上,再增加一条规则:每一次数据竞争都有一个优胜者,意味着线程不会无穷的相互干扰,导致所有线程都不能离开临界区。因此在理论上,无锁会更加合理,但也不是绝对的。
有研究表明,在实践中,无障碍的线程可能会出不去临界区,但是这是一个运气+概率的这么一个事件,一般情况下,运气都不会差到线程会卡死。

1.2. CAS

无锁的实现原理就是CAS,CAS是Compare And Swap的缩写,下面借助一个案例来说明CAS。

--假设在多线程环境下,需要修改临界区中的变量t。

--分析:

因为无锁是无障碍的,因此线程可以同时进入临界区,而能成功修改的线程有且只有一个,那么如何判断哪一个线程可以成功呢?CAS就给出了一个判断规则。

--CAS判断规则:

在线程进行修改变量t之前,需要给出t的期望值,如果t的期望值和实际值是相等的,那么就可以修改t的值,否则就是修改失败。

--为什么期望值和实际值不相等,就修改失败呢?

如果期望值和实际值不相等,那么数据在当前线程修改的间隙中,可能被其他线程修改过了,因为这个数据已经被修改过了,因此当前线程拿到的数据都是不正确,没有意义的,因此这次修改就没有意义,所以修改失败。

--引申问题:由于CAS经历了三个步骤:读取、比较、修改,因此CAS操作过程中,线程t1读取并比较了数据,正当修改之前,线程t2进入了临界区,把数据给修改了,那么t1还还行修改操作吗?(由于CAS操作太多,会出现线程干扰问题,那么CAS还是一个合适的技术方案吗?)

实际上,这个担心是多余的,因为CAS整个操作过程是一个原子操作,是由一条CPU指定完成的,并不是说读取、比较、修改是分别在不同的CPU指令上执行的,因此不会出现CAS操作线程干扰的问题发生。

1.3. CPU指令

在计算机上CAS操作使用【cmpxchg】指令完成的。大致逻辑如下:

/*

1.4. 已经有Synchronized为什么还使用CAS?

首先需要知道Synchronized是悲观锁的代表,而CAS可以理解为乐观锁。
Synchronized悲观锁认为同一时间只能有一个线程进入临界区操作数据,而其他线程都会阻塞,有研究表名:阻塞一个线程需要消耗8万个CPU时间片,而CAS则没有实际的锁概念,而使用通过Compare和Swap来实现数据的一致性的,正是没有锁,因此就不会出现线程阻塞,但是由于CAS操作中,只有一个线程可以胜出,而其他线程都会失败,并且可能会出现重试(从头执行一次代码),因此在循环体不是很复杂的情况下(仅仅只是2到3条、或者10条以内的语句),那么失败的线程需要多消耗2到3个、或者10个以内的CPU时间片来重试代码,但是相对于8万个CPU时间片来说,CAS在性能上还是有非常大的优势。

1.5. 总结

CAS算法过程:它包含3个参数CAS(V, E, N)。V表示要更新的变量,E表示预设值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做,随后,CAS返回当前V的真实值。CAS操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用CAS操作同一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是操作失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

2. 无锁类的使用

在java中,提供了一些无锁类,所谓的无锁类指的是,这些锁底层实现使用了CAS,无锁类典型代表有:AtomicInteger、AtomicReference、AtomicStampedReference、AtomicIntegerArray、AtomicIntegerFiledUpdater。

2.1. AtomicInteger

public 

AtomicInteger继承自Number,是一个数字类,可以理解为一个Integer,但是与Integer不同的是,AtomicInteger提供的访问接口,能够保证在多线程环境下,数据的一致性,底层实现就是CAS。

--主要接口:

// 获取当前值

--AtomicInteger源码解读:

打开AtomicInteger源码之后,可以发现有一个int类型的value字段。

private volatile int value;

这个字段,存储了一个整数,并且所有的接口操作的都是这个value字段,如get、set方法:

public 

因此AtomicInteger无非只是一个包装类,包含了一个int类型数值,真正存储数据的是value属性。

--接下来看一个典型接口compareAndSet:

/**

compareAndSet方法的两个参数:expect表示期望值,update表示新值,当修改成功,方法返回true,否则返回false,如果返回false则表示实际值不等于期望值,这和CAS原理是相符的。

在这个方法中使用了unsafe操作,顾名思义unsafe应该封装了一些不安全的操作,但是Java相对于C、C++安全的原因在于,Java封装了指针的操作,而在某些环境下,需要像C、C++那样通过偏移量操作指针,所以Java就提供了unsafe操作指针。如下:

unsafe.compareAndSwapInt(this, valueOffset, expect, update);

--unsafe的compareAndSwapInt方法的参数含义:

this:当前对象(AtomicInteger) valueOffset:偏移量
expect:期望值
update:新值

那么这个方法的作用就是将AtomicInteger对象在偏移量valueOffset上的实际值和期望值expect是否相等,相等就设置新值update。

--再来看一个典型接口getAndIncrement:

/**

这个方法调用了unsafe的getAndAddInt方法:

/**

--getAndAddInt方法主要完成三个步骤:

[1] 获取AtomicInteger在var2偏移量上的值,作为期望值var5。
[2] 取出获取AtomicInteger在var2偏移量上的值,判断与期望值var5是否相等,相等就修改AtomicInteger在var2偏移量上的值(这里是递增var4)不相等就转[1]。
[3] 返回AtomicInteger在var2偏移量上的 旧值

步骤[1]和[2]是一个死循环,在CAS中称为自旋,因此CAS也成为自旋锁。

通过这个方法,可以很好的解释了CAS的工作原理:比较(Compare)与交换(Swap)。通过不断的比较,来完成交换。

--案例:

--启动10个线程,每个线程将变量i累加1万,如果AtomicInteger是线程安全的话,那么最后i的值为10万。

public 

b749326db07081ff638007d6baa39c52.png

由打印结果,可以知道,AtomicInteger是线程安全的。

2.2. Unsafe

Unsafe中提供了一些非安全的操作,譬如:

[1] 根据偏移量设置值。
[2] park,停止线程。
[3] 底层CAS操作。

由于Unsafe是非安全的,所以Unsafe是非公开API(不希望开发者使用),只在JDK内部使用,并且在不同版本的JDK中,可能有较大差异。

--偏移量valueOffset

Unsafe提供的根据偏移量设置值,可能对于接触过C的人来说,非常好理解,因为C中是通过指针操作对象的,操作性能最高,但是其存在非常大的安全隐患,如:误操作了一些系统级别的对象,导致系统崩溃。面对这个问题,Java使用引用替代指针,引用底层无非就是封装了指针,从而使得开发者无需过多的关注底层指针操作,因此对于java程序员,可能不太清楚什么叫偏移量,那么接下来就来说明一下偏移量。

--假设在C++中有一个结构体(struct),结构体有两个int类型的字段a、b,如下:

Struct 

我们知道一切对象或者程序代码都会对应在内存中的一块位置,并且计算机提供了指针来访问内存,在C++中直接使用指针操作内存,那么当我们知道了一个结构体的地址之后,可以通过字段类型所占的内存大小计算出计算字段的实际物理地址,譬如:当结构体的地址为0X0001(相当于1)时,根据一个int类型占4个字节,可以计算出字段a的地址为0X00101(相当于5 = 1 + 4),字段b的地址为0X1001(相当于9 = 1 + 8)。其中的4和8就是偏移量。

由于java封装了指针,因此java对象一般会包含一些对象头信息,会占用一些内存地址。,但是通过偏移量计算出属性的地址的过程是相同的。如下图:

53b4e77d9f6f39a131ff6a07cad3aafe.png

因此unsafe中使用偏移量访问对象的属性时,那么unsafe是如何知道属性的偏移量的呢?由于偏移量已经作为一个参数传递给了unsafe的方法,因此想要找到计算偏移量的代码,需要在调用unsafe方法的类中找,这里在AtomicInteger中找到了偏移量属性valueOffset:

private static final long valueOffset;

并且在AtomicInteger类加载的时候,就已经就已经计算好了value属性的偏移量,如下:

static 

计算属性偏移量使用了unsafe的objectFieldOffset方法:

public native long objectFieldOffset(Field var1);

由于java是没有指针的,因此涉及指针操作的objectFieldOffset方法交给了其他语言来实现。

通过static代码块,结合偏移量的计算过程,AtomicInteger中的偏移量应该就是value的类型所占的比特+对象头信息所占的比特(大于4)。

由于unsafe是非公开的API,因此获取Unsafe没有提供对外的构造方法,但是提供了一个静态属性unsafe来获取Unsafe对象:

public 

--通过反射获取Unsafe:

public 

--主要接口:

// 获得给定对象偏移量上的int值

其他类似的还有AtomicBoolean、AtomicLong。

2.3. AtomicReference

AtomicInteger解决了线程安全的访问一个整数,而AtomicReference则是能否线程安全的修改一个对象的引用。

AtomicReference使用了泛型,使得可以对任意类型的对象实现线程安全的操作:

public class AtomicReference<V> implements java.io.Serializable {

与AtomicInteger类似的,它也将对象作为一个属性存在:

private volatile V value;

也有Unsafe、valueOffset属性:

private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
    try {
        valueOffset = unsafe.objectFieldOffset
            (AtomicReference.class.getDeclaredField("value"));
    } catch (Exception ex) { throw new Error(ex); }
}

--主要接口:

// 获取当前对象

--案例:

启动10个线程,如果AtomicReference的值为“abc”,那么就修改为“def”,因此如果AtomicReference是线程安全的话,那么无论执行多少次,都只有一个线程修改成功,其余都失败告终。

public 

3537878e38ad2cf0dd1f077c9e2558af.png

2.4. AtomicStampedReference

AtomicStampedReference比AtomicReference多了一个Stamped,stamp的意思是戳、印记的意思,也就是一个有唯一性标识的字段,代入编程中就是时间戳的意思。比如说从数字0一直递增上去,没有重复、唯一的。

--试想一个问题:假设有一个引用R,其指向对象A,此时有一个线程t1执行了compareAndSet方法,读取了引用R的值,此时t1的CPU时间片到了,轮到了线程t2执行,t2修改了引用R的值,使其指向了对象B,此时t2的CPU时间片也到了,轮到了线程t3执行,t3也修改了引用R的值,使其重新指向了对象A,此时t3的CPU时间片到了,重新轮到t1线程执行,此时t1将读取出来的R的值和当前内存中的R的实际值进行比对,发现相等,那么应不应该修改R的值呢?

--如下图:

58f113f8d5dcdfbb670855379b1c8c15.png
--应该,那么是因为读取到的R值和实际的R值相等,应该修改R的值。
--不应该,那么因为在比较R值之前,引用R出现了状态的改变,认为,当前线程读取到的R值,不是最新的数据,是被修改过的,因此不应该修改R的值。

如果引用的只是一个数值,那么数值进行了加减操作,即发生的改变与过程无关的,可以认为应该修改。但是如果发生的改变与过程是相关的,比如给低于20块钱的用于充值20块钱话费,此时就认为不能修改了。因此为了解决与状态过程有关系的修改问题,提出了AtomicStampedReference。在原始的AtomicReference上给数据增加一个唯一标识,一旦引用发生了改变,那么就修改其唯一标识,对引用进行了唯一标识,不再仅仅判断最终的引用是否相等。

--案例:

--给话费低于20块钱的用户充值20块钱(注意:这里需要关注状态过程变量,需要使用AtomicStampedReference)。

public 

379551ef2accc7a15d375536c6037e0d.png

从打印结果来看,只有第一次低于19块钱才充值20元,随后就算再次低于19元都不充值。因此当我们需要关注引用的状态变化过程,那么就可以使用AtomicStampedReference来做数据安全访问。

--底层实现:

由于AtomicStampedReference中对引用增加了一个唯一标识,因此使用了一个内部类Pair来封装引用和唯一标识:

public 

以前使用value来存储引用,现在对value进行了一层封装,变成了Pair:

private volatile Pair<V> pair;

那么getReference和getStamp方法,当然就是获取Pair上的reference和stamp:

public 

接下来看下compareAndSet方法:

public 

根据源码可以知道,对Pair的reference和stamp进行修改有三种情况:

[1] 当reference的期望值和实际值相等,并且stamp期望值和实际值也相等。
expectedReference == current.reference && expectedStamp == current.stamp
[2] 当reference的新值和实际值相等,并且stamp的新值也和实际值相等。
newReference == current.reference && newStamp == current.stamp
[3] Pair的CAS操作执行成功。
casPair(current, Pair.of(newReference, newStamp))

期望值和实际值相等,那么修改是固然的,而新值和实际值相等,也修改的想法也是可以,相当于没有修改,数据也是正确的。而对于第三种情况,则是比对当前线程拿到了的Pair和内存中的实际值是否相等,相等的话就修改。

--casPair的源码:

private 

可以发现,Pair的CAS操作和AtomicInteger的CAS操作是基本一致的,都是借助Unsafe工具类来通过偏移量直接操作属性值。

--主要接口:

// 获取Pair的reference

2.5. AtomicIntegerArray

相应的,对于整型数组的CAS操作,java提供了AtomicIntegerArray支持,如下:

public class AtomicIntegerArray implements java.io.Serializable {

类似的,AtomicIntegerArray无非就是对于IntegerArray进行了一层封装:

private final int[] array;

--主要接口:

// 获取数组第i个下标的元素

常用的接口记忆技巧:记住4个组合关键字get、increment、decrement、add,一个逻辑关键字add,其中方法的组成必须有get,剩下的increment、decrement、add和get进行搭配,如果get在前,那么就返回旧值,在后,就返回新值,比如:getAddIncrement(int i),就表示返回旧值,并将下标为i的元素加1。其他以此类推,最后还有记住一个万变不变的方法:compareAndSet,表示使用CAS操作修改数据,是最直观的CAS操作。这个技巧可以应用到所有的Atomic类中。

--案例:

public 

f4be04a59b8171f8b201c5e5561ae139.png

从数组的打印结果来看,每个元素都是10000,意味着AtomicIntegerArray确实维护了一个线程安全的IntegerArray。

--底层实现:

以get方法为例:

public 

通过checkedByteOffset方法找到第i个元素的偏移量,而getRow方法通过偏移量找到对应的元素值。

沿着偏移量的执行调用链,可以发现,计算偏移量的底层方法是byteOffset,如下:

private 

其中的bese值为IntegerArray的基地值(第一个元素相对数组所在地址的相对地址,可以理解为一个元素的偏移量):

private static final int base = unsafe.arrayBaseOffset(int[].class);

shift是偏移倍数:

private 

首先借助unsafe的arrayIndexSacle计算整型数组的每一个元素大小,就是4。接下来是判断sacle的奇偶性(通过按位与运算),由于基本数据类型所占的内存都是2的倍数,因此如果出现scale为奇数时,会抛出一个错误。最后借助Integer包装类上的numberOfLeadingZeros方法来计算scale的前导零个数。

--前导零计算

前导零指的就是将一个数转换为二进制后,从第一位开始计数0的个数,直到第一个不为0的数值。如一个int类型的4,二进制为:100。由于int类型占32bit,因此需要填充29个0,因此int类型的4的前导0个数就是29。

numberOfLeadingZeros方法由于传入的就是4,刚好前导零的个数为29,所以方法返回值为29,这里使用31去减29得到2,所以shift值就是2。

接着在byteOffset方法中,通过了位运算来计算偏移量。

(long) i << shift

当想要知道第6个元素的偏移量时,即i=6,那么i << shift的运算结果就是24,刚好是6个int类型数据的大小(4*6=24),最后在加上IntegerArray的基地值,不就是第6个元素的偏移量了吗?

--问题:为什么使用位运算计算呢,为何不直接使用java乘法计算呢?

这是出于性能的考虑,一个算法的实现,越接近C,那么效率就越高,而位运算就是一个接近C的计算。

--偏移量的计算过程图:

859707b920b3d8d371aa1dadc02262d1.png

计算前导零为了获得参与位运算的左移位数。

接下来继续看getRow方法:

private 

既然已经得到了偏移量,那么根据IntegerArray的地址,可以轻易的得到给定偏移量上的元素。

与AtomicIntegerArray类似的还有AtomicLongArray、AtomicReferenceArray。

2.6. AtomicIntegerFiledUpdater

AtomicIntegerFieldUpdater主要是让普通的变量都能享受CAS操作。

假设有一个变量,一开始没有声明为Atomic类型,但是在后续的代码编写中,发展这个变量需要被多个线程共享,而处于性能的考虑,不希望使用Synchronized,因此AtomicXXXFieldUpdater就显得格外有用了。但是使用AtomicXXXFieldUpdater的前提是属性是volitale修饰的。

--案例:

public 

428b985fa3b51a29788da445ce7c5dac.png

可以发现,无论执行多少次,score和allScore的值都是相等的,因此AtomicIntegerFieldUpdater确实可以将一个普通变量实现CAS操作。

AtomicIntegerFieldUpdater可以通过修改尽量少的代码来实现原子操作,这是该类最主要的作用。

类似的还有AtomicLongFieldUpdater、AtomicReferenceFieldUpdater。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值