信息化 频道

面向多核处理器实现高性能中间件

    【IT168 信息化

    1、引言

    根据IDC新近发布的预测,2009年几乎所有服务器、PC和笔记本电脑产品将全面实现多核化。随着这些硬件产品在多核技术上的逐渐就绪,如今整个IT业界面临的最大挑战就是如何将原先仅用于高端应用开发的并行编程方式推广到所有软件开发的过程中,以打造出更多支持多线程并行化运行的应用软件——尤其是处于关键地位的中间件,全面释放多核处理器的性能潜力。

    目前的中间件产品,尽管大部分都是基于多线程或者多进程的,但是由于传统单线程编程的思维定势以及开发语言和工具的限制,使得软件应用限制了多核处理器性能的发挥。对此,微软首席研究官Craig Mundie称,软件行业所面临的核心挑战就是对多核处理器的编程。相比 C/C++ 程序员而言,利用 Java 编写多线程应用已经简单了很多。然而,多线程程序想要达到高性能仍然不是一件容易的事情。当CPU 进入多核时代之后,软件的性能调优就不再是一件简单的事情。没有并行化的程序在新的硬件上可能会运行得比从前更慢。

    2、JSR-166并发包

    自从Java发布以来,就提供了Thread类和低层次语言结构,例如synchronized、wait、notify和volatile等开发并发应用程序。但是,用这些特性来构建并发应用程序并不是简单的事情。开发者要面对以下的挑战:

    线程死锁

    线程竞争

    编程复杂,容易出错

    Java5.0提供了JSR-166(Java concurrency utilities),其提供大范围并发编程的重要类库和功能,大大简化了开发并发应用。

    一些JSR-166的最显著的特点是:

    使用标准的接口和框架自定义线程子系统

    提供异步任务执行和调用,很方便的可以使用Future设计模式

    提供非同步的高性能数据结构,如ConcurrentHashMap、ConcurrentLinkedQueue等

    提供线程同步器,例如Semaphore, Mutexe, Barrier, Latche和 Exchanger等。

    提供非阻塞的原子变量,如AtomicInteger等

 

    3、协同中间件并发性能提升实践

    协同对其中间件产品升级过程中大量应用了JSR-166所提供的类库和并发功能,并基于其开发了更高层次的类库,获得了深刻的应用经验,下面将就此进行讨论,以期抛砖引玉。

    3.1使用原子变量

    通常在中间件产品的实现中,有很多情况需要实现多线程使用的计数器或随机数生成器作为ID,这时一般会使用锁来保护共享变量。这样会使得多线程对锁竞争,造成大量线程处于等待状态,浪费了cpu资源,损害了吞吐量。

    volatile 变量虽然可以使用比同步更低的成本存储共享变量,但它只可以保证其他线程能够立即看到对 volatile 变量的写入,无法保证读-修改-写的原子性。因此,volatile 变量无法用来实现正确的计数器和随机数生成器。

    在这种情况下,可以利用JSR-166包中的原子变量,实现更高效的计数器和随机数生成器。这些原子变量包括 AtomicInteger、AtomicLong、AtomicBoolean 以及数组 AtomicIntergerArray、AtomicLongArray等类型。

    下面是计数器的传统实现:

    public final class Counter {

    private long value = 0;

    public synchronized long getValue() {

    return value ++;

    }

    }

    下面则是采用原子变量实现的计数器:

    public final class Counter {

    private AtomicLong value = 0;

    public long getValue() {

    return value. getAndIncrement();

    }

    }

    3.2使用并发数据结构

    Collection 框架曾为 Java 程序员带来了很多方便,但在多核时代,Collection 框架变得有些不大适应。多线程之间的共享数据总是存放在数据结构之中,如 Map、Stack、Queue、List、Set 等。 Collection 框架中的这些数据结构在默认情况下并不是多线程安全的,也就是说这些数据结构并不能安全地被多个线程同时访问。 JDK 通过提供 SynchronizedCollection 为这些类提供一层线程安全的接口,它是用 synchronized 关键字实现的,相当于为整个数据结构加上一把全局锁保证线程安全。

    JSR-166中提供了更加高效 collection,如 ConcurrentHashMap/Set, ConcurrentLinkedQueue, ConcurrentSkipListMap/Set, CopyOnWriteArrayList/Set .这些数据结构是为多线程并发访问而设计的,使用了细粒度的锁和新的 Lock-free 算法。除了在多线程条件下具有更高的性能,还提供了如 put-if-absent 这样适合并发应用的原子函数。

    使用并发数据结构的优势:

    使用者不需要锁

    并发性能优异,特别适合多核多线程应用

    在并发情况下,更容易使用,不容易出错
 

    3.3使用线程池—Executor

    大多数并发应用程序是以执行任务(task)为基本单位进行管理的。

    “为每个任务创建一个线程,当任务完成时撤消对应的线程”是一种常见的线程生命周期管理策略,但这种方法存在明显的缺陷:①线程的创建需要一定的时间,给任务请求的响应带来延迟,线程的创建和撤消也给操作系统带来额外的管理负担,若这种“创建和撤消”频繁,则将明显增加系统的额外开销。②如果创建的线程数量多于系统中的处理器数,则这些线程不但占用了更多内存,而且加剧了线程对处理器的竞争,同时也增加了垃圾回收站的压力。

    一种更加合理的使用多线程的方法是使用线程池(Thread Pool)。 JSR-166提供了一个灵活的线程池实现:Executor 框架。这个框架可以用于异步任务执行,而且支持很多不同类型的任务执行策略。它还为任务提交和任务执行之间的解耦提供了标准的方法,为使用 Runnable 描述任务提供了通用的方式。 Executor 的实现还提供了对生命周期的支持和 hook 函数,可以添加如统计收集、应用程序管理机制和监视器等扩展。

    在线程池中执行任务线程,可以重用已存在的线程,免除创建新的线程。这样可以在处理多个任务时减少线程创建、消亡的开销。同时,在任务到达时,工作线程通常已经存在,用于创建线程的等待时间不会延迟任务的执行,因此提高了响应性。通过适当的调整线程池的大小,在得到足够多的线程以保持处理器忙碌的同时,还可以防止过多的线程相互竞争资源,导致应用程序在线程管理上耗费过多的资源。

    Executor 默认提供了一些有用的预设线程池,可以通过调用 Executors 的静态工厂方法来创建。

    newFixedThreadPool:提供一个具有最大线程个数限制的线程池。

     newCachedThreadPool:提供一个没有最大线程个数限制的线程池。

    newSingleThreadExecutor:提供一个单线程的线程池。保证任务按照任务队列说规定的顺序(FIFO,LIFO,优先级)执行。

    newScheduledThreadPool:提供一个具有最大线程个数限制线程池,并支持定时以及周期性的任务执行。

    下面的代码演示了线程池的使用:

    Executor executor = Executors.newFixedThreadPool(10);

    Runnable task = new Runnable() {

    public void run() {

    System.out.println("task over");

    }

    };

    executor.execute(task);

    其实上述这些工厂方法返回的Executor 都是ThreadPoolExecutor()类的常用实例,能满足大部分线程池的应用需求。ThreadPoolExecutor()类的构造方法,还能用于更专门的线程池定制:

    public ThreadPoolExecutor(

    int corePoolSize,

    int maximumPoolSize,

    long keepAliveTime,

    TimeUnit unit,

    BlockingQueue workQueue,

    RejectedExecutionHandler handler

    )

    其中:

    corePoolSize:核心池的大小,即池中(即使没有任务执行)必须保持的线程数。

    maximumPoolSize:池中允许的最大线程数。当工作队列(workQueue)充满后添加新线程时所限制线程数的上界。

    keepAliveTime:当池中的线程数大于核心池尺寸时,空闲线程被回收前等待新任务的最长存活时间。

    unit:keepAliveTime 参数的时间单位。

    workQueue:任务队列。保持着由execute 方法提交的、因池中暂时没有空闲线程为之服务而阻塞的Runnable 任务。

    handler:异常处理程序。

    下面这段代码就定制了一个线程池:线程数最少为5,最多为10,多余线程做多存活时间1分钟,队列任务数100。

    Executor pool =new ThreadPoolExecutor( 5, 10, 60, TimeUnit.SECONDS,

    new ArrayBlockingQueue(100) );
 

    3.4使用异步任务执行—Future

    Executor 任务执行框架不仅支持线程池的建立和管理,而且为描述任务和执行提供了通用方式。

    Executor 框架以Runnable 作为其接受的任务的基本描述形式。但诸如完成数据库查询、获取网络资源、进行复杂计算等工作的任务都会引起延迟,并需要返回结果,而Runnable 不支持结果的返回,因此JSR-166提供了Callable 接口:

    public interface Callable{

    V call() throws Exception

    }

    该接口的实现者必须实现一个不带任何参数并返回V 类型值的call 方法。进一步,JSR-166的Future 接口抽象异步计算结果的获取,提供判定计算是否完成的isDone方法,其get 方法负责等待计算的完成并获取结果。

    在这些接口的基础上,Java 并发包中的FutureTask 类提供了对Future 接口的基本实现,仅在计算完成时才能获取结果,如果计算尚未完成,则自动阻塞get 方法。因为FutureTask 也实现了Runnable 接口,所以可使用FutureTask 包装Callable 或Runnable 对象,并将FutureTask 提交给执行框架去异步执行,再用get 方法等待任务的完成。

    下面这段代码演示了Callable和FutureTask的使用:

    Callable func = new Callable(){

    public Integer call() throws Exception { // 实现call方法,执行异步任务

    System.out.println("inside callable");

    Thread.sleep(1000);

    return new Integer(8);

    }

    };

    FutureTask futureTask = new FutureTask(func);

   Thread newThread = new Thread(futureTask); //启动线程执行异步任务

   newThread.start();

    try {

    System.out.println("blocking here");

    Integer result = futureTask.get(); //检查异步任务的返回结果

    System.out.println(result);

    } catch (InterruptedException ignored) {

    } catch (ExecutionException ignored) {

    }

    3.5 尽可能减小锁的粒度

    粗粒度的全局锁在保证线程安全的同时,也会损害应用的性能。仔细考虑锁的粒度在构建高可扩展 Java 应用时非常重要。当 CPU 个数和线程数较少时,全局锁并不会引起激烈的竞争,因此获得一个锁的代价很小(JVM 对这种情况进行了优化)。随着 CPU 个数和线程数增多,对全局锁的竞争越来越激烈。除了一个获得锁的 CPU 可以继续工作外,其他试图获得该锁的 CPU 都只能闲置等待,导致整个系统的 CPU 利用率过低,系统性能不能得到充分利用。当我们遇到一个竞争激烈的全局锁时,可以尝试将锁划分为多个细粒度锁,每一个细粒度锁保护一部分共享资源。通过减小锁的粒度,可以降低该锁的竞争程度。
 

0
相关文章