继续上周的编程范式话题,今天想聊一下并发范式。java
真正的并发式编程,毫不只是调用线程API或使用synchronized、lock之类的关键字那么简单。从宏观的架构设计,到微观的数据结构、流程控制乃至算法,相比一般的串行式编程均可能发生变化。绝不夸张的说,是又一场思想和技术上革命。程序员
在平常开发中,并发编程难度是比较高的,属于高级程序员才能掌握的内容。其难点在哪里,咱们平常习惯的是线性思惟,这与并发编程的多维世界观是不一样的,提高思考的维度无疑是艰难。但还好,在大神们的努力下,已逐渐化繁为简,这也是并发范式带来的力量。在并发领域有许多的模型,让咱们来巡礼一下。算法
并发编程以资源共享和竞争为主线。这意味着程序设计将围绕进程的划分与调度、进程之间的通讯与同步等来展开。合理的并发式设计须要诸多方面的权衡考量。数据库
线程是对底层硬件过程的形式化,是并发编程的核心。不一样的线程各自独立运行,有如一个个的平行宇宙。可是,并发编程并不只仅串行化编程的叠加,主要的差别在于,线程之间存在共享和竞争。共享资源会带来哪些问题呢?编程
当线程1对共享数据进行修改时,线程2有可能会读处处于中间状态的数据,这个问题称之为脏读。安全
解决的思路比较简单,就是让线程1仅提交最终修改结果,在修改过程当中产生并使用快照数据。这种相似影分身的技术称之为MVCC(Multi-Version Concurrency Control)。数据结构
当线程1对数据进行修改时,若是线程2同时修改,因为采用了MVCC,双方各自没法看到,那最终提交时,极可能会形成其中一个线程结果与预期不一致,这个问题称为丢失更新。
其解决方法是加锁,在修改前进行加锁,一旦占用,则第二个线程没法获取。多线程
脏读和丢失更新是须要同时考虑的,因此标准的多线程处理是同时使用到了MVCC和锁这两个技术。架构
在已解决了丢失更新和脏读的状况下,下面要考虑屡次读取的状况。以下图所示,线程1对数据集进行了屡次读取,可是部分数据在线程2中进行了更新,这时候出现了线程1在没有任何做为的状况下,两次读取不一致的状况!!!这个问题称为幻读。并发
解决幻读的方法是扩大数据的锁范围,不只仅是更改过的记录,全部读取的记录都要加锁。
这就是目前咱们最主流的并发与锁的实现思路方法,有很是普遍的使用。不知道你们读完这段的感受怎么,我看的时候,第一个感受是复杂,真的很是的烧脑,因为大量概念的堆积对于初学者来讲很是不友好;第二个感受是矛盾,按照最终幻读的解决方案,实际上就是放弃了程序间的并行,绕了一圈,又回到了原点。正由于如此,目前主流的数据库,实际上默认都是放弃对于幻读问题解决的,这也是开发上的一大坑。
综合的来看,这种解决方式学习成本很高,并且还没能解决所有的问题,并不能让人满意。有没有更好点的方法,让咱们继续。
传统并发模型中,最使人纠结的无疑就是共享数据访问这块了。若不爽,就另辟蹊径。咱们能不能不对共享数据进行写入呢?有什么样的程序是只读不写的呢,大神们已经找到了答案,就是上周介绍的函数式编程。
首先想说明的事,纯函数式的编程功能上并不完备,有很是多的缺陷,但其有一个自然的适用场景,就是数学运算分析,也就是咱们如今时常挂在嘴边的大数据计算。
因为抛弃掉了共享状态,其代码的健壮性和扩展性获得了大大的加强,只要有足够的计算资源就能够处理无限大的数据。
函数式编程思惟比较数学化,难度是比较高的,在此基础上,诞生了Lambda框架,是对应用模式的固化,有助于下降学习成本和大范围推广。Lambda框架既使用了能够进行大规模批处理的MapReduce技术,也使用了能够快速处理数据并及时反馈的流处理技术,这样的混搭可以为大数据问题提供扩展性、响应性和容错性都能优秀的解决方案。
Lambda架构也能够这样来描述:在该架构中,被读取的数据是不可变的,在并行处理过程当中数据会依次进入批处理系统(batch system)与流处理系统。从逻辑上看,传输过程发生了两次,一次是在批处理中,一次是在流处理中。在查询时,当这二者都返回结果后,才算是完成一次完整的查询。
函数式编程模型的应用使得并发编程的应用踏入了工业级,带动了大数据的热潮。可是其解决思路是抛弃了可变状态,服务是有损的。对于必须提供无损服务的场景该如何进行改进呢。
从最一开始线程与锁的模型中,咱们能够看到串行化是最重的解决方案,可是为了串行化,咱们须要MVCC、锁等一系列的工具,比较复杂,Actor模型就是用来简化此类操做的。
Actor模型中抽象出了两个概念Actor和Mailbox,Actor就是指代共享数据,Mailbox管理数据的操做。对于每一个Actor的操做,要经过mailbox来进行,在mailbox端实现了队列的控制,从而实现了序列化的效果。
Actor模型会带来一些额外的好处:
class Pong extends Actor { def act() { var pongCount = 0 while (true) { receive { case Ping => if (pongCount % 1000 == 0) Console.println("Pong: ping " + pongCount) sender ! Pong pongCount = pongCount + 1 case Stop => Console.println("Pong: stop") exit() } } } }
不少状况下咱们须要一个高效的、线程安全的并发解决方案。高效意味着耗用资源要少,程序处理速度要快;线程安全也很是重要,这个在多线程下能保证数据的正确性。有一个解决方案是原子变量。
一般状况下,在Java里面,++i或者--i不是线程安全的,这里面有三个独立的操做:得到变量当前值,为该值+1/-1,而后写回新的值。在没有额外资源能够利用的状况下,只能使用加锁才能保证读-改-写这三个操做是“原子性”的。
下面是示例代码:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next; } }
在这里采用了CAS操做,每次从内存中读取数据而后将此数据和+1后的结果进行CAS操做,若是成功就返回结果,不然重试直到成功为止。而compareAndSet利用JNI来完成CPU指令的操做。
原子变量在一些对性能有极端要求的系统中(好比Jetty、Tomcat)有很是普遍的应用,是一种精益求精的体现,其在可靠性和性能方面表现很突出,但在易用性方面比较偏计算机思惟,理解难度较大,并不够简洁,须要反复练习才能掌握。
在今天的篇文章中,列举了并发范式的四个主流模型:线程与锁、函数式编程、Actor、原子变量。能够看到,每一个模型都是在功能、性能和易用之间寻求了一种平衡,并无一种模型在功能、性能和易用三方面同时达到最优,也就是说没有银弹。 这是咱们面对并发问题时的困境,也是挑战,也正说明了并发并非一个简单的线性问题,咱们须要针对具体场景、具体问题进行分析,寻找最适合的解决方法,这也是开发人员须要养成的一种重要素养。