基于组合 Future 的并行任务流

Future 是一个实现异步编程的便利工具,Akka 在最开始提供了自己的实现。同时,Twitter Finagle 和 scalaz 也分别给出了 Future 的实现来证明可行性。在经历验证阶段之后,Scala 在 2.10 版本之后通过提升的方式对 scala.concorrent 包重新进行了设计,Future 现在已经是标准库的功能。

Future 的基本用法参见:Scala + Future 实现异步编程 - 掘金 (juejin.cn) 。本章的重点是 —— 如何组合 Future 来准确表述出期望执行的并行计算。在 《 Functional Programming in Scala 》 的第七章中,笔者还会进一步讨论函数式风格的并行运算。

在开篇之前,需要熟悉 Scala 的这个语法糖:当函数 / 方法调用只有一个参数时,可以将 f(g) 的写法替换为花括号的形式 f { g }。同时, Scala 允许使用空格 ' ' 代替访问操作符 .。比如:

// 这种写法适用于 DSL 风格。
List {1} map {_ + 1} foreach {print}
// 标准写法。
List(1).map(_ + 1).foreach(print)
复制代码

后文会出现大量 Future { codeblock } 形式的代码块,这实质是对 Future.apply 构建方法的传名调用。Future 内部的 codeblock 被延迟计算,它在另一个线程中被求值。

使用 Future 构建任务需要一个隐式的 Execution。它是一个基于已有线程池实现上进行任务调度的抽象,可简称调度器。Scala 默认提供的调度器是基于工作窃取 ( work-stealing ) 类型的 fork-join 线程池。

import scala.concurrent.ExecutionContext.Implicits.global
复制代码

组合机制 ( 重要 )

Future 之间通过 flatMap / map 组合子进行嵌套组合,flatMap 接收 S => Future[U] 参数,这表示我们可以用该组合子嵌套任意多的 Future,并在递归的最深处使用 map 组合子收尾,细节部分见代码注释。

val eventualInts: Future[List[Int]] = Future {List(1, 2, 3, 4, 5)}

// 注意,我们实际创建的是一段串行代码。
val convertedEventualInts: Future[List[Int]] = eventualInts flatMap {
    // 这个 Future 内部有一个自由变量 list,
    // 这个自由变量 list 来自闭包 eventualInts,
    // 因此这个 Future 和外部的 eventualInts 存在数据相关。
    list => Future { list map {_+1}} flatMap { list =>
        // 同理,这个被创建的 Future 也和上层的 Future 数据相关。
        Future { list map {_ * 2}} map{l => l}
    }
}
复制代码

使用 flatMapmap 做过多的嵌套会使得代码的可读性变差。对于纯粹由 flatMapmap 构成的 Future 任务,使用命令式的 for - yield 表达式更加简单明了。

val convertedEventualInts2: Future[List[Int]] = for {
    list          <- eventualInts
    convertedList <- Future {list.map(_ + 1)}   	 // f1
    finalList     <- Future {convertedList.map(_ * 2)}   // f2
} yield finalList
复制代码

代码风格的改变掩盖不了两个 Future f1f2 数据相关的问题。这里的f2 还是必须等待 f1 传入的结果,因此这段代码会串行执行。

事实上,如果在 flatMap / map ( 包括等价的 for 表达式 ) 内传递临时构造的 Future { ... } 构造块,或者是临时调用返回 Future[A] 的函数,这总会导致 for 表达式边初始化 Future 边执行,通俗点说就是多个 Future 任务被完全串行排列,哪怕这些任务之间数据无关。

比如,下面是一段具有迷惑性的,看起来应当并行的 Map - Reduce 任务,然而这段程序的耗时是各个子任务的耗时之和。

val futures : Future[Int] = for {
    l1            <- Future {Thread.sleep(100);1}
    l2            <- Future {Thread.sleep(200);2}
    l3            <- Future {Thread.sleep(300);3}
    l4            <- Future {Thread.sleep(400);4}
} yield l1 + l2 + l3 + l4
// 程序至少需要 1s 的时间去运行,因此这段代码会抛出异常。
val r2 = Await.result(futures, .9 second)
复制代码

正确的做法是:首先在外部创建 Future 任务,然后再通过 for 表达式收集这些任务的结果。

val f1 = Future  {Thread.sleep(100);1}
val f2 = Future  {Thread.sleep(200);1}
val f3 = Future  {Thread.sleep(300);1}
val f4 = Future  {Thread.sleep(400);1}

val futures : Future[Int] = for {
  l1            <- f1
  l2            <- f2
  l3            <- f3
  l4            <- f4
} yield l1 + l2 + l3 + l4
val r2 = Await.result(futures, .4 second)
复制代码

只改变了一点点写法,程序现在只需要 0.4s 的时间就可以执行完成。for 表达式是被优先推荐的语法,下面的代码是它的 flatMap / map 组合子版本:

val f1: Future[Int] = Future {Thread.sleep(100);1}
val f2: Future[Int] = Future {Thread.sleep(200);2}
val f3: Future[Int] = Future {Thread.sleep(300);3}
val f4: Future[Int] = Future {Thread.sleep(400);4}

val re: Future[Int] = f1 flatMap {
    v1 =>
    f2 flatMap {
        v2 =>
        f3 flatMap {
            v3 =>
            f4 map {
                v4 => v1 + v2 + v3 + v4
            }
        }
    }
}
复制代码

避免多个 Future 被串行化,还可以创建一个函数,然后通过参数列表一次性将多个 Future 传递并执行。

def map3(f1 : Future[Int],f2 : Future[Int],f3 : Future[Int]): Future[Int] ={
  f1.flatMap(v1 => f2.flatMap( v2 => f3.map(v3 => v1 + v2 + v3)))
}

val f = map3(
  Future {Thread.sleep(300);2},
  Future {Thread.sleep(300);3},
  Future {Thread.sleep(400);4}
)

Await.result(f,.4 second)
复制代码

下面是更进一步的抽象:使用 S => Future[S] 表达一个待执行的 Future 计划。这段测试代码分别演示了三种组合方式。它们的表达写法类似,但是 map2Futurejob0 和 ( map2FutureJob1map2FutureJob2 ) 的语义不同,且 map2FutureJob1map2FutureJob2 两者执行效率也不同。

type FutureJob[S] = S => Future[S]

val futureJob1 : FutureJob[Int] = (i : Int) => Future {Thread.sleep(200);i + 1}
val futureJob2 : FutureJob[Int] = (i : Int ) => Future {Thread.sleep(150);i + 2}

// 串行任务,先执行 futureAction1,再用它的结果执行 futureAction2。
// result = 6
val map2FutureJob0 = (futureAction1 : FutureJob[Int], futureAction2 : FutureJob[Int]) => (zeroValue : Int) => {
    futureAction1(zeroValue).flatMap(futureAction2)
}

// 可并行的任务,但是仍串行执行。
// result = 9
val map2FutureJob1 = (futureAction1 : FutureJob[Int], futureAction2 : FutureJob[Int]) => (zeroValue : Int) => {
    futureAction1(zeroValue).flatMap(i => futureAction2(zeroValue).map { j => i + j })
}

// 并行执行   
// result = 9
val map2FutureJob2 = (futureAction1 : FutureJob[Int], futureAction2 : FutureJob[Int]) => (zeroValue : Int) => {
    val f1: Future[Int] = futureAction1(zeroValue)
    val f2: Future[Int] = futureAction2(zeroValue)
    f1.flatMap { i => f2.map(j => i + j)}
}

// ok
Await.result(map2FutureJob2(futureJob1,futureJob2)(3), .2 second)
// bad
Await.result(map2FutureJob1(futureJob1,futureJob2)(3), .2 second)
// bad
Await.result(map2FutureJob0(futureJob1,futureJob2)(3), .2 second)
复制代码

造成 map2FutureJob1map2FutureJob2 性能出现差异的原因在于:futureActionX 是一个返回 Future[A] 的函数,而非 Future[A] 本身。因此,必须要在外部先显式地调用 futureActionX 来创建 Future[A],否则就会导致它们在 flatMap 内部被串行化。

Future.fold

并行执行 Map - Reduce 语义的另一个风格是 Future.fold 的归并方法。

val f1 = Future{Thread.sleep(100);1}
val f2 = Future{Thread.sleep(200);2}
val f3 = Future{Thread.sleep(300);3}
val f4 = Future{Thread.sleep(400);4}

val jobs = List(f1,f2,f3,f4)
val parallelMR = Future.fold(jobs)(0){_ + _}

val result = Await.result(parallelMR,.4 second)
println(result)
复制代码

Future.fold 会两两归并计算任务,在不存在数据相关且线程足够的情况下,各个 Future 将完全并行。其代价估计可以用一个树状图来描述:

fold_future.png

从运算耗时来看,无论子 Future 之间以哪个次序归并,整体计算的耗时只取决于最慢的那个,因此上述的测试代码只需要在 .4s 的时间就能完成任务。

从运算过程来看,Future.fold左折叠 ( 指从左向右折叠 ) 的。如果 Map-Reduce 的归并运算 (R,T)=> R 满足交换律和结合律,那么最后的计算结果就与 Futures 的排列顺序无关,否则相关 ( 如笛卡尔积 )。

Future.sequence & traverse

考虑更加复杂的情形。我们通常处理的任务并非简单的 Future[A] 类型,而是 Future[M[A]],或者是 M[Future[A]] 类型。M[A] 是一个可被遍历的序列类型,为了方便理解,下文使用 List[A] 来举例。

Future.traverse 首先接收一个 List[A],然后通过 f : (A => Future[A]) 映射将这个普通列表提升为 Future[List[A]]

val list = List(1,2,3,4,5)
def lift(a : Int) = Future{Thread.sleep(100);a}
val futureList: Future[List[Int]] = Future.traverse(list)(lift)
复制代码

Future.sequence 则接收包含多个 Future 的列表 List[Future[Int]],然后将其翻转成一个包含列表的 Future Future[List[Int]]

val list = List(1,2,3,4,5)
def lift(a : Int) = Future{Thread.sleep(100);a}

val listFuture = list map lift
val futureList = Future.sequence(listFuture)
复制代码

traversesequence 都是并行执行的。它们存在这样的关联:

F u t u r e . t r a v e r s e ( l i s t ) ( f ) = F u t u r e . s e q u e n c e ( l i s t    m a p    f ) Future.traverse(list)(f) = Future.sequence(list\space\space map \space\space f)

下面的代码验证了两者的等价关系。

def lists: List[Int] = List[Int](1, 2, 3, 4, 5)
def lift[A](a: A): Future[A] = Future {a}  // 最基本的提升函数 lift: A => Future[A]

Future.traverse(lists)(lift).andThen {
    case Success(value) => value mustBe lists
}

Future.sequence(lists map lift).andThen {
    case Success(value) => value mustBe lists
}

// 不要以这种方式直接比较:
// Future.traverse(lists)(lift) mustBe Future.sequence(lists map lift)
复制代码

熟练使用 Future.sequenceFuture.traverse 有助于对 Future 做各种高阶组合。

容错机制

Future 具备容错机制,在遇到非致命异常 NonFatal 时可以主动恢复。使用 recover 方法挂载 PartialFunction[Throwable,T] 类型的偏函数来对异常信息进行处理,比如将错误记录到日志,或者发送至 Kafka 队列。之后,recover 要求返回默认值 T 保证计算任务继续运行。

type Recovery[T] = PartialFunction[Throwable, T]

def withNone[T]: Recovery[Option[T]] = {case NonFatal(_) => None}

def withEmptySeq[T]: Recovery[List[T]] = {case NonFatal(_) => List()}
// Guide => Throwable => Guide
def withPrevious(previous: Guide): Recovery[Guide] = {case NonFatal(_) => previous}
复制代码

NonFatal(e) 所描述的非致命异常不包括:VirtualMachineError ( 典型的有 OutOfMemoryErrorStackOverFlowError ),ThreadDeathLinkedErrorInterruptedExceptionControlThrowable

DEMO:用户出行娱乐 APP

这个例子的原型来自于《 Akka in Action 》的第五章节。不过,这里讨论的重点应当是 Future 用法而非业务本身。笔者基于原先的例子做了大量精简,删去了一些无用的字段。我们大致要实现一个为用户出行娱乐提供指南 ( Guide ) 的 APP,系统的核心逻辑很简单:

  1. 用户携带自己的 usrName 字段向系统发送请求。
  2. 系统接受请求之后异步调用天气服务,出行服务,娱乐服务,装配结构到 Guide 样例类中并返回。

Guide 样例类的结构如下:

case class Guide(usrName: String,
                 weather: Option[Weather] = None, 
                 travelAdvice: Option[TravelAdvice] = None,
                 filmDetails : List[FilmDetails] = List[FilmDetails]()
                )
复制代码

每个服务模块都是独立的,这是系统得以面向 Future 组合异步任务的基础。

基于构建者模式的异步任务清单

声明一个 MockWebServiceCalls 特质模拟一个 Web 服务器。用户通过 getServices 方法向服务器发送请求,服务器在接受到用户请求后创建一个空的 Guide 实例,随后分别请求三个服务接口模块填充剩下的 weathertravelAdvicefilmDetails 字段。

trait MockWebServiceCalls
extends WeatherServiceX
with WeatherServiceY
with TrafficService
with TheaterService {
    type Recovery[T] = PartialFunction[Throwable, T]
    def withNone[T]: Recovery[Option[T]] = {case NonFatal(_) => None}  // 容错
    def withEmptySeq[T]: Recovery[List[T]] = {case NonFatal(_) => List()} // 容错
    def withPrevious(previous: Guide): Recovery[Guide] = {case NonFatal(_) => previous} // 容错
    def getServices(usrName: String): Future[Guide] = ???  // 处理用户请求
    def requestWeather(userGuide: Guide): Future[Guide] = ???   // 请求天气信息
    def requestTrafficSuggestion(userGuide: Guide): Future[Guide] = ??? // 请求出行信息
    def requestAmusement(): Future[List[FilmDetails]] = ??? // 请求娱乐信息 (电影服务)
}

object MockWebServiceCalls extends MockWebServiceCalls    
复制代码

服务端接口已经预设好了三种容错方法:

  1. withNone:检测到非致命异常时,返回 None。
  2. withEmptySeq:检测到非致命异常时,返回空列表 List()。列表类型由泛型 T 决定。
  3. withPrevious:检测到非致命异常时,返回之前状态的 Guide 数据。

系统的设计风格遵循构建者模式。在这个例子中,每个字段由专门的第三方负责填充。除了性能上的优势外,这为开发者带来了一个额外的好处:那就是可以进行 错误隔离。比如,天气服务挂掉不会影响其它模块正常运行。即便是所有第三方服务都失效的情况下,系统也能够通过 withPrevious 方法保证至少返回一个内容为空的 Guide 实例,而非一个 500 服务器错误。

在第三方服务提供接口这里返回预设好的数据,每个服务接口职责分离。

trait WeatherServiceX {
  this :  MockWebServiceCalls =>
  def callWeatherXService(): Future[Option[Weather]] = Future {
    Thread.sleep(400)
    Some(Weather(25.3))
  }
}

trait WeatherServiceY {
  this : MockWebServiceCalls =>
  def callWeatherYService(): Future[Option[Weather]] = Future {
    Thread.sleep(800)
    Some(Weather(25.1))
  }
}

trait TrafficService {
  this : MockWebServiceCalls =>  
  def callTrafficService(): Future[Option[TrafficAdvice]] = Future {
    Thread.sleep(300)
    Some(TrafficAdvice("route by car"))
  }

  def callPublicTransportService(): Future[Option[PublicTransportAdvice]] = Future {
    Thread.sleep(1000)
    Some(PublicTransportAdvice("No.382 bus station"))
  }
}

trait TheaterService {
  this : MockWebServiceCalls =>
  // get info from DB
  def callRecentFilmNameLists(): Future[List[Film]] = Future {
    Thread.sleep(300)
    List(Film("Seabiscuit"), Film("Million Dollar Baby"), Film("King of Comedy"))
  }

  // get info from DB
  def callFilmInformation(filmName: String): Future[FilmDetails] = Future {
    Thread.sleep(500)
    filmName match {
      case "Seabiscuit" => FilmDetails("Seabisuit", "Tobias Vincent Maguire")
      case "Million Dollar Baby" => FilmDetails("Million Dollar Baby", "Clint Eastwood")
      case "King of Comedy" => FilmDetails("King of Comedy", "Robert De Niro")
    }
  }
}
复制代码

为了便于和串行程序的性能做明显对比,服务接口的每个方法内都通过 Thread.sleep(...) 在各个 services 模块的服务方法中增加了显著的延迟。

Service Method delay
WeatherServiceX callWeatherXService 0.4 s
WeatherServiceY callWeatherYService 0.8 s
TrafficService callTrafficService 0.3 s
callPublicTransportService 1.0 s
TheaterService callRecentFilmNameLists 0.3 s
callFilmInformation 0.5 s

显然,系统若在完全串行的条件下执行,响应时延将达到 3.3s。我们期望响应时延最终缩短到 1s 之内。

class WebClient extends WordSpecLike with MustMatchers {
  "The future task" must {
    "finish the job in 1s" in {
      val eventualGuide: Future[Guide] = MockWebServiceCalls.getServices("author")
      val guide: Guide = Await.result(eventualGuide, 1 second)
      println(guide)
    }
  }
}
复制代码

其它的样例类还包括:

// Weather Module
case class Weather(temperature: Double)

// Travel Module
case class TravelAdvice(trafficAdvice: Option[TrafficAdvice] = None,
                        publicTransportAdvice: Option[PublicTransportAdvice] = None)
case class TrafficAdvice(advice: String)
case class PublicTransportAdvice(advice: String)

// Amusement Module
case class Film(name: String)
case class FilmDetails(name: String, actor: String)
复制代码

天气服务模块

requestWeather 方法使用 Future.firstcompletedOf 选择第一个成功响应的天气信息,然后直接丢弃另一个慢的结果。所以,该模块的实际耗时取决于运行 较快 的那个 Future 任务。

  def requestWeather(userGuide: Guide): Future[Guide] = {
    val maybeWeatherX: Future[Option[Weather]] = callWeatherXService().recover(withNone)
    val maybeWeatherY: Future[Option[Weather]] = callWeatherYService().recover(withNone)
    Future.firstCompletedOf(List(maybeWeatherX, maybeWeatherY)) map {
      getWeather => userGuide.copy(weather = getWeather)
    }
  }
复制代码

这种逻辑天然具备 冗余容灾 的特点,因为只要 WeatherServiceX 和 WeatherServiceY ( 或者更多的备用服务 ) 至少有一个能正确响应,那么这个模块的服务就是正常的。除了 firstCompletedOf 之外,另一个可选择的实现方式是 find 方法。

Future.find(List(maybeWeatherX,maybeWeatherY))(_.isDefined) map {
      case None => userGuide
      case Some(x) => userGuide.copy(weather = x)
}
复制代码

本例到处都在使用 copy 方法来传递 Guide 数据,该方法是 Scala 编译器为 具备属性的 样例类自动生成的 值拷贝 方法,这一点很重要:

reduce_result.png

值拷贝意味着 Future 任务之间不会争抢同一份 Guide 数据,每个子任务拿到相同的起始副本,然后只修改自己负责的那一部分数据。这样,子任务间数据读写 Guide 的区域没有交叉,也就意味着 Future 之间不存在数据竞争。

正常情况下需要考虑到对大对象数据不断进行值拷贝而带来的显著性能消耗。但在本例中,值拷贝的代价显然要比加锁低得多。

出行建议模块

requestTrafficSuggestion 方法提供内部同时启动两个 Future 任务,最终通过 zip 方法尝试返回包含两个结果的二元组。该模块响应时延取决于运行 较慢 的那个 Future 任务。

def requestTrafficSuggestion(userGuide: Guide): Future[Guide] = {
    val maybeTrafficAdvice: Future[Option[TrafficAdvice]] = callTrafficService().recover(withNone)
    val maybePublicTransportAdvice: Future[Option[PublicTransportAdvice]] = callPublicTransportService().recover(withNone)

    maybeTrafficAdvice.zip(maybePublicTransportAdvice) map {
      case (maybeAdvice, maybeAdvice1) =>
        userGuide.copy(travelAdvice = Some(TravelAdvice(maybeAdvice,maybeAdvice1)))
    }
}
复制代码

map 代码可以用下面的 for 表达式替换:

for {(adviceA,adviceB) <- maybeTrafficAdvice.zip(maybePublicTransportAdvice)}
    yield userGuide.copy(travelAdvice = Some(TravelAdvice(adviceA,adviceB)))
复制代码

娱乐清单模块

requestAmusement 方法首先调用 callRecentFilmNameLists 方法获取 List[Film] 电影名清单,随后基于该清单再次请求电影的完整信息列表 List[FilmDetails]。此处的业务逻辑决定了这两个 Future 任务必须串行执行。所以,该模块的响应时延是两个 Future 任务的耗时之和。

此外,在 requestAmusement 方法中没有进行数据拷贝,结果被发送到了 getServices 方法中填充。

def requestAmusement(): Future[List[FilmDetails]] = {
  val maybeRecentFilms: Future[List[Film]] = callRecentFilmNameLists().recover(withEmptySeq)
  maybeRecentFilms flatMap {
      films => {
          val eventualFilmDetails : List[Future[FilmDetails]] = films.map { each => callFilmInformation(each.name)}
          Future.sequence(eventualFilmDetails)
      }
  }
}
复制代码

这里需要借助 Future.sequenceList[Future[FileDetails]] 翻转为 Future[List[FileDetails]]。这段 flatMap 逻辑不好直接转换成 for 表达式,但是可以使用 Future.traverse 给出等价实现:

for {
      films <- maybeRecentFilms
      list <- Future.traverse(films)(f => callFilmInformation(f.name))
} yield list
复制代码

整合处理结果

至此,我们对响应时延做一个简单的分析,这里使用 f1f2f3f4 来标记中间任务:

parallel_work.png

在理想情况下,将时延缩短到 1s 之内是完全合理的。下文用多种方式描述数据整合的任务,它们最终产出同样的结果,但是性能上却存在差异。

第一种方案,先通过 copyWeatherAndTravelAdvice 归并 weathertravelAdvice,然后 串接 另一个 Future 来拷贝剩下的 filmDetails 信息。

def getServices(usrName: String): Future[Guide] = {

  val init: Future[Guide] = Future {Guide(usrName)}
  val copyWeatherAndTrafficAdivce: Future[Guide] = init flatMap { ths =>
    val eventualWeather: Future[Guide] = requestWeather(ths)
    val eventualTrafficSuggestion: Future[Guide] = requestTrafficSuggestion(ths)
    val guides = List(eventualWeather, eventualTrafficSuggestion)
      
    Future.fold(guides)(ths) {
      (acc, elem) =>
        val (weather, travelAdvice) = (elem.weather, elem.travelAdvice)
        acc.copy(
          weather = weather.orElse(acc.weather),
          travelAdvice = travelAdvice.orElse(acc.travelAdvice)
        )
    }
  }

  val response: Future[Guide] =
    for {
        guide <- copyWeatherAndTrafficAdivce
        // requestAmusement() 没有在外部声明
        filmDetails <- requestAmusement()
    } yield guide.copy(filmDetails = filmDetails)

  response.recover(withPrevious(Guide(usrName))) 
}
复制代码

注意,copy 方法不具备交换律,要将累积的结果放到参数列表的左侧 ( 否则会返回空结果 ),因为 Future.fold 是从左向右折叠的。这段代码的响应时延是 1.9s。原因是 f1f2 虽然得到了并行,但 f3f4 是串行执行的,因此总时延变成了 f3f4 两者的延迟加和,而非两者最大值。

第二种方式:使用 for 表达式一次性归并所有的结果:

 def getServicesBySlowForExpr(usrName: String): Future[Guide] = {
    val r = for {
      // 所有 Future 都没有在外部声明  
      init    <- Future { Guide(usrName)}
      weatherChunk <- requestWeather(init)
      travelChunk  <- requestTrafficSuggestion(init)
      list <- requestAmusement()
    } yield {
      init.copy(
        weather = weatherChunk.weather,
        travelAdvice = travelChunk.travelAdvice,
        filmDetails = list
      )
    }
    r.recover(withPrevious(Guide(usrName)))
  }
复制代码

这段看似十分整洁的代码的响应时延达到 2.3s ( f1f2f4 的时延之和 )。性能变差的原因如前文所述,Future 的创建全部放到了 for 表达式代码内,导致程序以串行的方式执行它们。下面是它的修正版本:

def getServicesByQuickForExpr(usrName: String): Future[Guide] = {

    def warpedRequestTrafficSuggestion(guide : Guide) : Future[Guide] = {
        requestAmusement() flatMap(list => Future {guide.copy( filmDetails = list)})
    }

    val meta = Guide(usrName)
    // 将初值包装为 Future
    val unit = Future {meta}
    // 在外部创建 Future 任务。
    val weatherJob: Future[Guide] = requestWeather(meta)
    val travelJob: Future[Guide] = requestTrafficSuggestion(meta)
    val amusementJob: Future[Guide] = warpedRequestTrafficSuggestion(meta)

    val r = for {
        init    <- unit
        weatherChunk <- weatherJob
        travelChunk  <- travelJob
        amusementChunk <- amusementJob
    } yield {
        init.copy(
            weather = weatherChunk.weather,
            travelAdvice = travelChunk.travelAdvice,
            filmDetails = amusementChunk.filmDetails
        )
    }

    r.recover(withPrevious(Guide(usrName)))
}
复制代码

这段程序的时延在 1s ( max (f3,f4) ) 之内,我们使用了正确的 for 表达式完成了最高效的版本。

附:基于 Future 的 Map-Reduce

这节是笔者结合 《Functional Programming in Scala 》的一些自己的思考。灵感来自另一篇专栏的文章:Scala:在纯函数中优雅地转移状态 - 掘金 (juejin.cn)

到目前为止,整个 getServices 服务方法的处理逻辑是偏命令式的。我们注意到在系统中,所有任务的目的只有一个:修改 ( 值拷贝 ) Guide 数据。从更泛化的角度说,Guide 这个整体代表了一个完整的 Work,而每个子任务只负责完成其中的一个 Partition。

换句话说,上述系统的所有任务具备 Map-Reduce 性质,所有任务可以只用一个 fold 来实现。因此,下面的代码会切换到另一种风格。首先构建一个这样的类型别称:

type FutureJob[S] = S => Future[S]
type GuideFutureJob = FutureJob[Guide]
复制代码

FutureJob[S] 现在代表一个计划中的子任务。当然,在本例也可以直接声明类型参数 SGuide ( 如 GuideFutureJob )。任何返回 FutureJob[Guide] 的方法现在都代表一个产出 Partition 的 行为

为了专注于 MR 任务,我们约定 FutureJob[S] 行为之间是数据无关的,它们可以 100% 并行。原例中的服务方法可以简单地通过 Eta 拓展或者是 curring 标准化成 FutureJob[Guide] 类型的行为。

// Scala 支持通过 Eta 拓展将方法赋值为表达式。
val futureJob1 : FutureJob[Guide] = MockWebServiceCalls.requestWeather
val futureJob2 : FutureJob[Guide] = MockWebServiceCalls.requestTrafficSuggestion
val futureJob3 : FutureJob[Guide] = guide => {
  MockWebServiceCalls.requestAmusement().flatMap { s => Future {guide.copy(filmDetails = s)}}
}
复制代码

我们计划声明一个 List 类型的行为列表。仅需要对这个列表调用 fold 方法就能将所有子任务归约成一个总的 FutureJob[Guide]。这需要自实现一个 (Future[Guide],Future[Guide])=>Future[Guide] 类型的函数,在这里可以命名它为 mergeFuture

mergeFuture 是 Future 层级的归并,内部还需要提供一个 Guide 层级的归并,见 merge 方法。我们事先不知道每个 FutureJob 会更改 Guide 哪些字段,因此索性令 merge 通过尾递归的形式检查所有可以拷贝的字段 ( 这么做其实是无奈之举,因为 Scala 的模式匹配是不穿透的 )。当尾递归函数作为内部方法或者是私有方法时,Scala 提供 @trailrec 注解令编译器进行尾递归优化 ( 翻译成等价的迭代表示 ) 。

merge 方法具备交换律,任意一方总能从另一方那里将所有可获得的数据补齐。这个特性保证了行为列表 List[FutureJob[Guide]] 从任意方向调用 mergeFuture 折叠,最终都能获得相同的结果。

def mergeFuture(acc : Future[Guide],guide : Future[Guide]) : Future[Guide] = {
  // 尾递归检查并复制可用片段。
  @tailrec
  def merge(acc: Guide, guide :Guide): Guide ={
    (acc ,guide) match {
      case _ if acc.weather.isEmpty && guide.weather.isDefined =>
        val v = acc.copy(weather = guide.weather);
        merge(v,guide)
      case _ if acc.travelAdvice.isEmpty && guide.travelAdvice.isDefined =>
        val v = acc.copy(travelAdvice = guide.travelAdvice);
        merge(v,guide)
      case _ if acc.filmDetails.isEmpty && guide.filmDetails.nonEmpty =>
        val v = acc.copy(filmDetails = guide.filmDetails);
        merge(v,guide)
      case _ => acc
    }
  }
  acc.flatMap( s =>
    Future.fold(List(guide))(s){
      (g0,g1) => merge(g0,g1)
    }
  )
}
复制代码

mergeFuture 能够保证传入的两个 Future 是并行的,原因见前文的组合机制。现在,我们对系统所有任务描述都精简到了 convolution 行为队列内。只需要传入一个Guide 类型的初值,这个行为就会被驱动执行,并返回处理结果。

"Functional Style" in {
    type FutureJob[S] = S => Future[S]
    def unit : FutureJob[Guide] = s => Future{s}
    val futureJob1 : FutureJob[Guide] = MockWebServiceCalls.requestWeather
    val futureJob2 : FutureJob[Guide] = MockWebServiceCalls.requestTrafficSuggestion
    val futureJob3 : FutureJob[Guide] = guide => {
        MockWebServiceCalls.requestAmusement().flatMap { s => Future {
            guide.copy(filmDetails = s)
        }
                                                       }

        // 任务的组合顺序无关紧要,无论从哪个方向进行折叠都是可以的。
        val convolution: FutureJob[Guide] = List[FutureJob[Guide]](futureJob1, futureJob2, futureJob3).fold(unit) {
            (f1, f2) => { s => {mergeFuture(f1(s),f2(s))}}
        }
        // 左折叠的另外一种表述。
        // (unit /: List[FutureJob[Guide]](futureJob1,futureJob2,futureJob3)){(f1,f2) => {s => {mergeFuture(f1(s),f2(s))}}}

        val guide: Guide = Await.result(convolution(Guide(usrName = "author")), 1 second)
        println(guide)
    }
复制代码

Actor with Future

我们早在第一个 Akka 实例中就接触过 Actor + Future 的使用,下面是截取自之前的代码片段:

import akka.pattern.{ask, pipe}
import akka.util.Timeout
class BoxOffice(implicit timeout: Timeout) extends Actor {
//  .....
    case GetEvents =>
      // 相当于创建了一个广播机制.相当于不断轮询自己是否有此 Event.
      def getEvents: Iterable[Future[Option[Event]]] = context.children.map {
        child => self.ask(GetEvent(child.path.name)).mapTo[Option[Event]]
      }

      // sequence 是一个在 FP 中常见的 "翻转" 操作,用于将 A[B] 翻转为 B[A].
      // 比如在 Option 中,sequence 方法可以将 Option[List[_]] 翻转为 List[Option[_]}.
      def convertToEvents(f: Future[Iterable[Option[Event]]]): Future[Events] = {
        // import akka.actor.TypedActor.dispatcher
        // Future { Iterable[Option[Event]] => Iterable[Event] => Events(Vector[Event]) }
        f.map(optionSeqs => optionSeqs.flatten).map(l => Events(l.toVector))
      }

      pipe(convertToEvents(Future.sequence(getEvents))) to sender()
    
// .....
复制代码

首先需要导入 akka.pattern.ask 装饰器模式,从而允许 Actor 调用 ask 方法请求另一方 Actor 的结果。

对于任何使用 Future 的 Actor,需要主动设置一个 Timeout 类型的截止时间,因为系统不允许无限期的等待结果。就像本章的测试代码一样,若等待时间超时,那么这个 Future 将立刻以异常的形式返回。

由于 Actor 可以返回任何消息,因此 ask 方法返回的是一个 Future[Any] 类型。使用 .mapTo 方法可以进一步把另一方 Actor 的响应转换为期望的消息类型。不过,如果 ask 获得的并不是期望的消息类型,那么 mapTo 会以一个失败的结果告终。

pipe 允许在 Future 可用时直接将结果转发 to 另一方,不需要在回调函数中复写转发逻辑。

当 Future 应用在 Actor 当中时,尤其应当避免共享 Actor 的可变状态,因为 Actor 是有状态的。聪明的做法是,将数据共享给另一方时,发送数值的值拷贝,而不是直接传递引用。

小结

本章介绍了有关于 Future 的内容,介绍如何基于 Future 构建一个异步的工作流。我们的目标是最大化利用资源,最小化不必要的延迟,同时避免错误的表达方式造成意外的串行。

Future 是最终可获得的函数结果的占位符,是把函数组合成异步流程的有力工具。因为 Future 是关于函数结果的,所以要组合这些结果就必须使用函数式的方法。Future 提供的组合方法和 Scala 集合也十分类似。函数尽可能以并行的方式执行,并在需要的地方顺序执行,最终提供有意义的结果。Future 返回的值有可能是成功的,也有可能是失败的。然而,有很多种机制可以保证对失败的值进行替换,从而保证系统继续运行。

猜你喜欢

转载自juejin.im/post/7077844813965426702