一、Actor模型:面向对象原生的并发模型
按照面向对象编程的理论,对象之间通信需要依靠消息,而实际上,像 C++、Java 这些面向对象的语言,对象之间通信,依靠的是对象方法。对象方法和过程语言里的函数本质上没有区别,有入参、有出参,思维方式很相似,使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢?很长一段时间里,我都以为对象方法是面向对象理论中消息的一种实现,直到接触到 Actor 模型,才明白消息压根不是这个实现法。
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 是一种基础的计算单元,具体来讲包括三部分能力,分别是:
- 处理能力,处理接收到的消息。
- 存储能力,Actor 可以存储自己的内部状态,并且内部状态在不同 Actor 之间是绝对隔离的。
- 通信能力,Actor 可以和其他 Actor 之间通信。 当一个 Actor 接收的一条消息之后,这个 Actor 可以做以下三件事:
- 创建更多的 Actor;
- 发消息给其他 Actor;
- 确定如何处理下一条消息。 其中前两条还是很好理解的,就是最后一条,该如何去理解呢?前面我们说过 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 模型基本上不会有违和感。
Actor 可以创建新的 Actor,这些 Actor 最终会呈现出一个树状结构,非常像现实世界里的组织结构,所以利用 Actor 模型来对程序进行建模,和现实世界的匹配度非常高。Actor 模型和现实世界一样都是异步模型,理论上不保证消息百分百送达,也不保证消息送达的顺序和发送的顺序是一致的,甚至无法保证消息会被百分百处理。虽然实现 Actor 模型的厂商都在试图解决这些问题,但遗憾的是解决得并不完美,所以使用 Actor 模型也是有成本的。
二、软件事务内存
其实很多编程语言都有从数据库的事务管理中获得灵感,并且总结出了一个新的并发解决方案:软件事务内存(Software Transactional Memory,简称 STM)。传统的数据库事务,支持 4 个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是大家常说的 ACID,STM 由于不涉及到持久化,所以只支持 ACI。
用 STM 实现转账
举一个并发转账的例子,示例代码如下。简单地使用 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就是个不错的选择。下面的示例代码就是借助 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 的工作原理和乐观锁非常相似。有不少 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 相对来说更简单。