并发问题的思考

    前些日子,同事发来一个Main函数,问:怎么保证高并发场景下计算结果的正确性。

    我们来举个例子:

/**
 * @author admin
 * @version $Id: Counter.java, v 0.1
 */
public class Counter {

    /** 共享变量 */
    public volatile static int count = 0;

    /**
     * @param args  args
     * @throws InterruptedException InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        // TODO Auto-generated method stub

        for (int i = 0; i < 100000; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println("Tid:" + Thread.currentThread().getName()
                                       + ",结果:Counter.count=" + Counter.count);

                    count++;
                }
            });
            thread.start();
        }

        System.out.println("运行结果:Counter.count=" + Counter.count);
    }
}

    很明显这不会得到正确的结果,因为volatile修饰符并不保证操作的原子性,经管它申明了count字段不保留线程副本。对于 count++; 这样的表达式,它其实包含了三个步骤:读取count值,count加1,对count赋值。volatile关键字只能保证我读到的count值是最新的。

    我们对上面的程序修改一下,加入并发判断:

    int tmp;
    while (true) {
        tmp = count;
        if (tmp == count) {
            tmp = count + 1;
            if (tmp - 1 == count) {
                count = tmp;
                return;
            }
        }
    }

    局部变量是线程安全的,两个if判断来防止当前读到的count改变,否则重试。但我们惊讶的发现,结果仍是不正确的,细想之后才顿悟:其实在瞬时拉起10W个线程这样的并发量下,两重并发判断能起到的作用微乎其微。

    如果在第二重if后加个锁会怎么样呢?而且,if (tmp - 1 == count) 这样的if判断效率太低,这么高的并发量,哪怕是慢一点点结果也会有些许差异吧。怀着这样的想法,我们再对上面的程序修改一下:

    final Object lock = new Object();
    for (int i = 0; i < 100000; i++) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Tid:" + Thread.currentThread().getName()
                                   + ",结果:Counter.count=" + Counter.count);

                int tmp, total;
                while (true) {
                    tmp = count;
                    if (tmp == count) {  // (1)
                        total = count + 1;
                        if (tmp == count) {  // (2)
                            synchronized (lock) {
                                count = total;
                            }
                            return;
                        }
                    }
                }
            }
        });
        thread.start();
    }

    结果仍旧是不正确的,而且效率更低、结果值偏差的更大。为什么呢?第一,当发现count改变后,我们让它自旋尝试重新计算,换言之,线程任务并没有少一个,并发量并没有下来;第二,加了同步锁意味着,如果到当前线程竞争不到锁资源则阻塞。假设现在100个线程刚好走到(2)号if并且不等式通过了,这个时候它们的total变量值都是一样的,然后它们之中某个线程竞争到锁、执行 count = total; 成功、释放锁,其它线程在继续竞争锁资源时,它们的total值还是一样的,但此时count值已经改变。

※ 通常的fail-fast思想要有一定的补偿机制,要么用户发起要么程序消化,本篇为了简单暂时不考虑这种方案。

※ AtomicInteger或Unsafe能解决这类问题,它们的底层是内存屏障+CAS算法,但本篇是想讨论一个纯粹的模拟并发的问题,暂时先不考虑这种方式。而且Unsafe太危险了,线上环境我们很少使用到它。

    好吧,加锁是错误的,我们尝试改变重试策略,以时间换空间,并且删除打印语句(因为它其实很慢),将并发量提至20W。程序修改如下:

    for (int i = 0; i < 200000; i++) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {

                int tmp, total;
                while (true) {
                    tmp = count;
                    if (tmp == count) {
                        total = count + 1;
                        if (tmp == count) {
                            count = total;
                            return;
                        } else {
                            try {
                                Thread.sleep(10);
                            } catch (InterruptedException e) {
                                // ~什么都不做
                            }
                        }
                    } else {
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            // ~什么都不做
                        }
                    }
                }
            }
        });
        thread.start();
    }

    多次运行后,结果值虽然仍是错的,但偏差值已经明显缩小。

    现在我们思考,这个问题的本质是什么?我们发现,这个例子其实模拟了一种高并发场景(因为for循环很快),在某个时间线内瞬间拉起20W个线程来统计count值,但它其实是错误的。我们在任务体内做的是加1和赋值,而实际上我们真正的需求,是异步计算、同步赋值!所以我们无论怎么做,在无法异步转同步的前提下,如果不减少并发量,最后的结果是难以保证的。而且你会发现,异步计算的效率比同步更低、开销更大、代价更高,所以它是一个错误的场景,又是一个很好的场景。在这个例子里,如果我只是for循环20W次来执行 count++;,只需要16毫秒,而拉起20W个线程异步计算则至少16秒以上,就是说前后差了一千倍,但如果把count看做一种分布在运算网络上的数据呢?

    我想说明的是:很多时候,并发计算是有瓶颈的,多个线程争夺一份资源,无论再怎么增加线程,效率是不会提高的,因为资源只有一份。我们可以将一份资源分成多份,以减低单个资源上的并发度,这也是我们通常的做法:以空间换时间。

import java.util.Objects;

/**
 * @author admin
 * @version $Id: Counter.java, v 0.1
 */
public class Counter {

    /** 资源数组 */
    public volatile static int count[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 };

    /**
     * @param args  args
     * @throws InterruptedException InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {

        long begin = System.currentTimeMillis();
        System.out.println("开始执行");

        // 加volatile避免副本拷贝
        // 引进安全的局部变量
        // 加预期值比较
        // 同步阻塞,不仅更慢,结果偏差的更大
        // 自旋并没有降低并发度

        // final Object lock = new Object();
        final int factor = count.length - 1;
        for (Integer i = 0; i < 200000; i++) {
            // 相比于取模运算,&运算更加高效
            // 当数组长度为2的n次幂时,碰撞的几率越小,数据在数组上分布就越均匀。上面我们将数组长度设为16,
            final int index = Objects.hashCode(i) & factor;
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    // System.out.println("Tid:" + Thread.currentThread().getName()
                    //     + ",结果:Counter.count=" + Counter.count);

                    int tmp, total;
                    while (true) {
                        tmp = count[index];
                        if (tmp == count[index]) {
                            total = tmp + 1;
                            if (tmp == count[index]) {
                                // synchronized(lock){
                                // 将元素均匀的分布在16等分的数组中,这样单个位置上的并发度变为原先的16分之1
                                count[index] = total; 
                                // }
                                return;
                            } else {
                                // try {
                                //     Thread.sleep(10);
                                // } catch (InterruptedException e) {
                                //     // ~什么都不做
                                // }
                            }
                        } else {
                            // try {
                            //     Thread.sleep(10);
                            // } catch (InterruptedException e) {
                            //     // ~什么都不做
                            // }
                        }
                    }
                }
            });
            thread.start();
            // thread.join();  // 异步转同步
        }

        Thread.sleep(3000); // 主线程延时3秒,避免子线程未执行完时,主线程回收

        // 汇总
        int result = 0;
        for (int j : count) {
            result += j;
        }

        System.out.println("执行耗时:" + (System.currentTimeMillis() - begin));
        System.out.println("运行结果:" + result);
    }
}

    如果我们再将并发量调至100W,甚至1000W呢?除了需要增加资源数组的长度,可以把fail-fast+补偿机制思想用起来,因为单个位置的并发度已经达到上限。当然,这种情况的模拟已经和实际相差甚远,百万级、千万级的并发量考量的不是单机的处理能力,而是应用的整体架构、服务器环境和业务是否具有超并发的合理性。

转载于:https://my.oschina.net/duofuge/blog/1528214

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值