I’ve noticed that often a lot of similar questions get asked within a few days of each other. Recently those questions have been about “fire-and-forget”, how it’s done in Cats Effect, and what issues one should be aware of. So I wrote up a little post with some background and techniques. Hope you enjoy it!
If you want to learn more about Cats Effect and concurrency, check out my book, Essential Effects!
What is “fire-and-forget”?
To understand fire-and-forget, let’s first distinguish it from a more typical method or function call whose purpose is to compute a value: a synchronous computation. We invoke the function, and can proceed once the value is produced.
def doSomething(i: Int): String = {
println(s"[${Thread.currentThread.getName}] doSomething($i)")
s"$i is a very nice number"
}
def doSomethingElse(): Int = {
println(s"[${Thread.currentThread.getName}] doSomethingElse()")
12
}
val doBoth = {
val result = doSomething(12)
println(s"[${Thread.currentThread.getName}] doSomething(12) produced: $result")
doSomethingElse
}
Notice: all println
outputs show the same thread.
Next let’s break down fire-and-forget. What’s fire?
It means to start a computation without waiting for its result, along with a way to access its eventual result: an asynchronous computation. (The term fire comes from the military, where a missile is launched. Eek!)
What would the type signature of such an asynchronous thing be? It requires the necessary inputs, but immediately returns to the caller. We’re not returning the “actual” result of the computation–we need to merely signal “ok, we’re starting, here is a value that lets you act on the eventual result”. We’ll model this as a trait with a fire
method that returns an “eventual result” signal (as a function):
trait Asynchronous {
type EventualResult[A] = () => A
def fire[A](run: => A): EventualResult[A]
}
We can implement this interface using Scala’s Future
type:
object Asynchronous {
import scala.concurrent._
import scala.concurrent.duration._
val global =
new Asynchronous {
implicit val ec = scala.concurrent.ExecutionContext.global
def fire[A](run: => A): EventualResult[A] = {
val res = Promise[A].completeWith(Future(run)) // start the computation
() => Await.result(res.future, Duration.Inf) // wait on demand
}
}
}
We then convert our synchronous code to use our Asynchronous
interface:
val doBothAsync = {
val await = Asynchronous.global.fire(doSomething(12))
val result = await()
println(s"[${Thread.currentThread.getName}] doSomething(12) produced: $result")
doSomethingElse
}
Notice: The println
for doSomething
happens in a different thread than doSomethingElse
.
Finally, we need to forget. That is, after we start the computation (fire), we don’t do anything with our handler to the eventual result. We “forget” it:
trait Asynchronous {
type EventualResult[A] = () => A
def fire[A](run: => A): EventualResult[A]
def fireAndForget[A](run: => A): Unit =
fire(run) // ignore the eventual result handler
}
object Asynchronous {
import scala.concurrent._
import scala.concurrent.duration._
val global =
new Asynchronous {
implicit val ec = scala.concurrent.ExecutionContext.global
def fire[A](run: => A): EventualResult[A] = {
val res = Promise[A]().completeWith(Future(run)) // start the computation
() => Await.result(res.future, Duration.Inf) // wait on demand
}
}
}
The fireAndForget
method invokes fire
, which starts the asynchronous computation, but then ignores the returned EventualResult
, meaning we won’t wait for any result at all.
Let’s update our example:
val doBothFireAndForget = {
Asynchronous.global.fireAndForget(doSomething(12))
doSomethingElse
}
Notice: we see the same asynchronous execution as the “fire”-only example, but don’t print out any intermediate results because we choose to not to wait for them.
Translating to Cats Effect
In Cats Effect, there are three parts to fire-and-forget:
- Define the synchronous computation as an effect.
- Fork the execution of the effect so it runs asynchronously (fire).
- Ignore the return value of the fork (forget).
Let’s do it:
import cats.effect._
import cats.implicits._
class Something extends IOApp {
def run(args: List[String]): IO[ExitCode] =
doBoth.as(ExitCode.Success)
def doSomething(i: Int): IO[String] = // <1>
for {
_ <- IO(println(s"[${Thread.currentThread.getName}] doSomething($i)"))
} yield s"$i is a very nice number"
def doSomethingElse(): IO[Int] =
for {
_ <- IO(println(s"[${Thread.currentThread.getName}] doSomethingElse()"))
} yield 12
val doBoth: IO[Int] =
for {
_ <- doSomething(12).start // <2> <3>
i <- doSomethingElse
} yield i
}
new Something().main(Array.empty)
- We change the signature from returning
String
to returningIO[String]
, to signal the definition of a (synchronous) effect. - We
start
theIO
effect value, forking its execution. It returns anIO[Fiber[IO, String]]
(IO[FiberIO[String]]
in Cats Effect 3) that lets you manage the asynchronous execution and eventual result. - We also ignore the
Fiber
produced bystart
by “anonymizing” its name as_
.
Is it safe to “forget”?
When we fire-and-forget using Cats Effect, we’re deliberately not keeping track of the forked computation. Is there a cost to this–is it safe?
To answer, we need to recall what we can do with the fiber of the forked effect: we can wait for its completion (via join
) or cancel it (via cancel
). Since we really want asychronous execution, we will ignore join
, leaving cancel
: would it ever be necessary to cancel
a computation that we fired and forgot?
It certainly could be important to do so! Consider a long-lived task, like an event-handling loop. We might think “oh, that effect runs forever, it doesn’t need to be canceled”, and therefore 🚀 + 🙈: fire-and-forget. But cancelation might be needed! That forked effect exists in some context; it might be important to ensure that it is cancelled if its “parent” context is cancelled:
val businessLogic =
for {
_ <- longLivedEffect.start
_ <- doSomeOtherStuff
} yield ()
In this example perhaps longLivedEffect
should be cancelled when businessLogic
completes, but fire-and-forget precludes that possibility. We could instead link the lifetime of that long-lived effect to the surrounding business logic. One way is to use guarantee
:
val businessLogic =
for {
fiber <- longLivedEffect.start
_ <- doSomeOtherStuff.guarantee(fiber.cancel) // <1>
} yield ()
- Cancel the fiber once the other logic completes successfully, with an error, or is cancelled.
But using guarantee
doesn’t scale to multiple effects–for example, we want the fiber to be cancelled if any subsequent effect inside businessLogic
is cancelled. We can instead model the lifecycle of an effect as a resource: a data structure that allows us to define a static region where state is used, along with effects for state acquisition and release:
val longLived: Resource[IO, IO[Unit]] =
longLivedEffect.background // <1>
val businessLogic =
longLived.use { join => // <2>
doSomeOtherStuff
}
- Instead of calling
start
to fork the execution,background
returns aResource
that manages the forked fiber for you. The managed state is itself an action–anIO[Unit]
that lets youjoin
the underlying fiber (if you want to). - Once we have a
Resource
, weuse
it to gain access to the managed state, where we can then perform our own effects. This defines a static region where the state is available, and outside this region we are guaranteed the state has been properly acquired and released. In the case of the resource returned bybackground
, the state acquisition effect is “fork a fiber” and the release effect is “cancel the fiber”.
Summary
The pattern of fire-and-forget is really built out of three concepts:
- defining synchronous effects;
- forking an effect to run asynchronously; and
- controlling the asynchronously executing effect, either waiting for its eventual result or canceling it.
If you have short-lived effects that you want to run asynchronously, by all means fire them off and forget about them! But also know that execution is always in some context, and you have techniques like background
to make the asynchronous lifecycle more visible.