Parallel Collections & Concurrency Libraries
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala
3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). The parallel collections, Cats Effect, ZIO, Ox and Pekko
examples downloaded their libraries from Maven Central. The scala-parallel-collections examples also ran on 3.9.0 (the 3.9
LTS line, released 2026-09-03) and 3.3.8, and a minimal one on Scala 2.13.18. Timings and thread counts are those observed
on a 10-core machine and will differ on yours.
Two ideas share the word "parallel". Data parallelism splits a collection’s elements across cores: that is the parallel
collections module. Task concurrency composes independent operations that may overlap: that is what Future from
Futures & Promises and the libraries in the second half of this
page do. Pick data parallelism for CPU-bound bulk work over large in-memory collections and task concurrency for I/O,
timeouts, fan-out and anything that can fail or be cancelled.
The scala-parallel-collections module
Until Scala 2.12 the standard library had a parallel version of every collection reachable with .par. In Scala 2.13 the
parallel collections were moved out of the standard library into a separate module, scala-parallel-collections, which is
published for Scala 2.12, 2.13 and 3. It is a normal library: you add it when you need it, and it follows the standard library.
Add it to a Scala CLI program with a directive, and import the converters that add the par method:
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
@main def run(): Unit =
val trails = (1 to 20).toList
val par = trails.par
println(par.getClass.getName)
println(par.sum)
println(par.map(_ * 2).toList.take(5))
println(par.seq.getClass.getName)
println(par.tasksupport.getClass.getName)
println(scala.collection.parallel.availableProcessors)
println(trails.par.map(_ => Thread.currentThread.getName).toSet.size > 1)
println(Map("a" -> 1, "b" -> 2).par.getClass.getName)
println(Array(1, 2, 3).par.getClass.getName)
println((1 to 5).par.getClass.getName)
println(Set(1, 2).par.getClass.getName)
scala.collection.parallel.immutable.ParVector
210
List(2, 4, 6, 8, 10)
scala.collection.immutable.Vector1
scala.collection.parallel.ExecutionContextTaskSupport
10
true
scala.collection.parallel.immutable.ParHashMap
scala.collection.parallel.mutable.ParArray
scala.collection.parallel.immutable.ParRange
scala.collection.parallel.immutable.ParHashSet
Source: Parallel Collections: Overview
For sbt, the same dependency is:
libraryDependencies += "org.scala-lang.modules" %% "scala-parallel-collections" % "1.2.0"
Without the dependency and the import, .par does not exist on a Scala 3 collection:
-- [E008] Not Found Error: p0.scala:3:24
3 | println(List(1, 2, 3).par.sum)
| ^^^^^^^^^^^^^^^^^
| value par is not a member of List[Int]
1 error found
Reading the first output: List(...).par returns a ParVector, not a ParList. A List is a linked list and cannot be
split in half cheaply, so par copies the elements into a structure that can. par.seq copies back, into a Vector, not
into the original List. The type is chosen as follows:
| Sequential collection | Parallel counterpart from .par |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The conversion cost is part of the price: par on an Array, a Range or a Vector is cheap, on a List it is linear. The
inverse, .seq, returns to the sequential world, and .toList or .toVector produce a sequential result in the original
order.
Common operations
The parallel types extend the same operations as the sequential ones, so most code needs only .par in front and .seq or
.toList behind. Results keep the order of the data, even though the work ran out of order.
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
import scala.collection.parallel.mutable.{ParArray, ParTrieMap}
@main def run(): Unit =
val words = List("trail", "peak", "ridge", "valley", "col", "pass")
val pw = words.par
println(pw.filter(_.length > 4).seq)
println(pw.groupBy(_.length).seq.toList.sortBy(_._1))
println(pw.exists(_.startsWith("v")))
println(pw.partition(_.length > 4))
println(pw.toList)
println(ParArray.tabulate(8)(i => i * i))
val tm = ParTrieMap[String, Int]()
words.par.foreach(w => tm.put(w, w.length))
println(tm.toList.sorted)
println(pw.map(_.length).max)
println(pw.zipWithIndex.seq.toList.take(2))
println(pw.reduce(_ + "," + _))
Vector(trail, ridge, valley)
List((3,ParVector(col)), (4,ParVector(peak, pass)), (5,ParVector(trail, ridge)), (6,ParVector(valley)))
true
(ParVector(trail, ridge, valley),ParVector(peak, col, pass))
List(trail, peak, ridge, valley, col, pass)
ParArray(0, 1, 4, 9, 16, 25, 36, 49)
List((col,3), (pass,4), (peak,4), (ridge,5), (trail,5), (valley,6))
6
List((trail,0), (peak,1))
trail,peak,ridge,valley,col,pass
ParTrieMap is the one parallel map that is also safe to modify from several threads, since TrieMap is a concurrent
collection; that is why the foreach that puts into it is fine.
Semantics: what changes when you add .par
Conceptually the framework recursively splits the collection, applies the operation to the pieces in parallel, and combines the partial results. That has two consequences, taken straight from the official overview: side effects and non-associative operations give non-deterministic results.
Side effects
An operation that modifies shared mutable state from the closure has a data race. The collection does nothing to protect your variable:
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
@main def run(): Unit =
val xs = (1 to 100000).toVector
var counter = 0
xs.par.foreach(_ => counter += 1)
println(s"unsafe counter: $counter (expected ${xs.size})")
val safe = java.util.concurrent.atomic.AtomicInteger()
xs.par.foreach(_ => safe.incrementAndGet())
println(s"atomic counter: ${safe.get}")
val seen = java.util.concurrent.ConcurrentLinkedQueue[Int]()
(1 to 20).par.foreach(seen.add(_))
println(seen.toArray.toList.mkString(","))
println((1 to 10).par.map(_ * 10).toList)
unsafe counter: 97583 (expected 100000)
atomic counter: 100000
1,2,3,4,5,6,7,8,9,10,16,17,18,19,20,11,13,12,14,15
List(10, 20, 30, 40, 50, 60, 70, 80, 90, 100)
The first line loses 2417 increments on this run (the number changes every time). The atomic counter is correct. The third
line shows that the order in which side effects execute is not the order of the data, while the fourth shows that map still
returns its results in order.
The rule of thumb is to prefer pure transformations (map, filter, fold, aggregate, groupBy) and to produce a new
collection rather than writing to a shared one. If you must accumulate, use aggregate or a concurrent structure.
Associativity
Operations such as reduce, fold, aggregate, sum and scan combine pieces in an arbitrary grouping, so the function
must be associative, and fold and aggregate need a neutral element for the starting value of every piece. Commutativity
is not required, because the pieces are put back in order.
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
@main def run(): Unit =
val seqMinus = (1 to 1000).foldLeft(0)(_ - _)
val parMinus = (1 to 1000).par.reduce(_ - _)
println(s"sequential subtraction: $seqMinus, parallel reduce: $parMinus")
println(s"again: ${(1 to 1000).par.reduce(_ - _)}")
println(("a" :: "b" :: "c" :: "d" :: Nil).par.reduce(_ + _))
println((1 to 10).par.fold(100)(_ + _))
println(List("trail", "peak", "ridge").par.aggregate(0)((n, s) => n + s.length, _ + _))
sequential subtraction: -500500, parallel reduce: -83366
again: 152926
abcd
455
14
Subtraction is not associative: the parallel result differs from the sequential one and differs from one run to the next.
String concatenation is associative though not commutative, so abcd always comes out right. fold(100)(_ + _) gives 455,
not 155, because the starting value is added once per piece instead of once (455 = 55 + 4 × 100, so four pieces on this
run); 0 is the neutral element for addition. aggregate takes a sequential operator (B, A) => B for folding inside a piece and a
combiner (B, B) => B for joining pieces, and avoids the neutral-element trap when the result type differs from the
element type.
Configuration: task support
Each parallel collection holds a tasksupport that decides which thread pool runs the tasks and how many pieces to create.
The default is ExecutionContextTaskSupport, which reuses the global ExecutionContext from scala.concurrent, which is
the same pool that Future uses by default. To confine a collection to its own pool, assign a ForkJoinTaskSupport:
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
import scala.collection.parallel.ForkJoinTaskSupport
import java.util.concurrent.ForkJoinPool
@main def run(): Unit =
val pool = ForkJoinPool(2)
val pc = (1 to 1000).toVector.par
pc.tasksupport = ForkJoinTaskSupport(pool)
val threads = pc.map(_ => Thread.currentThread.getName).seq.toSet
println(s"threads used with a 2-thread pool: ${threads.size}")
println(threads.toList.sorted.map(_.replaceAll("\\d+$", "N")).distinct)
println(pc.tasksupport.parallelismLevel)
pool.shutdown()
val dflt = (1 to 1000).toVector.par
println(s"default parallelism level: ${dflt.tasksupport.parallelismLevel}")
threads used with a 2-thread pool: 2
List(ForkJoinPool-1-worker-N)
2
default parallelism level: 10
The assignment pc.tasksupport = ... is per collection object. The task support is dropped when a collection is
serialized and reset to the default when it is deserialized. A pool you
create yourself must be shut down, as in the example, or its threads accumulate. The alternatives are
ThreadPoolTaskSupport (a plain thread pool executor) and a custom TaskSupport implementation, which needs only execute,
executeAndWaitResult and parallelismLevel.
Putting the collection on its own pool is a good idea whenever the closure blocks (for example on I/O), because blocking inside a shared pool starves everything else that uses it, including your `Future`s.
Performance: when it pays off
A parallel operation has to split, schedule and combine, and par itself may copy the data. The work per element must be
large enough to pay for that. The next program measures three cases, three rounds each in one JVM, so that the JIT has warmed
up in the later rounds:
//> using scala 3.10.0
//> using dep org.scala-lang.modules::scala-parallel-collections:1.2.0
import scala.collection.parallel.CollectionConverters.*
def isPrime(n: Int): Boolean = n > 1 && (2 to math.sqrt(n).toInt).forall(n % _ != 0)
def time[A](label: String)(body: => A): A =
val t0 = System.nanoTime
val r = body
println(f"$label%-28s ${(System.nanoTime - t0) / 1_000_000}%5d ms")
r
@main def run(): Unit =
val big = (1 to 3_000_000).toVector
val small = (1 to 200).toVector
for round <- 1 to 3 do
println(s"round $round")
val a = time("seq count primes, 3M")(big.count(isPrime))
val b = time("par count primes, 3M")(big.par.count(isPrime))
assert(a == b)
time("seq map(+1), 3M")(big.map(_ + 1).size)
time("par map(+1), 3M")(big.par.map(_ + 1).size)
time("seq sum of squares, 200")((1 to 1000).map { _ => small.map(x => x * x).sum }.size)
time("par sum of squares, 200")((1 to 1000).map { _ => small.par.map(x => x * x).sum }.size)
round 1
seq count primes, 3M 270 ms
par count primes, 3M 129 ms
seq map(+1), 3M 104 ms
par map(+1), 3M 98 ms
seq sum of squares, 200 12 ms
par sum of squares, 200 119 ms
round 2
seq count primes, 3M 303 ms
par count primes, 3M 59 ms
seq map(+1), 3M 19 ms
par map(+1), 3M 15 ms
seq sum of squares, 200 5 ms
par sum of squares, 200 72 ms
round 3
seq count primes, 3M 279 ms
par count primes, 3M 60 ms
seq map(+1), 3M 14 ms
par map(+1), 3M 18 ms
seq sum of squares, 200 1 ms
par sum of squares, 200 39 ms
The CPU-heavy prime count speeds up about fivefold on 10 cores once warmed up. The trivial map(_ + 1) gets no benefit,
because each element’s work is smaller than the cost of moving it, and parallelizing a 200-element collection is far
slower than the sequential version. This is a quick demonstration, not a rigorous benchmark: the official page explains why
JIT warm-up, garbage collection and boxing distort such measurements and recommends running real benchmarks in a
fresh JVM after a warm-up phase.
Heuristics: parallelize large collections (thousands of elements or more), CPU-bound closures, operations that are pure and
associative, and collections that split cheaply (ParArray, ParVector, ParRange). Do not parallelize I/O, List
(a copy first) or small collections.
Java and Kotlin comparison
-
Java’s closest feature is
parallelStream()on a collection. It also splits across cores, and also requires stateless, non-interfering, associative operations. UnlikeParVector, a Java parallel stream always runs on the sharedForkJoinPool.commonPool(), so isolating it from other work needs a trick of submitting the terminal operation to your own pool. See Java Collections Framework. -
Kotlin has no built-in parallel collection. Data parallelism is usually written with coroutines (
asyncover chunks) and a dispatcher; see Kotlin Coroutines Basics. -
In Scala the same operations exist on the sequential and the parallel type, so switching is a one-word change; the price is the semantic hazards above.
What effect and actor libraries add
Future is part of the standard library and is enough for modest concurrency. It is eager, has no cancellation, no
resource safety, no backpressure and no structured lifetime (see
Futures & Promises). Four libraries from the wider ecosystem
address those gaps. They are not part of Scala and are covered only in outline here; the
Ecosystem page lists them and related libraries with
their sites.
| Library | Model | What it adds over Future |
|---|---|---|
Cats Effect 3.7.1 |
Purely functional effects: |
Lazy and referentially transparent values, fibers, cancellation, |
ZIO 2.1.27 |
Purely functional effects: |
Typed errors, dependency injection by environment type, fibers and cancellation, |
Ox 1.0.9 |
Direct style on JDK 21+ virtual threads; no effect type. |
Structured concurrency ( |
Apache Pekko 1.7.1 |
Actors, a typed message-passing model, with streams (Pekko Streams) and clustering. |
Isolated mutable state per actor, supervision, location transparency and backpressured streams. |
All four versions are the latest stable releases found on Maven Central on 2026-10-15; Pekko also has a 2.0.0 milestone line (2.0.0-M4) that is not recommended for production.
The next programs run the same small job in each effect library: three stations are read in parallel, two computations race for the fastest, and a slow computation times out. Each was run for real.
Cats Effect
//> using scala 3.10.0
//> using dep org.typelevel::cats-effect:3.7.1
import cats.effect.{IO, IOApp}
import cats.syntax.all.*
import scala.concurrent.duration.*
object Main extends IOApp.Simple:
def reading(station: String): IO[Int] =
IO.sleep(200.millis) *> IO.pure(station.length)
val run: IO[Unit] =
for
readings <- List("north", "south", "ridge").parTraverse(reading)
_ <- IO.println(s"readings: $readings")
winner <- IO.race(IO.sleep(1.second).as("slow"), IO.sleep(50.millis).as("fast"))
_ <- IO.println(s"race: $winner")
timed <- IO.sleep(1.second).timeout(100.millis).attempt
_ <- IO.println(s"timeout: $timed")
yield ()
readings: List(5, 5, 5)
race: Right(fast)
timeout: Left(java.util.concurrent.TimeoutException: 100 milliseconds)
The for here is the same syntax as for futures, but nothing runs until IOApp runs the IO value. Describing the program
first and running it later is what makes timeouts and cancellation first-class: the timeout cancels the sleeping fiber.
ZIO
//> using scala 3.10.0
//> using dep dev.zio::zio:2.1.27
import zio.*
object Main extends ZIOAppDefault:
def reading(station: String): UIO[Int] = ZIO.sleep(200.millis).as(station.length)
val run =
for
readings <- ZIO.foreachPar(List("north", "south", "ridge"))(reading)
_ <- Console.printLine(s"readings: $readings")
winner <- ZIO.sleep(1.second).as("slow").race(ZIO.sleep(50.millis).as("fast"))
_ <- Console.printLine(s"race: $winner")
timed <- ZIO.sleep(1.second).timeout(100.millis)
_ <- Console.printLine(s"timeout: $timed")
yield ()
readings: List(5, 5, 5)
race: fast
timeout: None
Source: ZIO documentation: Fibers
Where Cats Effect reports a timeout as a TimeoutException, ZIO’s timeout returns an Option (None on timeout).
Ox
//> using scala 3.10.0
//> using dep com.softwaremill.ox::core:1.0.9
import ox.*
import scala.concurrent.duration.*
def reading(station: String): Int =
Thread.sleep(200)
station.length
@main def run(): Unit =
val readings = par(List("north", "south", "ridge").map(s => () => reading(s)))
println(s"readings: $readings")
val winner = raceSuccess(
{ Thread.sleep(1000); "slow" },
{ Thread.sleep(50); "fast" }
)
println(s"race: $winner")
val timed = scala.util.Try(timeout(100.millis)(Thread.sleep(1000)))
println(s"timeout: $timed")
supervised:
val a = fork { Thread.sleep(100); 1 }
val b = fork { Thread.sleep(100); 2 }
println(s"fork results: ${a.join() + b.join()}")
readings: List(5, 5, 5)
race: fast
timeout: Failure(java.util.concurrent.TimeoutException: Timed out after 100 milliseconds)
fork results: 3
Source: Ox documentation: Fork and join
Ox needs no new monad: Thread.sleep really blocks, but only a virtual thread, and a supervised scope guarantees that
every fork has finished (or been interrupted) when the scope ends. That is structured concurrency, the idea Kotlin
coroutines and Java’s StructuredTaskScope share. See
Java Virtual Threads for the platform feature it builds on.
Pekko
Pekko replaces shared state with actors: each actor processes one message at a time, so its state needs no locks. The typed API makes the protocol a Scala type.
//> using scala 3.10.0
//> using dep org.apache.pekko::pekko-actor-typed:1.7.1
import org.apache.pekko.actor.typed.{ActorRef, ActorSystem, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import org.apache.pekko.actor.typed.scaladsl.AskPattern.*
import org.apache.pekko.util.Timeout
import scala.concurrent.Await
import scala.concurrent.duration.*
object Counter:
sealed trait Command
case class Add(n: Int) extends Command
case class Report(replyTo: ActorRef[Int]) extends Command
def apply(total: Int = 0): Behavior[Command] = Behaviors.receiveMessage:
case Add(n) => apply(total + n)
case Report(to) =>
to ! total
Behaviors.same
@main def run(): Unit =
given system: ActorSystem[Counter.Command] = ActorSystem(Counter(), "trailhead")
given Timeout = 2.seconds
system ! Counter.Add(5)
system ! Counter.Add(7)
println(Await.result(system.ask(Counter.Report(_)), 3.seconds))
system.terminate()
12
The program printed three SLF4J warnings about a missing logging provider (they are harmless) before 12. The state is a
plain Int held by the Behavior and replaced on every Add, with no var. Pekko is the Apache-licensed fork of Akka 2.6
and keeps its API shape.
Choosing
-
Bulk CPU work over an in-memory collection: parallel collections, or simply sequential code if the benchmark says so.
-
A handful of independent I/O calls in an application that already uses
Future:Future.traverseandFuture.zip. -
Cancellation, resource safety, retries and timeouts, or a large concurrent program: an effect system (Cats Effect or ZIO) or direct-style structured concurrency (Ox).
-
Many stateful components exchanging messages, possibly across machines: Pekko.
The libraries do not interoperate directly; each one has adapters to and from Future (for example IO.fromFuture,
ZIO.fromFuture and IO.unsafeToFuture), so they can be adopted one module at a time.
Where the books are outdated
-
Books and tutorials written for Scala 2.9 to 2.12 call
.paron any collection with no setup. From 2.13 it needs the module and theCollectionConvertersimport shown above. -
Older material mentions
scala.actorsand Akka 2.5 or earlier as the concurrency answer.scala.actorsis gone, and Akka moved to a commercial license in 2022, which is why Pekko exists. Check the license of any Akka dependency in a new project. -
Pre-2.13 material uses the
Gen*types (GenSeq,GenTraversableOnce) to write code that accepts both sequential and parallel collections; those types do not exist in 2.13 or 3. -
The Scala 3 Book’s concurrency chapter only covers
Future; the parallel collections and the libraries above are in the Scala 2 overviews and each project’s own site.
Scala 3.9 LTS versus 3.10
The module is a plain library and does not depend on a compiler feature. The scala-parallel-collections examples above
(.par, the semantics demos, and ForkJoinTaskSupport) compiled and ran unchanged on 3.3.8 and 3.9.0, and a minimal .par
program ran on Scala 2.13.18 (it used import scala.collection.parallel.CollectionConverters._ and printed 10100).
Cats Effect, ZIO, Ox and Pekko are published for Scala 3 and were run on 3.10.0; check each project’s compatibility
statement for the 3.9 LTS line before pinning a version in a library.