本文从操作系统层面和硬件层面介绍了并发编程需要了解的一些底层内容,知道底层知识对理解并发和写好多线程程序有较大的帮助,做到知其然并知其所以然。

操作系统

任务类型

任务可以划分为I/O消耗性和处理器消耗型:

  • I/O消耗型是指进程大部分时间用来提交I/O请求或者等待I/O请求,这样的进程经常处于可运行状态,但是通常都是运行短短的一会儿,因为他在等待更多的I/O请求时最后会阻塞。
  • 处理器消耗型是指进程把大多时间都用在执行代码上。除非被抢占,否则他们通常一直处于不停地运行状态,因为他们没有太多的I/O请求,对于这类处理器消耗型的进程调度策略往往是尽量降低它的调度频率,而延长其运行时间。

调度策略通常要在两个矛盾的目标中间寻找平衡:进程响应迅速和最大系统利用率(吞吐量),实现实时是以牺牲系统的吞吐率为代价的,因此实时性越好,系统吞吐率就越低。Linux更倾向于优先调度I/O消耗性进程。

任务调度

运行队列(run queue)中就是那些已准备好运行、正等待可用CPU的轻量级进程。如果准备运行的轻量级进程数超过系统所能处理的上限,运行队列就会很长,所以在Linux系统中通常很关注系统负载,Load高意味着进程得到Cpu资源周期长,从而响应时间变慢。

时间片长度时间片一般被设置成20~50ms,时间片到了才会触发线程切换,操作系统调度其它任务执行,运行队列长表明系统负载可能已经饱和,任务可能会等待很长一段时间才被调度,轮到一个任务执行的时间就越长,响应就越慢。系统运行队列长度等于处理器个数时,用户不会明显感觉到性能下降。当运行队列长度达到虚拟机处理的4倍或者更多时,系统的响应就非常迟缓了。

调度程序负责决定那个进程投入运行,何时运行以及运行多长时间。在一组处于可运行状态的进程中选择一个来执行,是调度程序所需要完成的基本工作,调度算法大都基于时间片和优先级设计的,如Linux中常用的CFS调度策略。

上下文切换

如果转线程是唯一的线程,那么基本上不会呗调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其它线程能够使用CPU,这将导致上下文切换。

上下文切换即任务切换,也就是从一个任务切换到另一个任务的过程,需要一定的时间进行事务处理:

  • 保存和装入紧蹙起值以及内存影响;
  • 更新各种表格和列表
  • 清除和重新调入内存高速缓存等

从上面可以看到上下文切换是有一定开销的,在java程序中,需要访问线程调度需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统、以及JVM都是用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU始终周期,引用程序可使用的CPU始终周期就越少。另外当一个新的线程被切换进来,他所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因为线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的额线程分配一个最小执行时间片,即使有许多其他的线程正在等待执行:他将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应行为代价).

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许他被交换出去。如果线程频繁的发生阻塞,那么他们将无法使用完整的调度时间片。在程序发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),就会发生越多的上下文切换,从而增加调度开销,并因此降低吞吐量。

我们可以通过vmstat命令查看系统当前的上下文切换频率。这些过程大概会消耗1ms左右的时间,时间片长度20~50ms,一个线程在这段时间内会完全占用cpu,但是如果发生阻塞,如等待io,就会立即释放时间片,这样就会产生较多的上下文切换,降低cpu利用率,理想情况下上下文切换的数目应该和时钟中断大体相同。

vmstat 命令查看上下文切换vmstat 命令查看上下文切换

上下文切换次数过于频繁,意味着系统存在缺陷,应进行优化,很多情况下上下文切换频繁和I/O等待有一定关系,可以结合wa参数来看。

线程的实现

用户线程:

用户级线程在用户空间执行线程操作,这就意味着线程由不能执行特权指令或者直接访问内核原语的运行时库所创建。用户级线程对操作系统来说是透明的,系统将多线程进程作为一个单元来调度,

  • 不足:不能很好的用于多处理器系统,因为内核不能同时为多个处理器调度一个进程的多个线程,因此用户及线程在多处理器系统中的性能并不理想。用户级进程阻塞之后,内核将不会调度另一个任务,任务调度完全由用户实现,
  • 优点:用户级线程不要求操作系统支持线程, 因此用户级线程具有更好的可移植性;因为他并不依赖特定操作系统的线程的API,另一个好处是,因为是由线程库而不是由操作系统来控制如何调度线程,所以应用程序开发人员能够调整线程库的调度算法,以满足特定应用程序的需求。另外用户级线程并不调用内核来制定调度决策或者同步过程。当中断(例如,系统调用)发生时,系统需要花许多处理器周期作为额外开销,因此频繁的执行线程操作(例如,调度和同步)的用户级多线程与依赖内核完成这种操作线程相比,具有开销少的优点

内核线程

内核线程是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对内核进行调度,并负责将线程任务映射到各个处理器上,内核一次可以将一个进程的多个线程调度给若干个处理器,这就提高了为并发执行而设计的应用程序的性能。

内核级线程使操作系统的调度程序能够单独识别出每个用户线程,如果操作系统实现基于优先级的调度算法,那么使用内核级线程的一个进程就能够调整每个线程从操作系统接收的服务级别,其方法是为它的每一个线程分配调整优先级。

java线程的实现

对于sunjdk和linux操作系统,java线程最终会映射到一个内核进程上面,java线程和操作系统内核进程是一对一的关系,线程的上下文切换都依赖于操作系统,而linux操作系统通常会采用时间片轮转和优先级算法进行任务调度,在java中如果设置成守护进程,优先级通常较低。

轻量级进程和内核线程之间1:1关系轻量级进程和内核线程之间1:1关系

JVM使用内核线程的一种高级接口-轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级金恒都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

死锁问题

如果一个进程集合中的每个进程都在等待只有该组进程中其他进程才能引发的时间,那么该组进程就是死锁的。

死锁发生的4个必要条件:

  • 互斥条件。每个资源要么已经分配给了一个进程,要么就是可用的。
  • 占有和登台条件。已经得到了某个资源的进程可以再申请新的资源。
  • 不可抢占条件。已经分配给一个进程的资源不能强制性的被抢占,它只能被占有它的进程显示的释放。
  • 环路等待条件。死锁发生时,系统中一定由两个或者两个以上的进程组成一条环路,该环路中的每个进程都在等待着下一个进程所占有的资源。

性能问题

我们使用锁机制来确保线程安全,但是如果过度的加锁,则可能导致锁顺序死锁。
如何避免死锁

  • 当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁会发生死锁。在编程过程中,要保证各个线程以相同的顺序访问锁,就可以避免死锁。
  • 支持定时的锁,一个线程在一定时间内申请不到锁,就释放自己拥有的所有锁,通过trylock方法。
  • 通过jstack分析线程栈,可以看到是否有死锁发生。

与单线程相比,使用多线程总会引入一些性能的开销,这些开销包括:与协调线程相关的开销(加锁、信号、内存同步),增加上下文的切换,线程的创建和消亡。

  我们希望CPU做有用的事情,单线程程序即不存在调度问题,也不存在同步开销,不需要使用锁来保证数据结构的一致性。调度和线程内部的协调都要付出性能的开销;

  如果可运行的线程的数目大于CPU的数量,那么最终会强行换出正在执行的线程,从而使其他线程能够使用CPU。这会引起上下文切换,他会保存当前运行线程的上下文,并重新调入线程的执行上下文。

  切换上下文是要付出代价的;线程的调度需要操控OS和JVM中共享的数据结构;当一个新的线程呗换入后,他所需要的数据可能不在当前处理器本地的缓存中,所以切换上下文会引起缓存缺失的小恐慌,因此线程在第一次调度的时候会运行的稍微慢一些;

 当线程因为竞争一个锁而阻塞时,JVM通常会将这个线程挂起,允许他被换出;如果线程频繁发生阻塞,那线程就不能完整的使用它的调度限额了,一个程序发生越多的阻塞(阻塞IO、等待竞争所、或者等待条件变量)备注:死锁可能会导致线程切换频繁,与受限于CPU的程序相比,就会造成越多的上下文切换,这增加了调度的开销,并减少了吞吐量;高内核占用率(超过10%)通常象征繁重的调度活动,这很可能是由I/O阻塞,或者竞争锁引起的。

硬件层面

关于伪共享

由于CPU直接访问主内存是非常慢的,现代CPU通常会为各个核加缓存,CPU Core和主内存之间有好几层缓存,L1和L2只归属于离自己最近的Core。L3被单个插槽上的所有 CPU 核共享,主存由全部插槽上的所有 CPU 核共享。

伪共享伪共享

缓存行是CPU最小缓存单元,缓存行的大小通常为32-256 字节。CPU从内存load数据时,这条数据周围的数据也会被同时被加载到缓存中。

系统必须保证缓存数据一致性:解决办法通过各种一致性协议来完成,如主流的MESI,下文有简单的介绍并给出了参考。

现在主流的处理器都是用MESI来保证缓存的一致性. M,E,S和I代表使用MESI协议时缓存行所处的四个状态:

  • M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有).
  • E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据
  • S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝
  • I(无效, Invalid): 缓存行失效, 不能使用

当CPU需要拥有缓存行数据的写权限时, 其它处理器的相应缓存行设为I, 除了它自已, 谁不能动这行数据. 这保证了数据的安全, 同时处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗. 发送RFO(Request For Owner)消息时有以下两种:

  1. 线程的工作从一个处理器移到另一个处理器, 它操作的所有缓存行都需要移到新的处理器上. 此后如果再写缓存行, 则此缓存行在不同核上有多个拷贝, 需要发送RFO请求了. 这也是上下文切换的消耗之一。
  2. 两个不同的处理器确实都需要操作相同的缓存行 。
    可见不同Core写被多个Core占用的缓存行时,会带来很大的性能消耗。

当CPU需要拥有缓存行数据的写权限时, 其它处理器的相应缓存行设为I, 除了它自已, 谁不能动这行数据. 这保证了数据的安全, 同时处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗. 发送RFO(Request For Owner)消息时有以下两种:

  1. 线程的工作从一个处理器移到另一个处理器, 它操作的所有缓存行都需要移到新的处理器上. 此后如果再写缓存行, 则此缓存行在不同核上有多个拷贝, 需要发送RFO请求了. 这也是上下文切换的消耗之一。
  2. 两个不同的处理器确实都需要操作相同的缓存行 。
    可见不同Core写被多个Core占用的缓存行时,会带来很大的性能消耗。

在Java程序中,数组的成员在内存中是连续存储的. 从而Java对象的相邻成员变量会加载到同一缓存行中. 如果多个线程操作不同的成员变量, 但是相同的缓存行,伪共享就发生了。

如上图所示:在核心1上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y。不幸的是,这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。如果核心1获得了所有权,缓存子系统将会使核心2中对应的缓存行失效。当核心2获得了所有权然后执行更新操作,核心1就要使自己对应的缓存行失效。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。

查看缓存行大小命令:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

 

ArrayBlockingQueue的tail和head变量会经常出现在一个缓存行里面,当多个消费者和多个消费者使用该队列时会产生伪共享,寄存器的缓存经常失效,需要从内存load.

如何避免伪共享问题?
我们要做的是尽量避免不同线程操作同一缓存行\被多个线程访问的变量不和线程自己的变量置于同一缓存行。可以采用补齐的办法来进行保证。

内存屏障

CPU使用了很多优化技术来实现一个目标:CPU执行单元的速度要远超主存访问速度。CPU避免内存访问延迟最常见的技术是将指令管道化,然后尽量重排这些管道的执行以最大化利用缓存,从而把因为缓存未命中引起的延迟降到最小。

当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。例如,在一个循环里,如果循环体内没用到这个计数器,循环的计数器什么时候更新(在循环开始,中间还是最后)并不重要。编译器和CPU可以自由的重排指令以最佳的利用CPU,只要下一次循环前更新该计数器即可。并且在循环执行中,这个变量可能一直存在寄存器上,并没有被推到缓存或主存,这样这个变量对其他CPU来说一直都是不可见的。

当一个程序执行时,只要最终的结果是一样的,指令是否被重排并不重要。例如,在一个循环里,如果循环体内没用到这个计数器,循环的计数器什么时候更新(在循环开始,中间还是最后)并不重要。编译器和CPU可以自由的重排指令以最佳的利用CPU,只要下一次循环前更新该计数器即可。并且在循环执行中,这个变量可能一直存在寄存器上,并没有被推到缓存或主存,这样这个变量对其他CPU来说一直都是不可见的。

CPU核内部包含了多个执行单元。例如,现代Intel CPU包含了6个执行单元,可以组合进行算术运算,逻辑条件判断及内存操作。每个执行单元可以执行上述任务的某种组合。这些执行单元是并行执行的,这样指令也就是在并行执行。但如果站在另一个CPU角度看,这也就产生了程序顺序的另一种不确定性。

最后,当一个缓存失效发生时,现代CPU可以先假设一个内存载入的值并根据这个假设值继续执行,直到内存载入返回确切的值。代码顺序并不是真正的执行顺序,只要有空间提高性能,CPU和编译器可以进行各种优化。缓存和主存的读取会利用load, store和write-combining缓冲区来缓冲和重排。这些缓冲区是查找速度很快的关联队列,当一个后来发生的load需要读取上一个store的值,而该值还没有到达缓存,查找是必需的,现代多核CPU,执行单元可以利用本地寄存器和缓冲区来管理和缓存子系统的交互。

内存屏障内存屏障

在多线程环境里需要使用某种技术来使程序结果尽快可见。请先假定一个事实:一旦内存数据被推送到缓存,就会有消息协议来确保所有的缓存会对所有的共享数据同步并保持一致。这个使内存数据对CPU核可见的技术被称为内存屏障或内存栅栏。

内存屏障提供了两个功能。首先,它们通过确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到CPU缓存子系统。

屏障类

  • Store屏障,是x86的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都刷到CPU缓存。这会使得程序状态对其它CPU可见,这样其它CPU可以根据需要介入。
  • Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。这使得从其它CPU暴露出来的程序状态对该CPU可见,这之后CPU可以进行后续处理。一个好例子是上面的BatchEventProcessor的sequence对象是放在屏障后被生产者或消费者使用。
  • Full屏障,是x86上的”mfence“指令,复合了load和save屏障的功能。

参考:

java performacen

摄入理解java虚拟机

Linux内核涉及与实现

https://github.com/LMAX-Exchange/disruptor/wiki/Introduction

http://mechanitis.blogspot.com/2011/07/dissecting-disruptor-writing-to-ring.html

http://ifeve.com/dissecting-disruptor-whats-so-special/

http://ifeve.com/the-disruptor-lock-free-publishing/

http://ifeve.com/?x=0&y=0&s=disruptor