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

Functional Concurrency with ZIO

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on.

Functional Concurrency with ZIO

Avatar for Itamar Ravid

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