Synchronized是Java非常常见的一种并发机制。哪怕我们不见得会直接用到,synchronzied仍在很多的公用库里会用到。使用Synchronized会有一些,其中的一个问题是,Synchronized是一种阻塞操作,带来了复杂性。本文将通过一种简单的方式来说明此问题,并说明选择Akka Actor获得更好、更易维护的并发性代码的理由。
考虑以下样例代码:
int x; if (x > 0) { return true; } else { return false; }
如果x为正数,则返回true 。很简单的一段代码。再考虑一下下面的计算器的代码:
x++;
以上代码都很简单,但如果在多线程环境下,代码可能会有很大的问题。
在第一个示例中,true或false不是由x的值确定的,而是由if判断确定。因此,如果在第一个线程通过if判断之后另一个线程将x更改为负数,即使x不再为正数,我们仍然会变为true。
第二个例子很具有欺骗性。尽管只是一行代码,但实际上有三个操作:读取x,对其进行递增并返回更新后的值。如果两个线程恰好同时运行,则更新的值可能会丢失。
当不同的线程同时访问和修改同一个变量的时候,就出现了竞争条件。如果我们仅只是想构建一个计数器,则Java提供了线程安全的Atomic变量,其中包括Atomic Integer,我们可以将其用于此目的。但是,Atomic仅适用于单个变量。如何使多个操作原子化?
第一反应是通过使用Synchronized块。看一个更详细的例子:
int x; public int withdraw(int deduct){ int balance = x - deduct; if (balance > 0) { x = balance; return deduct; } else { return 0; } }
上面的代码实现了一种基本的提取现金的处理过程。多线程情况上,很有可能会有很大问题:即使余额不足,两个线程同时运行也可能导致银行提供两次提款。
下面的代码是加上同步块之后的代码:
volatile int x; public int withdraw(int deduct){ synchronized(this){ int balance = x - deduct; if (balance > 0) { x = balance; return deduct; } else { return 0; } } }
同步块的想法很简单。一个线程进入并锁定,其它线程必须等待。锁是一个对象,在我们的例子中是this。进入同步块的代码执行完成后,将锁释放并传递给另一个线程,然后该线程将执行相同的操作。另,请注意需要使用关键字volatile,以防止线程使用变量x的本地CPU缓存。
加入同步块后,哪怕在多线程复杂的执行环境下,银行也不会意外提供多次提款。但是,当有越来越多的同步块和并发锁存在的时候,这种代码结构往往带来复杂的代码逻辑,而处理多个同步锁的过程也容易引发错误。多个同步块可能会在不经意间互相持有同步锁并锁定整个应用。还有一个非常重要的情况是,Synchronized同步块在很多情况下存在效率问题:当一个线程运行的时候,所有其它线程都需要等待。
和上面的同步块类似的是队列,可以考虑使用队列来实现相同的功能。想象一个电子邮件系统,发送电子邮件时,会将电子邮件拖放到收件人的邮箱中,不必等到接收方阅读就直接返回。Actor模型和Akka框架就是基于此。
Actor封装状态和行为。但是,与OOP的封装不同,actor根本不公开其状态和行为。actor相互交流的唯一方法是交换消息。传入邮件将被放入邮箱中,并按照先进先出的顺序进行进行处理。
下面代码是Akka和Scala中重写的示例:
case class Withdraw(deduct: Int) class SlaveActor extends Actor { var x = 10; def receive: Receive = { case Withdraw(deduct) => val r = withdraw(deduct) } } class BossActor extends Actor { var slave = context.actorOf(Props[SlaveActor]) slave ! Withdraw(6) slave ! Withdraw(9) }
SlaveActor负责具体的工作,而BossActor负责向SlaveActor发送命令。sign(tell)是一个参与者将消息异步发送到另一个参与者的两种方法之一(另一种是ask)。tell在执行时不等待答复。因此,BossActor告诉SlaveActor做两次撤单。这些消息到达slave的接收器,其中的每个消息都会被相应的处理程序处理。这种情况下,Withdraw执行withdraw执行金额扣除操作。操作完成后,将前行到队列中的下一条消息。
以上代码改动带来了什么优势?首先,不需要担心锁和使用原子/并发类型带来的线程安全性问题。Actor的封装和排队机制已经保证了线程安全性。线程只是发送消息就返回,也不需要再等待。结果稍后通过Ask或Tell传递,模型很简单,也很有效。
Akka基于JVM,在Scala和Java中均可使用。本文并未对Java与Scala语言进行对比,但Scala的模式匹配和函数式编程在管理Actor的数据消息传递方面很有用,可以避免Java的方括号和分号,可以编写出较短、但同样有效的代码。