异步互斥锁
Async Mutexes

原始链接: https://matklad.github.io/2025/11/04/on-async-mutexes.html

作者反思了其语言设计理念与数据库系统TigerBeetle架构之间的矛盾。最初,作者倾向于单线程并发模型,在这种模型中,由于固有的原子性,不需要互斥锁。然而,作者意识到,即使在这种模型下,如果未显式标注原子区域,也可能发生逻辑竞争,这促使他们考虑像Kotlin那样的显式`async/await`。 这种认识与TigerBeetle的设计相冲突,后者依赖于单线程内的隐式排斥来管理诸如压缩(磁盘读/写合并)等操作期间的共享状态。TigerBeetle的回调函数直接修改共享的`Compaction`状态,需要保证防止并发修改。 显式加锁可能需要全局锁,从而有效地恢复到类似于当前系统的隐式锁API。作者得出结论,这突出了不同的范式:`async/await`通常适用于CSP风格的并发,具有独立线程,而TigerBeetle的状态机/Actor模型,由手动回调和广泛的状态验证驱动,则受益于其当前的隐式方法。

## 异步互斥锁:一则黑客新闻讨论总结 一则黑客新闻讨论了在单线程、协作式多任务环境中(如JavaScript)使用异步互斥锁的问题。核心观点是,**在这些系统中,异步互斥锁通常是一种反模式**。 原因在于:由于代码在`await`调用让出控制权之前实际上是“原子”的,正确的编程方式应该侧重于在让出控制权*之前*确保状态一致性,或者在代码块开始时重建状态。异步互斥锁表明对`await`调用时机的理解不足,并且可能引入调试问题和性能延迟。 建议的替代方案包括仔细的状态管理、异步队列和利用响应式编程模式。一些评论者指出,在特定用例中异步互斥锁*可能*是必要的(例如,具有异步依赖项的单例实例化),而另一些人则将其与Angular等框架中滥用的依赖注入模式相提并论。 讨论还涉及更广泛的概念,例如Actor模型、无共享架构以及在没有共享内存的情况下实现真正并发的挑战,并参考了Erlang以及Zig和C++中的实现。最终,共识倾向于避免异步互斥锁,而倾向于更谨慎和高效的方法来管理异步代码中的状态。
相关文章

原文

A short note on contradiction or confusion in my language design beliefs I noticed today.

One of the touted benefits of concurrent programming multiplexed over a single thread is that mutexes become unnecessary. With only one function executing at any given moment in time data races are impossible.

The standard counter to this argument is that mutual exclusion is a property of the logic itself, not of the runtime. If a certain snippet of code must be executed atomically with respect to everything else that is concurrent, then it must be annotated as such in the source code. You can still introduce logical races by accidentally adding an .await in the middle of the code that should be atomic. And, while programming, you are adding new .awaits all the time!

This argument makes sense to me, as well its as logical conclusion. Given that you want to annotate atomic segments of code anyway, it makes sense to go all the way to Kotlin-style explicit async implicit await.

The contradiction I realized today is that for the past few years I’ve been working on a system built around implicit exclusion provided by a single thread — TigerBeetle! Consider compaction, a code that is responsible for rewriting data on disk to make it smaller without changing its logical contents. During compaction, TigerBeetle schedules a lot of concurrent disk reads, disk writes, and CPU-side merges. Here’s an average callback:

fn read_value_block_callback(
    grid_read: *Grid.Read,
    value_block: BlockPtrConst,
) void {
    const read: *ResourcePool.BlockRead =
        @fieldParentPtr("grid_read", grid_read);
    const compaction: *Compaction = read.parent(Compaction);

    const block = read.block;
    compaction.pool.?.reads.release(read);

    assert(block.stage == .read_value_block);
    stdx.copy_disjoint(.exact, u8, block.ptr, value_block);
    block.stage = .read_value_block_done;
    compaction.counters.in +=
        Table.value_block_values_used(block.ptr).len;
    compaction.compaction_dispatch();
}

This is the code (source) that runs when a disk read finishes, and it mutates *Compaction — shared state across all outstanding IO. It’s imperative that no other IO completion mutates compaction concurrently, especially inside that compaction_dispatch monster of a function.

Applying “make exclusion explicit” rule to the code would mean that the entire Compaction needs to be wrapped in a mutex, and every callback needs to start with lock/unlock pair. And there’s much more to TigerBeetle than just compaction! While some pairs of callbacks probably can execute concurrently relatively to each other, this changes over time. For example, once we start overlapping compaction and execution, those will be using our GridCache (buffer manager) at the same time. So explicit locking probably gravitates towards having just a single global lock around the entire state, which is acquired for the duration of any callback. At which point, it makes sense to push lock acquisition up to the event loop, and we are back to the implicit locking API!

This seems to be another case of two paradigms for structuring concurrent programs. The async/await discussion usually presupposes CSP programming style, where you define a set of concurrent threads of execution, and the threads are mostly independent, sharing a little of data. TigerBeetle is written in a state machine/actor style, where the focal point is the large amount of shared state, which is evolving in discrete steps in reaction to IO events (there’s only one “actor” in TigerBeetle). Additionally, TigerBeetle uses manual callbacks instead of async/await syntax, so inserting an .await in the middle of critical section doesn’t really happen. Any new concurrency requires introducing an explicit named continuation function, and each continuation (callback) generally starts with a bunch of assertions to pin down the current state and make sure that the ground hasn’t shifted too far since the IO was originally scheduled. Or, as is the case with compaction_dispatch, sometimes the callback doesn’t assume anything at all about the state of the world and instead carries out an exhaustive case analysis from scratch.

联系我们 contact @ memedata.com