Right, one benefit of the disconnected design is that we'd be able to allow non-Sendable
data flow in and out of the closure (even if the stored value is non-Sendable
) as long as the disconnected invariant is preserved — we don't have to do anything special to prevent the closure from being contaminated with non-Sendable
values from the surrounding region or vice-versa because the inout disconnected T
alone states exactly the condition we need to enforce.
Please correct me if I'm wrong, but this problem only arises because most implementations either produce UB or something like EPERM when a mutex is unlocked from another thread. In reality, this wouldn't actually break the correctness of accessing the protected resource. In theory, it seems possible to implement a "Task-aware" mutex at some extra cost. For example, instead of checking the thread id, we could generate a unique token upon locking the mutex and return it to the caller. Owning this token would indicate that the unlock
caller is entitled to do so.
let unlocker = mutex.lock()
unlocker.unlock()
It also seems theoretically possible to make a mutex safe to use on threads belonging to the concurrency pool. When we are in an asynchronous function, we can make the lock
call a suspension point. This way, waiting for the mutex acquisition doesn't block the thread from running other tasks.
func f() async {
await mutex.lock() // Called on Thread A
// Resumed on Thread B
}
When we are in a synchronous function running on a task, it would be much harder, but we probably still can suspend it by saving all the registers on the stack and swapping the stack pointer and program counter (like boost does). However, in this case, we need to ensure that the execution will be resumed on the same thread.
func g() {
// Called on Thread A
mutex.lock() // Other tasks might be executed on this thread while the lock isn't acquired
// Resumed on Thread A
}
You are correct that the ordering guarantees of mutexes would not be broken by a mutex that allowed itself to be unlocked on a different thread as long as the unlock happens-after the lock. However, existing mutexes have good reasons to disallow this. In particular, pretty much any mutex smarter than a spin lock offers priority inversion avoidance, and making that work across task suspension is a very deep conceptual problem. Swift concurrency has been carefully designed to avoid introducing this kind of thread-on-task execution dependency.
I'm not certain about that. One of the key things locks provide is memory barriers, too, to ensure things that happen between CPU cores are reflected appropriately.
It's unclear (from the lock's perspective) what the semantics are supposed to be if the unlock happens on a different thread¹ (and therefore potentially an unrelated core). How does it know which CPU core(s) actually issued memory writes while the lock was held, in order to know where memory barrier instructions must be issued? Can it know that whatever mechanism transferred it across threads made sure to issue the necessary memory barriers?
Of course, you can leave that to the user of the lock, in the "Unsafe" sense as Swift uses the term. I'm just speculating as to why many lock implementations enforce that same-thread expectation.
¹ Threads aren't pinned to CPU cores (typically), of course, but context switches while a lock is held aren't an issue because the OS scheduler inserts the memory barriers, I believe.
This same problem exists across threads that are being synchronized with a lock. The lock just issues a full barrier that synchronizes all reads and writes in the synchronization domain.
I think much more simple example would be:
func test() {
mutex.lock()
downloadImage(...)
if someCondition { return } // forgot to unlock
mutex.unlock()
}
I am on the fence. On one hand lock+unlock would simplify certain use patterns. OTOH it's less safe. If to choose one I'd go with "safety first" approach, at the expense of inconvenience in some cases.
I second the "safety first" approach. Looking at this as somebody who is NOT an expert on this subject, a safe synchronous locking mechanism is very attractive.
Especially as it's very well possible that a mistake like forgetting to unlock in a code path might very well show up quite late. Maybe when usage increases above a certain threshold? Maybe in a very specific edge case?
So lets start with a safe default. And when the need arises, one can always use the underlying primitives to craft a specific solution for the problem at hand.
I can provide an example where lock()
and unlock()
can not be replaced with withLock { ... }
.
In two (1, 2) distinct implementations of an async semaphore, we can see that both call unlock()
from the withUnsafeContinuation
closure argument, in order to only release the lock after the continuation was acquired:
mutex.lock()
...
await withUnsafeContinuation { continuation in
... // use continuation
mutex.unlock()
}
Such code can not be written with withLock
only:
mutex.withLock {
// Can't call `await withUnsafeContinuation` here
}
@Alejandro (and other forum readers), what would be the suggested way to implement a semaphore, if the Mutex
of the standard library had no lock
and unlock
method?
Here's mine, a sketch example:
class C {
var mutex: Mutex
var payload: Payload
}
var a: [C] = ...
var b: [C] = ...
var i = 0, j = 0
while i < a.count && j < b.count {
a[i].mutex.lock()
b[j].mutex.lock()
let result = foo(a[i].payload, b[j].payload)
if result {
b[j].payload.mutate()
a[i].mutex.unlock()
i += 1
} else {
a[i].payload.mutate()
b[j].mutex.unlock()
j += 1
}
}
if i < a.count { a[i].mutex.unlock() }
if j < b.count { b[j].mutex.unlock() }
i.e. you have two or more sequences that you scan "together" at different paces locking/unlocking elements as you scan throw them.
I don't think it could be implemented in terms of "withLock {...}"
or can it?
Speaking of:
func test() async {
mutex.lock() // Called on Thread A
await downloadImage(...) // <--- Potential suspension point
mutex.unlock() // May be called on Thread A, B, C, etc.
}
the second unlock could trap if called on an inappropriate thread, so the error could be noticed and corrected reasonably soon. Or it could trap on the "await downloadImage(...)"
line with a "can't suspend while holding a lock" runtime error.
This is a deadlock waiting to happen. Have you not heard of "Dining Philosophers"?
You should not have per-item locks, not OS ones. Databases do per-item locks with special mechanisms that allow aborting the transaction on deadlock detection. These are generally not OS-managed locks, but rather, custom code involving callbacks, queues, and other data structures.
As a general rule, a lock should protect an object's internal state. i.e. It should only protect memory reads and writes, not any other operation, including calling other objects, which may perform their own locking.
This may be a dumb question but:
- How does this proposal differ from just using
NSLock
? - How does it differ from making LockedState non-internal, or exposing LockedState._Lock as an independent class/struct?
It’s stored inline, rather than in a separate allocation.
This (at least to me) seems to be the most common use case for separate lock
and unlock
that has been brought up. Although it feels gross, since it comes up a lot, and all the involved types are in the standard library, I wonder if we could just provide a special standard library function just for taking a lock then releasing it after the continuation is acquired:
await mutex.withLock {
// mutex is locked here, we're guaranteed not to suspend during this block
// since it's not async
...
} thenWithUnsafeContinuation { continuation in
// mutex is locked here, we're guaranteed not to suspend during this block
// since it's not async
...
// mutex is unlocked after block exit before suspending the task
}
That doesn’t work if you only need a continuation conditionally depending on the protected state. This is how NIO implements its AsyncSequences.
Would it be sufficient to have the first block return a boolean indicating whether to acquire the continuation or not?
Semaphores also need a continuation depending on the protected state.
The proposed api that mixes mutexess and continuations seems pretty much ad-hoc and quite not composable. It looks like a patch, and it's unclear if it will fill all the holes created by the absence of lock/unlock
. Maybe we can solve the initial problem without a very focused api like this one?
The pitch exludes the primitive lock/unlock methods because of "peril". Are there any other stronger reasons for excluding them?
For example, one problem with lock
and unlock
is api design: how do we allow mutation of the state on a locked mutex, but not on an unlocked mutex?
Couldn't ~Copyable
types help here?
let mutex = Mutex<...>(...)
mutex.withLock { $0.mutate } // 👍
let lockedMutex = mutex.lock()
lockedMutex.value.mutate() // 👍
lockedMutex.unlock()
lockedMutex.value.mutate() // ❌ lockedMutex was consumed
Another possibility would be to expose lock
and unlock
on Mutex<Void>
. The protected state would then be stored outside of the mutex:
var value: Value
let mutex: Mutex<Void>
mutex.withLock { value.mutate } // 👍
mutex.lock()
value.mutate() // 👍
mutex.unlock()
value.mutate() // 👎 still possible
Could non-copyable types help here?
let protected = Lock(<state>)
let hold = protected.lock()
hold.value = …
hold.release()
If the type of hold
is non-copyable (but Sendable
?) and doesn't allow implicit deinit, then the compiler will ensure that unlock
is inevitably called.
Reading the doc of std::sync::Mutex
, it looks like Rust picked a similar api: you can only mutate and unlock from the result of lock
:
let mutex = Mutex::new(0);
let mut guard = mutex.lock().unwrap();
*guard += 20;
Mutex::unlock(guard);
More precisely, this would need lifetime-constrained types, so that the lockedMutex
guard is lifetime-dependent on borrowing mutex
. This is how Rust's LockGuard<'a, T>
type works for instance. That addresses the ownership model peril, so the main remaining hazard would be introducing the possibility of suspending your async task while holding a lock. Since the withUnsafeContinuation
operation as a whole is async
, any attempts to statically prevent suspending the task while a LockGuard
is alive would still exclude withUnsafeContinuation
unless we break it into unscoped components as well.
OK. And those guards are in the "future directions" section of the pitch by @Alejandro.