前言
笔者到目前学习过scala、java、go,它们在并发程序的实现上模型不同,汇总一下是个蛮有意思的事情。
我们在说并发模型时,我们在说什么?
- 如何创建、停止、结束一个并发执行体
- 如何获取一个并发执行体的执行结果,并发执行体之间如何通信
- 模型对应的问题如何解决,比如java的共享内存方式带来的线程安全问题
2019.4.27补充:《软件架构设计》:用Java的人通常写的是“单进程多线程”程序,而用C++的人还可以写“多进程多线程”程序。因为Java 并不直接运行在Linux上,而是运行在JVM之上。JVM 是一个Linux进程,每一个JVM 都是一个独立的“沙盒”,互不通信。而C++直接运行在Linux 系统上,可以直接利用Linux 提供的进程间通信机制创建多个进程并通信。之所以要开多线程,主要是为了应对 IO密集型应用。
进程是资源分配的基本单位,进程间不共享资源(当然也可以共享内存),通过管道或Socket 方式通信,这种通信方式天生符合“不要通过共享内存来实现通信,而应通过通信实现共享内存” 的原则。
Java和操作系统交互细节线程的出现是为了减少进程的上下文切换(线程的上下文切换比进程小很多),以及更好适配多核心 CPU 环境
理念变化
应用 fork-join 框架 基本要点:
-
硬件趋势驱动编程语言,一个时代的主流硬件平台形成了我们创建语言、库和框架的方法,语言、库和框架形成了我们编写程序的方式。
语言 类库 硬件的并行性越来越高 synchronized、volatile Thread 大部分是单核,线程更多用来异步 java1.5/1.6 java.util.concurrent 包 多核,适合粗粒度的程序,比如web服务器、数据库服务器的多个独立工作单元 java1.7 fork-join 多核、每核多逻辑核心,细粒度的并行逻辑,比如分段遍历集合 -
将一个任务分解为可并行执行的多个任务,Divide and conquer
Result solve(Problem problem) { if (problem.size < SEQUENTIAL_THRESHOLD) return solveSequentially(problem); else { Result left, right; INVOKE-IN-PARALLEL { left = solve(extractLeftHalf(problem)); right = solve(extractRightHalf(problem)); } return combine(left, right); } }
并发之痛 Thread,Goroutine,Actor中的几个基本要点:
-
那我们从最开始梳理下程序的抽象。开始我们的程序是面向过程的,数据结构+func。后来有了面向对象,对象组合了数结构和func,我们想用模拟现实世界的方式,抽象出对象,有状态和行为。但无论是面向过程的func还是面向对象的func,本质上都是代码块的组织单元,本身并没有包含代码块的并发策略的定义。于是为了解决并发的需求,引入了Thread(线程)的概念。
-
We believe that writing correct concurrent, fault-tolerant and scalable applications is too hard. Most of the time it’s because we are using the wrong tools and the wrong level of abstraction. —— Akka。,有论文认为当前的大多数并发程序没出问题只是并发度不够,如果CPU核数继续增加,程序运行的时间更长,很难保证不出问题
-
最让人头痛的还是下面这个问题:系统里到底需要多少线程?从外部系统来观察,或者以经验的方式进行计算,都是非常困难的。于是结论是:让”线程”会说话,吃饱了自己说,自管理是最佳方案。
-
能干活的代码片段就放在线程里,如果干不了活(需要等待,被阻塞等),就摘下来。我自己的感觉就是:按需(代码被阻塞)调度,有别于cpu的按时间片调度。
- 异步回调方案 典型如NodeJS,遇到阻塞的情况,比如网络调用,则注册一个回调方法(其实还包括了一些上下文数据对象)给IO调度器(linux下是libev,调度器在另外的线程里),当前线程就被释放了,去干别的事情了。等数据准备好,调度器会将结果传递给回调方法然后执行,执行其实不在原来发起请求的线程里了,但对用户来说无感知。
- GreenThread/Coroutine/Fiber方案 这种方案其实和上面的方案本质上区别不大,关键在于回调上下文的保存以及执行机制。为了解决回调方法带来的难题,这种方案的思路是写代码的时候还是按顺序写,但遇到IO等阻塞调用时,将当前的代码片段暂停,保存上下文,让出当前线程。等IO事件回来,然后再找个线程让当前代码片段恢复上下文继续执行,写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但对程序无感。
- 小结一下:前者即全异步操作,代码直观体现。后者还是阻塞操作,代码顺序写,只是阻塞的是goroutine 之类。
fork-join实现原理
Fork and Join: Java Can Excel at Painless Parallel Programming Too! 在介绍 forkjoin 原理的同时,详述了java 多线程这块的 演化思路。
akka实现原理
actor ! msg
本质上是 executorService execute mbox
,mox实现了ForkJoinTask和Runnable接口。所以说,actor模式的消息是异步的,除了设计理念外,实现上也是没办法。
如何理解akka代表的actor模式?
2019.03.24补充:actor 是一种并发计算模型,其中所有的通信,通过发送方的消息传递机制和接收方的信箱队列,在被称为Actor的实体之间发生。Erlang使用Actor 作为它的主体架构成分,随着Akka工具在JVM平台上的成功,actor模型随后人气激增。
实现细粒度并行的共同点
- 提供新的并行执行体抽象、线程level的调度逻辑,线程的业务逻辑变成:决定下一个执行体 ==> 执行
- 针对共享数据、io等问题,不能执行当前任务的时候,不会阻塞线程(硬件并行资源),执行下一个执行体,绝不闲着。这需要改写共享数据的访问、io等代码。
只是fork join做的还比较简单,体现在
- 提出了新的并行执行体抽象,这个抽象要交给专门的fork-join pool执行
- 对共享数据、io等阻塞操作无能为力,只是在合并任务时(特别场景下,可能阻塞的代码),不再干等而已。
golang从设计上就支持协程,goroutine是默认的并行单元,单独开辟线程反而要特别的代码。不像java有历史负担,fork-join着眼点更多在于,基于现有的基本面,如何提高并发性 ==> 需要先分解任务等。fork-join只是提高在特定场景(可以进行子任务分解与合并)下的并行性。所以,goroutine和fork-join根本就不是一回事。前者是匹敌进程、线程的并发模型,后者是特定问题的并发框架
为什么java系不能实现goroutine
《软件架构设计》:多线程除了锁的问题, 还有一个问题是线程太多时切换的开销很大。协程相比线程,有两个关键特点:
- 更好地利用CPU,线程的调度由操作系统完成,应用程序干预不了,协程可以由应用程序自己调度
- 更好地利用内存,协程的堆栈大小不是固定的,按需使用,内存利用率更高
关于Golang和JVM中并发模型实现的探讨 基本要点:
goroutine中最重要的一个设计就在于它将所有的语言层次上的api都限制在了goroutine这一层,进而屏蔽了执行代码与具体线程交互的机会。JDK中存在许多已有的阻塞操作,而这些阻塞操作的调用会直接让线程被阻塞。不管你怎么进行设计,你都始终无法摆脱JDK中协程状态和线程状态不统一的情况。除非做到像Go中一样,所有的阻塞操作均被wrap到协程的层次来进行操作。
自己的一点感觉
对于网络io来说,我们知道有bio、nio和aio,为何效率逐渐变高呢?因为我们尽可能的利用了内核的机制(select、epoll这些),io内核来调度。
而对于并发/并行,从进程、线程到协程,越来越轻量级,调度也有系统级上移到了语言级、语言库级。
从java1.5 到java1.8,一开始直接暴露线程的接口,到丰富各种工具类、集合,提供线程池的封装,再到forkjoin,新的东西也不全是原有接口的替代,而是进一步细化场景。谈不上forkjoin 干的Executor 不能干,更不用说直接用Thread了。只是部分场景下,新东西的存在,让老东西看起来很笨拙。并且,即便用了java8,对于一个简单任务,兴许还是new Thread(()->{}).start()
最合适。