并发#
并发编程在如今已经习以为常了。但是许多语言中,并发编程所提供的 API 却仍然比较原始。
比如 wait、join、cancel、await 这些操作,大多是对单个任务的管理。可以等待一个任务结束,可以取消一个任务,也可以获取一个任务的结果。但是对于一组并发任务之间的父子关系,往往没有非常明确的约束。
也就是说,父任务发起子任务之后,父任务的生命周期与子任务的生命周期不一定有强联系。如果父任务只是发起任务,而不处理等待、取消、错误传播和资源回收,那么就很容易出现孤儿任务。
这和没有回收的内存有点类似。内存被分配出来之后,如果没有明确的所有者,也没有释放机制,就会造成泄漏。并发任务也是一样。任务被启动之后,如果没有明确的父任务负责它的生命周期,它就可能在后台继续运行,直到失败、阻塞、泄漏资源,或者产生一些已经不该发生的副作用。
为了解决这个问题,引入了结构化并发的理念,用来对并发任务进行结构化管理。其核心思想是父任务要对子任务的生命周期负责。
非结构化并发的问题#
所谓非结构化并发,并不是说完全没有办法管理任务。传统并发结构当然也可以手动维护结构。比如保存线程句柄,然后 join;保存 Future,然后 get;保存 goroutine 的 channel,然后等待结果返回。问题在于,这些结构通常不是语言或者 API 强制要求的,而是依赖程序员自己维护。这就会导致隐式行为。而经验表明,隐式行为容易导致问题。
比如任务泄露,父任务已经结束,子任务仍然在运行。从业务角度看,这些任务可能没有继续执行的意义。但从运行时角度看,只要没有取消,任务本身也没有结束,就会继续运行。由于 API 本身没有强制的约束,子任务在就不再受到管控。
以 Go 的 goroutine 为例:
func handleRequest(ctx context.Context) {
// 启动一个后台任务
go doSomeWork()
}
handleRequest 很快就返回了,或者 ctx 被取消了,但 doSomeWork 依然在后台静默运行,成为了无人管辖的孤儿任务,goroutine 是典型的 “fire-and-forget”。doSomeWork 在空间上开辟了新的控制流,但在生命周期上与父流程完全脱钩。即使父级因各种原因取消了,子任务仍然运行。为了解决这个问题,往往需要手动将子任务也挂靠到 ctx 上,但这种非语法强制的隐蔽问题恰恰是容易疏漏的。
一旦出现非预期的任务泄露,与其生命周期相关的控制都不再有效。任务本身也经常持有资源。如果没有明确的生命周期边界,这些资源很难确定应该在哪里释放。错误处理、任务取消,资源回收都会变得不确定。
以 JS 的 Promise 为例:
try {
await Promise.all([
fetchUser(),
fetchPosts()
])
} catch (err) {
}
上述代码中,如果 fetchUser 先失败,Promise.all 会立刻抛错并进入错误处理流程。此时期望行为一般是取消 fetchPosts。但 fetchPosts 实际上仍会继续推进(无法自动取消)。而后续如果 fetchPosts 也失败,fetchPosts 的错误就会被静默丢失(错误丢失)。在其他场景下,而如果某个子任务包含副作用,还可能产生错误提交。
在 JS 的非结构化并发的 API 下,需要搭配 AbortController 机制才能解决一部分问题。
const controller = new AbortController()
try {
await Promise.all([
fetchUser({ signal: controller.signal }),
fetchPosts({ signal: controller.signal })
])
} catch (err) {
controller.abort()
// other stall
}
但这存在大量的 boilerplate,需要底层异步 API 配合,使用场景很受限。
结构化编程的启发#
无论是错误丢失、取消失效、还是资源回收困难,实际上都是因为任务本身脱离了管控。这有点像几十年前关于结构化编程的讨论。
在 1960s 年代,编程语言(如 Fortran)普遍依赖 goto 语句进行复杂的控制流跳转。goto 能力很强,自由度很高。但其无条件跳转的能力却导致代码理解成本指数级增加。1968 年,Dijkstra 通过论文 Go To Statement Considered Harmful,指出 goto 的问题。在 Goto Statement Considered Harmful 发表一年多后,又发表了 Notes on Structured Programming,表达了其理想的编程范式,提出结构化编程的概念。
他提出需要有意识进行抽象复用,将复杂任务进行分解,分解为程序块。
通过有限的、可嵌套的控制结构(顺序、分支、循环、函数调用)来组合不同的块,并确保每个结构有且只有一个入口和一个出口。从而保证控制流具有单一入口和单一出口。实现代码逻辑的抽象与封装。其核心在于对控制流的出口入口进行限制,保证控制流的出入口在可理解的范围内。
上述并发问题与 GOTO 的问题类似,并发的导致其对应的控制流不再受结构化编程的约束。并发导致控制流的隐式跳转,子任务控制流的出入口变得不再直观。
为了解决并发控制流散落各处的问题,2016 年,Python 异步库 Trio 的作者 Nathaniel 发表文章 《Notes on structured concurrency, or: Go statement considered harmful》,提出结构化并发(Structured Concurrency)” 的概念。
他提出要像当年用 while 和函数调用取代 goto 一样,用具有明确生命周期边界的 “作用域结构(Scopes)”,来取代像 go 语句这样 fire and forget 的非结构化并发原语。
结构化并发的处理思路#
任务树#
结构化并发的基本模型是一棵任务树。
一个任务可以创建子任务,子任务可以继续创建孙任务。每个任务都有明确的父节点。父任务拥有子任务,子任务不能无缘无故逃出父任务的生命周期。
这意味着,一个作用域内部启动的并发任务,必须在这个作用域结束前被处理掉:完成、失败,或者被取消。
以 Kotlin 为例:
suspend fun loadPage() = coroutineScope {
val user = async { loadUser() }
val posts = async { loadPosts() }
Page(
user = user.await(),
posts = posts.await()
)
}
这里 loadUser 和 loadPosts 是并发执行的,但它们并没有脱离 loadPage。
loadPage 返回时,可以确定:
loadUser 已经结束;
loadPosts 已经结束;
如果其中一个失败,错误会传播到 loadPage;
如果 loadPage 被取消,两个子任务也会被取消。
这就是结构化并发的关键:并发可以展开,但必须在明确边界上收束。
父任务等待子任务#
父任务不能在子任务仍然运行时正常结束。
也就是说,如果一个函数内部启动了并发任务,那么这个函数返回时,内部任务应该处于终态,已经完成、失败或被取消。
这让并发任务重新变成当前控制流的一部分。
父任务取消时取消子任务#
如果父任务被取消,子任务也应该一起取消。
这对应很多现实场景:请求超时、用户关闭页面、组件卸载、服务停止。父上下文已经失效,子任务继续运行通常没有意义。
子任务失败的错误传播应明确#
结构化并发要求错误有明确归属。普通作用域里,一个子任务失败,通常会导致整个作用域失败,并取消其他兄弟任务。当然,这并非唯一策略。有些场景希望一个子任务失败不影响其他子任务,这就需要 supervisor 模型。也就是子任务的错误传播策略必须明确,可以被观测。
作用域负责清理#
作用域结束时,内部任务要被收束,资源也要被释放。网络连接、文件句柄、锁、订阅、临时状态,都应该有明确的释放边界。结构化并发把任务生命周期和资源生命周期放在同一个结构里管理。
不同语言里的结构化并发 API#
Kotlin:CoroutineScope 与 Job#
Kotlin 的结构化并发比较成熟。
它的核心是 CoroutineScope 和 Job 的父子关系。
suspend fun load() = coroutineScope {
val a = async { fetchA() }
val b = async { fetchB() }
combine(a.await(), b.await())
}
coroutineScope 会等待内部所有子协程完成。如果某个子协程失败,默认会取消其他子协程,并把错误传播出去。
Kotlin 还有 supervisorScope,一个子任务失败,不会自动取消兄弟任务。
suspend fun loadAll() = supervisorScope {
val a = async { fetchA() }
val b = async { fetchB() }
listOf(
runCatching { a.await() },
runCatching { b.await() },
)
}
Swift TaskGroup 与 async let#
Swift 提供了两类常见结构化并发 API。
固定数量的并发任务可以用 async let:
async let user = fetchUser()
async let posts = fetchPosts()
let page = await Page(user: user, posts: posts)
动态数量的并发任务可以用 withTaskGroup 或 withThrowingTaskGroup:
try await withThrowingTaskGroup(of: Item.self) { group in
for id in ids {
group.addTask {
try await fetchItem(id)
}
}
var items: [Item] = []
for try await item in group {
items.append(item)
}
return items
}
Swift 的关键点是 child task 不能超过创建它的作用域。作用域退出时,子任务必须被等待或取消。
这种设计让并发任务的生命周期接近普通局部变量:进入作用域时创建,退出作用域前处理。
Python:asyncio.TaskGroup 与 Trio nursery#
Python 标准库里的 asyncio.TaskGroup 是结构化并发模型:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_user())
tg.create_task(fetch_posts())
async with 块退出时,TaskGroup 会等待内部任务完成。如果某个任务失败,通常会取消剩余任务,并把异常组合后抛出。
Python 生态里的 Trio nursery 更接近结构化并发的原型:
async with trio.open_nursery() as nursery:
nursery.start_soon(task1)
nursery.start_soon(task2)
nursery 的语义非常直接:任务在 nursery 中启动,也必须在 nursery 结束前被收束。
它强调的是不能产生 orphan task,也就是无主任务。
Java:StructuredTaskScope#
Java 在 Project Loom 之后引入了 StructuredTaskScope API,用来把一组 virtual thread 组织成一个作用域。
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> loadUser());
var posts = scope.fork(() -> loadPosts());
scope.join();
return new Page(user.get(), posts.get());
}
Go:errgroup with ctx#
Go 的 goroutine 是典型的非结构化并发,go doSomething()。goroutine 不绑定到调用者。调用者启动它以后,如果不额外设计等待和取消机制,就很难知道它什么时候结束、失败如何处理、父流程取消时它是否退出。
为了在 Go 中做到并发管理,通常需要搭配 errgroup 与 context:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return fetchUser(ctx)
})
g.Go(func() error {
return fetchPosts(ctx)
})
if err := g.Wait(); err != nil {
return err
}
errgroup 可以等待一组 goroutine,传播第一个错误,并通过 context 取消其他任务。
边界逃逸#
尽管结构化并发利用 “块结构” 强行闭合生命周期的的优势非常显著,但是总归是要考虑边界情况的。
在实际业务中,后台日志聚合、MQ 消费、全局事件等任务,从设计上就需要超越某个特定请求或函数的生命周期。所谓的 fire-and-forget,问题不在于 “fire”,而在于 “forget”。如果一个任务需要脱离当前函数的作用域,那应该有一个更高级的常驻作用域来承载。在一个设计良好的结构化并发架构中,作用域应该表现为一棵与软件架构同构的树:
- Request Scope:随着 HTTP 请求进来而创建,请求返回或超时则销毁闭合。
- Service Scope:随着某个核心业务模块(如连接池、RPC 客户端)的启停而存活。
- Application Scope:与整个进程同寿。
局部上看,从当前作用域影响全局作用域违背结构化原则。但这种影响是相对可控的,正如同 goto 本身在现代编程语言中也有其适用的场景,这种逃逸跳转只要保持在可理解范围范围内,也是可以接受的。