为什么选择 Cats Effect 3

在 Scala 生态系统里,函数式并发一直是热门话题。Cats Effect 3(简称 CE3)作为 Typelevel 栈的核心组件,为 Scala 开发者提供了一套纯函数式的并发框架。与传统的
1 | java.util.concurrent |
或基于回调的异步模型不同,CE3 通过
1 | IO |
这种延迟计算的数据类型,把副作用和并发控制优雅地封装了起来。
CE3 相比 CE2 做了大量简化和重构。它移除了
1 | ContextShift |
和
1 | Timer |
这类概念,改用统一的
1 | Sync |
和
1 | Async |
类型类体系。调度完全由运行时负责,用户不再需要手动管理线程池的切换。这让编写并发代码的体验更接近 Haskell 的
1 | async |
库。
这篇文章不会停留在”什么是 IO”的入门层面,而是深入 CE3 的核心机制:Fiber 调度、Resource 安全治理、并发原语(Ref、Dequeue、Mutex)、以及自定义运行时的调优策略。无论你是在构建高吞吐的微服务还是数据处理管道,这些知识都会直接派上用场。
IO:不仅仅是”延迟执行”
1 | IO[A] |
在 CE3 里是一个描述计算的纯值——它不执行任何操作,直到你主动调用
1 | unsafeRunSync |
或通过
1 | IOApp |
运行。但”延迟”只是最表层的特性。真正让 IO 强大的是它对并发的内建支持和代数安全性。
IO 的代数本质
从范畴论的角度看,
1 | IO |
是一个 Monad,这意味着它支持
1 | flatMap |
和
1 | pure |
,满足了顺序组合的需求。但 CE3 更进一步:IO 还是
1 | Concurrent |
的实例——这是 CE3 最核心的类型类之一,定义了
1 | start |
、
1 | race |
、
1 | parSequence |
等并发操作。
1
2
3
4
5
6
7
8
9
10 import cats.effect.*
import cats.effect.syntax.all.*
import cats.syntax.all.*
import scala.concurrent.duration.*
val task1: IO[Int] = IO.sleep(1.second) >> IO.pure(42)
val task2: IO[String] = IO.sleep(2.seconds) >> IO.pure("hello")
// 并行执行
val result: IO[(Int, String)] = (task1, task2).parTupled
这里的
1 | parTupled |
会自动把两个 IO 分配到不同的 Fiber 上并行执行。不需要手动
1 | start |
再
1 | join |
。这种语义上的”代数并行”让并发代码的阅读难度大幅降低。
IO 的执行模型
CE3 采用了 trampolined 调度:IO 的内部每一步都是一个
1 | FiberStep |
,运行时通过队列来串行化执行,避免了
1 | StackOverflowError |
。这不是简单的”ForkJoinPool 扔进去”——IO 的 bind(即 flatMap)是线程安全的,并且支持
1 | auto-cede |
:每当 IO 链达到一定深度,运行时自动让出当前线程给其他 Fiber。
1
2
3
4
5 // 这个循环在 CE2 会 stack overflow,CE3 安全运行
def loop(n: Int): IO[Unit] =
IO.println(s"Step $n") >> loop(n + 1)
loop(0).unsafeRunSync() // CE3 秒过
Fiber:CE3 的轻量级并发单元

Fiber 是 CE3 里的”绿色线程”。与操作系统线程不同,Fiber 是完全由运行时调度的逻辑执行单元。一个 JVM 线程上可以跑成千上万个 Fiber,它们的切换开销远低于线程切换。
Fiber 的生命周期
Fiber 从
1 | IO#start |
开始其生命周期:
1
2
3
4
5
6
7 val launched: IO[Fiber[IO, Throwable, Int]] =
IO.println("Running on fiber").start
// 等待结果
launched.flatMap { fiber =>
fiber.join // 返回 Outcome[IO, Throwable, Int]
}
1 | start |
会创建一个新的 Fiber 并立即开始调度。父 Fiber 可以
1 | join |
等待它完成,
1 | cancel |
取消它,或者通过
1 | race |
让多个 Fiber 竞争。CE3 的 Fiber 是结构化的:如果父 Fiber 被取消,所有未完成的子 Fiber 也会被取消。这叫做 structured concurrency(结构化并发),与 Java 21 虚拟线程的理念一致。
Outcome:Fibers 的三种终态
当 Fiber 执行完毕,它的结果不是普通的
1 | Either[Throwable, A] |
,而是
1 | Outcome[IO, Throwable, A] |
:
| 终态 | 含义 | 示例 | ||||
|---|---|---|---|---|---|---|
|
Fiber 正常完成,返回结果 |
|
||||
|
Fiber 抛出了未捕获异常 |
|
||||
|
Fiber 被外部取消 |
|
这种设计让错误处理更加精确。假如你并行跑多个任务,一个任务失败并不一定意味着整个程序需要终止——你可以根据 Outcome 决定是否重试或补偿。
1
2
3
4
5
6 def safeJoin[A](fiber: Fiber[IO, Throwable, A]): IO[A] =
fiber.join.flatMap {
case Outcome.Succeeded(fa) => fa
case Outcome.Errored(e) => IO.raiseError(e)
case Outcome.Canceled() => IO.raiseError(new RuntimeException("Fiber was canceled"))
}
Resource:安全管理声明式资源
在 Java/Scala 世界里,管理资源(文件句柄、数据库连接、HTTP 客户端)一直是一件需要小心翼翼的事情。try-finally 或 try-with-resources 虽然能用,但在复杂场景下容易遗漏或出现嵌套混乱的问题。
CE3 的
1 | Resource[IO, A] |
解决了这个问题。它是一个描述资源分配和释放的纯值,保证释放操作无论如何都会执行——无论是正常完成、抛出异常还是 Fiber 被取消。
Resource 的基本用法
1
2
3
4
5
6
7
8
9
10 def openFile(path: String): Resource[IO, BufferedReader] =
Resource.make(
IO.blocking(new BufferedReader(new FileReader(path)))
)(reader =>
IO.blocking(reader.close()).handleErrorWith(_ => IO.unit)
)
openFile("/etc/hosts").use { reader =>
IO.blocking(reader.readLine())
}
1 | Resource.make(acquire)(release) |
定义了获取和释放。使用
1 | .use |
来安全地使用资源。即使
1 | use |
内部的代码抛出异常、Fiber 被取消、或者运行时崩溃,
1 | release |
始终会被调用。
组合多个 Resource
真正的威力来自于组合:
1
2
3
4
5
6
7
8
9
10
11 val httpClient: Resource[IO, HttpClient] = ???
val database: Resource[IO, DatabasePool] = ???
val app: Resource[IO, AppContext] =
(httpClient, database).parMapN { (http, db) =>
AppContext(http, db)
}
app.use { ctx =>
ctx.run()
}
1 | parMapN |
会并行获取两个资源,释放顺序自动逆序。不需要担心”如果数据库连接成功但 HTTP 客户端初始化失败,数据库连接是否会泄漏”——Resource 的代数组合保证了一切。
Resource 的高级技巧:Finalizer 与嵌套
Resource 还支持
1 | .onFinalize |
添加额外的清理逻辑,以及
1 | .flatMap |
做条件性的资源分配:
1
2
3
4
5
6
7
8
9
10 val config: Resource[IO, Config] = Resource.pure(loadConfig())
val tracing: Resource[IO, Unit] = Resource.make(initTracing())(_ => shutdownTracing())
val fullApp: Resource[IO, Unit] =
for {
_ <- tracing
http <- httpClient
db <- database
cfg <- config
} yield ()
这种声明式的资源管理不仅安全,而且可测试。因为 Resource 只是值,你可以在单元测试中构造 mock 版本的资源组合链。
并发原语:Ref、Dequeue 与 Mutex
CE3 提供了一组线程安全的并发原语,它们全部基于 Fiber 调度而非 JVM 的
1 | synchronized |
或
1 | Lock |
,因此不会阻塞线程。
Ref:原子化状态容器
1 | Ref[IO, A] |
是不可变数据结构的原子引用,类似于 Java 的
1 | AtomicReference |
,但专门为 CE3 的 Fiber 调度做了优化。
1
2
3
4
5
6 case class Counter private (ref: Ref[IO, Int]):
def increment: IO[Int] = ref.modify(n => (n + 1, n + 1))
def get: IO[Int] = ref.get
object Counter:
def make: IO[Counter] = Ref.of[IO, Int](0).map(Counter(_))
1 | modify |
是 CAS(Compare-And-Swap)操作的代数抽象:传入一个
1 | A => (A, B) |
的纯函数,Ref 保证在多 Fiber 并发访问时的原子性。
需要特别注意的是:
1 | modify |
内的函数必须是纯的,不能有副作用。如果需要在更新过程中执行 IO 操作,应该用
1 | access |
或
1 | modifyWith |
。
Mutex:轻量级的互斥锁
虽然 CE3 推崇无锁编程,但有些场景——比如写入共享的可变资源、避免”惊群效应”——仍然需要互斥访问。CE3 的
1 | Mutex[IO] |
提供了基于 Fiber 的互斥锁:
1
2
3
4
5
6
7 val mutex: IO[Mutex[IO]] = Mutex[IO]
mutex.flatMap { m =>
m.lock.surround {
IO.println("Only one fiber at a time")
}
}
1 | lock.surround |
模式保证了锁的获取和释放是安全的。与 Java 的
1 | synchronized |
不同,CE3 的 Mutex 不会阻塞线程——当 Fiber 需要等待锁时,运行时会自动让出线程给其他 Fiber 执行,线程永远不会被浪费。
Queue:生产者-消费者
CE3 提供了多种队列实现,每种适合不同的场景:
| 队列类型 | 特点 | 适用场景 | ||
|---|---|---|---|---|
|
无界队列,生产者永不阻塞 | 日志系统、事件总线 | ||
|
有界队列,满时生产者等待 | 任务队列、流量控制 | ||
|
有界循环缓冲区,满时丢弃最旧元素 | 实时指标采样 | ||
|
支持双端操作 | 工作窃取型调度 |
1
2
3
4
5
6
7
8 import cats.effect.std.Queue
val program: IO[Unit] = for {
queue <- Queue.bounded[IO, Int](100)
producer = (1 to 1000).toList.traverse_(i => queue.offer(i))
consumer = queue.take.flatMap(i => IO.println(s"Got: $i")).foreverM
_ <- (producer, consumer).parTupled
} yield ()
这种基于队列的生产者-消费者模式是构建流处理管道的基础。与 akka-stream 相比,CE3 的队列更轻量——没有 Actor 系统开销,直接运行在 IO 运行时上。
自定义运行时与调度器调优
CE3 默认的
1 | IOApp |
使用了一个配置合理的线程池:
1 | WorkStealingThreadPool |
,这是 CE3 团队手写的线程池实现,比 Java 的
1 | ForkJoinPool |
更适合 IO 调度。但生产环境中,你可能需要根据应用的特征进行调优。
创建自定义运行时
1
2
3
4
5
6
7
8
9
10
11
12
13 import cats.effect.unsafe.*
val customRuntime: IORuntime = IORuntime.builder()
.setCompute(WorkStealingThreadPool(
threadCount = Runtime.getRuntime.availableProcessors() * 2,
threadNamePrefix = "ce3-compute"
))
.setBlocking(ExecutorServicePlatform(
Executors.newCachedThreadPool(),
() => ()
))
.setScheduler(Scheduler.createDefault())
.build()
注意区分两个线程池:
- Compute 池:运行 CPU 密集型的 IO 操作(纯计算、map/flatMap 链)。应该绑定到 CPU 核心数以内。默认是
1availableProcessors()
。
- Blocking 池:运行
1IO.blocking
和
1IO.interruptible操作。应该使用无界线程池(或缓存线程池),因为阻塞操作可能持锁等待。
IO.blocking 与 IO.interruptible
当 IO 操作涉及真正的线程阻塞(如读取 JDBC 结果集、调用 BlockingQueue.take),必须用
1 | IO.blocking |
包裹起来。这会让运行时将 Fiber 转移到专门的 Blocking 线程池,避免阻塞 Compute 池中的工作线程。
1
2
3
4
5 val jdbcQuery: IO[ResultSet] =
IO.blocking {
val stmt = connection.createStatement()
stmt.executeQuery("SELECT * FROM large_table")
}
1 | IO.interruptible |
更进一步——它允许运行在被取消时对线程进行中断:
1
2
3
4
5
6 val cancellableQuery: IO[ResultSet] =
IO.interruptible(true) {
// 这个操作可以被 Fiber.cancel 中断
Thread.sleep(10000) // 取消时抛出 InterruptedException
42
}
实际案例:构建并发爬虫

让我们组合前面学到的知识,构建一个小型并发爬虫。这个例子展示了 Fiber 调度、Resource 管理和并发原语的真实用法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46 import cats.effect.*
import cats.effect.std.*
import cats.syntax.all.*
import scala.concurrent.duration.*
case class CrawlResult(url: String, title: String, links: List[String])
object Crawler extends IOApp.Simple:
// 有界队列做 URL 调度
val urlQueue: Resource[IO, Queue[IO, String]] =
Resource.eval(Queue.bounded[IO, String](1000))
// 共享结果集
val results: Resource[IO, Ref[IO, Map[String, CrawlResult]]] =
Resource.eval(Ref.of[IO, Map[String, CrawlResult]](Map.empty))
// HTTP 客户端(假设已有)
val httpClient: Resource[IO, SimpleHttpClient] =
Resource.make(IO(new SimpleHttpClient()))(c => IO(c.close()))
def worker(
queue: Queue[IO, String],
results: Ref[IO, Map[String, CrawlResult]],
client: SimpleHttpClient
): IO[Unit] =
queue.take.flatMap { url =>
for {
_ <- IO.println(s"Crawling: $url")
result <- client.fetch(url)
_ <- results.update(_ + (url -> result))
_ <- result.links.distinct
.filterNot(_.startsWith("#"))
.traverse_(link => queue.tryOffer(link))
} yield ()
}.foreverM
def run: IO[Unit] =
(urlQueue, results, httpClient).parTupled.use {
case (queue, ref, client) =>
for {
_ <- queue.offer("https://example.com")
workers = List.fill(10)(worker(queue, ref, client))
_ <- workers.parSequence
} yield ()
}
这个爬虫在 CE3 的运行时上以 10 个 Fiber 并行运行。每个 worker 从队列获取 URL、抓取内容、更新结果集、并将新链接放回队列——整个流程完全无锁。所有共享状态通过
1 | Ref |
和
1 | Queue |
安全地在线程间传递。
CE3 与 Akka/Java 虚拟线程的比较
很多团队在选择并发框架时会在 CE3 与 Akka Actor 或 Java 21 虚拟线程之间犹豫。这里从几个维度做一个客观比较:
| 维度 | Cats Effect 3 | Akka Typed | Java 21 Virtual Threads |
|---|---|---|---|
| 并发模型 | 代数式 + Fiber(绿色线程) | Actor (消息传递) | 虚拟线程(Continuation) |
| 副作用控制 | 纯函数式,显式声明 | 封装在 Actor 中 | 无(通用编程) |
| 错误处理 | 类型安全的 Outcome/IO | 监督策略(Supervisor) | 标准异常机制 |
| 学习曲线 | 较高(需理解 Monad) | 中等 | 低 |
| 生态系统 | Http4s, Doobie, Fs2, Tapir | Akka HTTP, Persistence, Cluster | Spring Boot, Helidon, Quarkus |
| 性能(吞吐) | 极高(WorkStealingTP 优化) | 高 | 高(但受限于 JVM 平台线程数量) |
如果你的团队已经在使用 Typelevel 技术栈(Http4s + Doobie + Fs2),CE3 是自然的选择。如果更偏好 Actor 模型和集群能力,Akka 仍然是强选项。Java 21 虚拟线程适合希望用熟悉的 OOP 风格编写并发代码的团队——但它缺少 CE3 的代数安全性和资源治理能力。
总结
Cats Effect 3 提供了一套优雅而强大的函数式并发框架。它的核心——IO、Fiber、Resource——覆盖了从最简单的延迟执行到最复杂的多 Fiber 协作的所有场景。并发原语如 Ref、Mutex、Queue 让无锁编程在 Scala 中成为现实,而自定义运行时给了你在生产环境调优的完全控制权。
CE3 的学习曲线确实比直接使用 CompletableFuture 或 Akka 更陡峭——你需要理解 Monad、Fiber 调度和类型类体系。但一旦跨过这个门槛,你会获得一个类型安全、可组合、代数化的并发工具集,这在其他编程语言中很少见到。
对于正在构建高并发微服务、实时数据处理管道或复杂异步系统的 Scala 团队来说,Cats Effect 3 是一个值得认真评估的选择。
汤不热吧