附1:多线程并发方案的不足——响应式Spring的道法术器

本系列文章索引《响应式Spring的道法术器》
本篇内容是响应式流的附录。java

(如下接响应式流的1.2.1.1节,关于“CPU眼中的时间”的内容。请不要单独看这一篇内容,不然有些内容可能让你摸不着头脑 0..0)react

多线程的方式有其不完美之处,并且有些难以驾驭——git

1、耗时的上下文切换github

CPU先生不太乐意切换进程,每次换进程的时候都须要一个小时,由于每次切换进程的时候,办公桌上全部的资料(进程上下文)都要从新换掉。可是每一个进程都必须雨露均沾地照顾到,要不客户就不满意了。算法

操做系统部很贴心,进程任务里边又能够分红一批线程子任务,若是某个线程子任务在等待I/O和网络的数据,就先放一边换另外一个线程子任务去作,而不是切换整个进程。切换线程任务是一种相对轻量级的操做,由于不一样的线程任务的许多数据都是共享的,因此只须要更新线程相关的数据,不用彻底从新整理办公桌。即使如此也须要将近半小时的切换时间,以便可以“熟悉”任务内容。数据库

不过毕竟,CPU先生的工做逐渐充实起来了。如图:缓存

CPU堵塞-多线程

注意到图中CPU的时间条中深褐色的为上下文切换的时间,能够想见,高并发状况下,线程数会很是多,那么上下文切换对资源的消耗也会变得明显起来。何况在切换过程当中,CPU并未执行任何业务上的或有意义的计算逻辑。服务器

这里咱们没有关注线程的建立时间,由于应用一般会维护一个线程池。须要新的线程执行任务的时候,就从线程池里取,用完以后返还线程池。相似的,数据库链接池也是一样的道理。网络

经过这个图,咱们能够获得估算线程池的大小的方法。为了让CPU可以刚好跑满,Java Web服务器的最佳工做线程数符合如下公式:多线程

( 1 + IO阻塞时间 / CPU处理时间 ) * CPU个数

2、烦人的互斥锁

因为讨生活不易,CPU先生练就了“左右互搏术”,对外就说“超线程”,特有逼格。对于有些任务,工做效率几乎能够翻倍了,不过计算题多的时候,就不太行了,毕竟虽有两只手却只有一个脑子啊。并且今时不一样往日,如今的CPU早就再也不单打独斗了,好比CPU先生的办公室就还有3个CPU同事,4个CPU核心组成一个团队提供计算服务。

也正由于如此,涉及到线程间共享的数据方面,互相之间合做时不时出现冲突。

做为“贴身秘书”的一级缓存,每一个CPU核心都会配置一个。CPU先生的秘书特别有眼力价,能随时准备好80%的数据给CPU先生使用,这些数据资料都是找内存组的同事要的。要的时候会让内存组的同事复印一份数据资料,拿来交给CPU先生。CPU先生算好以后的结果,它会再拿给内存组的同事。

有时候两个执行不一样线程任务的CPU几乎同时让秘书来内存组要复印的数据资料,各自算好以后,再返回给内存组的同事更新,因而就会有一个结果会覆盖另外一个结果,也就是说先算完的结果等于白算了。以下图“a)无锁”所示,线程1中的值已更新,但还没有通知到内存,也就是说线程2拿到的是过期的值,结果显然就不对了。因此,对于多线程频繁变化的共享数据,CPU先生会额外留个心眼,让秘书拿数据资料的时候,顺便告诉内存把这个数据先锁起来。直到算完的数据再拿回给内存的时候,才让它把锁解开。上锁期间,别人不能拿这个数据。其实不光是防别的CPU,CPU先生本身处理的多个线程之间都有可能出现这种冲突的状况,毕竟CPU先生虽然算题快,记性却很是差。

如此,下图“b)加锁”(怎么读起来感受这么顺嘴0_0)这种方式就可以保证数据的一致性了。

附1:多线程并发方案的不足——响应式Spring的道法术器

这种方式叫作互斥锁。CPU先生粗略算了一下,每次加锁或解锁,大概会花费它1分钟左右的时间,可是可能被加锁的数据就是为了花几秒算个自增。这锁能起到做用还好,更糟心的是,许多时候,线程可能没有不少,撞到同一份数据资料的几率其实很低,可是以防万一,还不得不加锁,白白增长工做时间。

数据被加锁的时候让CPU先生很烦躁,谁知道它啥时候才会解锁,只能让“贴身秘书”时不时去看看了,锁解开以前这个线程就被阻塞住了。

更过度的是CPU先生遇到的一次死锁事件,至今令它心有余悸。那天它让“贴身秘书”找内存要数据,并让内存把数据锁起来。拿到手才发现数据有A、B两部分,它们只拿到了A。CPU先生让秘书去要B,问了好屡次,一直都被锁着。后来才知道,另一个CPU的秘书几乎同时拿到并锁了B,也一直在等A。WTF!

3、乐观不起来的乐观锁

CPU先生与其余众CPU一合计,不是频繁改动的数据就不加锁了,你们在往回更新数据的时候先看看有没有被人动过不就得了。以下图,

附1:多线程并发方案的不足——响应式Spring的道法术器

执行线程2的时候取走的是i==1,算完回来要更新的时候,发现i==2了,那刚才算的不做数,重新取值再算一遍。这种“比较并交换(Compare-and-Swap,CAS)”的指令是原子的,“现场检查现场更新”,不会给其余线程以可乘之机。

乐观状况下,若是线程很少,互相冲突的概率不大的话,不多致使阻塞状况的出现,既确保了数据一致性,又保证了性能。

但乐观锁也有其局限性,在高并发环境下,若是乐观锁所保护的计算逻辑执行时间稍微长一些,可能会陷入一直被别人更新的状态,每每性能还不如悲观锁。因此高并发且数据竞争激烈的状况下,乐观锁出场率并不高。

若是在高并发且某些变量容易被频繁改动的状况下,CAS比较失败并从新计算的几率就高了。咱们不妨作个实验,扩展一个具备CAS算法的AtomicInteger

MyAtomicInteger.java

public class MyAtomicInteger extends AtomicInteger {
    private AtomicLong failureCount = new AtomicLong(0);

    public long getFailureCount() {
        return failureCount.get();
    }

    /**
     * 从如下两个方法 inc 和 dec 能够看出 Atomic* 的原子性的实现原理:
     * 这是一种乐观锁,每次修改值都会【先比较再赋值】,这个操做在CPU层面是原子的,从而保证了其原子性。
     * 若是比较发现值已经被其余线程变了,那么就返回 false,而后从新尝试。
     */
    public void inc() {
        Integer value;
        do {
            value = get();
            failureCount.getAndIncrement();
            //try {
            //    TimeUnit.MILLISECONDS.sleep(2);
            //} catch (InterruptedException e) {
            //    e.printStackTrace();
            //}
        } while (!compareAndSet(value, value + 1));
    }

    public void dec() {
        Integer value;
        do {
            value = get();
            failureCount.getAndIncrement();
            //try {
            //    TimeUnit.MILLISECONDS.sleep(2);
            //} catch (InterruptedException e) {
            //    e.printStackTrace();
            //}
        } while (!compareAndSet(value, value - 1));
    }
}

测试

@Test
public void testCustomizeAtomic() throws InterruptedException {
    final MyAtomicInteger myAtomicInteger = new MyAtomicInteger();
    // 执行自增和自减操做的线程各10个,每一个线程操做10000次
    Thread[] incs = new Thread[10];
    Thread[] decs = new Thread[10];
    for (int i = 0; i < incs.length; i++) {
        incs[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                myAtomicInteger.inc();
            }
        });
        incs[i].start();
        decs[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                myAtomicInteger.dec();
            }
        });
        decs[i].start();
    }

    for (int i = 0; i < 10; i++) {
        incs[i].join();
        decs[i].join();
    }

    System.out.println(myAtomicInteger.get() + " with " + myAtomicInteger.getFailureCount() + " failed tries.");
}

我电脑上跑出的结果是:

0 with 223501 failed tries.

总共20万次操做,失败了22万屡次。

高并发状况下,若是计算时间比较长,那么就容易陷入老是被别人更新的状态,而致使性能急剧降低。好比上例,若是把MyAtomicInteger.java中sleep的注释打开,再次跑测试,就会出现久久都没法执行结束的状况。

4、莫名躺枪的指令重排

对于CPU先生来讲,没有多线程的日子挺美好的。有了多线程以后,老是会莫名踩坑,好比一次接到的任务是有两个线程子任务:

// 一个线程执行:
a = 1;
x = b;

// 另外一个线程执行:
b = 1;
y = a;

a, b, x, y 的初始值都是0。

CPU先生的注意力只在当前线程,而历来记不住切换过来以前的线程作了什么,作到哪了。客户也知道CPU先生记性很差,并且执行到半截可能会切到另外一个线程去,对于可能出现的结果也有心理准备:

  1. x==0 && y==1:可能的执行顺序好比:a = 1; x = b; b = 1; y = a
  2. x==1 && y==0:可能的执行顺序好比:b = 1; y = a; a = 1; x = b
  3. x==1 && y==1:可能的执行顺序好比:a = 1; b = 1; x = b; y = a

无外乎这三种结果嘛。结果CPU先生给算出了 x==0 && y==0!出现这种结果的缘由只能是 x = b; y = a; 是在 a = 1; b = 1;这两句以前执行的。Buy why?

事实上CPU和编译器对于程序语句的执行顺序还有会作一些优化的。好比第一个线程的两句程序,相互之间并没有任何依赖关系,对于当前线程来讲,调整执行顺序并不会影响逻辑结果。好比执行第一句的时候a的结果CPU先生和“秘书”一级缓存都没有,这时候会跟二级缓存甚至内存要,可是CPU先生闲着难受,等待a的值的工夫就先把x = b执行了。第二个线程也有可能出现一样的状况,从而致使了第四种结果的出现。

对于没有依赖关系的执行语句,编译期和CPU会酌情进行指令重排,以便优化执行效率。这种优化在单线程下是没问题的,可是多线程下就须要在开发程序的时候采起一些措施来避免这种状况了。

CPU先生:怪我咯?~

5、委屈的内存

多线程的处理方式对于解决客户的高并发需求确实很给力,虽然许多线程是处于等待数据状态,但总有一些线程能让CPU先生的工做饱和起来。客户对计算组CPU们吃苦耐劳的工做态度赞扬有加。

内存组则有些委屈了,多线程也有它们的很大功劳,CPU的“贴身秘书”只是保管一小部分临时数据,绝大多数的数据仍是堆在内存组。多一个线程就得须要为这个线程准备一起“工做内存”,虽然划拨给内存组的空间愈来愈大,可是若是动不动就开成百上千个线程的话,面对堆积如山的数据还老是不太够。

6、多线程并不是银弹

搞IT的若是不套用一句“XXX并不是银弹”老是显得逼格不够,因此我也郑重其事地说一句“多线程并不是银弹”。以上概括下来:

  • 高并发环境下,多线程的切换会消耗CPU资源;
  • 应对高并发环境的多线程开发相对比较难,而且有些问题难以在测试环境发现或重现;
  • 高并发环境下,更多的线程意味着更多的内存占用。
相关文章
相关标签/搜索