通过讲述JavaThread、Erlangprocess、Golanggoroutine的详细机制阐述并发的秘密,解密Erlang和Golang的高并发支持。Erlang和Gola
通过讲述Java Thread、Erlang process、Golang goroutine的详细机制阐述并发的秘密,解密Erlang和Golang的高并发支持。
Erlang和Golang都提供并发支持,能够支持几万甚至几十万的并发,但是Java或是C/C++在线程达到几千的时候,CPU性能就开始明显的下降,所以在并发度上来看,Java或是C/C++相对于Erlang、Golang简直就是弱爆了。
是什么让Erlang、Golang能够支持几万几十万的并发呢?接下来为你一一讲述。
一、Java Thread线程
线程的定义:线程(thread, 台湾称“执行绪”)是进程中某个单一顺序的控制流。也被称为轻量进程(lightweight processes)(来自百度百科)。
一个标准的线程由线程ID,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
在上述的线程简介中有一个关键是系统独立调度和分派,系统可以是操作系统,也可以是用户系统,就是线程可以由操作系统调度,也可以由用户自定义实现调度,Java 中的 Thread就是由操作系统调度的,这意味着CPU需要去调度用户创建的Java Thread,所以Java Thread是内核级线程,每个Thread都将去抢占CPU。
内核级线程包含许许多多系统指令,如进入内核需要中断指令,需要保存现场等,以至于内核级别的线程会占用一定量的资源,这个资源比起用户级线程大的多,一个内核级线程占去的资源可能是用户级线程的几十上百倍,Java Thread创建时占用的内存默认为1M。
Java Thread的状态:
1 新生状态(New): 当一个线程的实例被创建即使用new关键字和Thread类或其子类创建一个线程对象后,此时该线程处于新生(new)状态,处于新生状态的线程有自己的内存空间,但该线程并没有运行,此时线程还不是活着的(notalive);
2 就绪状态(Runnable): 通过调用线程实例的start()方法来启动线程使线程进入就绪状态(runnable);处于就绪状态的线程已经具备了运行条件,但还没有被分配到CPU即不一定会被立即执行,此时处于线程就绪队列,等待系统为其分配CPCU,等待状态并不是执行状态; 此时线程是活着的(alive);
3 运行状态(Running): 一旦获取CPU(被JVM选中),线程就进入运行(running)状态,线程的run()方法才开始被执行;在运行状态的线程执行自己的run()方法中的操作,直到调用其他的方法而终止、或者等待某种资源而阻塞、或者完成任务而死亡;如果在给定的时间片内没有执行结束,就会被系统给换下来回到线程的等待状态;此时线程是活着的(alive);
4 阻塞状态(Blocked):通过调用join()、sleep()、wait()或者资源被暂用使线程处于阻塞(blocked)状态;处于Blocking状态的线程仍然是活着的(alive)
5 死亡状态(Dead):当一个线程的run()方法运行完毕或被中断或被异常退出,该线程到达死亡(dead)状态。此时可能仍然存在一个该Thread的实例对象,当该Thready已经不可能在被作为一个可被独立执行的线程对待了,线程的独立的callstack已经被dissolved。一旦某一线程进入Dead状态,他就再也不能进入一个独立线程的生命周期了。对于一个处于Dead状态的线程调用start()方法,会出现一个运行期(runtimeexception)的异常;处于Dead状态的线程不是活着的(notalive)。
在Java进程中,处于运行状态的线程可以是很多的,Java中线程进入CPU是采用抢占式的,一个CPU只能有一个线程进入,在当前多核的处理器下允许与核数相同的线程同时进入CPU。
并发编程的模型:
并发编程中需要处理线程通信和线程同步。许多情况下线程之间的通信机制有共享内存和消息传递。
在共享内存的模型中,线程之间共享程序的数据和状态,线程之间通过读写内存中的数据和状态来隐士的进行通信。消息传递的并发模型中,线程没有公共的的共享数据和状态,必须通过明确的发送消息来进行通信。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发是采用共享内存模型实现的,Java Thread的通信是隐士进行的。Java线程之间的通信有Java内存模型控制,决定一个线程对共享变量的写入何时对另一个线程可见。
Java线程之间共享的变量是存储在主存的中,当然Java每个线程还有自己私有的内存空间,该内存空间保存了共享变量的副本。Java Thread共享内存通信模型如下图:
(图形来自网络)
从上图来看,线程A和线程B通信需要经历下面两个过程:
1 线程A将私有空间的共享变量的副本数据刷入到主存;
2 线程B从主存读取共享变量的数据到私有空间;
Java中会对指令做重排序,用以优化提高性能,不详述。
Happens-before
Happens-before意思是如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered beforethe second)。
happens-before规则如下:
1 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
2 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
3 volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
4 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
Java Thread都是内核级线程,每个线程都会去抢占CPU,线程在用户态与内核态的切换的开销比较大,线程数量上升到一定程度的时候,线程上下文的切换将占用大量资源,线程切换占去大量CPU,于是性能会下降的很快,同时Java线程本身占用的资源也导致不能使用大量的线程,比如上万线程。
二、Erlang process进程
Erlang也是使用虚拟机的模式来获得可移植性,也使用了垃圾回收机制来省去程序员内存管理的麻烦。
Erlang process是运行于Erlang虚拟机之中的类似Java Thread线程的最小单位,姑且叫他E线程,E线程其实是伪线程,实际上不仅仅E线程是伪线程,很多轻量级线程都是伪线程,所谓伪线程是指运行在用户态的线程,伪线程是用户的执行逻辑,不会直接调用内核来进入内核运行。
轻量级线程不直接调用内核进入内核态,那么轻量级线程是怎么运行的呢?这就需要调度器来实现。
首先看看Erlang的内存机制,Java虚拟机维护了一个可以被所有线程使用和共享的堆,占用了虚拟机的大部分内存,包括虚拟机的一些特殊数据区域,例如代码缓存和永久区,这些都是被所有线程共享的。Erlang中使用了私有堆的概念,每个E线程都有自己的小堆,这个堆里面包含了这个E线程使用到的数据以及线程栈,这个堆是在E线程创建的时候分配的,当E线程结束后,Erlang虚拟机就会将这个私有堆回收掉。除了私有堆以外,Erlang中还有二进制堆和消息堆,二进制堆分配了大量的数据块用来线程之间共享数据,比如文件输入、网络缓冲区。消息堆中存放的是消息数据。
与Java线程的隐士通信机制不同的是,Erlang的E线程通信是采用消息传递的方式实现通信的,是从发送线程复制一份数据到接收线程,这些发送的数据就是存放在消息堆中的。Erlang的E线程通信是完全异步的,Erlang将消息发送到消息堆中则完成,有另外的E线程去读取该消息,消息队列不抑制消息的增长。
Erlang中因为有私有堆的存在,Erlang线程对自己的数据检查不需要采用任何形式的锁,并且避免了破坏性的写,只有E线程自身会去写私有堆,这样就完全没有必要对共享数据加锁。
Erlang的缺点是没有抑制内存增长的机制,当Erlang线程积累了大量数据的时候,Erlang虚拟机会重新分配空间,扩大私有堆,然而,这个重新分配的算法会导致堆空间急速增长。
“私有堆”是一个非常强大的工具。它避免了实时系统里的锁机制,这个意味着它将比java更具扩展性。而java的硬性限制内存的模型,则能在你的系统压力剧增,或是遭受DDOS攻击的时候保持稳定。
Erlang线程是处在用户态层面的线程,那么Erlang本身就要实现对process的调度。
Erlang一般情况是在计算机的每个核心上运行一个线程,这个线程是内核级别的线程,同时这个线程就是process的调度器。调度器无法在多个核心之间切换,每个调度器都是绑定了指定计算机核心的。
Erlang创建的轻量级线程process是非常小的,一般不到1k,根据不同的系统有所区别,创建的process都将放入调度器的队列中,当process获得了时间片以后就可以在调度器绑定的核心上运行,所以process的切换是用户态下进行的,与Java Thread的内核切换是不同的。
Erlang的process在进入调度器的队列后可能并不一定在调度器绑定的CPU核心中运行,可能发生迁移,这种迁移是为了保证计算机的所有CPU能够得到充分的利用,调度器并不是一直运行在CPU上的,可能会进入睡眠状态,当调度器队列中有process需要处理的时候就被唤醒,抢占CPU,执行process,同时调度器在队列上所有process执行完后也不是马上进入睡眠状态的,调度器会去其他调度器上获取process,并且即使调度器从其他调度器上拿不到process也不会马上进入睡眠状态,而是再等待一段时间,如果还是没有process进来,调度器才会进入睡眠状态。
Erlang将延时看的比吞吐量更加重要,更多需要的是低延时的处理,Erlang调度器在抢占到CPU后,可能会长时间的占有CPU,然后调度器让队列中的process使用CPU进行处理。
从总体来说是每个process都会进入内核进行调用,但是每个process并不是直接的内核线程,process进入内核是由调度器帮助进行的。
三、Golang goroutine去程
Golang中的并发单位是goroutine暂且称为去程。
Goroutine也是轻量级线程,可以在一个进程中创建执行数十万的goroutine,与Erlang的process一样是用户态下的伪线程,同样相对于Java而言,用户态的线程可以大量创建,大量创建的goroutine不会导致CPU忙于上下文的切换,goroutine的切换是在用户态下实现的。Goroutine负责的是代码执行,那么goroutine之间的通信如何实现呢?Golang中还有一种称为channel的单位,暂且称为程道,程道负责的是goroutine之间消息或者说事件的传递。
程道是协程之间的数据传输通道。程道可以在众多的去程之间传递数据,具体可以值也可以是个引用。程道有两种使用方式。
1 去程可以试图向程道放入数据,如果程道满了,会挂起去程,直到程道可以为他放入数据为止。
2 去程可以试图向程道索取数据,如果程道没有数据,会挂起去程,直到程道返回数据为止。
如此,程道就可以在传递数据的同时,控制去程的运行。有点像事件驱动,也有点像阻塞队列。
这两个概念非常的简单,各个语言平台都会有相应的实现。在Java和C上也各有库可以实现两者。
Golang |
Erlang |
Scala(Actor) |
去程 |
goroutines |
process |
actor |
消息队列 |
channel |
mailbox |
channel |
对于一个goroutine来说,它其中变量的读, 写操作执行表现必须和从所写的代码得出的预期是一致的。也就是说,在不改变程序表现的情况下,编译器和处理器为了优化代码可能会改变变量的操作顺序即: 指令乱序重排。但是在两个不同的goroutine对相同变量操作时, 会因为指令重排导致不同的goroutine对变量的操作顺序的认识变得不一致。例如,一个goroutine执行a = 1; b = 2;,在另一个goroutine中可能会现感知到变量b先于变量a被改变。
为了解决这种二义性问题,Go语言中引进一个happens before的概念,它用于描述对内存操作的先后顺序问题。如果事件e1 happens before 事件 e2,我们说事件e2 happens after e1。如果,事件e1 does not happen before 事件 e2,并且 does not happen after e2,我们说事件e1和e2同时发生。
对于一个单一的goroutine,happens before 的顺序和代码的顺序是一致的。
如果能满足以下的条件,一个对变量v的读事件r可以感知到另一个对变量v的写事件w:
- 写事件w happens before 读事件r。
- 没有既满足 happens after w 同时满主 happens before r 的对变量v的写事件w。
为了保证读事件r可以感知对变量v的写事件,我们首先要确保w是变量v的唯一的写事件。同时还要满足以下条件:
- 写事件w happens before 读事件r。
- 其他对变量v的访问必须 happens before 写事件w 或者 happens after 读事件r。
第二组条件比第一组条件更加严格。因为,它要求在w和 r并行执行的程序中不能再有其他的读操作。
对于在单一的goroutine中两组条件是等价的,读事件可以确保感知到对变量的写事件。但是,对于在 两个goroutines共享变量v,我们必须通过同步事件来保证 happens-before 条件 (这是读事件感知写事件的必要条件)。
将变量v自动初始化为零也是属于这个内存操作模型。
读写超过一个机器字长度的数据,顺序也是不能保证的。
(以上happens before解释是来自Google的说明)
Goroutine是运行在用户态的线程,那么同样需要对goroutine实现相应的调度器。Golang中调度器负责调度goroutine在内核级线程上执行。
Golang中有内核级线程M、处理器Processer、去程G:
· M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。
· P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine,这个P的角色可能有一点让人迷惑,一开始容易和M冲突,后面重点聊一下它们的关系。
· G就是goroutine实现的核心结构了,G维护了goroutine需要的栈、程序计数器以及它所在的M等信息。
P是一组goroutine的组合,进程中P的数量可以由设定GOMAXPROCS参数来配置数量,进程中P的数量默认最大是256,就是当GOMAXPROCS超过256的时候,进程中的P同样是只有256个。
Golang中的内核级线程是由Go虚拟机创建的,用户不能显式的创建内核级线程,用户只能创建goroutine,M内核线程是Go虚拟机根据实际情况来创建的,是调用clone系统调用来创建内核线程,M的创建同时会分配空闲的P。
调度器调度内核线程M会从P中获取需要执行的goroutine,取不到goroutine的时候会从其他内核线程M的P中获取goroutine,甚至于调度会将M的P中的goroutine重新分发到其他的M的P中,也可能会创建新的内核线程M。
与Erlang不同的是,Golang的内核线程是可以配置多个的,而Erlang一般是根据CPU的数量来创建内核线程的。
Goroutine上下文的切换也是用户态下的,不会涉及到内核线程的切换,因为Golang中可能创建有许多的内核线程,数量最大为256,所以Golang也存在内核线程的切换。
从Java Thread到Erlang process,再到Golang goroutine,分别讲述了他们的区别以及内存模型,如何并发,如何通信的,对于Erlang和Golang的设计来看,确实是比Java Thread更加震撼,尽量的减少对CPU资源的浪费,确实是提高了他们的并发度。
许多高并发语言都是通过所谓的轻量级线程,即用户态线程,实现了高层并发,减少了线程切换的开销以及线程自身的开销。Akka.Actor、Scala.Actor等支持的高并发也是高层实现的并发支持。
这里并不否认Java Thread自己本身的特点,Erlang、Golang高并发支持并不是适用于所有人,更多还是需要自己根据实际情况选择。