Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Functional Concurrency with ZIO

Functional Concurrency with ZIO

Itamar Ravid

June 24, 2019
Tweet

More Decks by Itamar Ravid

Other Decks in Programming

Transcript

  1. Agenda • 2 minutes of background • An overview of

    ZIO • Map-Reduce with ZIO Itamar Ravid - @itrvd 3
  2. About me • Freelance software developer • Doing functional programming,

    microservices, DevOps • Co-organize the Underscore Scala meetup • Maintain ZIO, ZIO Streams, ZIO Kafka Itamar Ravid - @itrvd 4
  3. Future doesn't block your thread: val background = Future {

    Thread.sleep(15000) println("Done sleeping!") } println("I'm not blocked!") Itamar Ravid - @itrvd 9
  4. Future lets you do things concurrently: val firstComputation: Future[Int] =

    Future(cpuBound(5)) val secondComputation: Future[Int] = Future(cpuBound(10)) val zipped: Future[(Int, Int)] = firstComputation zip secondComputation Itamar Ravid - @itrvd 10
  5. The problems with Future It's eager. Consider a database request,

    and a fallback: def runRequests(userId: String): Future[Option[User]] = { val mainRequest: Future[Option[User]] = getUser(mainDb, userId) val fallbackRequest: Future[Option[User]] = getUser(fallbackDb, userId) mainRequest.recoverWith { case e => log.warn("Main DB failed. Falling back to secondary") fallbackRequest } } Itamar Ravid - @itrvd 11
  6. Why Future is not good enough No cancellation: def runRequests(userId:

    String): Future[Option[User]] = Future.firstCompletedOf( List( getUser(firstReplicaDb, userId), getUser(secondReplicaDb, userId) ) ) Itamar Ravid - @itrvd 12
  7. More concretely val program: ZIO[Config, AppError, String] • A lazy

    Future that doesn't necessarily fail with Throwable • A purely functional description for asynchronous, concurrent programs • Something that is kind-of like Config => Either[AppError, String] Itamar Ravid - @itrvd 14
  8. Creating programs using ZIO val constant = ZIO.succeed(42) val neverFails

    = ZIO.effectTotal(fibonacci(4)) val mayFail = ZIO.effect(readLines("/tmp/dict")) Itamar Ravid - @itrvd 15
  9. Creating programs using ZIO val constant: ZIO[Any, Nothing, Int] =

    ZIO.succeed(42) val neverFails: ZIO[Any, Nothing, Int] = ZIO.effectTotal(fibonacci(4)) val mayFail: ZIO[Any, Throwable, List[String]] = ZIO.effect(readLines("/tmp/dict")) Itamar Ravid - @itrvd 16
  10. Adapters val anEither: Either[String, Boolean] = Right(true) val fromEither: ZIO[Any,

    String, Boolean] = ZIO.fromEither(anEither) Itamar Ravid - @itrvd 18
  11. Adapters def loadUser(id: Int) (implicit ec: ExecutionContext): Future[Option[User]] val fromFuture:

    ZIO[Any, Throwable, Option[User]] = ZIO.fromFuture { implicit ec => loadUser(42) } Itamar Ravid - @itrvd 19
  12. Value transformations ZIO features the standard transformations on the value

    type: val user: ZIO[Any, Throwable, User] val username: ZIO[Any, Throwable, String] = user.map(_.name) val group: ZIO[Any, Throwable, Group] = user.flatMap(user => getGroup(user.groupId)) Itamar Ravid - @itrvd 20
  13. Error transformations We can also run transformations on the error

    type: val user: ZIO[Any, Throwable, User] val justTheMessage: ZIO[Any, String, User] = user.mapError(_.getMessage) val asEither: ZIO[Any, Nothing, Either[Throwable, User]] = user.either Itamar Ravid - @itrvd 21
  14. Error transformations We can also run transformations on the error

    type: val user: ZIO[Any, Throwable, User] val recovered: ZIO[Any, Nothing, Option[User]] = user .map(user => Some(user)) .catchAll(e => ZIO.succeed(None)) val recovered2: ZIO[Any, Nothing, Option[User]] = user.fold( e: Throwable => None, user => Some(user) ) Itamar Ravid - @itrvd 22
  15. Error widening Chaining two ZIOs with different error types automatically

    widens the error: sealed trait AppError case class AuthError(msg: String) extends AppError case class DbError(msg: String) extends AppError def authenticate: ZIO[Any, AuthError, Token] def getUser(token: Token): ZIO[Any, DbError, User] Itamar Ravid - @itrvd 23
  16. Error widening Chaining two ZIOs with different error types automatically

    widens the error: val composed: ZIO[Any, AppError, User] = for { token <- authenticate user <- getUser(token) } yield user No annotations, no compiler helping, nothing! Itamar Ravid - @itrvd 24
  17. ZIO's error model Here's a scenario that Future (and other

    effect monads) do not faithfully represent: def getUser(id: Int): ZIO[Any, Throwable, User] val twoUsers: ZIO[Any, Throwable, (User, User)] = getUser(10) zipPar getUser(20) We know what happens if one of them fails. What happens when both fail? Itamar Ravid - @itrvd 25
  18. ZIO's error model All errors are preserved properly: res12: Exit[Throwable,

    (User, User)] = Failure( Both( Fail(java.lang.Exception: Not found: 10), Fail(java.lang.Exception: Not found: 20) ) ) Itamar Ravid - @itrvd 26
  19. ZIO's error model Defects are misbehaved code: Task(doSomeIO()).map { result

    => if (resultIsOk(result)) result else throw new RuntimeException() // <- Defect! } Itamar Ravid - @itrvd 29
  20. ZIO's error model Several combinators let us "expose" the full

    Cause[E]: val user: ZIO[Any, Throwable, User] val sandboxed: ZIO[Any, Cause[Throwable], User] = user.sandbox Itamar Ravid - @itrvd 31
  21. ZIO's error model Several combinators let us "expose" the full

    Cause[E]: val folded = user.foldCauseM( cause: Cause[Throwable] => // .., user: User => // .. ) val fullExit: ZIO[Any, Nothing, Exit[Throwable, User]] = user.run Itamar Ravid - @itrvd 32
  22. Helpful type aliases type UIO[A] = ZIO[Any, Nothing, A] type

    IO[E, A] = ZIO[Any, E, A] type Task[A] = ZIO[Any, Throwable, A] type TaskR[R, A] = ZIO[R, Throwable, A] Itamar Ravid - @itrvd 33
  23. Concurrency Every sequential execution in ZIO is a fiber. We

    can fork computations using fork: val heavyComputation: ZIO[Any, Throwable, Int] for { fiber: Fiber[Throwable, Int] <- heavyComputation.fork // do other stuff result <- fiber.join } yield result Itamar Ravid - @itrvd 34
  24. Fine-grained cancellation Fibers can be interrupted in order to immediately

    stop their execution. for { fiber <- heavyComputation.fork shouldStop <- makeDecision result <- if (shouldWeStop) fiber.interrupt.const(None) else fiber.join.map(Some(_)) } yield result Itamar Ravid - @itrvd 35
  25. When do fibers get interrupted? • In between flatMaps (e.g.

    "between" for-comprehension steps) • On constructors that can be interrupted Itamar Ravid - @itrvd 36
  26. Resource management Under fine-grained cancellation, proper resource management is critical:

    for { fileHandle <- openFile("/tmp/dict") lines <- readLines(fileHandle, 3) _ <- fileHandle.close } yield lines We don't want to leak file handles! Itamar Ravid - @itrvd 37
  27. Resource management To do this safely, we can use a

    bracket: val lines: ZIO[Any, Throwable, List[String]] = openFile("/tmp/dict") .bracket(fileHandle => fileHandle.close) { fileHandle => readLines(handle, 3) } Itamar Ravid - @itrvd 38
  28. Resource management What happens when we need to acquire multiple

    resources? connectRedis.bracket(_.close) { redis => // use Redis } Itamar Ravid - @itrvd 43
  29. Resource management What happens when we need to acquire multiple

    resources? connectRedis.bracket(_.close) { redis => connectMySQL.bracket(_.close) { mysql => // use Redis, MySQL } } Itamar Ravid - @itrvd 44
  30. Resource management What happens when we need to acquire multiple

    resources? connectRedis.bracket(_.close) { redis => connectMySQL.bracket(_.close) { mysql => connectElastic.bracket(_.close) { elastic => // use Redis, MySQL, Elastic .. } } } Itamar Ravid - @itrvd 45
  31. Resource management We have ZManaged for that: case class Resources(redis:

    Redis, mysql: MySQL, elastic: Elastic) val resources: ZManaged[Any, Throwable, Resources] = for { redis <- ZManaged.make(connectRedis)(_.close) mysql <- ZManaged.make(connectMySQL)(_.close) elastic <- ZManaged.make(connectElastic)(_.close) } yield Resources(redis, mysql, elastic) Itamar Ravid - @itrvd 47
  32. Resource management We have ZManaged for that: resources.use { case

    Resources(redis, mysql, elastic) => // use everything } Itamar Ravid - @itrvd 48
  33. Resource management If ZManaged looks obscure, watch my talk about

    it: https://youtu.be/ybXuBXZ8NA0?t=1138 Itamar Ravid - @itrvd 49
  34. Retries and repetitions I can't count how many times I've

    written this function: def retry[A](task: Task[A], maxAttempts: Int, baseDelay: FiniteDuration): Task[A] = { def go(curr: Int) = task.catchAll { e => if (curr == maxAttempts) Task.fail(e) else go(curr - 1).delay((baseDelay.toSeconds * math.pow(2, curr)).seconds) } go(0) } Itamar Ravid - @itrvd 51
  35. Retries and repetitions What happens when we want to add

    jitter? def retry[A](task: Task[A], maxAttempts: Int, baseDelay: FiniteDuration, jitter: Double): Task[A] = { def go(curr: Int) = task.catchAll { e => if (curr == maxAttempts) Task.fail(e) else go(curr - 1).delay((baseDelay.toSeconds * math.pow(2, curr) * Random.nextDouble(jitter)).seconds) } go(0) } Itamar Ravid - @itrvd 52
  36. ZSchedule With ZIO, the previous combinator reduces to this: task.retry(

    ZSchedule.exponential(50.millis).jittered && ZSchedule.recurs(5) ) Itamar Ravid - @itrvd 53
  37. ZSchedule val schedule: ZSchedule[R, A, B] A schedule: • Uses

    an environment of type R, • consumes values of type A, • computes a delay and outputs values of type B Itamar Ravid - @itrvd 54
  38. ZSchedule composition We can combine schedules using intersection: val intersect

    = ZSchedule.exponential(50.millis) && ZSchedule.recurs(5) This schedule will repeat using the max delay, for as long as both schedules repeat. Itamar Ravid - @itrvd 55
  39. ZSchedule composition by using union: val intersect = ZSchedule.exponential(50.millis) ||

    ZSchedule.fixed(1.second) which will use the min delay, if either schedule wants to repeat; Itamar Ravid - @itrvd 56
  40. ZSchedule composition or by sequencing: val intersect = ZSchedule.recurs(5) andThen

    ZSchedule.spaced(1.second) which runs the first schedule, and switches to the second schedule. Itamar Ravid - @itrvd 57
  41. Concurrent data structures ZIO features all of the data structures

    you need to create concurrent algorithms. Itamar Ravid - @itrvd 58
  42. Promise[E, A] A Promise[E, A] is a signal you can

    trigger across threads: for { promise <- Promise.make[Throwable, String] // Wait on the promise in a background fiber: fiber <- (for { result <- promise.await _ <- UIO(println(s"Received $result")) } yield ()).fork // Resolve it from this fiber: _ <- promise.succeed("42") // Wait for the fiber to exit: _ <- fiber.join } yield () Itamar Ravid - @itrvd 59
  43. Promise[E, A] A Promise[E, A] is a signal you can

    trigger across threads: for { promise <- Promise.make[Throwable, String] // Wait on the promise in a background fiber: fiber <- (for { result <- promise.await _ <- UIO(println(s"Received $result")) } yield ()).fork // Make it fail: _ <- promise.fail(new Exception) // Wait for the fiber to exit: _ <- fiber.join } yield () Itamar Ravid - @itrvd 60
  44. Ref[A] A Ref[A] is a mutable reference you can change

    across threads: for { counter <- Ref.make(0) incrementer <- counter.update(_ + 1) .repeat(Schedule.spaced(1.second)) .fork _ <- UIO.sleep(5.seconds) result <- counter.get } yield result Itamar Ravid - @itrvd 61
  45. Queue[A] A high-performance, lock-free concurrent queue: trait Queue[A] { //

    .. def offer(x: A): UIO[Boolean] def take: UIO[A] // .. } Itamar Ravid - @itrvd 62
  46. Queue[A] Here's, for example, a fiber copying items between queues:

    for { input <- Queue.bounded[Int](16) output <- Queue.bounded[Int](16) _ <- input.offerAll(List.fill(16)(10)) fiber <- (for { element <- input.take _ <- output.offer(element) } yield ()).forever.fork _ <- UIO.sleep(1.second) _ <- fiber.interrupt result <- output.takeAll } yield result Itamar Ravid - @itrvd 63
  47. Semaphore A counter we can use to limit access to

    a resource: for { permits <- Semaphore.make(10) expensiveComputation = permits.withPermit { compute() } computations <- ZIO.collectAllPar(List.fill(50)(expensiveComputation)) } yield computations Itamar Ravid - @itrvd 64
  48. Async traces Say you're using Future to write an HTTP

    crawler: def httpCall(url: String): Future[String] def first = httpCall("first:8080") def second = httpCall("second:8080") val result: Future[(String, String)] = first zip second Itamar Ravid - @itrvd 66
  49. Async traces Your job is running in production and hits

    a failure. This is your stack trace: java.lang.RuntimeException at test.Crawler$.$anonfun$httpCall$1(bla.scala:8) at scala.concurrent.Future$.$anonfun$apply$1(Future.scala:660) at scala.concurrent.impl.Promise$Transformation.run(Promise.scala:430) at scala.concurrent.BatchingExecutor$AbstractBatch.runN(BatchingExecutor.scala:134) at scala.concurrent.BatchingExecutor$AsyncBatch.apply(BatchingExecutor.scala:163) at scala.concurrent.BatchingExecutor$AsyncBatch.apply(BatchingExecutor.scala:146) at scala.concurrent.BlockContext$.usingBlockContext(BlockContext.scala:107) at scala.concurrent.BatchingExecutor$AsyncBatch.run(BatchingExecutor.scala:154) at java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(ForkJoinTask.java:1402) at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289) at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056) at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692) at java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:157) Which call failed??? Itamar Ravid - @itrvd 67
  50. Async traces With ZIO, you're getting something much more useful:

    A checked error was not handled. java.lang.RuntimeException // <regular stack trace> Fiber:0 was supposed to continue to: a future continuation at zio.ZIO.zipWith(ZIO.scala:788) Fiber:0 execution trace: at ammonite.$file.bla$.httpCall(bla.scala:6) at ammonite.$file.bla$.google(bla.scala:8) Itamar Ravid - @itrvd 68
  51. Wordcount over several files We will: • List files in

    a directory • Enqueue all paths into a queue • Create 4 worker fibers: • dequeue paths from the queue and count the words Itamar Ravid - @itrvd 71
  52. Listing files This is a good example of using blocking

    effects: import java.nio.file.{Files, Paths} import scala.collection.JavaConverters._ import zio.blocking._ def listFiles(dir: String): ZIO[Blocking, Throwable, List[Path]] = effectBlocking { Files.list(Paths.get(dir)).iterator.asScala.toList } Itamar Ravid - @itrvd 72
  53. Count words in a file Another blocking I/O effect: def

    readContents(path: Path): ZIO[Blocking, Throwable, String] = effectBlocking { new String( Files.readAllBytes(path), "UTF-8" ) } Itamar Ravid - @itrvd 73
  54. Put them together: def countAllWords(dir: String): ZIO[Blocking, Throwable, Long] =

    for { paths <- listFiles(dir) counts <- ZIO.foreach(paths) { path => readContents(path).map { contents => contents.split(' ').size } } } yield counts.sum Itamar Ravid - @itrvd 74
  55. Actually making this map/reduce def mapReduce[A, B](dir: String) (map: String

    => A) (z: B)(reduce: (B, A) => B): ZIO[Blocking, Throwable, B] = for { paths <- listFiles(dir) mapped <- ZIO.foreach(paths) { path => readContents(path).map { contents => map(contents) } } } yield mapped.foldLeft(z)(reduce) def countAllWords(dir: String) = mapReduce(dir)(_.split(' ').size)(0L)(_ + _) Itamar Ravid - @itrvd 75
  56. Use workers to process the data def mapReduce[A, B](dir: String,

    workers: Int) (map: String => A) (z: B)(reduce: (B, A) => B): ZIO[Blocking, Throwable, B] = for { paths <- listFiles(dir) mapped <- ZIO.foreachParN(workers)(paths) { path => readContents(path).map { contents => map(contents) } } } yield mapped.foldLeft(z)(reduce) Itamar Ravid - @itrvd 76
  57. Set things up for directory monitoring Our map worker: def

    createMapWorker[A](inputQueue: Queue[Path], reduceQueue: Queue[A])(map: String => A) = (for { path <- inputQueue.take contents <- readContents(path) a = map(contents) _ <- reduceQueue.offer(a) } yield ()).forever.fork Itamar Ravid - @itrvd 77
  58. Set things up for directory monitoring Our reduce worker: def

    createReduceWorker[A, B](inputQueue: Queue[A], outputQueue: Queue[B], latest: Ref[B]) (reduce: (B, A) => B) = (for { b <- latest.get a <- inputQueue.take newB = reduce(b, a) _ <- latest.set(newB) _ <- outputQueue.offer(newB) } yield ()).forever.fork Itamar Ravid - @itrvd 78
  59. Set things up for directory monitoring Launch everything: def mapReduce[A,

    B](inputQueue: Queue[Path], reduceQueue: Queue[A], outputQueue: Queue[B], workers: Int) (map: String => A) (z: B)(reduce: (B, A) => B): ZIO[Blocking, Throwable, Unit] = for { mapFibers <- ZIO.collectAll( List.fill(workers)(createMapWorker(inputQueue, reduceQueue)(map)) ) reduceFiber <- for { bRef <- Ref.make(z) fiber <- createReduceWorker(reduceQueue, outputQueue, bRef)(reduce) } yield fiber _ <- Fiber.joinAll(reduceFiber :: mapFibers) } yield () Itamar Ravid - @itrvd 79
  60. Set things up for directory monitoring And we need something

    to print intermediate results: def printer(queue: Queue[Long]) = (for { count <- queue.take _ <- UIO(println("Current count: $count")) } yield ()).forever.fork Itamar Ravid - @itrvd 80
  61. Set things up for directory monitoring And our final function

    for running: def countAllWords(dir: String, workers: Int) = for { inputQueue <- Queue.bounded[Path](16) reduceQueue <- Queue.bounded[Long](16) outputQueue <- Queue.bounded[Long](16) paths <- listFiles(dir) _ <- inputQueue.offerAll(paths) _ <- mapReduce(inputQueue, reduceQueue, outputQueue, workers)( _.split(' ').size)(0L)(_ + _).fork printerFib <- printer(outputQueue) _ <- printerFib.await } yield () Itamar Ravid - @itrvd 81
  62. That's it! But we haven't covered: • The R type

    parameter • STM - a super-easy way to construct concurrent applications • Fiber-local variables - a pure equivalent to thread-locals • ZStreams - ZIO's streaming module Itamar Ravid - @itrvd 82
  63. Interested to learn more? We've got John de Goes coming

    to Israel to teach a workshop about ZIO, graciously hosted by IronSource! https://www.eventbrite.com/e/advanced-async-and-concurrent- programming-with-zio-by-john-a-de-goes-tickets-63867611746 Itamar Ravid - @itrvd 84