1. 前言
我在最近一份工作里使用 Elixir 进行编程快半年了,在从 0 到使用 Elixir 的过程中有一些感悟我认为可以记录下来,顺便推广一下 Elixir 这个语言,故计划写一写关于自己学习和使用 Elixir 的心得。
由于自己的能力所限,会有一些误差,希望 Erlang 和 Elixir 的同学可以大方留言指正,共同进步。
Elixir 目前的中文资料还是比较少,但也不可否认它是一个独特且有力的语言,希望这系列小文章会能够给某些技术同学带来一些新的思路,进而达到推广 Elixir 的目的。
2. OOP 的局限
2.1 OOP 的历史
OOP 的设计雏形可以追溯到 1960 年代,成为主导编程思想主要是 C++ 的提出。OOP 在用户界面编程上有天然的优势,而 80 年代就是用户界面编程的潮流。90 年代后,Java 从 C++ 接过了 OOP 的大旗,在21世纪中使 OOP 依然处于主导地位。
在封装、多态、继承、抽象的加持下,OOP 编程思想确实非常易于剖析和分解一些业务问题,大家围绕 OOP 哲学构建起了大量的极为出色的系统和中间件。
2.2 OOP 局限
但 OOP 缺并非银弹。
2.2.1 并发环境下的OOP
封装本身是 OOP 的核心原则之一。这意味着外界不能直接访问内部数据,只能通过对象开放的一系列方法去访问或修改对象数据。在单线程环境下,这个做法是没有问题。但在并发环境下。对象内部的数据便不可控了。
如图2,假设红线、绿线是两个不同的线程,则他们相交的部分就会变得不可控了。按照我们以往的做法,很快就会想到用 锁(lock)。但是用锁其也会带来新的问题:
- 锁是非常耗性能的。假如该对象是一个热点对象,在一个 32cpu 的服务器中对该对象上锁,很有可能让整个服务器的性能快速下降。CPU 的性能越高,锁的副作用就越大;
- 锁还会导致死锁。
因此,OOP 在并发环境下,进入了两难:
- 没有锁的保护,状态是无法被保障的;
- 粒度过大,性能会下降得很快;粒度过小,会容易导致死锁(千万不要认为死锁离我们很远)。
在分布式环境,分布式锁带来的性能下降会更加严重,这意味着锁的性能消耗需要带上网络上的开销,强依赖了外部环境。
小结一下,以上就是 OOP 在并发环境下的局限。
2.3 调用栈 call stacks 的天然缺陷
提到调用栈 call stacks 其实并不陌生,我们往代码打了断点,就可以轻易地观察到一个调用栈,如何从 main 方法到当前断点。
但我们需要更加深入地讨论调用栈。首先 call stacks 的提出是在单核 CPU 的时代,其次是 call stacks 是无法进行跨线程分析的。
但很多时候,我们的调用都是跨线程的。例如 DB 的线程池,我们的 caller 线程如何和 DB 的线程(callee)进行交互呢?一般来说,我们会抽象一个 Task,将 Task 写入一个内存中(线程安全的队列),然后由 DB 的线程读取并消费。RPC 网络通信的设计也差不多如此,通过网络将数据发送到另外一个主机的内存中,由另外一个主机的线程池进行消费。
但这样的设计天然存在问题:callee 线程如何通知 caller 线程 task 已经完成或异常呢?这在调用栈上是不存在的,但在分布式系统上缺天然存在。
这个问题可以演变得非常严重:例如 caller 线程不短地向 queue 提交任务,但 callee 线程因为某些原因已经挂了或状态错误一直无法恢复。这样的情况下造成的脏数据是非常危险的。
可能有同学会认为,callee 是否可以回调 callback caller 线程来完成通知?可以是可以,但这样会引起一个新的问题 callback hell 回调地狱。代码结构会变得非常烂,难以维护。虽然也是一个有用的工具,但是我们应该认为能够使用同步,就有限使用同步。
整体来说,在 call stacks 的语义下讨论跨线程跨进程的编程都是比较困难的。
3. 小结
综上所述,我认为OOP编程模型在分布式/并发的系统中其实并不适用。虽然工程师们为解决这些问题编写了很多强大的库,例如:Java 的 concurrent 库,也定义了很多 rpc 框架,dubbo 这些。但始终无法逃脱上面描述的缺陷,仅仅是将问题屏蔽。
4. 异军突起
那在 1980 年,OOP 开始占领主导地位的情况下,是否有不同的声音被提出?答案是肯定的,在 1973 年,actor model 作为不同于 OOP 的编程模型并提出,在 actor model 的理论下诞生的语言也一直在发展。其中以后来的 Erlang 为比较主流。