iOS
Swift
NSLock

失敗して覚えるiOSの排他処理「デッドロックを起こしてみる」

RxSwiftの実装を見ていたら排他処理を利用していたので、ちょっと試してみました。

最初からうまく行くように覚えていくよりも、ぶっ壊して「何をやっちゃいけないのか」を学びながら正しい方法を学んで行く方が頭に入るので、しばらくこのシリーズやりたいと思います。

排他処理でデッドロックを起こす

NSLockを利用します。

これは排他処理を行うためのクラスで、lock()をかけている間は、unlock()が呼び出されるまで、他スレッドからの呼び出しを待たせる事が出来ます。ただし、unlock() を呼び出す前に同一スレッドから lock() を呼び出されると、デッドロックを起こします。(他にもデッドロックの条件はあります)

deadlock.swift
class Sample {
    private let lock = NSLock()

    private var _number = 0
    var number: Int {
        get {
            defer { lock.unlock() }
            lock.lock()
            return _number
        }

        set {
            defer { lock.unlock() }
            lock.lock()
            _number = newValue
        }
    }
}

let lockSample = Sample()
lockSample.number = 3
print(lockSample.number)

extension Sample {
    func sayHello() {
        defer { lock.unlock() }
        lock.lock()

        // すでにlock()しているのに、さらにlock()する
        if number > 0 {
            print("Hello")
        }
    }
}

lockSample.sayHello()
3 // Helloが出力されない

これを回避する

NSRecursiveLock を利用するだけです。
これは同一スレッドからの再lock()は透過させます。なのでHelloが出力されます。

このNSRecursiveLockBehaviorSubjectなどで利用されているのを見かけました。(継承してサブクラス化したものを使っているようでしたが)

avoidDeadlock.swift
class Sample {
    private let lock = NSRecursiveLock()

    private var _number = 0
    var number: Int {
        get {
            defer { lock.unlock() }
            lock.lock()
            return _number
        }

        set {
            defer { lock.unlock() }
            lock.lock()
            _number = newValue
        }
    }
}

let lockSample = Sample()
lockSample.number = 3
print(lockSample.number)

extension Sample {
    func sayHello() {
        defer { lock.unlock() }
        lock.lock()

        // すでにlock()しているのに、さらにlock()する
        if number > 0 {
            print("Hello")
        }
    }
}

lockSample.sayHello()
3
Hello

「DispatchQueue.sync」 を利用する

以下の記事によると、2016年のWWDCからは排他処理には「DispatchQueue.sync」がオススメされているようです。
https://qiita.com/mono0926/items/45413be5aa64128bc6d2

DispatchQueueSync.swift
// 参照記事通りのコード
class Account {
    private var _balance: Int = 0
    // デフォルトは、シリアルキュー
    let lockQueue = DispatchQueue(label: "Account lock serial queue")
    // attributesに.concurrentを指定すると並行処理されるキューとなり同期処理の用途には不適切
    //    private let queue = DispatchQueue(label: "", attributes: .concurrent)

    var balance: Int {
        // getterを同期処理に
        // クロージャー内で返した型をgetterの戻り値として返せる
        get {
            return lockQueue.sync { _balance }
        }
        // setterを同期処理に
        set {
            lockQueue.sync { _balance = newValue }
        }
    }

    // _balanceの増減操作をするには読み取りと書き込みをセットで括る必要あり
    func add(_ value: Int) {
        lockQueue.sync { _balance += value }
    }
}

let account = Account()

for i in 0..<1_000 {
    DispatchQueue.global().async {
        account.add(10)
        // 次のように同期処理を介さず操作すると最終結果がズレる(低くなる)
        //account.balance += 10
    }
}
final balance: 10000

「DispatchQueue.sync」をデッドロックさせようとしてみる

deadlock.swift
class DeadLockAccount: Account {

    override func add(_ value: Int) {
        // 変更前のコードは _balance =+ value
        lockQueue.sync { balance += value }
    }
}

let deadlockAccount = DeadLockAccount()
deadlockAccount.add(10)
print("final balance: \(deadlockAccount.balance)")

Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)

エラーで落ちた。