Scala并发编程:STM与Actor模型实践
1. Scala中使用STM
1.1 选择STM方案
Scala是JVM上的静态类型语言,融合了面向对象和函数式编程风格。在Scala中使用软件事务内存(STM)有多种选择,可能会倾向于Akka解决方案,因为其提供的Scala API更流畅。不过,如果项目已经在使用Clojure STM,也可以在Scala中使用它。
1.2 使用Clojure STM
在Scala中使用Clojure STM与Java类似,可以使用
Ref
类和
LockingTransaction
类的
runInTransaction()
方法。以下是一个账户转账的示例:
import clojure.lang.Ref
import clojure.lang.LockingTransaction
import java.util.concurrent.Callable
class Account(val initialBalance : Int) {
val balance = new Ref(initialBalance)
def getBalance() = balance.deref
def deposit(amount : Int) = {
LockingTransaction.runInTransaction(new Callable[Boolean] {
def call() = {
if(amount > 0) {
val currentBalance = balance.deref.asInstanceOf[Int]
balance.set(currentBalance + amount)
println("deposit " + amount + "... will it stay")
true
} else throw new RuntimeException("Operation invalid")
}
})
}
def withdraw(amount : Int) = {
LockingTransaction.runInTransaction(new Callable[Boolean] {
def call() = {
val currentBalance = balance.deref.asInstanceOf[Int]
if(amount > 0 && currentBalance >= amount) {
balance.set(currentBalance - amount)
true
} else throw new RuntimeException("Operation invalid")
}
})
}
}
def transfer(from : Account, to : Account, amount : Int) = {
LockingTransaction.runInTransaction(new Callable[Boolean] {
def call() = {
to.deposit(amount)
from.withdraw(amount)
true
}
})
}
def transferAndPrint(from : Account, to : Account, amount : Int) = {
try {
transfer(from, to, amount)
} catch {
case ex => println("transfer failed " + ex)
}
println("Balance of from account is " + from.getBalance())
println("Balance of to account is " + to.getBalance())
}
val account1 = new Account(2000)
val account2 = new Account(100)
transferAndPrint(account1, account2, 500)
transferAndPrint(account1, account2, 5000)
运行上述代码,输出如下:
deposit 500... will it stay
Balance of from account is 1500
Balance of to account is 600
deposit 5000... will it stay
transfer failed java.lang.RuntimeException: Operation invalid
Balance of from account is 1500
Balance of to account is 600
从输出可以看出,第一次转账成功,账户余额相应改变;第二次转账失败,账户余额不受影响。
1.3 使用Akka/Multiverse STM
Akka是一个不错的选择,它用Scala编写,其API对Scala程序员来说很自然。Scala作为混合函数式编程语言,允许创建可变(
var
)和不可变(
val
)变量。为了更好地实践,应尽量使用不可变变量,确保状态不可变,只有标识引用(Refs)是可变的。
2. 倾向于隔离可变性
2.1 并发编程的痛点
在并发编程中,共享可变性是导致问题的根源。使用JDK线程API创建线程很容易,但防止线程冲突和混乱却很困难。STM在一定程度上缓解了这个问题,但在Java等语言中,仍需小心避免未管理的可变变量和副作用。当共享可变性消失时,这些问题也会随之消失。
2.2 基于事件的消息传递
一种更好的方法是基于事件的消息传递。将任务视为应用程序或JVM内部的轻量级进程,不直接让它们访问数据,而是向它们传递不可变消息。任务完成后,将不可变结果传递给其他协调任务。通过设计协调的Actor来异步交换不可变消息,可以避免同步和共享可变性带来的问题。
2.3 Actor模型的优势
Actor模型在Erlang中非常成功和流行,2003年Scala引入时将其带到了JVM领域。在Java中,有多种提供基于Actor并发的库可供选择,如ActorFoundary、Actorom、Actors Guild、Akka等。本文主要使用Akka来介绍基于Actor的并发编程。
2.4 隔离可变性
Java将面向对象编程变成了基于可变性的开发,而函数式编程强调不可变性,这两种极端都存在问题。在实际应用中,不能让所有内容都不可变,但必须避免共享可变性。共享可变性是并发问题的根源,而隔离可变性是一种很好的折衷方案,它让只有一个线程(或Actor)可以访问可变变量。
在面向对象编程中,通过封装让实例方法操作对象状态,但不同线程调用这些方法会导致并发问题。在基于Actor的编程模型中,只允许一个Actor操作对象状态,应用程序虽然是多线程的,但每个Actor是单线程的,因此不存在可见性和竞争条件问题。
以下是Actor编程的设计流程:
graph LR
A[将问题分解为异步计算任务] --> B[将任务分配给不同Actor]
B --> C[每个Actor专注执行指定任务]
C --> D[将可变状态限制在最多一个Actor内]
D --> E[确保Actor间传递的消息不可变]
E --> F[Actor接收不可变数据执行任务]
F --> G[完成任务后将不可变结果传递给其他Actor]
2.5 Actor的特性
- 消息队列 :每个Actor都有内置的消息队列,类似于手机的消息队列。多个Actor可以同时发送消息,发送者默认是非阻塞的,发送消息后继续处理自己的业务。Actor库会让指定的Actor顺序处理消息。
- 生命周期 :Actor创建后可以启动或停止。启动后准备接收消息,处于活动状态时,要么正在处理消息,要么等待新消息。停止后不再接收消息。
- 线程解耦 :Actor库通常将Actor与线程解耦,线程对于Actor就像食堂座位对于办公室员工。Actor有消息要处理时会被分配一个可用线程,不处理任务时会释放线程,这样可以让更多的Actor处于不同状态并高效利用有限的线程资源。
2.6 创建Actor
2.6.1 在Java中创建Actor
在Java中使用Akka创建Actor,需要继承
akka.actor.UntypedActor
类并实现
onReceive()
方法。以下是一个示例:
import akka.actor.ActorRef;
import akka.actor.Actors;
import akka.actor.UntypedActor;
public class HollywoodActor extends UntypedActor {
public void onReceive(final Object role) {
System.out.println("Playing " + role +
" from Thread " + Thread.currentThread().getName());
}
}
public class UseHollywoodActor {
public static void main(final String[] args) throws InterruptedException {
final ActorRef johnnyDepp = Actors.actorOf(HollywoodActor.class).start();
johnnyDepp.sendOneWay("Jack Sparrow");
Thread.sleep(100);
johnnyDepp.sendOneWay("Edward Scissorhands");
Thread.sleep(100);
johnnyDepp.sendOneWay("Willy Wonka");
Actors.registry().shutdownAll();
}
}
运行上述代码,需要使用
javac
编译,并指定Akka库文件的类路径:
javac -d . -classpath $AKKA_JARS HollywoodActor.java UseHollywoodActor.java
java -classpath $AKKA_JARS com.agiledeveloper.pcj.UseHollywoodActor
其中,
AKKA_JARS
的定义如下:
export AKKA_JARS="$AKKA_HOME/lib/scala-library.jar:\
$AKKA_HOME/lib/akka/akka-stm-1.1.3.jar:\
$AKKA_HOME/lib/akka/akka-actor-1.1.3.jar:\
$AKKA_HOME/lib/akka/multiverse-alpha-0.6.2.jar:\
$AKKA_HOME/lib/akka/akka-typed-actor-1.1.3.jar:\
$AKKA_HOME/lib/akka/aspectwerkz-2.2.3.jar:\
$AKKA_HOME/config:\
."
运行结果如下:
Playing Jack Sparrow from Thread akka:event-driven:dispatcher:global-1
Playing Edward Scissorhands from Thread akka:event-driven:dispatcher:global-2
Playing Willy Wonka from Thread akka:event-driven:dispatcher:global-3
如果需要在创建Actor时传递参数,可以通过实现
UntypedActorFactory
接口来实现:
import akka.actor.ActorRef;
import akka.actor.Actors;
import akka.actor.UntypedActor;
import akka.actor.UntypedActorFactory;
public class HollywoodActor extends UntypedActor {
private final String name;
public HollywoodActor(final String theName) { name = theName; }
public void onReceive(final Object role) {
if(role instanceof String)
System.out.println(String.format("%s playing %s", name, role));
else
System.out.println(name + " plays no " + role);
}
}
public class UseHollywoodActor {
public static void main(final String[] args) throws InterruptedException {
final ActorRef tomHanks = Actors.actorOf(new UntypedActorFactory() {
public UntypedActor create() { return new HollywoodActor("Hanks"); }
}).start();
tomHanks.sendOneWay("James Lovell");
tomHanks.sendOneWay(new StringBuilder("Politics"));
tomHanks.sendOneWay("Forrest Gump");
Thread.sleep(1000);
tomHanks.stop();
}
}
运行结果如下:
Hanks playing James Lovell
Hanks plays no Politics
Hanks playing Forrest Gump
2.6.2 在Scala中创建Actor
在Scala中创建Akka Actor,需要继承
Actor
特质并实现
receive()
方法。以下是一个示例:
import akka.actor.Actor
import akka.actor.Actor.actorOf
import akka.actor.Actors
class HollywoodActor extends Actor {
def receive = {
case role =>
println("Playing " + role +
" from Thread " + Thread.currentThread().getName())
}
}
object UseHollywoodActor {
def main(args : Array[String]) :Unit = {
val johnnyDepp = actorOf[HollywoodActor].start()
johnnyDepp ! "Jack Sparrow"
Thread.sleep(100)
johnnyDepp ! "Edward Scissorhands"
Thread.sleep(100)
johnnyDepp ! "Willy Wonka"
Actors.registry.shutdownAll
}
}
编译和运行代码时,需要使用
scalac
编译器并指定Akka库文件的类路径:
scalac -classpath $AKKA_JARS HollywoodActor.scala UseHollywoodActor.scala
java -classpath $AKKA_JARS com.agiledeveloper.pcj.UseHollywoodActor
运行结果与Java版本类似:
Playing Jack Sparrow from Thread akka:event-driven:dispatcher:global-1
Playing Edward Scissorhands from Thread akka:event-driven:dispatcher:global-2
Playing Willy Wonka from Thread akka:event-driven:dispatcher:global-3
如果需要传递参数,在Scala中实现起来更简单:
import akka.actor.Actor
import akka.actor.Actor.actorOf
import akka.actor.Actors
class HollywoodActor(val name : String) extends Actor {
def receive = {
case role : String => println(String.format("%s playing %s", name, role))
case msg => println(name + " plays no " + msg)
}
}
object UseHollywoodActor {
def main(args : Array[String]) :Unit = {
val tomHanks = actorOf(new HollywoodActor("Hanks")).start()
tomHanks ! "James Lovell"
tomHanks ! "Politics"
tomHanks ! "Forrest Gump"
Actors.registry.shutdownAll
}
}
综上所述,Scala提供了多种并发编程的方式,STM和基于Actor的并发编程都有各自的优势。在实际应用中,可以根据具体需求选择合适的方式。对于频繁读取和合理写入冲突的应用,可以使用STM;当写入冲突较大时,基于Actor的模型可能更合适。
3. STM与Actor模型的对比与总结
3.1 STM与Actor模型的对比
特性 | STM | Actor模型 |
---|---|---|
适用场景 | 适用于频繁读取和合理写入冲突的应用,能自动处理并发冲突,保证事务的原子性和一致性。 | 适用于写入冲突较大的场景,通过隔离可变性和消息传递避免共享可变带来的并发问题。 |
编程复杂度 | 编程相对简单,事务由系统自动管理,但需要确保值的不可变和事务的幂等性。 | 编程模型需要重新理解和设计,要将问题分解为异步任务并分配给不同Actor,确保消息的不可变性。 |
并发控制 | 基于事务机制,通过系统自动检测和解决冲突。 | 基于单线程的Actor,每个Actor内部顺序处理消息,避免了内部的竞争条件。 |
可扩展性 | 在处理大量并发写入时可能性能下降,因为冲突处理会带来额外开销。 | 可以通过增加Actor数量来扩展系统,Actor与线程解耦,能高效利用系统资源。 |
3.2 总结与选择建议
STM和Actor模型都是解决并发编程问题的有效手段,但它们的适用场景和编程方式有所不同。在选择使用哪种模型时,需要考虑应用的具体需求和特点。
如果应用中存在大量的并发读取操作,并且写入冲突相对较少,STM是一个不错的选择。它可以让开发者专注于业务逻辑,而不必过多担心并发冲突的处理。例如,在一个数据库查询系统中,多个用户可能同时查询数据,偶尔有少量的写入操作,使用STM可以很好地保证数据的一致性。
如果应用中写入冲突较为频繁,或者需要处理大量的异步任务,Actor模型可能更合适。通过将任务分配给不同的Actor,每个Actor独立处理消息,可以避免共享可变带来的并发问题,提高系统的可扩展性和稳定性。例如,在一个实时消息处理系统中,需要处理大量的用户消息,使用Actor模型可以让每个消息处理任务独立运行,提高系统的吞吐量。
以下是选择并发模型的决策流程:
graph LR
A[开始] --> B{写入冲突是否频繁?}
B -- 是 --> C[选择Actor模型]
B -- 否 --> D{读取操作是否频繁?}
D -- 是 --> E[选择STM]
D -- 否 --> F[根据其他需求选择合适模型]
C --> G[设计异步任务和Actor]
E --> H[确保值的不可变和事务幂等性]
F --> I[综合考虑性能、复杂度等因素]
G --> J[实现Actor逻辑和消息传递]
H --> K[编写事务代码]
I --> L[确定最终方案]
J --> M[测试和优化系统]
K --> M
L --> M
M --> N[结束]
3.3 最佳实践建议
无论是使用STM还是Actor模型,都有一些最佳实践可以遵循,以提高代码的质量和系统的性能。
3.3.1 STM最佳实践
- 确保值的不可变 :在事务中使用不可变对象,避免因对象状态的改变导致并发问题。
- 保证事务的幂等性 :事务应该可以多次执行而不产生额外的影响,这样可以提高事务的可靠性。
- 减少事务的粒度 :尽量将事务的操作范围缩小,减少冲突的可能性。
3.3.2 Actor模型最佳实践
- 设计合理的Actor结构 :将问题分解为合适的Actor,每个Actor专注于一个特定的任务,提高系统的可维护性。
- 确保消息的不可变 :Actor之间传递的消息应该是不可变的,避免因消息状态的改变导致并发问题。
- 避免Actor之间的过度依赖 :Actor应该尽量独立,减少相互之间的依赖,提高系统的可扩展性。
通过遵循这些最佳实践,可以让并发编程更加高效和可靠,避免常见的并发问题。在实际开发中,还需要不断地测试和优化系统,根据实际情况调整模型和代码,以达到最佳的性能和稳定性。
总之,Scala提供的STM和Actor模型为并发编程提供了强大的工具,开发者可以根据具体需求灵活选择和使用,以应对不同的并发场景。通过合理的设计和实践,可以开发出高效、稳定的并发应用程序。