Concurrency: Futures, actors and effect systems
Compose asynchronous work with Future, recover from failure, use actors for stateful concurrency, and see what an effect system adds.
Future and ExecutionContext
import scala.concurrent.{Future, ExecutionContext, Await}
import scala.concurrent.duration.*
import scala.util.{Success, Failure}
given ExecutionContext = ExecutionContext.global // use a bounded pool in production
def fetchPrice(sku: String): Future[BigDecimal] = Future {
Thread.sleep(50) // stands in for a blocking HTTP call
BigDecimal(9.99)
}
def fetchStock(sku: String): Future[Int] = Future(3)
// a for-comprehension sequences Futures: flatMap chains them, map transforms
val summary: Future[String] =
for
price <- fetchPrice("ABC-1")
stock <- fetchStock("ABC-1")
yield s"$stock units at $price"
// run two independent Futures in parallel, not one after the other
val both: Future[(BigDecimal, Int)] =
fetchPrice("ABC-1").zip(fetchStock("ABC-1"))
// traverse starts every element, then collects the results in order
val all: Future[List[BigDecimal]] =
Future.traverse(List("A", "B", "C"))(fetchPrice)
// recovery belongs at the edge, where you can decide what to do
val safe: Future[String] = summary
.recover { case e: java.io.IOException => s"unavailable: ${e.getMessage}" }
.recoverWith { case _ => Future.successful("fallback") }
summary.onComplete {
case Success(v) => println(v)
case Failure(e) => println(s"failed: $e")
}
// Await only in tests and at a main method, never inside a library
Await.result(summary, 3.seconds)- A
Futurestarts immediately when constructed. Building one inside aforbody is the classic bug: everything runs eagerly, before the chain is composed. UseFuture.deferwhen you need laziness. Future.traverseis eager over the input collection;Future.sequencetakes an existingList[Future[A]].- Every
map,flatMapandrecoverneeds anExecutionContext. The global pool is fine for CPU work and wrong for blocking calls, which starve it. Await.resultblocks a thread and can deadlock if the pool is exhausted. Return theFutureand let the caller wait.- A
TimeoutExceptiondoes not cancel the underlying work:Futurehas no cancellation. The thread keeps running to completion.
For blocking I/O, do not use the global pool. Either give the blocking work its own bounded executor, or use a non-blocking client. Exhausting the global pool with blocking calls is the most common way a Scala service stops responding.
Actors for stateful concurrency
// Pekko (the Apache fork of Akka) or Akka typed actors
import org.apache.pekko.actor.typed.{ActorRef, ActorSystem, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
object Counter:
sealed trait Command
final case class Increment(n: Int, replyTo: ActorRef[Int]) extends Command
final case class Get(replyTo: ActorRef[Int]) extends Command
def apply(): Behavior[Command] = active(0)
// state is a parameter: no locks, no shared mutable field
private def active(value: Int): Behavior[Command] = Behaviors.receive { (context, message) =>
message match
case Increment(n, replyTo) =>
val next = value + n
replyTo ! next
active(next) // return the new behaviour, with the new state
case Get(replyTo) =>
replyTo ! value
Behaviors.same
}
@main def run(): Unit =
val system = ActorSystem(Counter(), "counter")
// messages are sent, not called; the actor processes them one at a time
system ! Counter.Increment(5, system.ignoreRef)| Concern | Future | Actor |
|---|---|---|
| Shared state | Protected by locks or avoided | Owned by the actor, never shared |
| Communication | Return a value | Send a message |
| Failure | recover on the chain | Supervision strategy restarts the actor |
| Backpressure | None built in | Bounded mailbox, or an explicit protocol |
| Composition | Monadic, static | Messages arrive one at a time, in order per sender |
An actor processes one message at a time, so its state needs no synchronisation. That is the whole advantage, and the whole constraint: a blocking call inside receive blocks every other message the actor could handle.
What an effect system adds
// cats-effect: an IO value is a description; nothing runs until unsafeRunSync
import cats.effect.{IO, IOApp, Resource}
import scala.concurrent.duration.*
object App extends IOApp.Simple:
val readFile: IO[String] = IO.blocking {
scala.io.Source.fromFile("data.txt").mkString
}
// resource acquisition and release are paired by construction
val managed: Resource[IO, java.io.BufferedReader] =
Resource.make(IO.blocking(new java.io.BufferedReader(new java.io.FileReader("data.txt")))) { r =>
IO.blocking(r.close())
}
val program: IO[Unit] =
for
text <- readFile
_ <- IO.println(s"read ${text.length} characters")
_ <- IO.sleep(100.millis)
_ <- managed.use(r => IO.blocking(r.readLine())).flatMap(l => IO.println(l))
yield ()
def run: IO[Unit] = program.timeout(5.seconds).handleErrorWith(e => IO.println(s"failed: ${e.getMessage}"))- An effect value is a description. Nothing happens until it is run, which makes it refactorable and testable without side effects at construction.
- Cancellation is first class:
timeoutactually interrupts the fibre, whichFuturecannot do. Resourcepairs acquire and release, so a file or connection is closed even when the body fails or is cancelled.IO.blockingtells the runtime that a thread will be parked, so it can size the pool accordingly instead of starving it.- ZIO offers the same idea with a built-in environment type and richer error typing; the mental model transfers.
💡
Reach for an effect system when you need resource safety with cancellation, retries with backoff, structured concurrency or streaming. For a handful of independent calls,
Future is simpler and the extra abstraction buys nothing.FAQ
What ExecutionContext should I use?
A fixed pool sized to the CPU count for compute, and a separate bounded pool for blocking work.
ExecutionContext.global is a fork-join pool with a limited compensation mechanism and it will starve under sustained blocking.Is Future cancellable?
No.
Await.result with a timeout throws, but the computation continues on its thread. For real cancellation you need an effect system, or an explicit cancellation flag checked by the work.Related
A small HTTP service with http4s or Play Option, Either and functional error handling
Last refreshed 2026-09-18.