Loading…

锲子

距离记事似乎已经很久了,按说应该动笔写F#那篇的,无奈前一段时间太忙,所以耽搁了,思路也就断了。 好不容易最近得了一些空,因此把一些以前指定的计划重新翻了一些出来,试图做些进展。其中之一就是学Scala和Akka。

之前由于IDE的原因刚起了个头就放弃了,但好在这次发现了Ensime这个项目,发现Scala在Atom上似乎也可以编的比较顺手。

正文

因为Node.js的缘故导致我在业余时对消息传递(Message Passing)系统很感兴趣,最早研究过Comet,后来把玩过Websocket,但随着学习的深入,发现后端的高并发支撑架构其实才是乐趣真正的所在,Non-bloking I/O、Coroutine、Asynchronous、Distributed etc,Node.js虽也以高并发成名,但碍于某些原因,业务一旦上了正轨,面临超高规模的并发挑战时,它还是会显得力有未逮。因此便将目光转移到了其他的语言与模型比如Erlang,不过与其说erl是一门语言,倒不如说是一个分布式系统,因为其剑走偏锋,单纯用语言来衡量并不完全(一直想用erl写一个socks5接口,到现在也只写了一半,罪过罪过)。WhatsApp后端基于erl架构获得了单节点最高可以负载C3000K的并发能力,凭的就是基于Actor(?)的消息通讯模型,异步、Immutable、完全的抢占式调度以及极易拓展的分布式结构,爱立信在二十年前就借助erl实现了工业级的电信系统并宣称可以达到9个9的可用性(99.9999999%,1ms downtime per year),然而erl毕竟是一门函数式语言,尚属小众(人才难觅)。随着人们提出“免费的午餐已经结束”的口号,语言内置的并发模型似乎成了一个关键指标,所以Golang内置了CSP和Coroutine,Scala则有Actor相伴左右。现如今人们面对并发模型选择时,倒是算三足鼎立(多线程模型已然说过太多因此在这不表了):

  • Callback
  • Actor/CSP
  • Multi-thread

Callback

import fs from 'fs'

fs.readFile('/etc/passwd', function (err, data) {  
  if (err) throw err;
  console.log(data);
});

上面这段代码想必都为人熟知,Callback是Node.js里内置的异步模型,由于其良好的利用了Reactor模式+非阻塞I/O+Callback模型+单线程,使得CPU的单个核心可以更为有效的工作而减少等待的时间,而异步模型基于回调,因此没有Context Switch(上下文切换)导致CPU开销更小,种种原因叠加在一起造就了Node.js出色的并发能力。

但随之而来的弊病显然也是罄竹难书——callback hell,导致人类线性的思维会被拆分,业务逻辑会被放置在各个回调函数中,调试和维护的难度陡然增加。不过好在ES2015发布了新的语法特性yield,也带来了异步抽象——Promise结构,但基于生成器(generator)实现的特性会略微降低Node.js原有的性能而无法两全其美,受限于JavaScript解释性语言的性质,使得其无法在不借助外部编译器,自行完成CPS变换。而scala则可以基于宏来实现这一功能,将原有的代码以同步的形式编写,但最终被编译成回调形式的代码,一来编码时逻辑得以线性的顺序组织,二来没有上下文切换的开销,两全其美。

Actor与CSP

事实上,Actor是一种原教旨的Object-oriented(面向对象)的模型,可以用非常简洁的模型但完整的表现了对象之间的“消息通信”。比如在Erlang中:

Pid = spawn(Mod,func,Args) %创建Actor  
func()->  
    receive %收到消息
        {From,Msg}-> 
            % 任何你想干的事儿
            func();
    end.

Pid ! {From,Msg} %发送消息  

从并发模型角度来说,Actor是一种它包含了状态,行为,Mailbox以及对应的supervisor策略的容器,也有人把这样形态的容器成为“Process”,不过此Process非彼Process,Actor是一种存在于线程之中的数据结构,由底层框架或者语言提供运行时的调度,在一个Actor系统中可以瞬间建立出成千上万的“Process”,在Akka的设计中,你只需花费1GB的内存就可以建立出250万个Actor/Process。由于轻量的特性,因此切换Actor的代价就变得十分廉价,开发者借助于这种模型,可以在每个Actor逻辑体中编写连续的逻辑,但最终可以被异步的运行。另外,Actor有且仅有一个Mailbox,这是一个FIFO(先入先出)的队列,由于不同的Actor在运行时可能会分布在不同的线程中,因此receiver会按照Message收到的时间来被Actor的逻辑执行。

在这里单独讲Actor,倒不如把它和CSP放在一起说。因为CSP(Communicating Sequential Process)和Actor是两门非常复古且外形接近的并发模型(这也是我为什么在上文中打了(?)号,按照Joe老爷子自己的说法,Erlang其实是受CSP的启发,而不是纯粹的Actor模型)。但CSP与Actor有以下几点比较大的区别:

  • CSP并不Focus发送消息的实体/Task,而是关注发送消息时消息所使用的载体,即channel。
  • 在Actor的设计中,Actor与信箱是耦合的,而在CSP中channel是作为first-class独立存在的。
  • 另外一点在于,Actor中有明确的send/receive的关系,而channel中并不区分这样的关系,执行块可以任意选择发送或者取出消息。

另外在默认情况下的channel是无缓存的, 对channel的send动作是同步阻塞的,直到另外一个持有该channel引用的执行块取出消息(channel为空),反之,receive动作亦然。藉此,我们可以得到一个基本确定的事实,by default时,实际的receive操作只会在send之后才被发生。而Actor中,由于send这个动作是异步的,因此Actor的receive会按照信箱接受到消息的顺序来进行处理。

当然,除此以外,channel还有种Buffered Channel的模式,在默认情况的基础上,你可以确定channel内的消息数量,当channel中消息数量不满足于初始化时Buffer数目时,send动作不会被阻塞,写入操作会立即完成(因此Buffered Channel在很大程度上与Actor非常接近),直到Buffer数目已满,则send动作开始阻塞。

Golang本身其实算不上一门设计的非常好的语言(连泛型都没有~),但因为内置出色的并发模型,Runtime级别的调度器,因此如果要开发服务端/网络程序,不得不说它确实是一门利器,借助于“goroutine”的存在,轻易就可以在代码中自由开启许许多多的异步块进行通讯,而不需要学习太多知识、处理过多的状态,例如:

package main

import "fmt"

var ch = make(chan string)

func message(){  
    msg := <- ch
    fmt.Println(msg)    
}

func main(){  
    go message()
    ch <- "Hello,CSP."
}

不过,虽说Golang和Erlang参照了形态接近的并发模型,但效率却是erl更为出色,这主要是因为golang的Runtime只会在Channel的消费时发生切换(我记得这方面Haskell只会在I/O时发生切换),而Erlang内置了“reduction as 时间片”的概念,所有操作比如函数调用、正则表达式匹配等等都会花费reduction,当reduction消耗完自动被切换。因此Erlang是少有的真抢占式调度,也更接近一个分时操作系统,而基于此的设计也当然可以获得更好的性能。

老赵曾有一篇很有意思的文章标题为“天下无处不乒乓”,囊括了F#、Java、Erlang语言下Actor模型的编码形式,但现在scala中actor的API已经变化了,在学习Akka伊始自然也是免不了俗重写了一段:

import akka.actor.{ ActorRef, ActorSystem, Props, Actor, Inbox }  
import scala.concurrent.duration._

class Ping(pong: ActorRef) extends Actor {

    def receive = {
        case "start" =>
            pong ! "ping"
        case "pong" =>
            sender ! "ping"
        case _ =>
            println("老子孔令辉")
    }
}

class Pong extends Actor {  
  def receive = {
    case "ping" =>
        sender ! "pong"
    case _ =>
        println("小娘邓亚萍")
  }
}


object PingPong extends App {  
    val system = ActorSystem("PingPong")
    val pong = system.actorOf(Props[Pong], name = "Pong")
    val ping = system.actorOf(Props(new Ping(pong)), name = "Ping")

    ping ! "start"
}

由于Akka参照了Erlang部分的设计,高容错、高可用、高拓展性,使得开发者现在在JVM的平台上一样可以有非常优异的并发通讯手段,以我手边的Mac Air为例 1.3的主频、8G内存、默认JVM参数,一秒之内Ping Pong次数也能达到1000K。而且Akka的实现极易拓展,借助于其supervisor的机制可以轻松搭载起一个分布式平台,不用受限于erl,还能使用Java开发,又何乐而不为呢? Akka着实是并发平台的一剂良药。

结尾

人们虽然说:免费的午餐已经结束,但好在大家都很慷慨,纵然不再免费,但成本也还不算太高:即有开袋即食的Node.js+Callback、Golang+CSP,亦有易于整合的Scala/Java+Akka+Actor,以及略微昂贵、但拥有工业级品质的、超高可用性的Erlang,在不同的层面和抽象上都有非常好的选择,算得上是百家争鸣了。

About the author
comments powered by Disqus