Synchronous Mutual Exclusion Lock
01 何が問題だったのか
並行プログラムで共有されるミュータブルな状態へのアクセスを同期する手段は、Swiftにも複数用意されてきました。アクターは状態を自身の isolation domain に閉じ込め、ドメイン外からは await 越しにしか触れないようにすることで、デフォルトの選択肢として良く機能します。
しかし、すべてのコードがアクターを採用できる(あるいは採用したい)わけではありません。たとえば次のような事情があります。
- 他のタスクが割り込まないよう、ある区間を同期的に実行しきる必要がある
asyncを持ち込めない既存のコードから、同じ保護対象にアクセスしたい
こうしたケースでは、従来型の同期的な相互排他ロック(mutex)が欲しくなります。mutexは「保護対象に触れる実行コンテキストが常にひとつだけ」であることを、ロックの取得と解放で保証する同期プリミティブです。
ところがSwiftの標準ライブラリにはこれまで公式のmutex型が存在せず、各プロジェクトが os_unfair_lock / pthread_mutex_t / SRWLOCK / futex などのプラットフォームAPIを独自にラップしていました。結果として実装ごとに作法が微妙に異なり、さらに以下のようなSwift固有の難しさも各自で解く必要がありました。
- ロックは「単一の安定したメモリ位置」に置く必要があり、コピーされてはいけない
var束縛にすると排他性チェック(Law of Exclusivity)が干渉しうる- 保護対象が non-
Sendableな値だった場合、アクター間で共有しても安全だと静的に示す方法がない
標準的で安全なmutexが、標準ライブラリに必要でした。
02 どのように解決されるのか
SE-0410 で追加された Synchronization モジュールに、新しい同期プリミティブ Mutex<Value> を追加します。Atomic と同様に、使うにはモジュールを明示的に import します。
import Synchronization
final class FancyManagerOfSorts {
let cache = Mutex<[String: Resource]>([:])
func save(_ resource: Resource, as key: String) {
cache.withLock {
$0[key] = resource
}
}
}
Mutex は保護したい値を内部に持ち、withLock のクロージャ内でのみその値に inout でアクセスできます。クロージャの先頭でロックが取得され、抜けるときに解放されます。
型と基本API
Mutex は ~Copyable な struct で、保護対象の型 State 自体も ~Copyable で構いません。
public struct Mutex<State: ~Copyable>: ~Copyable {
public init(_ state: sending consuming State)
}
extension Mutex: Sendable where State: ~Copyable {}
extension Mutex where State: ~Copyable {
public borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: (sending inout State) throws(E) -> sending Result
) throws(E) -> sending Result
public borrowing func withLockIfAvailable<Result: ~Copyable, E: Error>(
_ body: (sending inout State) throws(E) -> sending Result?
) throws(E) -> sending Result?
}
withLockIfAvailable はロックを即時取得できたときだけクロージャを実行し、取得できなかった場合は nil を返します(tryLock 相当)。
基盤となるロック実装はプラットフォームごとに異なり、fairnessは保証されません。保証されるのは「同時に一つの実行コンテキストだけが critical section に入る」ことだけです。
- Darwin系:
os_unfair_lock - Linux:
futex - Windows:
SRWLOCK
Sendable と non-Sendable な保護対象
Mutex は State が何であっても無条件に Sendable です。これを安全に成立させているのが、初期化子とクロージャの sending 注釈(SE-0430)です。
init(_:)の引数がsending consumingなので、初期化に使った non-Sendableな値は以降呼び出し元で参照できません。withLockのクロージャ引数inout Stateもsendingなので、クロージャ内でinoutされている値をクロージャ外へ逃がすことはできません。
この仕組みによって、Mutex それ自体をひとつの isolation domain とみなせます。non-Sendable な参照や内部のポインタをロックの外へこっそり送り出す経路が静的に塞がれます。
class NonSendableReference {
var prop: UnsafeMutablePointer<Int>
}
let nonSendableRef: NonSendableReference = ...
let lockedPointer = Mutex<UnsafeMutablePointer<Int>>(...)
func something() {
lockedPointer.withLock {
// error: sending 'inout' parameter transferred out
// but hasn't had a value transferred back in.
nonSendableRef.prop = $0
}
}
どうしても中身を外へ渡したい場合は、クロージャから抜ける前に新しい値を戻す($0 を再代入する)ことで、Mutexの domain に新しい値を「送り返した」ことを示します。
func something() {
lockedPointer.withLock {
nonSendableRef.prop = $0
$0 = ... // 新しい値を domain に送り込む
}
}
var / inout / mutating の禁止
Mutex は Atomic と同じく @_staticExclusiveOnly が付き、var での宣言や inout 渡しはコンパイルエラーになります。これはロックの安定したアドレスを保ったまま、Swiftの動的排他性チェックと干渉しないようにするためです。let で保持し、borrowing または consuming で渡します。
非再帰であること
Mutex は非再帰です。withLock の中で同じ Mutex に対して再び withLock / withLockIfAvailable を呼ぶと、プラットフォーム依存の挙動になり(デッドロック、プロセスパニック、未規定のいずれか)、少なくともロックが再取得されることはありません。
また、async な文脈でも withLock 自体は使えますが、クロージャ内に await は書けません。中断ポイントを挟んだ先でロックが別スレッドから解放される事態を避けるため、クロージャは同期的に実行しきる必要があります。長時間の非同期処理を守りたい場合はアクターなど別の仕組みを検討します。
アクターとの使い分け
mutexとアクターは、いずれも共有ミュータブル状態を守る仕組みですが性質が大きく異なります。
- mutex: 同期的なロック。取得待ちのスレッドは前進できず、デッドロックやスレッド競合の可能性もある一方、
asyncを持ち込めない同期コードからも使える。 - アクター: 非同期。待機中のタスクは別の仕事を進められ、通常の使い方ではデッドロックしない。一方、中断ポイントでの再入(reentrancy)に伴う状態変化には注意が必要。
デフォルトではアクターで十分なことが多く、Mutex は「アクターが使えない/合わない」場面の補完として位置づけられます。
今後の見通し
以下のような拡張が将来の方向性として挙げられています(speculativeで、実現を約束するものではありません)。
- ガードベースAPI: C++の
std::lock_guardやRustのMutexGuardのように、取得トークンのスコープでロックの寿命を表現するAPI。~Copyableかつ~Escapableな戻り値で安全に表現する案が考えられていますが、nonescapable型の整備を待つ必要があります。 - reader-writerロックや再帰ロック: 読み取りと書き込みで排他性の粒度を変えるロックや、同じスレッドが複数回取得できる再帰ロックといった、別種の同期プリミティブを
Synchronizationモジュールに追加する方向。