Sleeping well in the lion's den with Monix Catnap

Piotr Gawryś

About me

  • An open source contributor for fun
  • One of the maintainers of Monix
  • Kraków Scala User Group co-organizer
    (let me know if you'd like to speak!)

https://github.com/Avasil

twitter.com/p_gawrys

Monix

  • Scala / Scala.js library for asynchronous programming
  • Multiple modules exposing Task, Observable, Iterant, Coeval and many concurrency utilities
  • Favors purely functional programming but provides for all

twitter.com/p_gawrys

Monix Niche

  • Mixed codebases 

twitter.com/p_gawrys

Monix Niche

  • Mixed codebases 
  • Good integration and consistency with Cats ecosystem

twitter.com/p_gawrys

Monix Niche

  • Mixed codebases 
  • Good integration and consistency with Cats ecosystem
  • Maturity/Stability

twitter.com/p_gawrys

Monix Niche

  • Mixed codebases 
  • Good integration and consistency with Cats ecosystem
  • Maturity/Stability
  • Performance-sensitive applications

twitter.com/p_gawrys

This talk

twitter.com/p_gawrys

  • monix-execution -low-level concurrency abstractions, companion to scala.concurrent
  • monix-catnap - purely functional abstractions, Cats-Effect friendly

Problem: Limiting parallelism

object SharedResource {
  private val counter = AtomicInt(2)

  def access(i: Int): Unit = {
    if (counter.decrementAndGet() < 0)
      throw new IllegalStateException("counter less than 0")
    Thread.sleep(100)
    counter.increment()
  }
}

implicit val ec = 
  ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))

val f: Int => Future[Unit] = i => Future {
  SharedResource.access(i)
}

Await.result(Future.traverse(List(1, 2, 3, 4, 5))(f), 60.second)

Exception in thread "main" java.lang.IllegalStateException: counter less than 0

Semaphore

  • Synchronization primitive
  • A counter is incremented when semaphore's permit is released
  • A counter is decremented when permit is acquired
  • acquire blocks until there is a permit available

twitter.com/p_gawrys

java.util.concurrent.Semaphore

implicit val ec = 
  ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))

def traverseN(n: Int, list: List[Int])(
  f: Int => Future[Unit]
): Future[List[Unit]] = {
  // java.util.concurrent.Semaphore
  val semaphore = new Semaphore(n)

  Future.traverse(list) { i =>
    val future = Future(semaphore.acquire()).flatMap(_ => f(i))
    future.onComplete(_ => semaphore.release())
    future
  }
}

val f: Int => Future[Unit] = i => Future {
  SharedResource.access(i)
}

Await.result(traverseN(2, List.range(1, 5))(f), Duration.Inf) // works!
Await.result(traverseN(2, List.range(1, 10))(f), Duration.Inf) // hangs forever...

Semantic/Asynchronous blocking

  • Blocks a fiber instead of an underlying thread
  • Can we do it for a Future?

twitter.com/p_gawrys

Let's see how!

Acquire

type Listener[A] = Either[Throwable, A] => Unit

private final case class State(
  available: Long,
  awaitPermits: Queue[(Long, Listener[Unit])]
)
  
def unsafeAcquireN(n: Long, await: Listener[Unit]): Cancelable
  • Check state
  • Are n permits available?
    • NO => add Listener to awaitPermits queue
    • YES => decrement permits and call Listener callback

Acquire Cancelation

type Listener[A] = Either[Throwable, A] => Unit

private final case class State(
  available: Long,
  awaitPermits: Queue[(Long, Listener[Unit])]
)

def cancelAcquisition(n: Long, isAsync: Boolean): (Listener[Unit] => Unit)
  • Check state
  • find Listener in awaitPermits and remove it
  • release n permits

Release

type Listener[A] = Either[Throwable, A] => Unit

private final case class State(
  available: Long,
  awaitPermits: Queue[(Long, Listener[Unit])]
)
  
def unsafeReleaseN(n: Long): Unit
  • Check state
  • Is anything awaiting permit?
    • NO => add permit
    • YES => go through queue and give permits

Implementing with Future

type Listener[A] = Either[Throwable, A] => Unit

private final case class State(
  available: Long,
  awaitPermits: Queue[(Long, Listener[Unit])]
)


def acquireN(n: Long): CancelableFuture[Unit] = {
  if (unsafeTryAcquireN(n)) {
    CancelableFuture.unit
  } else {
    val p = Promise[Unit]()
    unsafeAcquireN(n, Callback.fromPromise(p)) match {
      case Cancelable.empty => CancelableFuture.unit
      case c => CancelableFuture(p.future, c)
    }
  }
}

monix.execution.AsyncSemaphore

implicit val ec = 
  ExecutionContext.fromExecutor(Executors.newFixedThreadPool(4))

def traverseN(n: Int, list: List[Int])(
  f: Int => Future[Unit]
): Future[List[Unit]] = {
  // monix.execution.AsyncSemaphore
  val semaphore = AsyncSemaphore(n)

  Future.traverse(list) { i =>
    semaphore.withPermit(() => f(i))
  }
}

val f: Int => Future[Unit] = i => Future {
  SharedResource.access(i)
}

Await.result(traverseN(2, List.range(1, 10))(f), Duration.Inf) // works!
object LocalExample extends App with StrictLogging {
  implicit val ec = ExecutionContext.global

  def req(requestId: String, userName: String): Future[Unit] = Future {
    logger.info(s"Received a request to create a user $userName")
    // do sth
  }.flatMap(_ => registerUser(userName))
  
  def registerUser(name: String): Future[Unit] = {
    // business logic
    logger.info(s"Registering a new user named $name")
    Future.unit
  }

  val requests = List(req("1", "Clark"), req("2", "Bruce"), req("3", "Diana"))
  Await.result(Future.sequence(requests), Duration.Inf)
}

Received a request to create a user Bruce
Registering a new user named Bruce
Received a request to create a user Diana
Registering a new user named Diana
Received a request to create a user Clark
Registering a new user named Clark

Problem: Logging Requests

def req(requestId: String, userName: String): Future[Unit] = Future {
  logger.info(s"$requestId: Received a request to create a user $userName")
  // do sth
}.flatMap(_ => registerUser(requestId, userName))

def registerUser(requestId: String, name: String): Future[Unit] = {
  // business logic
  logger.info(s"$requestId: Registering a new user named $name")
  Future.unit
}

3: Received a request to create a user Diana
3: Registering a new user named Diana
1: Received a request to create a user Clark
1: Registering a new user named Clark
2: Received a request to create a user Bruce
2: Registering a new user named Bruce

Logging Requests

logger.info("Logging something.")
MDC.put("requestId", "1")
logger.info("Logging something with MDC.")


: Logging something.
1: Logging something with MDC.

Propagating context with MDC

def req(requestId: String, userName: String): Future[Unit] = Future {
  MDC.put("requestId", requestId)
  logger.info(s"Received a request to create a user $userName")
  // more flatmaps to add async boundaries
}.flatMap(_ => Future(()).flatMap(_ => Future())).flatMap(_ => registerUser(userName))

def registerUser(name: String): Future[Unit] = {
  // business logic
  logger.info(s"Registering a new user named $name")
  Future.unit
}

3: Received a request to create a user Diana
2: Received a request to create a user Bruce
1: Received a request to create a user Clark
1: Registering a new user named Clark
2: Registering a new user named Bruce
2: Registering a new user named Diana

MDC and concurrency

monix.execution.misc.Local

  • ThreadLocal with a flexible scope which can be propagated over async boundaries
  • Supports Future and Monix Task
  • Good for context propagation like MDC nad OpenTracing without manually passing parameters
  • Quite low level and still have rough edges
  • First version introduced in 2017

twitter.com/p_gawrys

Local Model

  • Local is shared unless told otherwise
  • Needs TracingScheduler for Future
  • TaskLocal is a pure version just for a Task
  • Task is a bit smarter about it and does not always require manual isolation
implicit val s = Scheduler.traced

// from https://github.com/mdedetrich/monix-mdc
MonixMDCAdapter.initialize()

def req(requestId: String, userName: String): Future[Unit] = Local.isolate {
  Future {
    MDC.put("requestId", requestId)
    logger.info(s"Received a request to create a user $userName")
    // more flatmaps to add async boundaries
  }.flatMap(_ => Future(()).flatMap(_ => Future())).flatMap(_ => registerUser(userName))
}

1: Received a request to create a user Clark
3: Received a request to create a user Diana
2: Received a request to create a user Bruce
3: Registering a new user named Diana
1: Registering a new user named Clark
2: Registering a new user named Bruce

MDC with Monix Local

Gotcha: Blackbox Asynchronous Code

implicit val ec = Scheduler.traced

val local = Local(0)

def blackbox: Future[Unit] = {
  val p = Promise[Unit]()
  new Thread {
    override def run(): Unit = {
      Thread.sleep(100)
      p.success(())
    }
  }.start()
  p.future
}

val f = Local.isolate {
  for {
    _ <- Future { local := local.get + 100 }
    _ <- blackbox
    _ <- Future { local := local.get + 100 }
  // can print 100 if blackbox is not isolated!
  } yield println(local.get) 
}

Await.result(f, Duration.Inf)

Tracking Parcel Delivery

COURIER

LOCATION UPDATE

SHIPPING SYSTEM

REGISTER PARCEL

PARCEL

CHECK STATUS

UPDATE STATUS

case class ParcelStatus(estimatedDelivery: OffsetDateTime, route: String)

case class ParcelDelivery(id: Long, getLatestStatus: Task[Option[ParcelStatus]])

class ShippingSystem {
  // adds new parcel to the system
  def registerParcel(
    id: Long, 
    destination: String
  ): Task[ParcelDelivery] = ???

  // sends a new location of the parcel
  def updateLocation(id: Long, location: String): Task[Unit] = ???
}

Tracking Parcel Delivery

case class ParcelStatus(estimatedDelivery: OffsetDateTime, route: String)

case class ParcelDelivery(id: Long, getLatestStatus: Task[Option[ParcelStatus]])

class ShippingSystem {
  // adds new parcel to the system
  def registerParcel(
    id: Long, 
    destination: String
  ): Task[ParcelDelivery] = ???

  // sends a new location of the parcel
  def updateLocation(id: Long, location: String): Task[Unit] = ???
}

Tracking Parcel Delivery

could use synchronization and/or back-pressure

Parcel Delivery with Queue

COURIER

LOCATION UPDATE

QUEUE

SHIPPING SYSTEM

REGISTER PARCEL

PARCEL

CHECK STATUS

UPDATE STATUS

Asynchronous Queue

  • A collection which allows to add elements to one end of the sequence and remove them from the other end
  • Producer is backpressured on offer if a queue is full
  • Consumer is backpressured on poll if a queue is empty
  • Useful for decoupling producer and consumer, distributing work

twitter.com/p_gawrys

Monix Queues

  • ConcurrentQueue[F[_], A] - a purely functional asynchronous queue for any Cats-Effect compliant effect
  • AsyncQueue[A] - impure asynchronous queue for scala.concurrent.Future

twitter.com/p_gawrys

case class ParcelLocation(id: Long, location: String)
type ParcelLocationQueue = ConcurrentQueue[Task, ParcelLocation]

class ShippingSystem private[example] (
    queue: ParcelLocationQueue,
    // impure data structure to cut boilerplate for the sake of example
    deliveries: TrieMap[Long, ParcelStatus]
) {
  def registerParcel(id: Long, destination: String): Task[ParcelDelivery] =
    Task {
      val parcelStatus = ParcelStatus(OffsetDateTime.now().plusYears(1L), s"route-to-$destination")
      deliveries.addOne(id -> parcelStatus)
      ParcelDelivery(id, Task(deliveries.get(id)))
    }

  def updateLocation(id: Long, location: String): Task[Unit] =
    queue.offer(ParcelLocation(id, location))

  def run(): Task[Unit] =
    queue.poll.flatMap(updateStatus).loopForever

  private def updateStatus(parcel: ParcelLocation): Task[Unit] = {
    Task(deliveries.get(parcel.id)).flatMap {
      case Some(lastStatus) =>
        // imagine some calculations here
        val newStatus = ParcelStatus(
          OffsetDateTime.now().plusHours(4L), lastStatus.route + s"-at-${parcel.location}")

        Task(deliveries.update(parcel.id, newStatus))
          .flatMap(_ => Task(println(s"Updated parcel ${parcel.id} with new status $newStatus")))
      case None =>
        Task(println(s"Received missing parcel ${parcel.id}"))
    }
  }
}
override def run(args: List[String]): Task[ExitCode] =
  for {
    // will back-pressure updateLocation if it's full
    queue <- ConcurrentQueue.bounded[Task, ParcelLocation](1024)
    shippingSystem = new ShippingSystem(
      queue, TrieMap.empty[Long, ParcelStatus])
    // run shippingSystem in the background
    _ <- shippingSystem.run().startAndForget
    parcel <- shippingSystem.registerParcel(0L, "NYC")
    _ <- shippingSystem.updateLocation(0L, "WAW")
    _ <- Task.sleep(100.millis)
    latestStatus <- parcel.getLatestStatus
  } yield {
    ExitCode.Success
  }

Other implementations

  • fs2: F[_] support, extra methods for fs2.Stream, lots of types of queues, fairness 
  • ZIO: ZIO support, profunctor queue (has contramap, map, filter etc.), fairness
  • Monix: F[_] + Future support, best performance

twitter.com/p_gawrys

What if there are more systems which need parcel location?

COURIER

LOCATION UPDATE

???

SHIPPING SYSTEM

REGISTER PARCEL

PARCEL

CHECK STATUS

UPDATE STATUS

ANALYTICS SYSTEM

monix.catnap.ConcurrentChannel

  • Created for the sole purpose of modeling complex producer-consumer scenarios
  • Supports multicasting / broadcasting to multiple consumers and workers
  • Sort of like ConcurrentQueue per Consumer with higher level API which allows termination, waiting on consumers etc.
  • Inspired by Haskell's Control.Concurrent.Chan

twitter.com/p_gawrys

monix.catnap.ConcurrentChannel

twitter.com/p_gawrys

final class ConcurrentChannel[F[_], E, A] {
  def push(a: A): F[Boolean]
  def pushMany(seq: Iterable[A]): F[Boolean]
  def halt(e: E): F[Unit]
  def consume: Resource[F, ConsumerF[F, E, A]]
  def consumeWithConfig(config: ConsumerF.Config): Resource[F, ConsumerF[F, E, A]]
  def awaitConsumers(n: Int): F[Boolean]
}

trait ConsumerF[F[_], E, A] {
  def pull: F[Either[E, A]]
  def pullMany(minLength: Int, maxLength: Int): F[Either[E, Seq[A]]]
}
type ParcelLocationChannel = ConcurrentChannel[Task, Unit, ParcelLocation]
  
class ShippingSystem private[example] (
    channel: ParcelLocationChannel,
    // impure data structure to cut boilerplate for the sake of example
    deliveries: TrieMap[Long, ParcelStatus]
) {

  // the same as queue version
  def registerParcel(id: Long, destination: String): Task[ParcelDelivery]

  def updateLocation(id: Long, location: String): Task[Unit] =
    channel.push(ParcelLocation(id, location)).map(_ => ())

  def run(): Task[Unit] = channel.consume.use(consumeChannel)

  private def consumeChannel(
      consumer: ConsumerF[Task, Unit, ParcelLocation]): Task[Unit] = {
    consumer.pull.flatMap {
      case Left(halt) =>
        Task(println(s"Closing ShippingSystem"))
      case Right(parcel) =>
        updateStatus(parcel).flatMap(_ => consumeChannel(consumer))
    }
  }
  
  // the same as queue version
  private def updateStatus(parcel: ParcelLocation): Task[Unit]
}

twitter.com/p_gawrys

override def run(args: List[String]): Task[ExitCode] =
  for {
    channel <- ConcurrentChannel.of[Task, Unit, ParcelLocation]
    shippingSystem = new ShippingSystem(
      channel, TrieMap.empty[Long, ParcelStatus])
    analytics = new AnalyticsSystem(channel)
    // run both systems in the background
    _ <- Task.parZip2(shippingSystem.run(), analytics.run()).startAndForget
    // wait until we have 2 subscribers
    _ <- channel.awaitConsumers(2)
    parcel <- shippingSystem.registerParcel(0L, "NYC")
    _ <- shippingSystem.updateLocation(0L, "new location")
    _ <- Task.sleep(100.millis)
    latestStatus <- parcel.getLatestStatus
  } yield {
    println(latestStatus)
    ExitCode.Success
  }

Parcel Delivery

Alternative solutions

  • monix.reactive.Observable: lots of options and control but sharing streams is not 100% pure 
  • ConcurrentChannel: simple, pure API based on F[_] + Resource
  • fs2.concurrent.Topic: simple, pure API based on fs2.Stream
  • Akka: Hubs for AkkaStreams and distributed PubSub with Actors

twitter.com/p_gawrys

...And there's more!

  • CircuitBreaker, Cancelables, CancelableFuture, Future utils, TestScheduler, Future-based MVar, ...
  • If you have any questions or more ideas, make sure to let us know at https://github.com/monix/monix or https://gitter.im/monix/monix
  • Contributions are very welcome!

twitter.com/p_gawrys

Thank you !

https://github.com/typelevel/cats-effect

https://github.com/functional-streams-for-scala/fs2

https://github.com/monix/monix

https://github.com/zio/zio

​Some of the projects worth checking out:

twitter.com/p_gawrys

Sleeping well in the lion's den with Monix Catnap (Typelevel Summit 2020)

By Piotr Gawryś

Sleeping well in the lion's den with Monix Catnap (Typelevel Summit 2020)

  • 147
Loading comments...

More from Piotr Gawryś