mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
127
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/42 | Actor模型:面向对象原生的并发模型.md
Normal file
127
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/42 | Actor模型:面向对象原生的并发模型.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<audio id="audio" title="42 | Actor模型:面向对象原生的并发模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/55/75/55cdd492a49d1fd9532e53aa1004fa75.mp3"></audio>
|
||||
|
||||
上学的时候,有门计算机专业课叫做面向对象编程,学这门课的时候有个问题困扰了我很久,按照面向对象编程的理论,对象之间通信需要依靠**消息**,而实际上,像C++、Java这些面向对象的语言,对象之间通信,依靠的是**对象方法**。对象方法和过程语言里的函数本质上没有区别,有入参、有出参,思维方式很相似,使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢?很长一段时间里,我都以为对象方法是面向对象理论中消息的一种实现,直到接触到Actor模型,才明白消息压根不是这个实现法。
|
||||
|
||||
## Hello Actor模型
|
||||
|
||||
Actor模型本质上是一种计算模型,基本的计算单元称为Actor,换言之,**在Actor模型中,所有的计算都是在Actor中执行的**。在面向对象编程里面,一切都是对象;在Actor模型里,一切都是Actor,并且Actor之间是完全隔离的,不会共享任何变量。
|
||||
|
||||
当看到“不共享任何变量”的时候,相信你一定会眼前一亮,并发问题的根源就在于共享变量,而Actor模型中Actor之间不共享变量,那用Actor模型解决并发问题,一定是相当顺手。的确是这样,所以很多人就把Actor模型定义为一种**并发计算模型**。其实Actor模型早在1973年就被提出来了,只是直到最近几年才被广泛关注,一个主要原因就在于它是解决并发问题的利器,而最近几年随着多核处理器的发展,并发问题被推到了风口浪尖上。
|
||||
|
||||
但是Java语言本身并不支持Actor模型,所以如果你想在Java语言里使用Actor模型,就需要借助第三方类库,目前能完备地支持Actor模型而且比较成熟的类库就是**Akka**了。在详细介绍Actor模型之前,我们就先基于Akka写一个Hello World程序,让你对Actor模型先有个感官的印象。
|
||||
|
||||
在下面的示例代码中,我们首先创建了一个ActorSystem(Actor不能脱离ActorSystem存在);之后创建了一个HelloActor,Akka中创建Actor并不是new一个对象出来,而是通过调用system.actorOf()方法创建的,该方法返回的是ActorRef,而不是HelloActor;最后通过调用ActorRef的tell()方法给HelloActor发送了一条消息 “Actor” 。
|
||||
|
||||
```
|
||||
//该Actor当收到消息message后,
|
||||
//会打印Hello message
|
||||
static class HelloActor
|
||||
extends UntypedActor {
|
||||
@Override
|
||||
public void onReceive(Object message) {
|
||||
System.out.println("Hello " + message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
//创建Actor系统
|
||||
ActorSystem system = ActorSystem.create("HelloSystem");
|
||||
//创建HelloActor
|
||||
ActorRef helloActor =
|
||||
system.actorOf(Props.create(HelloActor.class));
|
||||
//发送消息给HelloActor
|
||||
helloActor.tell("Actor", ActorRef.noSender());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过这个例子,你会发现Actor模型和面向对象编程契合度非常高,完全可以用Actor类比面向对象编程里面的对象,而且Actor之间的通信方式完美地遵守了消息机制,而不是通过对象方法来实现对象之间的通信。那Actor中的消息机制和面向对象语言里的对象方法有什么区别呢?
|
||||
|
||||
## 消息和对象方法的区别
|
||||
|
||||
在没有计算机的时代,异地的朋友往往是通过写信来交流感情的,但信件发出去之后,也许会在寄送过程中弄丢了,也有可能寄到后,对方一直没有时间写回信……这个时候都可以让邮局“背个锅”,不过无论如何,也不过是重写一封,生活继续。
|
||||
|
||||
Actor中的消息机制,就可以类比这现实世界里的写信。Actor内部有一个邮箱(Mailbox),接收到的消息都是先放到邮箱里,如果邮箱里有积压的消息,那么新收到的消息就不会马上得到处理,也正是因为Actor使用单线程处理消息,所以不会出现并发问题。你可以把Actor内部的工作模式想象成只有一个消费者线程的生产者-消费者模式。
|
||||
|
||||
所以,在Actor模型里,发送消息仅仅是把消息发出去而已,接收消息的Actor在接收到消息后,也不一定会立即处理,也就是说**Actor中的消息机制完全是异步的**。而**调用对象方法**,实际上是**同步**的,对象方法return之前,调用方会一直等待。
|
||||
|
||||
除此之外,**调用对象方法**,需要持有对象的引用,**所有的对象必须在同一个进程中**。而在Actor中发送消息,类似于现实中的写信,只需要知道对方的地址就可以,**发送消息和接收消息的Actor可以不在一个进程中,也可以不在同一台机器上**。因此,Actor模型不但适用于并发计算,还适用于分布式计算。
|
||||
|
||||
## Actor的规范化定义
|
||||
|
||||
通过上面的介绍,相信你应该已经对Actor有一个感官印象了,下面我们再来看看Actor规范化的定义是什么样的。Actor是一种基础的计算单元,具体来讲包括三部分能力,分别是:
|
||||
|
||||
1. 处理能力,处理接收到的消息。
|
||||
1. 存储能力,Actor可以存储自己的内部状态,并且内部状态在不同Actor之间是绝对隔离的。
|
||||
1. 通信能力,Actor可以和其他Actor之间通信。
|
||||
|
||||
当一个Actor接收的一条消息之后,这个Actor可以做以下三件事:
|
||||
|
||||
1. 创建更多的Actor;
|
||||
1. 发消息给其他Actor;
|
||||
1. 确定如何处理下一条消息。
|
||||
|
||||
其中前两条还是很好理解的,就是最后一条,该如何去理解呢?前面我们说过Actor具备存储能力,它有自己的内部状态,所以你也可以把Actor看作一个状态机,把Actor处理消息看作是触发状态机的状态变化;而状态机的变化往往要基于上一个状态,触发状态机发生变化的时刻,上一个状态必须是确定的,所以确定如何处理下一条消息,本质上不过是改变内部状态。
|
||||
|
||||
在多线程里面,由于可能存在竞态条件,所以根据当前状态确定如何处理下一条消息还是有难度的,需要使用各种同步工具,但在Actor模型里,由于是单线程处理,所以就不存在竞态条件问题了。
|
||||
|
||||
## 用Actor实现累加器
|
||||
|
||||
支持并发的累加器可能是最简单并且有代表性的并发问题了,可以基于互斥锁方案实现,也可以基于原子类实现,但今天我们要尝试用Actor来实现。
|
||||
|
||||
在下面的示例代码中,CounterActor内部持有累计值counter,当CounterActor接收到一个数值型的消息message时,就将累计值counter += message;但如果是其他类型的消息,则打印当前累计值counter。在main()方法中,我们启动了4个线程来执行累加操作。整个程序没有锁,也没有CAS,但是程序是线程安全的。
|
||||
|
||||
```
|
||||
//累加器
|
||||
static class CounterActor extends UntypedActor {
|
||||
private int counter = 0;
|
||||
@Override
|
||||
public void onReceive(Object message){
|
||||
//如果接收到的消息是数字类型,执行累加操作,
|
||||
//否则打印counter的值
|
||||
if (message instanceof Number) {
|
||||
counter += ((Number) message).intValue();
|
||||
} else {
|
||||
System.out.println(counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
//创建Actor系统
|
||||
ActorSystem system = ActorSystem.create("HelloSystem");
|
||||
//4个线程生产消息
|
||||
ExecutorService es = Executors.newFixedThreadPool(4);
|
||||
//创建CounterActor
|
||||
ActorRef counterActor =
|
||||
system.actorOf(Props.create(CounterActor.class));
|
||||
//生产4*100000个消息
|
||||
for (int i=0; i<4; i++) {
|
||||
es.execute(()->{
|
||||
for (int j=0; j<100000; j++) {
|
||||
counterActor.tell(1, ActorRef.noSender());
|
||||
}
|
||||
});
|
||||
}
|
||||
//关闭线程池
|
||||
es.shutdown();
|
||||
//等待CounterActor处理完所有消息
|
||||
Thread.sleep(1000);
|
||||
//打印结果
|
||||
counterActor.tell("", ActorRef.noSender());
|
||||
//关闭Actor系统
|
||||
system.shutdown();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Actor模型是一种非常简单的计算模型,其中Actor是最基本的计算单元,Actor之间是通过消息进行通信。Actor与面向对象编程(OOP)中的对象匹配度非常高,在面向对象编程里,系统由类似于生物细胞那样的对象构成,对象之间也是通过消息进行通信,所以在面向对象语言里使用Actor模型基本上不会有违和感。
|
||||
|
||||
在Java领域,除了可以使用Akka来支持Actor模型外,还可以使用Vert.x,不过相对来说Vert.x更像是Actor模型的隐式实现,对应关系不像Akka那样明显,不过本质上也是一种Actor模型。
|
||||
|
||||
Actor可以创建新的Actor,这些Actor最终会呈现出一个树状结构,非常像现实世界里的组织结构,所以利用Actor模型来对程序进行建模,和现实世界的匹配度非常高。Actor模型和现实世界一样都是异步模型,理论上不保证消息百分百送达,也不保证消息送达的顺序和发送的顺序是一致的,甚至无法保证消息会被百分百处理。虽然实现Actor模型的厂商都在试图解决这些问题,但遗憾的是解决得并不完美,所以使用Actor模型也是有成本的。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
255
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/43 | 软件事务内存:借鉴数据库的并发经验.md
Normal file
255
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/43 | 软件事务内存:借鉴数据库的并发经验.md
Normal file
@@ -0,0 +1,255 @@
|
||||
<audio id="audio" title="43 | 软件事务内存:借鉴数据库的并发经验" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/ce/a3c1bfcb3946a823b59da193a830aece.mp3"></audio>
|
||||
|
||||
很多同学反馈说,工作了挺长时间但是没有机会接触并发编程,实际上我们天天都在写并发程序,只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库,在解决并发问题方面,可谓成绩斐然,它的**事务机制非常简单易用**,能甩Java里面的锁、原子类十条街。技术无边界,很显然要借鉴一下。
|
||||
|
||||
其实很多编程语言都有从数据库的事务管理中获得灵感,并且总结出了一个新的并发解决方案:**软件事务内存(Software Transactional Memory,简称STM)**。传统的数据库事务,支持4个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是大家常说的ACID,STM由于不涉及到持久化,所以只支持ACI。
|
||||
|
||||
STM的使用很简单,下面我们以经典的转账操作为例,看看用STM该如何实现。
|
||||
|
||||
## 用STM实现转账
|
||||
|
||||
我们曾经在[《05 | 一不小心就死锁了,怎么办?》](https://time.geekbang.org/column/article/85001)这篇文章中,讲到了并发转账的例子,示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题,因为还存在死锁问题。
|
||||
|
||||
```
|
||||
class UnsafeAccount {
|
||||
//余额
|
||||
private long balance;
|
||||
//构造函数
|
||||
public UnsafeAccount(long balance) {
|
||||
this.balance = balance;
|
||||
}
|
||||
//转账
|
||||
void transfer(UnsafeAccount target, long amt){
|
||||
if (this.balance > amt) {
|
||||
this.balance -= amt;
|
||||
target.balance += amt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
该转账操作若使用数据库事务就会非常简单,如下面的示例代码所示。如果所有SQL都正常执行,则通过 commit() 方法提交事务;如果SQL在执行过程中有异常,则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁,而且还能保证前面我们说的原子性、一致性、隔离性和持久性,也就是ACID。
|
||||
|
||||
```
|
||||
Connection conn = null;
|
||||
try{
|
||||
//获取数据库连接
|
||||
conn = DriverManager.getConnection();
|
||||
//设置手动提交事务
|
||||
conn.setAutoCommit(false);
|
||||
//执行转账SQL
|
||||
......
|
||||
//提交事务
|
||||
conn.commit();
|
||||
} catch (Exception e) {
|
||||
//出现异常回滚事务
|
||||
conn.rollback();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
那如果用STM又该如何实现呢?Java语言并不支持STM,不过可以借助第三方的类库来支持,[Multiverse](https://github.com/pveentjer/Multiverse)就是个不错的选择。下面的示例代码就是借助Multiverse实现了线程安全的转账操作,相比较上面线程不安全的UnsafeAccount,其改动并不大,仅仅是将余额的类型从 long 变成了 TxnLong ,将转账的操作放到了 atomic(()->{}) 中。
|
||||
|
||||
```
|
||||
class Account{
|
||||
//余额
|
||||
private TxnLong balance;
|
||||
//构造函数
|
||||
public Account(long balance){
|
||||
this.balance = StmUtils.newTxnLong(balance);
|
||||
}
|
||||
//转账
|
||||
public void transfer(Account to, int amt){
|
||||
//原子化操作
|
||||
atomic(()->{
|
||||
if (this.balance.get() > amt) {
|
||||
this.balance.decrement(amt);
|
||||
to.balance.increment(amt);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一个关键的atomic()方法就把并发问题解决了,这个方案看上去比传统的方案的确简单了很多,那它是如何实现的呢?数据库事务发展了几十年了,目前被广泛使用的是**MVCC**(全称是Multi-Version Concurrency Control),也就是多版本并发控制。
|
||||
|
||||
MVCC可以简单地理解为数据库事务在开启的时候,会给数据库打一个快照,以后所有的读写都是基于这个快照的。当提交事务的时候,如果所有读写过的数据在该事务执行期间没有发生过变化,那么就可以提交;如果发生了变化,说明该事务和有其他事务读写的数据冲突了,这个时候是不可以提交的。
|
||||
|
||||
为了记录数据是否发生了变化,可以给每条数据增加一个版本号,这样每次成功修改数据都会增加版本号的值。MVCC的工作原理和我们曾经在[《18 | StampedLock:有没有比读写锁更快的锁?》](https://time.geekbang.org/column/article/89456)中提到的乐观锁非常相似。有不少STM的实现方案都是基于MVCC的,例如知名的Clojure STM。
|
||||
|
||||
下面我们就用最简单的代码基于MVCC实现一个简版的STM,这样你会对STM以及MVCC的工作原理有更深入的认识。
|
||||
|
||||
## 自己实现STM
|
||||
|
||||
我们首先要做的,就是让Java中的对象有版本号,在下面的示例代码中,VersionedRef这个类的作用就是将对象value包装成带版本号的对象。按照MVCC理论,数据的每一次修改都对应着一个唯一的版本号,所以不存在仅仅改变value或者version的情况,用不变性模式就可以很好地解决这个问题,所以VersionedRef这个类被我们设计成了不可变的。
|
||||
|
||||
所有对数据的读写操作,一定是在一个事务里面,TxnRef这个类负责完成事务内的读写操作,读写操作委托给了接口Txn,Txn代表的是读写操作所在的当前事务, 内部持有的curRef代表的是系统中的最新值。
|
||||
|
||||
```
|
||||
//带版本号的对象引用
|
||||
public final class VersionedRef<T> {
|
||||
final T value;
|
||||
final long version;
|
||||
//构造方法
|
||||
public VersionedRef(T value, long version) {
|
||||
this.value = value;
|
||||
this.version = version;
|
||||
}
|
||||
}
|
||||
//支持事务的引用
|
||||
public class TxnRef<T> {
|
||||
//当前数据,带版本号
|
||||
volatile VersionedRef curRef;
|
||||
//构造方法
|
||||
public TxnRef(T value) {
|
||||
this.curRef = new VersionedRef(value, 0L);
|
||||
}
|
||||
//获取当前事务中的数据
|
||||
public T getValue(Txn txn) {
|
||||
return txn.get(this);
|
||||
}
|
||||
//在当前事务中设置数据
|
||||
public void setValue(T value, Txn txn) {
|
||||
txn.set(this, value);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
STMTxn是Txn最关键的一个实现类,事务内对于数据的读写,都是通过它来完成的。STMTxn内部有两个Map:inTxnMap,用于保存当前事务中所有读写的数据的快照;writeMap,用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务ID txnId,这个txnId是全局递增的。
|
||||
|
||||
STMTxn有三个核心方法,分别是读数据的get()方法、写数据的set()方法和提交事务的commit()方法。其中,get()方法将要读取数据作为快照放入inTxnMap,同时保证每次读取的数据都是一个版本。set()方法会将要写入的数据放入writeMap,但如果写入的数据没被读取过,也会将其放入 inTxnMap。
|
||||
|
||||
至于commit()方法,我们为了简化实现,使用了互斥锁,所以事务的提交是串行的。commit()方法的实现很简单,首先检查inTxnMap中的数据是否发生过变化,如果没有发生变化,那么就将writeMap中的数据写入(这里的写入其实就是TxnRef内部持有的curRef);如果发生过变化,那么就不能将writeMap中的数据写入了。
|
||||
|
||||
```
|
||||
//事务接口
|
||||
public interface Txn {
|
||||
<T> T get(TxnRef<T> ref);
|
||||
<T> void set(TxnRef<T> ref, T value);
|
||||
}
|
||||
//STM事务实现类
|
||||
public final class STMTxn implements Txn {
|
||||
//事务ID生成器
|
||||
private static AtomicLong txnSeq = new AtomicLong(0);
|
||||
|
||||
//当前事务所有的相关数据
|
||||
private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>();
|
||||
//当前事务所有需要修改的数据
|
||||
private Map<TxnRef, Object> writeMap = new HashMap<>();
|
||||
//当前事务ID
|
||||
private long txnId;
|
||||
//构造函数,自动生成当前事务ID
|
||||
STMTxn() {
|
||||
txnId = txnSeq.incrementAndGet();
|
||||
}
|
||||
|
||||
//获取当前事务中的数据
|
||||
@Override
|
||||
public <T> T get(TxnRef<T> ref) {
|
||||
//将需要读取的数据,加入inTxnMap
|
||||
if (!inTxnMap.containsKey(ref)) {
|
||||
inTxnMap.put(ref, ref.curRef);
|
||||
}
|
||||
return (T) inTxnMap.get(ref).value;
|
||||
}
|
||||
//在当前事务中修改数据
|
||||
@Override
|
||||
public <T> void set(TxnRef<T> ref, T value) {
|
||||
//将需要修改的数据,加入inTxnMap
|
||||
if (!inTxnMap.containsKey(ref)) {
|
||||
inTxnMap.put(ref, ref.curRef);
|
||||
}
|
||||
writeMap.put(ref, value);
|
||||
}
|
||||
//提交事务
|
||||
boolean commit() {
|
||||
synchronized (STM.commitLock) {
|
||||
//是否校验通过
|
||||
boolean isValid = true;
|
||||
//校验所有读过的数据是否发生过变化
|
||||
for(Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()){
|
||||
VersionedRef curRef = entry.getKey().curRef;
|
||||
VersionedRef readRef = entry.getValue();
|
||||
//通过版本号来验证数据是否发生过变化
|
||||
if (curRef.version != readRef.version) {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
//如果校验通过,则所有更改生效
|
||||
if (isValid) {
|
||||
writeMap.forEach((k, v) -> {
|
||||
k.curRef = new VersionedRef(v, txnId);
|
||||
});
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面我们来模拟实现Multiverse中的原子化操作atomic()。atomic()方法中使用了类似于CAS的操作,如果事务提交失败,那么就重新创建一个新的事务,重新执行。
|
||||
|
||||
```
|
||||
@FunctionalInterface
|
||||
public interface TxnRunnable {
|
||||
void run(Txn txn);
|
||||
}
|
||||
//STM
|
||||
public final class STM {
|
||||
//私有化构造方法
|
||||
private STM() {
|
||||
//提交数据需要用到的全局锁
|
||||
static final Object commitLock = new Object();
|
||||
//原子化提交方法
|
||||
public static void atomic(TxnRunnable action) {
|
||||
boolean committed = false;
|
||||
//如果没有提交成功,则一直重试
|
||||
while (!committed) {
|
||||
//创建新的事务
|
||||
STMTxn txn = new STMTxn();
|
||||
//执行业务逻辑
|
||||
action.run(txn);
|
||||
//提交事务
|
||||
committed = txn.commit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
```
|
||||
|
||||
就这样,我们自己实现了STM,并完成了线程安全的转账操作,使用方法和Multiverse差不多,这里就不赘述了,具体代码如下面所示。
|
||||
|
||||
```
|
||||
class Account {
|
||||
//余额
|
||||
private TxnRef<Integer> balance;
|
||||
//构造方法
|
||||
public Account(int balance) {
|
||||
this.balance = new TxnRef<Integer>(balance);
|
||||
}
|
||||
//转账操作
|
||||
public void transfer(Account target, int amt){
|
||||
STM.atomic((txn)->{
|
||||
Integer from = balance.getValue(txn);
|
||||
balance.setValue(from-amt, txn);
|
||||
Integer to = target.balance.getValue(txn);
|
||||
target.balance.setValue(to+amt, txn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
STM借鉴的是数据库的经验,数据库虽然复杂,但仅仅存储数据,而编程语言除了有共享变量之外,还会执行各种I/O操作,很显然I/O操作是很难支持回滚的。所以,STM也不是万能的。目前支持STM的编程语言主要是函数式语言,函数式语言里的数据天生具备不可变性,利用这种不可变性实现STM相对来说更简单。
|
||||
|
||||
另外,需要说明的是,文中的“自己实现STM”部分我参考了[Software Transactional Memory in Scala](http://www.codecommit.com/blog/scala/software-transactional-memory-in-scala)这篇博文以及[一个GitHub项目](https://github.com/epam-mooc/stm-java),目前还很粗糙,并不是一个完备的MVCC。如果你对这方面感兴趣,可以参考[Improving the STM: Multi-Version Concurrency Control](http://www.codecommit.com/blog/scala/improving-the-stm-multi-version-concurrency-control) 这篇博文,里面讲到了如何优化,你可以尝试学习下。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
144
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/44 | 协程:更轻量级的线程.md
Normal file
144
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/44 | 协程:更轻量级的线程.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<audio id="audio" title="44 | 协程:更轻量级的线程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/06/ba6e712ec378d7a8a18ccaa81a4f5206.mp3"></audio>
|
||||
|
||||
Java语言里解决并发问题靠的是多线程,但线程是个重量级的对象,不能频繁创建、销毁,而且线程切换的成本也很高,为了解决这些问题,Java SDK提供了线程池。然而用好线程池并不容易,Java围绕线程池提供了很多工具类,这些工具类学起来也不容易。那有没有更好的解决方案呢?Java语言里目前还没有,但是其他语言里有,这个方案就是**协程**(Coroutine)。
|
||||
|
||||
我们可以把**协程**简单地理解**为一种轻量级的线程**。从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有1M,而协程栈的大小往往只有几K或者几十K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。
|
||||
|
||||
支持协程的语言还是挺多的,例如Golang、Python、Lua、Kotlin等都支持协程。下面我们就以Golang为代表,看看协程是如何在Golang中使用的。
|
||||
|
||||
## Golang中的协程
|
||||
|
||||
在Golang中创建协程非常简单,在下面的示例代码中,要让hello()方法在一个新的协程中执行,只需要`go hello("World")` 这一行代码就搞定了。你可以对比着想想在Java里是如何“辛勤”地创建线程和线程池的吧,我的感觉一直都是:每次写完Golang的代码,就再也不想写Java代码了。
|
||||
|
||||
```
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
func hello(msg string) {
|
||||
fmt.Println("Hello " + msg)
|
||||
}
|
||||
func main() {
|
||||
//在新的协程中执行hello方法
|
||||
go hello("World")
|
||||
fmt.Println("Run in main")
|
||||
//等待100毫秒让协程执行结束
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们在[《33 | Thread-Per-Message模式:最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)中介绍过,利用协程能够很好地实现Thread-Per-Message模式。Thread-Per-Message模式非常简单,其实越是简单的模式,功能上就越稳定,可理解性也越好。
|
||||
|
||||
下面的示例代码是用Golang实现的echo程序的服务端,用的是Thread-Per-Message模式,为每个成功建立连接的socket分配一个协程,相比Java线程池的实现方案,Golang中协程的方案更简单。
|
||||
|
||||
```
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
)
|
||||
|
||||
func main() {
|
||||
//监听本地9090端口
|
||||
socket, err := net.Listen("tcp", "127.0.0.1:9090")
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
}
|
||||
defer socket.Close()
|
||||
for {
|
||||
//处理连接请求
|
||||
conn, err := socket.Accept()
|
||||
if err != nil {
|
||||
log.Panicln(err)
|
||||
}
|
||||
//处理已经成功建立连接的请求
|
||||
go handleRequest(conn)
|
||||
}
|
||||
}
|
||||
//处理已经成功建立连接的请求
|
||||
func handleRequest(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
//读取请求数据
|
||||
size, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
//回写相应数据
|
||||
conn.Write(buf[:size])
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 利用协程实现同步
|
||||
|
||||
其实协程并不仅限于实现Thread-Per-Message模式,它还可以将异步模式转换为同步模式。异步编程虽然近几年取得了长足发展,但是异步的思维模式对于普通人来讲毕竟是有难度的,只有线性的思维模式才是适合所有人的。而线性的思维模式反映到编程世界,就是同步。
|
||||
|
||||
在Java里使用多线程并发地处理I/O,基本上用的都是**异步非阻塞模型**,这种模型的异步主要是靠注册回调函数实现的,那能否都使用同步处理呢?显然是不能的。因为同步意味着等待,而线程等待,本质上就是一种严重的浪费。不过对于协程来说,等待的成本就没有那么高了,所以基于协程实现**同步非阻塞**是一个可行的方案。
|
||||
|
||||
OpenResty里实现的cosocket就是一种同步非阻塞方案,借助cosocket我们可以用线性的思维模式来编写非阻塞的程序。下面的示例代码是用cosocket实现的socket程序的客户端,建立连接、发送请求、读取响应所有的操作都是同步的,由于cosocket本身是非阻塞的,所以这些操作虽然是同步的,但是并不会阻塞。
|
||||
|
||||
```
|
||||
-- 创建socket
|
||||
local sock = ngx.socket.tcp()
|
||||
-- 设置socket超时时间
|
||||
sock:settimeouts(connect_timeout, send_timeout, read_timeout)
|
||||
-- 连接到目标地址
|
||||
local ok, err = sock:connect(host, port)
|
||||
if not ok then
|
||||
- -- 省略异常处理
|
||||
end
|
||||
-- 发送请求
|
||||
local bytes, err = sock:send(request_data)
|
||||
if not bytes then
|
||||
-- 省略异常处理
|
||||
end
|
||||
-- 读取响应
|
||||
local line, err = sock:receive()
|
||||
if err then
|
||||
-- 省略异常处理
|
||||
end
|
||||
-- 关闭socket
|
||||
sock:close()
|
||||
-- 处理读取到的数据line
|
||||
handle(line)
|
||||
|
||||
```
|
||||
|
||||
## 结构化并发编程
|
||||
|
||||
Golang中的 go 语句让协程用起来太简单了,但是这种简单也蕴藏着风险。要深入了解这个风险是什么,就需要先了解一下 goto 语句的前世今生。
|
||||
|
||||
在我上学的时候,各种各样的编程语言书籍中都会谈到不建议使用 goto 语句,原因是 goto 语句会让程序变得混乱,当时对于这个问题我也没有多想,不建议用那就不用了。那为什么 goto 语句会让程序变得混乱呢?混乱具体指的又是什么呢?多年之后,我才了解到所谓的混乱指的是代码的书写顺序和执行顺序不一致。代码的书写顺序,代表的是我们的思维过程,如果思维的过程与代码执行的顺序不一致,那就会干扰我们对代码的理解。我们的思维是线性的,傻傻地一条道儿跑到黑,而goto语句太灵活,随时可以穿越时空,实在是太“混乱”了。
|
||||
|
||||
首先发现 goto 语句是“毒药”的人是著名的计算机科学家艾兹格·迪科斯彻(Edsger Dijkstra),同时他还提出了结构化程序设计。在结构化程序设计中,可以使用三种基本控制结构来代替goto,这三种基本的控制结构就是今天我们广泛使用的**顺序结构**、**选择结构**和**循环结构**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/20/501db93634beff4776dd803eb4463920.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/bd/1528f3026981910eef7624aed9c72dbd.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/fd/0d5fa62f62d1e999601ed7e2d52a1dfd.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/9d/c7814d5d1563ab38c312b75c953c4c9d.png" alt="">
|
||||
|
||||
这三种基本的控制结构奠定了今天高级语言的基础,如果仔细观察这三种结构,你会发现它们的入口和出口只有一个,这意味它们是可组合的,而且组合起来一定是线性的,整体来看,代码的书写顺序和执行顺序也是一致的。
|
||||
|
||||
我们以前写的并发程序,是否违背了结构化程序设计呢?这个问题以前并没有被关注,但是最近两年,随着并发编程的快速发展,已经开始有人关注了,而且剑指Golang中的 go 语句,指其为“毒药”,类比的是 goto 语句。详情可以参考[相关的文章](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/)。
|
||||
|
||||
Golang中的 go 语句不过是快速创建协程的方法而已,这篇文章本质上并不仅仅在批判Golang中的 go 语句,而是在批判开启新的线程(或者协程)异步执行这种粗糙的做法,违背了结构化程序设计,Java语言其实也在其列。
|
||||
|
||||
当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。而结构化的程序,可以有分支,但是最终一定要汇聚,不能有多个出口,因为只有这样它们组合起来才是线性的。
|
||||
|
||||
## 总结
|
||||
|
||||
最近几年支持协程的开发语言越来越多了,Java OpenSDK中Loom项目的目标就是支持协程,相信不久的将来,Java程序员也可以使用协程来解决并发问题了。
|
||||
|
||||
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:**易用性**。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是**要适应我们的思维模式**,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
|
||||
|
||||
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
133
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/45 | CSP模型:Golang的主力队员.md
Normal file
133
极客时间专栏/Java并发编程实战/第五部分:其他并发模型/45 | CSP模型:Golang的主力队员.md
Normal file
@@ -0,0 +1,133 @@
|
||||
<audio id="audio" title="45 | CSP模型:Golang的主力队员" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/d6/b7b0c4eb9cf2a5064c25fde0ac247ed6.mp3"></audio>
|
||||
|
||||
Golang是一门号称从语言层面支持并发的编程语言,支持并发是Golang一个非常重要的特性。在上一篇文章[《44 | 协程:更轻量级的线程》](https://time.geekbang.org/column/article/99787)中我们介绍过,Golang支持协程,协程可以类比Java中的线程,解决并发问题的难点就在于线程(协程)之间的协作。
|
||||
|
||||
那Golang是如何解决协作问题的呢?
|
||||
|
||||
总的来说,Golang提供了两种不同的方案:一种方案支持协程之间以共享内存的方式通信,Golang提供了管程和原子类来对协程进行同步控制,这个方案与Java语言类似;另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang的这个方案是基于**CSP**(Communicating Sequential Processes)模型实现的。Golang比较推荐的方案是后者。
|
||||
|
||||
## 什么是CSP模型
|
||||
|
||||
我们在[《42 | Actor模型:面向对象原生的并发模型》](https://time.geekbang.org/column/article/98903)中介绍了Actor模型,Actor模型中Actor之间就是不能共享内存的,彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似,Golang程序员中有句格言:“**不要以共享内存方式通信,要以通信方式共享内存**(Don’t communicate by sharing memory, share memory by communicating)。”虽然Golang中协程之间,也能够以共享内存的方式通信,但是并不推荐;而推荐的以通信的方式共享内存,实际上指的就是协程之间以消息传递方式来通信。
|
||||
|
||||
下面我们先结合一个简单的示例,看看Golang中协程之间是如何以消息传递的方式实现通信的。我们示例的目标是打印从1累加到100亿的结果,如果使用单个协程来计算,大概需要4秒多的时间。单个协程,只能用到CPU中的一个核,为了提高计算性能,我们可以用多个协程来并行计算,这样就能发挥多核的优势了。
|
||||
|
||||
在下面的示例代码中,我们用了4个子协程来并行执行,这4个子协程分别计算[1, 25亿]、(25亿, 50亿]、(50亿, 75亿]、(75亿, 100亿],最后再在主协程中汇总4个子协程的计算结果。主协程要汇总4个子协程的计算结果,势必要和4个子协程之间通信,**Golang中协程之间通信推荐的是使用channel**,channel你可以形象地理解为现实世界里的管道。另外,calc()方法的返回值是一个只能接收数据的channel ch,它创建的子协程会把计算结果发送到这个ch中,而主协程也会将这个计算结果通过ch读取出来。
|
||||
|
||||
```
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 变量声明
|
||||
var result, i uint64
|
||||
// 单个协程执行累加操作
|
||||
start := time.Now()
|
||||
for i = 1; i <= 10000000000; i++ {
|
||||
result += i
|
||||
}
|
||||
// 统计计算耗时
|
||||
elapsed := time.Since(start)
|
||||
fmt.Printf("执行消耗的时间为:", elapsed)
|
||||
fmt.Println(", result:", result)
|
||||
|
||||
// 4个协程共同执行累加操作
|
||||
start = time.Now()
|
||||
ch1 := calc(1, 2500000000)
|
||||
ch2 := calc(2500000001, 5000000000)
|
||||
ch3 := calc(5000000001, 7500000000)
|
||||
ch4 := calc(7500000001, 10000000000)
|
||||
// 汇总4个协程的累加结果
|
||||
result = <-ch1 + <-ch2 + <-ch3 + <-ch4
|
||||
// 统计计算耗时
|
||||
elapsed = time.Since(start)
|
||||
fmt.Printf("执行消耗的时间为:", elapsed)
|
||||
fmt.Println(", result:", result)
|
||||
}
|
||||
// 在协程中异步执行累加操作,累加结果通过channel传递
|
||||
func calc(from uint64, to uint64) <-chan uint64 {
|
||||
// channel用于协程间的通信
|
||||
ch := make(chan uint64)
|
||||
// 在协程中执行累加操作
|
||||
go func() {
|
||||
result := from
|
||||
for i := from + 1; i <= to; i++ {
|
||||
result += i
|
||||
}
|
||||
// 将结果写入channel
|
||||
ch <- result
|
||||
}()
|
||||
// 返回结果是用于通信的channel
|
||||
return ch
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## CSP模型与生产者-消费者模式
|
||||
|
||||
你可以简单地把Golang实现的CSP模型类比为生产者-消费者模式,而channel可以类比为生产者-消费者模式中的阻塞队列。不过,需要注意的是Golang中channel的容量可以是0,容量为0的channel在Golang中被称为**无缓冲的channel**,容量大于0的则被称为**有缓冲的channel**。
|
||||
|
||||
无缓冲的channel类似于Java中提供的SynchronousQueue,主要用途是在两个协程之间做数据交换。比如上面累加器的示例代码中,calc()方法内部创建的channel就是无缓冲的channel。
|
||||
|
||||
而创建一个有缓冲的channel也很简单,在下面的示例代码中,我们创建了一个容量为4的channel,同时创建了4个协程作为生产者、4个协程作为消费者。
|
||||
|
||||
```
|
||||
// 创建一个容量为4的channel
|
||||
ch := make(chan int, 4)
|
||||
// 创建4个协程,作为生产者
|
||||
for i := 0; i < 4; i++ {
|
||||
go func() {
|
||||
ch <- 7
|
||||
}()
|
||||
}
|
||||
// 创建4个协程,作为消费者
|
||||
for i := 0; i < 4; i++ {
|
||||
go func() {
|
||||
o := <-ch
|
||||
fmt.Println("received:", o)
|
||||
}()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Golang中的channel是语言层面支持的,所以可以使用一个左向箭头(<-)来完成向channel发送数据和读取数据的任务,使用上还是比较简单的。Golang中的channel是支持双向传输的,所谓双向传输,指的是一个协程既可以通过它发送数据,也可以通过它接收数据。
|
||||
|
||||
不仅如此,Golang中还可以将一个双向的channel变成一个单向的channel,在累加器的例子中,calc()方法中创建了一个双向channel,但是返回的就是一个只能接收数据的单向channel,所以主协程中只能通过它接收数据,而不能通过它发送数据,如果试图通过它发送数据,编译器会提示错误。对比之下,双向变单向的功能,如果以SDK方式实现,还是很困难的。
|
||||
|
||||
## CSP模型与Actor模型的区别
|
||||
|
||||
同样是以消息传递的方式来避免共享,那Golang实现的CSP模型和Actor模型有什么区别呢?
|
||||
|
||||
第一个最明显的区别就是:**Actor模型中没有channel**。虽然Actor模型中的 mailbox 和 channel 非常像,看上去都像个FIFO队列,但是区别还是很大的。Actor模型中的mailbox对于程序员来说是“透明”的,mailbox明确归属于一个特定的Actor,是Actor模型中的内部机制;而且Actor之间是可以直接通信的,不需要通信中介。但CSP模型中的 channel 就不一样了,它对于程序员来说是“可见”的,是通信的中介,传递的消息都是直接发送到 channel 中的。
|
||||
|
||||
第二个区别是:Actor模型中发送消息是**非阻塞**的,而CSP模型中是**阻塞**的。Golang实现的CSP模型,channel是一个阻塞队列,当阻塞队列已满的时候,向channel中发送数据,会导致发送消息的协程阻塞。
|
||||
|
||||
第三个区别则是关于消息送达的。在[《42 | Actor模型:面向对象原生的并发模型》](https://time.geekbang.org/column/article/98903)这篇文章中,我们介绍过Actor模型理论上不保证消息百分百送达,而在Golang实现的**CSP模型中,是能保证消息百分百送达的**。不过这种百分百送达也是有代价的,那就是有可能会导致**死锁**。
|
||||
|
||||
比如,下面这段代码就存在死锁问题,在主协程中,我们创建了一个无缓冲的channel ch,然后从ch中接收数据,此时主协程阻塞,main()方法中的主协程阻塞,整个应用就阻塞了。这就是Golang中最简单的一种死锁。
|
||||
|
||||
```
|
||||
func main() {
|
||||
// 创建一个无缓冲的channel
|
||||
ch := make(chan int)
|
||||
// 主协程会阻塞在此处,发生死锁
|
||||
<- ch
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
Golang中虽然也支持传统的共享内存的协程间通信方式,但是推荐的还是使用CSP模型,以通信的方式共享内存。
|
||||
|
||||
Golang中实现的CSP模型功能上还是很丰富的,例如支持select语句,select语句类似于网络编程里的多路复用函数select(),只要有一个channel能够发送成功或者接收到数据就可以跳出阻塞状态。鉴于篇幅原因,我就点到这里,不详细介绍那么多了。
|
||||
|
||||
CSP模型是托尼·霍尔(Tony Hoare)在1978年提出的,不过这个模型这些年一直都在发展,其理论远比Golang的实现复杂得多,如果你感兴趣,可以参考霍尔写的[Communicating Sequential Processes](http://www.usingcsp.com/cspbook.pdf)这本电子书。另外,霍尔在并发领域还有一项重要成就,那就是提出了霍尔管程模型,这个你应该很熟悉了,Java领域解决并发问题的理论基础就是它。
|
||||
|
||||
Java领域可以借助第三方的类库[JCSP](https://www.cs.kent.ac.uk/projects/ofa/jcsp/)来支持CSP模型,相比Golang的实现,JCSP更接近理论模型,如果你感兴趣,可以下载学习。不过需要注意的是,JCSP并没有经过广泛的生产环境检验,所以并不建议你在生产环境中使用。
|
||||
|
||||
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user