文档章节

关于Golang和JVM中并发模型实现的探讨

tantexian
 tantexian
发布于 2016/08/19 10:52
字数 3687
阅读 120
收藏 0
  • 说起来貌似有好久没有更新过博客了,主要是因为最近一段时间都在各种看书和看源码,所做的记录大部分也都是属于读书笔记性质,所以就没有整理到博客上来,之后会陆续整理一些东西上来。

    引子

    话说最近终于决定把之前收藏了很久的mit-6.824课程的lab拿出来做一下,这门课最有价值的地方就在于它设计了一些列的lab,让你能够在一定程度可控的工作量的coding之后比较深入地体会到众多分布式程序中所面临的一些列公共的问题以及如何去解决它们。例如,分布式容错、并发、网络底层实现等等。这门课的targeted language是golang。原因自然不说,因为golang的简洁所以非常适合用来替代C++等语言来作为lab的实现语言。

    在实现的过程当中,我遇到的一个最主要的问题就是,如何在CPU密集型任务、I/O密集型任务以及充分利用多核CPU提升程序性能上找到一个平衡点。当然,这之中最容易想到的解决方案就是多线程。但是由于分布式程序的特殊性,它可能拥有大量的网络I/O或者计算任务。这就不可避免需要将使用同步的方式来抒写异步的情怀,解决方案就是将这些计算或者IO放到新的线程中去做,具体的线程调度交给操作系统来完成(虽然我们可以使用异步IO,但是异步IO由于存在大量的callback,不便于程序逻辑组织,所以这里不考虑直接使用异步IO)。这样有一个问题就在于,这之中会有大量的线程在context中,所以线程的上下文切换的开销不可忽视。如果我们在jvm中实现的话,大量的thread可能会很快耗尽jvm堆的内存,不仅会造成堆溢出,而且增大GC时间和不稳定性。因此,最近我就考察了几种常见的并发编程模型以及其对应常见的实现方式。

    常见并发编程模型分类

    并发编程模型,顾名思义就是为了解决高并发充分利用多核特性减少CPU等待提高吞吐量而提出的相关的编程范式。目前为止,我觉得比较常见的并发编程模型大致可以分为两类:

  • 基于消息(事件)的活动对象
  • 基于CSP模型的协程的实现

 

是的,貌似有大神已经做过了,学术界太可怕了!

首先第一个问题,当M发现在P中的gorouine链表已经全部执行完毕时,将会从其他的P中偷取goroutine然后执行,其策略就是一个工作密取的机制。当其他的P也没有可执行的goroutine时,就会从全局等待队列中寻找runnable的goroutine进行执行,如果还找不到,则M让出CPU调度。

第二个问题,例如阻塞IO读取本地文件,此时调用会systemcall会陷入内核,不可避免地会使得调用线程阻塞,因此这里goroutine的做法是将所有可能阻塞的系统调用均封装为gorouine友好的接口。具体做法为,在每次进行系统调用之前,从一个线程池从获取一个OS Thread并执行该系统调用,而本来运行的gorouine则将自己的状态改为Gwaiting,并将控制权交给scheduler继续调度,系统调用的返回通过channel进行同步即可。因此,这里其实goroutine也没有办法做到完全的协程化,因为系统调用总会阻塞线程。具体可以参考stackoverflow上的讨论链接

第三个问题,go支持简单的抢占式调度,在goruntime中有一个sysmon线程,负责检测goruntime的各种状态。sysmon其中一项职责就是检测是否有长时间占用CPU的goroutine,如果发现了就将其抢占过来。

JDK上无法实现goroutine的原因

到这里,我们已经大致了解了goroutine的原理,可见goroutine中最重要的一个设计就在于它将所有的语言层次上的api都限制在了goroutine这一层,进而屏蔽了执行代码与具体线程交互的机会。所以在goroutine中,我们实际上就可以忽略线程的存在,把goroutine当成是一个非常廉价能够大量创建的Thread。

然而在Java中或者说打算和JDK交互的JVM系语言(如scala,clojure),本质上都无法完全实现goroutine(clojure虽然有async,但是依然无法和JDK中的阻塞api结合良好)。假设,我们在Java中基于Thread之上实现一个scheduler,一个轻量级的协程以及协程相关的原语(如resume, pause等),我们也只能基于我们自己封装的api来协助协程调度。如果在创建的协程中直接使用Java阻塞api,结果就是使得用来调度协程的OS Thread陷入阻塞,无法继续运行scheduler进行调度。

综上所述,如果在不更改JDK Native实现的前提下,我们是无法彻底实现类似goroutine的协程的。

TODO

于是乎,要使得JDK支持coroutine,那么我们就只能改JDK了,是的我就是这么执着!=。=

先列出一些Related Work:

JCSP CSP for Java Part 1 CSP for Java Part 2

Alt text

如上图所示,我们可以看到图中有两个M,即两个OS Thread线程,分别对应一个P,每一个P有负责调度多个G。如此一来,就组成的goroutine运行时的基本结构。

下面我们对G M P的具体代码进行分析

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

 

struct G

{

uintptr stackguard0;// 用于栈保护,但可以设置为StackPreempt,用于实现抢占式调度

uintptr stackbase; // 栈顶

Gobuf sched; // 执行上下文,G的暂停执行和恢复执行,都依靠它

uintptr stackguard; // 跟stackguard0一样,但它不会被设置为StackPreempt

uintptr stack0; // 栈底

uintptr stacksize; // 栈的大小

int16 status; // G的六个状态

int64 goid; // G的标识id

int8* waitreason; // 当status==Gwaiting有用,等待的原因,可能是调用time.Sleep之类

G* schedlink; // 指向链表的下一个G

uintptr gopc; // 创建此goroutine的Go语句的程序计数器PC,通过PC可以获得具体的函数和代码行数

};

struct P

{

Lock; // plan9 C的扩展语法,相当于Lock lock;

int32 id; // P的标识id

uint32 status; // P的四个状态

P* link; // 指向链表的下一个P

M* m; // 它当前绑定的M,Pidle状态下,该值为nil

MCache* mcache; // 内存池

// Grunnable状态的G队列

uint32 runqhead;

uint32 runqtail;

G* runq[256];

// Gdead状态的G链表(通过G的schedlink)

// gfreecnt是链表上节点的个数

G* gfree;

int32 gfreecnt;

};

struct M

{

G* g0; // M默认执行G

void (*mstartfn)(void); // OS线程执行的函数指针

G* curg; // 当前运行的G

P* p; // 当前关联的P,要是当前不执行G,可以为nil

P* nextp; // 即将要关联的P

int32 id; // M的标识id

M* alllink; // 加到allm,使其不被垃圾回收(GC)

M* schedlink; // 指向链表的下一个M

};

这里,G最重要的三个状态为Grunnable Grunning Gwaiting。具体的状态迁移为Grunnable -> Grunning -> Gwaiting -> Grunnable。goroutine在状态发生转变时,会对栈的上下文进行保存和恢复。下面让我们来开一下G中的Gobuf的定义

 

1

2

3

4

5

6

 

struct Gobuf

{

uintptr sp; // 栈指针

uintptr pc; // 程序计数器PC

G* g; // 关联的G

};

当具体要保存栈上下文时,最重要的就是保存这个Gobuf结构中的内容。goroutine具体是通过void gosave(Gobuf*)以及void gogo(Gobuf*)这两个函数来实现栈上下文的保存和恢复的,具体的底层实现为汇编代码,因此goroutine的context swtich会非常快。

接下来,我们来具体看一下goroutine scheduler在几个主要场景下的调度策略。

goroutine将scheduler的执行交给具体的M,即OS Thread。每一个M就执行一个函数,即void schedule(void)。这个函数具体做得事情就是从各个运行队列中选择合适的goroutine然后执行goroutine中对应的func

具体的schedule函数如下:

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

 

// 调度的一个回合:找到可以运行的G,执行

// 从不返回

static void schedule(void)

{

G *gp;

uint32 tick;

top:

gp = nil;

// 时不时检查全局的可运行队列,确保公平性

// 否则两个goroutine不断地互相重生,完全占用本地的可运行队列

tick = m->p->schedtick;

// 优化技巧,其实就是tick%61 == 0

if(tick - (((uint64)tick*0x4325c53fu)>>36)*61 == 0 && runtime·sched.runqsize > 0) {

runtime·lock(&runtime·sched);

gp = globrunqget(m->p, 1); // 从全局可运行队列获得可用的G

runtime·unlock(&runtime·sched);

if(gp)

resetspinning();

}

if(gp == nil) {

gp = runqget(m->p); // 如果全局队列里没找到,就在P的本地可运行队列里找

if(gp && m->spinning)

runtime·throw("schedule: spinning with local work");

}

if(gp == nil) {

gp = findrunnable(); // 阻塞住,直到找到可用的G

resetspinning();

}

// 是否启用指定某M来执行该G

if(gp->lockedm) {

// 把P给指定的m,然后阻塞等新的P

startlockedm(gp);

goto top;

}

execute(gp); // 执行G

}

于是这里抛出几个问题:

当M发现分配给自己的goroutine链表已经执行完毕时怎么办? 当goroutine陷入系统调用阻塞后,M是否也一起阻塞? 当某个gorouine长时间占用CPU怎么办?

首先,我们假设能够在JDK中建立起一套基于已有Thread模型的coroutine机制,并且可以通过调用某些方法来创建coroutine对象,分配coroutine任务并执行。但是JDK中存在许多已有的阻塞操作,而这些阻塞操作的调用会直接让线程被阻塞,这样一来依托于线程的coroutine就会失去重新调度的能力。也许你有很多其他的方法进行设计,但是这里本质问题是不管你怎么进行设计,你都始终无法摆脱JDK中协程状态和线程状态不统一的情况。除非做到像Go中一样,所有的阻塞操作均被wrap到协程的层次来进行操作。所以,一旦我们用到JDK中和线程绑定的阻塞API时,那么这种并发模型就基本歇菜了。

那么下面我们来分析一下goroutine的实现原理从而解释为什么Java无法做到goroutine那样的协程。

Goroutine原理

在操作系统的OS Thread和编程语言的User Thread之间,实际上存在3中线程对应模型,也就是:1:1,1:N,M:N。

JVM specification中规定JVM线程和操作系统线程为1:1的关系,也就是说Java中的Thread和OS Thread为1:1的关系。也有把线程模型实现为1:N的模式,不过这样做其实不够灵活。goroutine google runtime默认的实现为M:N的模型,于是这样可以根据具体的操作类型(操作系统阻塞或非阻塞操作)调整goroutine和OS Thread的映射情况,显得更加的灵活。

在goroutine实现中,有三个最重要的数据结构,分别为G M P:

G:代表一个goroutine M:代表 一个OS Thread P:一个P和一个M进行绑定,代表在这个OS Thread上的调度器

其中基于消息(事件)的活动对象的并发模型,最典型的代表就是Akka的actor。actor的并发模型是把一个个计算序列按抽象为一个一个Actor对象,每一个Actor之间通过异步的消息传递机制来进行通讯。这样一来,本来顺序阻塞的计算序列,就被分散到了一个一个Actor中。我们在Actor中的操作应该尽量保证非阻塞性。当然,在akka中actor是根据具体的Dispatcher来决定如何处理某一个actor的消息,默认的dispatcher是ForkJoinExecutor,只适合用来处理非阻塞非CPU密集型的消息;akka中还有另外一些Dispatcher可以用于处理阻塞或者CPU密集型的消息,具体的底层实现用到CachedThreadPool。这两种Dispatcher结合起来,我们便能在jvm上建立完整的并发模型。

基于协程的实现,这里主要的代表就是goroutine。Golang的runtime实现了goroutine和OS thread的M:N模型,因此实际的goroutine是基于线程的更加轻量级的实现,我们便可以在Golang中大量创建goroutine而不用担心昂贵的context swtich所带来的开销。goroutine之间,我们可以通过channel来进行交互。由于go已将将所有system call都wrap到了标准库中,在针对这些systemcall进行调用时会主动标记goroutine为阻塞状态并保存现场,交由scheduler执行。所以在golang中,在大部分情况下我们可以非常安心地在goroutine中使用阻塞操作而不用担心并发性受到影响。

goroutine的这种并发模型有一个非常明显的优势,我们可以简单地使用人见人爱的阻塞编程方式来抒发异步的情怀,只要能合理运用go关键字。相比较于akka的actor而言,goroutine的程序可读性更强且更好定位错误。

Java能否做到goroutine这样?

既然goroutine这么好用,那么我们能否基于jdk来实现一套类似goroutine的并发模型库??(这里我在知乎提了一个相关的问题,详见这里)很遗憾,如果基于JDK的话,是无法实现的。下面我们来分析一下这个问题的本质。

下面我将goroutine的并发模型定义为以下几个要点:

基于Thread的轻量级协程 通过channel来进行协程间的消息传递 只暴露协程,屏蔽线程操作的接口

本文转载自:http://www.nyankosama.com/2015/04/03/java-goroutine/

tantexian
粉丝 228
博文 527
码字总数 746616
作品 0
成都
架构师
私信 提问
go学习笔记0-helloWorld

有一次听一个沙龙技术演讲,讲师说go是互联网时代的c,听了详细介绍以后感觉很赞,尤其是并行层面真正在语言层面上做了控制及特殊的内存管理机制等。。。 好吧,下面上先上helloWorld代码。(...

老范的自留地
2014/06/01
64
0
go笔记1-helloWorld

有一次听一个沙龙技术演讲,讲师说go是互联网时代的c,听了详细介绍以后感觉很赞,尤其是并行层面真正在语言层面上做了控制及特殊的内存管理机制等。。。 好吧,下面上先上helloWorld代码。(...

_凤求凰_
2012/10/29
787
4
求你了,再问你Java内存模型的时候别再给我讲堆栈方法区了…

GitHub 4.1k Star 的Java工程师成神之路 ,不来了解一下吗? GitHub 4.1k Star 的Java工程师成神之路 ,真的不来了解一下吗? GitHub 4.1k Star 的Java工程师成神之路 ,真的确定不来了解一下吗...

Hollis
07/02
0
0
再有人问你Java内存模型是什么,就把这篇文章发给他!

前几天,发了一篇文章,介绍了一下JVM内存结构、Java内存模型以及Java对象模型之间的区别。有很多小伙伴反馈希望可以深入的讲解下每个知识点。Java内存模型,是这三个知识点当中最晦涩难懂的...

技术小能手
2018/09/30
0
0
JVM内存结构 VS Java内存模型 VS Java对象模型

Java作为一种面向对象的,跨平台语言,其对象、内存等一直是比较难的知识点。而且很多概念的名称看起来又那么相似,很多人会傻傻分不清楚。比如本文我们要讨论的JVM内存结构、Java内存模型和...

Java架构
2018/07/11
0
0

没有更多内容

加载失败,请刷新页面

加载更多

HashSet和HashMap有什么区别?

HashSet 底层是采用 HashMap 实现,HashSet 的实现比较简单,HashSet 的绝大部分方法都是通过调用 HashMap 的方法来实现 调用 HashSet 的 add 方法时,实际上是向 HashSet 对象内部持有的 Ha...

ConstXiong
5分钟前
1
0
击穿JVM虚拟机

什么是JVM虚拟机 首先我们需要了解什么是虚拟机,为什么虚拟机可以实现夸平台,虚拟机在计算机中扮演一个什么样的角色。 (从下向上看) 看上图的操作系统与虚拟机层,可以看到,JVM是在操作...

兜兜毛毛
12分钟前
2
0
OpenNMS 利用 Sentinel处理Netflow(流量流向分析)

准备环境 CentOS-7-x86_64 Java8 OpenNMS 23.0.4 minion-23.0.4 sentinel-23.0.4 elasticsearch-6.7.1.tar.gz OpenNMS 配置 1 配置ActiveMQ vi $OPENNMS_HOME/etc/opennms-activemq.xml 取消......

qoswork
16分钟前
3
0
PHP Socket初探---先从一个简单的socket服务器开始

socket的中文名字叫做套接字,这种东西就是对TCP/IP的“封装”。现实中的网络实际上只有四层而已,从上至下分别是应用层、传输层、网络层、数据链路层。最常用的http协议则是属于应用层的协议...

bengozhong
23分钟前
2
0
Git

指令 git init :创建版本库,生成.git文件夹 git add XX:上传代码到暂存区 git state:查看目前本地工作起、暂存区、分支,三者之间的文件状态 git diff demo.html:查看工作区和暂存区的代码...

Hui先生
43分钟前
6
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部