Slide 1

Slide 1 text

Itamar Ravid - @itrvd 1

Slide 2

Slide 2 text

Functional Concurrency with Itamar Ravid - @itrvd 2

Slide 3

Slide 3 text

Agenda • 2 minutes of background • An overview of ZIO • Map-Reduce with ZIO Itamar Ravid - @itrvd 3

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

To be useful, a program has to do I/O Itamar Ravid - @itrvd 5

Slide 6

Slide 6 text

To be efficient, a program should probably use asynchronicity Itamar Ravid - @itrvd 6

Slide 7

Slide 7 text

To be fast, a program probably needs to use concurrency Itamar Ravid - @itrvd 7

Slide 8

Slide 8 text

Scala's answer: scala.concurrent.Future[T] Itamar Ravid - @itrvd 8

Slide 9

Slide 9 text

Future doesn't block your thread: val background = Future { Thread.sleep(15000) println("Done sleeping!") } println("I'm not blocked!") Itamar Ravid - @itrvd 9

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

What's ZIO? Itamar Ravid - @itrvd 13

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

Adapters import scala.util.Try val fromTry: ZIO[Any, Throwable, String] = ZIO.fromTry(Try("OK!")) Itamar Ravid - @itrvd 17

Slide 18

Slide 18 text

Adapters val anEither: Either[String, Boolean] = Right(true) val fromEither: ZIO[Any, String, Boolean] = ZIO.fromEither(anEither) Itamar Ravid - @itrvd 18

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

ZIO's error model Itamar Ravid - @itrvd 27

Slide 28

Slide 28 text

ZIO's error model Itamar Ravid - @itrvd 28

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

ZIO's error model Itamar Ravid - @itrvd 30

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

When do fibers get interrupted? • In between flatMaps (e.g. "between" for-comprehension steps) • On constructors that can be interrupted Itamar Ravid - @itrvd 36

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

Resource management Itamar Ravid - @itrvd 39

Slide 40

Slide 40 text

Resource management Itamar Ravid - @itrvd 40

Slide 41

Slide 41 text

Resource management Itamar Ravid - @itrvd 41

Slide 42

Slide 42 text

Resource management Itamar Ravid - @itrvd 42

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

Resource management Itamar Ravid - @itrvd 46

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

Resource management We have ZManaged for that: resources.use { case Resources(redis, mysql, elastic) => // use everything } Itamar Ravid - @itrvd 48

Slide 49

Slide 49 text

Resource management If ZManaged looks obscure, watch my talk about it: https://youtu.be/ybXuBXZ8NA0?t=1138 Itamar Ravid - @itrvd 49

Slide 50

Slide 50 text

Retries and repetitions Who's written a function for exponential backoff retrying before? Itamar Ravid - @itrvd 50

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

ZSchedule With ZIO, the previous combinator reduces to this: task.retry( ZSchedule.exponential(50.millis).jittered && ZSchedule.recurs(5) ) Itamar Ravid - @itrvd 53

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

Concurrent data structures ZIO features all of the data structures you need to create concurrent algorithms. Itamar Ravid - @itrvd 58

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

Semaphore Or, more shortly: ZIO.collectAllParN(10) { List.fill(50)(compute()) } Itamar Ravid - @itrvd 65

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

Async traces With ZIO, you're getting something much more useful: A checked error was not handled. java.lang.RuntimeException // 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

Slide 69

Slide 69 text

A full example Let's see everyone's favourite example! Itamar Ravid - @itrvd 69

Slide 70

Slide 70 text

! Map / Reduce Itamar Ravid - @itrvd 70

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

Interested to learn more? • https://zio.dev/ • https://github.com/zio/zio • https://gitter.im/zio/core Itamar Ravid - @itrvd 83

Slide 84

Slide 84 text

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