Asynchronous programming in late 2018: Cats-Effect
Piotr Gawryś | twitter.com/p_gawrys
Agenda
- Cats-Effect introduction
- Problem description
- Connect and register
- Process passwords
- Work parallelization
- Error handling
- Optimizations
Referential Transparency
Replacing an expression by its bound value doesn't alter the behavior of your program. Why?
- Local reasoning
- Fearless refactoring
- Explicit behavior
- Inversion of control
- Compositionality
See this excellent description if you wish to know more!
Cats-Effect IO
- Referentially transparent
- Manages both synchronous and asynchronous effects
- Resource Safety (bracket)
- Error Handling
- Concurrency, Parallelism
- Building blocks for sharing state and synchronization (Ref, MVar, Deferred)
Cats-Effect IO - Basic Usage
import cats.effect.IO
val ioa = IO { println("hi KSUG!") }
// nothing happens yet, it's just a description
// of evaluation that will produce `Unit` or
// end with error
val program: IO[Unit] =
for {
_ <- ioa
_ <- ioa
} yield ()
// prints "hi KSUG!" twice
program.unsafeRunSync()
import cats.effect.IO
import cats.syntax.apply._
val program: IO[Unit] =
ioa.flatMap(_ => ioa)
val program: IO[Unit] =
ioa *> ioa
import cats.effect.IO
import scala.concurrent.duration._
// do `fa` followed by `fb`,
// discard the first result
def *>[A, B](fa: F[A])(fb: F[B]): F[B] =
map2(fa, fb)((_, b) => b)
// waits 100 millis (without blocking!)
// then prints "woke up"
IO.sleep(100.millis) *> IO(println("woke up"))
Cats-Effect IO - Error Handling I
import cats.effect.IO
val ioa = IO(println("A"))
val iob = IO.raiseError(new Exception("B"))
val ioc = IO(println("C"))
val program: IO[Int] =
for {
_ <- ioa
_ <- iob // short circuits IO with error
_ <- ioc // ioc will never happen!
} yield 42
// A
// Exception in thread "main" java.lang.Exception: B
program.unsafeRunSync()
// A
// Process finished with exit code 0
val safeProgram: IO[Either[Throwable, Int]] =
program.attempt
safeProgram.unsafeRunSync()
// will print A, B and C
val program: IO[Int] =
for {
_ <- ioa
// in case of error executes IO in argument
_ <- iob.handleErrorWith(_ => IO(println("B")))
_ <- ioc
} yield 42
Cats-Effect IO - Error Handling II
import cats.syntax.applicativeError._
import cats.effect.IO
val ioa = IO(println("A"))
val iob = IO.raiseError(new Exception("B"))
val ioc = IO(println("C"))
def handleError: PartialFunction[Throwable, IO[Unit]] = {
case _ => IO(println("errB"))
}
// prints "A", "errB" and ends with error
val program =
for {
_ <- ioa
// executes PartialFunction and rethrows error
_ <- iob.onError(handleError)
_ <- ioc
} yield 42
Good place to look for methods are MonadError and ApplicativeError
Cats-Effect IO - Parallelism
import cats.effect.IO
import cats.instances.list._
import cats.syntax.all._
val ioa = IO(println("A"))
val iob = IO(println("B"))
val listOfIO: List[IO[Unit]] = List(ioa, iob)
val ioOfList: IO[List[Unit]] = listOfIO.parSequence
// using Parallel typeclass instance
val parIO: IO[Unit] =
(ioa, iob).parMapN((_, _) => ())
val ioInParallel: IO[Unit] =
for {
// run in "background"
fibA <- ioa.start
fibB <- iob.start
// wait for ioa result
_ <- fibA.join
_ <- fibB.join
} yield ()
// run both in parallel
// and cancel the loser
val raceIO: IO[Either[Unit, Unit]] =
IO.race(ioa, iob)
See documentation and scala docs for more information!
Cats-Effect IO - Ref I
Purely functional wrapper for AtomicRef. Allows for safe concurrent access and mutation but preserves referential transparency.
abstract class Ref[F[_], A] {
def get: F[A]
def set(a: A): F[Unit]
def modify[B](f: A => (A, B)): F[B]
// ... and more
}
changes Ref to A but can return something else to the caller
Cats-Effect IO - Ref II
import cats.effect.IO
import cats.effect.concurrent.Ref
// Creating `Ref` returns `IO[Ref[...]]`
// To use it we need to flatMap over it.
// Keep in mind that IO is just description
// of operation - running it several times
// will repeat everything, so running it twice
// will produce two different Refs
val stringsRef: IO[Ref[IO, List[String]]] =
Ref.of[IO, List[String]](List())
def take(ref: Ref[IO, List[String]]): IO[Option[String]] =
ref.modify(list => (list.drop(1), list.headOption))
def put(ref: Ref[IO, List[String]], s: String): IO[Unit] =
ref.modify(list => (s :: list, ()))
val program: IO[Unit] =
for {
_ <- stringsRef.flatMap(put(_, "KSUG"))
element <- stringsRef.flatMap(take)
} yield println(element)
program.unsafeRunSync() // prints "None"
val program: IO[Unit] =
for {
ref <- stringsRef
_ <- put(ref, "KSUG")
element <- take(ref)
} yield println(element)
program.unsafeRunSync() // prints "Some(KSUG)"
Cats-Effect IO - Ref III
Confusing? On the contrary!
It forces us to share state in only one possible way - passing it as a parameter.
The regions of sharing are exactly the same of your call graph - no more wondering which class share this state.
Want to go more in-depth regarding sharing state in FP? Check out this blog post
Problem to solve
Request passwords from the server and decrypt it using provided Decrypter class.
It can process up to 4 calls in parallel, decrypts in three stages (prepare, decode, decrypt), randomly fails with Exception and silently corrupts other running instances unless you restart it.
The task is to have as high throughput and correctness as you can achieve
Problem to solve
Decrypter has three methods that we need to invoke in order:
class Decrypter {
...
def prepare(password: String): PasswordPrepared
def decode(state: PasswordPrepared): PasswordDecoded
def decrypt(state: PasswordDecoded): String
}
- There can be only a certain amount of decrypter instances in one JVM.
- When one decrypter instance fails then all instances silently get corrupted and produce wrong results.
Connect and Register
PasswordClient which handles HTTP requests is already implemented.
If you with to try it locally:
git clone https://github.com/VirtusLab/akka-workshop-client.git
cd akka-workshop-client
git checkout cats-effect-master-step1
git clone https://github.com/VirtusLab/akka-workshop.git
cd akka-workshop/server
sbt run
Connect and Register
In Main.scala you'll see run method which starts HTTP Client and proceeds to invoke methods responsible for decrypting passwords.
override def run(args: List[String]): IO[ExitCode] =
Http1Client[IO]()
.bracket { httpClient =>
val client = PasswordClient.create(httpClient)
for {
token <- client.requestToken("Piotrek")
_ <- decryptForever(client, token)(timer)
} yield ExitCode.Success
}(_.shutdown)
change it to your name
LEADERBOARD
http://async-in-2018.herokuapp.com/?mode=remote
Process passwords
For the first step we have to use existing PasswordClient to request, decrypt and validate few passwords! For now we don't care about Decrypter failures.
Requesting Passwords
abstract class PasswordClient[F[_]](httpClient: Client[F]) {
def requestToken(userName: String): F[Token]
def requestPassword(token: Token): F[EncryptedPassword]
def validatePassword(token: Token, encryptedPassword: String,
decryptedPassword: String): F[Status]
}
Methods should be self-explanatory - feel free to modify their implementation if you want to add logging - I recommend .flatTap(a => IO(println(a)) defined in cats!
If you don't recognize F[_] don't worry - it just means that we could write implementation for something other than IO
Work parallelization
Go up on the leaderboard!
http://async-in-2018.herokuapp.com/?mode=parallel
If you follow provided skeleton the structure is just IO running forever so now try to do the same for N IO running in parallel.
One of the earlier slides should give you a hint!
Error Handling
If you see full leaderboard you're probably notice awful correctness after parallelization.
This is because one of the parallel IO can corrupt the decrypter and it won't restart until all of them fail.
Let's think about the mechanism that could stop other tasks as soon as one of them fails.
Error Handling
Unlike Akka, Cats-Effect doesn't have any supervision mechanism included.
The point of Cats-Effect and IO model in general is to have simple, composable building blocks to easily build more complicated structures.
Error Handling
If you share state between parallel IOs you can use it to signal failure. I'd suggest refreshing Ref
Most straightforward way is to introduce checkpoints in the decryption code to determine whether we should stop our recursive calls or not.
On failure we should set signal and terminate.
Optimizations
With previous step done it's possible to have 100% correctness without sacrificing parallelism.
It doesn't mean we can't improve our throughput.
Saving partially decrypted passwords
So far we restart as soon as possible if error occurs in any task but we don't have to discard our progress.
If we save our last step we could improve performance quite a bit!
Saving partially decrypted passwords
To do that I'd suggest sharing state once again. This time we could use something like Ref[IO, List[Password]].
All that has to be done is to introduce new parameter and save the password before terminating.
Recovering partially decrypted passwords
def getPassword(client: PasswordClient[IO], token: Token): IO[Password] = {
client.requestPassword(token)
}
This looks like a good place to take saved passwords instead of requesting them each time.
Cleaning up
Now that everything is working fast and correct we could take a closer look at how the code is organized.
fullDecryption functions is probably cluttered with two Refs and a lot of Ref specific logic like modify.
Hiding Ref
We could get inspiration from existing PasswordClient and define algebra that exposes clean interface, e.g.:
trait PasswordQueue[F[_]] {
def take: F[Option[Password]]
def put(password: Password): F[Unit]
}
Hiding Ref
And the implementation:
object PasswordQueue {
def create[F[_] : Sync]: F[PasswordQueue[F]] =
Ref.of[F, List[Password]](List.empty).map { state =>
new PasswordQueue[F] {
def take: F[Option[Password]] = ???
def put(password: Password): F[Unit] = ???
}
}
}
This trick completely separates Ref behind interface closer to domain logic.
Feel free to contact in case of any questions and feedback!
Asynchronous programming in late 2018: Cats-Effect
By Piotr Gawryś
Asynchronous programming in late 2018: Cats-Effect
- 1,016