Share
Embed
Functional Concurrency with ZIO
Itamar Ravid
June 24, 2019
Transcript
84 slides
-
Agenda • 2 minutes of background • An overview of
ZIO • Map-Reduce with ZIO Itamar Ravid - @itrvd 3 -
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 -
To be useful, a program has to do I/O Itamar
Ravid - @itrvd 5 -
To be efficient, a program should probably use asynchronicity Itamar
Ravid - @itrvd 6 -
To be fast, a program probably needs to use concurrency
Itamar Ravid - @itrvd 7 -
Future doesn't block your thread: val background = Future {
Thread.sleep(15000) println("Done sleeping!") } println("I'm not blocked!") Itamar Ravid - @itrvd 9 -
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 -
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 -
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 -
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 -
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 -
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 -
Adapters import scala.util.Try val fromTry: ZIO[Any, Throwable, String] = ZIO.fromTry(Try("OK!"))
Itamar Ravid - @itrvd 17 -
Adapters val anEither: Either[String, Boolean] = Right(true) val fromEither: ZIO[Any,
String, Boolean] = ZIO.fromEither(anEither) Itamar Ravid - @itrvd 18 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
When do fibers get interrupted? • In between flatMaps (e.g.
"between" for-comprehension steps) • On constructors that can be interrupted Itamar Ravid - @itrvd 36 -
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 -
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 -
Resource management What happens when we need to acquire multiple
resources? connectRedis.bracket(_.close) { redis => // use Redis } Itamar Ravid - @itrvd 43 -
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 -
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 -
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 -
Resource management We have ZManaged for that: resources.use { case
Resources(redis, mysql, elastic) => // use everything } Itamar Ravid - @itrvd 48 -
Resource management If ZManaged looks obscure, watch my talk about
it: https://youtu.be/ybXuBXZ8NA0?t=1138 Itamar Ravid - @itrvd 49 -
Retries and repetitions Who's written a function for exponential backoff
retrying before? Itamar Ravid - @itrvd 50 -
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 -
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 -
ZSchedule With ZIO, the previous combinator reduces to this: task.retry(
ZSchedule.exponential(50.millis).jittered && ZSchedule.recurs(5) ) Itamar Ravid - @itrvd 53 -
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 -
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 -
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 -
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 -
Concurrent data structures ZIO features all of the data structures
you need to create concurrent algorithms. Itamar Ravid - @itrvd 58 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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