走进并发世界

并发计算的由来

20世纪中期以来,硬件的快速发展也使得单核CPU的主频逐步逼近极限,多核CPU架构称为了一种必然的技术趋势。所以,多线程并发计算便显得越来越重要。线程并发的一个重要应用场景就是服务端编程。

在了解并发之前,我们可能需要先明白为什么需要并发?并发有什么作用?首先我们先看下面两个例子:

图像处理:一张1024*768像素的图片,包含多达78万6千多个像素,我们即使将所有的像素都遍历一遍,也得花大量的时间,更何况,图像处理会涉及到大量的矩阵计算,矩阵的规模和数量都非常大,这么大密集的计算,我们该怎么去解决?
淘宝双十一:在淘宝”双十一“一天内,支付宝核心数据库集群处理了41亿个事务,执行了285亿次SQL,生成了15TB日志,访问了1931亿次内存数据块,13亿个物理读。如此密集的访问,我们又该如何去解决?
很明显,对于上面的第一个例子,我们使用单核CPU单线程也能解决,只不过会花费巨大的时间,但是对于淘宝双十一的处理,一天的时间处理这么多的数据,依目前的计算机水平,恐怕任何一台单机都难以胜任,因此,并发计算也就自然成了唯一出路。

摩尔定律的消失

摩尔定律是由英特尔创始人之一戈登·摩尔提出来的。其内容为:集成电路上可容纳的电晶体(晶体管)数目,约每隔24个月便会增加一倍,英特尔首席执行官大卫·豪斯后来提出:预计18个月会将芯片性能提高一倍(即更多的晶体管使其更快)。

说的直白点,就是每隔18个月到24个月,我们的计算机性能就能翻一倍。

但是,摩尔定律并不是一种自然法则或者物理定律,它只是基于人为观测数据后,对未来的预测。摩尔定律的有效性一直持续了半个世纪,直到2004年,Intel宣布将4GHz芯片的发布时间推迟到2005年,在2004年秋季,Intel宣布彻底取消4GHz计划。因此,摩尔定律在CPU的计算性能上可能已经失效。虽然,现在Intel已经研制出了4GHz芯片,但可以看到,在近十几年的发展中,CPU主频的提升已经明显遇到了一些暂时不可逾越的瓶颈。摩尔定律的失效,意味着很难在单个CPU的技术上取得重大的性能提升了,但是这个时候多核CPU产生了,我们不再追求单核CPU的计算速度,但是我们可以将多个独立的计算单元整合到一个CPU中,也就是我们所说的多核CPU,摩尔定律在CPU的计算性能上已经失效了,CPU开始向多核发展。我们可以预测,在未来的每过18~24个月,CPU的核心数便会翻一倍,那么计算机的性能也会提高一倍。

所以,如何让多个CPU有效并且正确地工作也就成为了一门技术,比如:多线程间如何保证线程安全,如何正确理解线程间的无序性、可见性,如何尽可能提高并发程序的设计,又如何将串行程序改造为并发程序等等,都是程序员在软件设计中必须考虑的问题。

串行、并发与并行

在讲解并发之前先理解下串行、并发与并行之间的关系。假设目前有3件事需要处理,每件事情所需的时间包括两部分(处理时间与等待时间),假设完成这些事情所需的时间分别为:事情A(处理5分钟,等待10分钟),事情B(处理3分钟,等待7分钟),事情C(处理8分钟,无等待)。那么我们有3种方式来完成这几件事情。如下图所示:采用串行的方式逐一完成所有事情,只需一人完成耗时需要33(15+10+8)分钟。采用并发的方式,完成事件A后在其等待事件内完成事件B,那么在这个过程中只需一人总耗时为16(5+3+8)分钟。采用并行的方式,这需要3个人分别同时去处理这三件事总耗时是15分钟。

image

可见,并发是串行的对立面,一般情况下并发往往能够提高处理效率,而并行是并发的特例。从软件角度来说,并发就是在一段时间内以交替的方式去完成多个任务。从硬件的角度来说,在一个处理器一次只能够运行一个线程的情况下,由于处理器可以使用时间片来实现同一段时间内运行多个线程,因此一个处理器就可以实现并发。而并行则需要多个处理器在同一时刻各自运行一个线程来实现。

2 并发的缺点

并发在提高系统的吞吐率以及充分利用多核处理器资源的同时也会带来自身伴随的风险和问题。

2.1 线程安全问题

多个线程共享数据的时候,如果没有采用相应的并发访问控制措施,那么就会产生数据一致性的问题,例如读取脏数据、丢失更新等。

public class RequestIDGenerator {
    private int sequence = 0;
    private final static RequestIDGenerator INSTANCE = new RequestIDGenerator();

    public static RequestIDGenerator getINSTANCE() {
        return INSTANCE;
    }

    public void newSequence() {
        try {
            System.out.println(Thread.currentThread().getName() + ": " + sequence);
            Thread.sleep(50);
            sequence++;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class Demo {
    public static void main(String[] args) {
        int numberOfThread = 3;
        Thread[] threads = new Thread[numberOfThread];
        for (int i = 0; i < threads.length; i++) {
            new WorkerThread(i, 50).start();
        }
    }
}

class WorkerThread extends Thread {
    private int count;

    public WorkerThread(int id, int count) {
        super("worker_" + id);
        this.count = count;
    }

    @Override
    public void run() {
        RequestIDGenerator instance = RequestIDGenerator.getINSTANCE();
        while (count-- > 0) {
            instance.newSequence();
        }
    }
}

在上面demo中,不同的线程“拿到”了重复的sequence。多个线程访问共享变量时,由于任何一个线程在访问共享变量的过程中都可以切换到其他的线程上。而其他线程一旦把共享变量的数据改变了,再切换回来时,错误数据就产生了。于是,我们很容易联想到一种保障线程安全的方法——将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程发访问结束后其他线程才能对其进行访问。最简单的就是在改变共享数据的访问方法上加上synchronized关键字。

2.2 上下文切换

我们知道,在单处理器上也能够以多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程。实际上是通过时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个线程由于时间片用完或者需要等待等原因暂停其运行时,另外一个线程就可以被线程调度器选中开始或继续运行。这个过程就叫作线程上下文切换。
这种方式意味着线程在切换的时候,需要在内存中保存或恢复相应的线程进度信息,这个进度信息被称为上下文。从性能方面上看,上下文切换有其不容小觑的开销,过于频繁地切换反而无法发挥出并发的优势。因此,减少上下文切换可以降低性能开销。通常可采用ConcurrentHashMap的锁分段技术,CAS算法,减少不必要的线程等方式。

2.3 线程活性故障

使用线程是为了提高处理效率,理想情况下希望线程一直处在RUNNABLE状态。但事实上由于程序自身缺陷故障或处理器资源稀缺会导致一个线程处于非RUNNABLE状态。由于程序自身缺陷故障或处理器资源稀缺会导致一个线程处于非RUNNABLE状态,或者线程处于RUNNABLE状态但执行的任务一直无法进展的现象称之为线程活性故障。

常见的活性故障包括以下几种:

  • 死锁。死锁产生的典型场景是一个线程X持有资源A的时候等待另一个线程释放资源B,而另一个线程Y持有线程B的时候却等待线程X释放资源A。死锁的外在表现是当前线程的生命周期永远处于非RUNNABLE状态而使其任务无法继续。
public class Demo {

    private static Object lock_A = new Object();
    private static Object lock_B = new Object();

    public static void main(String[] args) {
        deadLock();
    }

    public static void deadLock() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock_A) {
                    try {
                        System.out.println("get Resource_1 lock_A ");
                        Thread.sleep(30);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lock_B) {
                        System.out.println("get resource_1 lock_B ");
                    }
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock_B) {
                    System.out.println("get Resource_2 lock_B ");
                    synchronized (lock_A) {
                        System.out.println("get resource_2 lock_A ");
                    }
                }
            }
        }).start();
    }
}

执行结果:

get Resource_1 lock_A 
get Resource_2 lock_B 

通常可以采用以下几种方式规避死锁:

  • 使用一个粗粒度的锁代替多个锁
  • 避免一个线程同时获得多个锁;
  • 相关线程使用全局统一的顺序申请锁
  • 针对那些不可能实现按序加锁并且锁超时也不可行的场景使用死锁检测
  • 使用ReentrantLock.tryLock(long,TimeUnit)来申请锁

  • 活锁。外在表现为线程可能处于RUNNABLE状态,但线程所要执行的任务没有丝毫进展。在试图进行死锁故障恢复可能导致活锁。

你可能感兴趣的:(走进并发世界)