1
/
5

明日から使えない!Swiftの排他制御

こんにちは!エンジニアの柳村です。

今回は、いい感じに設計していると普段はほとんど使う場面はなさそうな排他制御(Swift)についての話 です。

Swift(iOS)で使える排他制御はざっとあげただけでもこれだけたくさんあります。

  • NSLock/NSRecursiveLock/NSConditionLock
  • objc_sync_enter(),objc_sync_exit()
  • DispatchSemaphore
  • OSSpinLock/os_unfiar_lock(iOS10以降)
  • pthread_mutex
  • semaphore
  • GCDのserialQueueとDispatchQueue.syncを使う

これらの排他制御の違いはなにでどういったときにどういったときにどれを使えばよいかといったことについて説明します。

結論

長くなるので先に結論を言っておきますと

  • そもそも同期処理はないほうがいいのでそうなるように設計しましょう
    • なるべくimmutableにする
    • 特定のスレッドからのみ操作する(複数のスレッドから操作させない)


  • どうしても同期処理が必要な場合
    • 再帰的にロックしても大丈夫にしたい場合は、objc_sync_enter(),objc_sync_exit()または NSRecursiveLock
    • それ以外はDispatchQueue.syncかDispatchSemaphoreかNSLock
    • 同時にクリティカルセクションにアクセスできるスレッドを複数にしたいならDispatchSemaphore
    • OSSpinLockは速いが問題がある
    • どうしても使いたいときはiOS10以降だったらos_unfair_lockを使う

各排他制御について

NSLock

NSLockはpthread_mutexの薄いラッパーです(ソース)。NSLockを使う = pthread_mutex を使っていると捉えてほぼ問題無いと思います。

ものすごくパフォーマンスを気にしない限りはpthread_mutexを直接叩かずにNSLockを使えばよいです。

単純に読み込みと書き込みに排他制御をかけたい場合の例です。

    class Sample {
        fileprivate let lock = NSLock()
    
        private var _counter: Int = 0
        var counter: Int {
            get {
                defer { lock.unlock() }
                lock.lock()
                return _counter
            }
            set {
                defer { lock.unlock() }
                lock.lock()
                _counter = newValue
            }
        }
    }

NSLockの場合は同じスレッドで再帰的にlockを呼ぶとデッドロックを起こすので下の例のsayEvenNumber()のようなことをすると駄目です。

     extension Sample {
        func sayEvenNumber() {
            defer { lock.unlock() }
            lock.lock()
            if counter % 2 == 0 {
                print(counter)
            }
        }
    }

また、NSLockはlockしたスレッドと同じスレッドでunlockしないといけません。

NSRecursiveLock

NSLockで再帰的にlockを呼んでしまうとデッドロックしてしまう問題を解決するのがNSRecursiveLockです。

NSRecursiveLockとNSLockの実装的な違いは、NSRecursiveLockのときはpthread_mutex_init()する際にPTHREAD_MUTEX_RECURSIVEにしている点です。

使い方はNSLockと同じです。NSLockであげた例のコードをNSRecursiveLockに差し替えると、sayEvenNumber()でデッドロックを起こしません。

objc_sync_enter()/objc_sync_exit()

objc_sync_enter(),objc_sync_exit()はObjective-Cでいうところの@synchronizeと同じです。

objc-sync.hにコメントで以下のように書いてあるように@synchronizeに入るときと出るときにそれぞれ呼ばれます

    'objc_sync_enter' is automatically called when entering a @synchronized() block.
    ...
    'objc_sync_exit' is automatically called when exiting from a @synchronized() block.

実際に簡単なコードを書いてアセンブリを見てみると確認することができます。

    // Sample.m
    @interface Sample : NSObject
    @property (nonatomic) int i;
    @end
    
    @implementation Sample
    - (void)method1 {
        @synchronized (self) {
            self.i++;
        }
    }
    @end
    // Sample.m(assembly)
    bl objc_sync_enter
    ...
    bl objc_sync_exit

プリプロセッサにかけた時点ではまだ@synchronizeのままだったので、@synchronizeはコンパイラによってobjc_sync_enter(),objc_sync_exit()に置き換えられているようでした。

objc_sync_enter(),objc_sync_exit()で何をやっているかというと、objc-sync.hのコメントに書いてあるように、recursive lockする点にあります。

It locks the recursive lock associated with 'object'. If 'object' is nil, it does nothing.

ソースをみると、objc_sync_enterは、同じスレッド上で同じオブジェクトに複数回lockが呼ばれた場合は、カウンタだけあげてpthread_mutex_lockは最初しかしない仕組みが入っています(ソース)。

     class Sample {
        func doSomething() {
            defer { objc_sync_exit() }
            objc_sync_enter()
            // do something
        }
    }

そのぶんobjc_sync_enterやNSRecursiveLockのほうがNSLockを呼ぶよりも余計な処理をしているため遅くなります。

DispatchSemaphore

一般的なセマフォと同じで、カウンタが0のときは待ち、カウンタが1以上のときは1減算して実行します。

中身はsemaphore_timedwait()を呼んでるのでsemaphoreのラッパーと思っていいと思います(ソース)。

この例のようにバイナリセマフォとして使う場合(DispatchSemaphore(value: 1))で使う場合はNSLockとほとんど変わらないのでどっちでもいいのではないかと思います。(NSLockは同じスレッドがlock/unlockしないといけませんが、セマフォは他のスレッドからでもsignalで起こすことができますが排他制御でそういった使い方をすることはないのではと思います。)

     class Sample {
        private let sem = DispatchSemaphore(value: 1)
        func doSomething() {
            defer { sem.signal() }
            sem.wait()
            // do something
        }
    }

排他制御で同時にアクセスできるスレッド数を1ではなくNにしたい場合は、DispatchSemaphoreの場合は、DispatchSemaphore(value: N)にすることで実現できます。

OSSpinLock

iOS10でdeprecatedになったので今後はあまり使うことはないのですが一応書いておきます。

OSSpinLockはこれまでの同期処理とは異なりスレッドを止めずにspin wait(ループ)して待ちます。

そのためpthread_mutex_lockなどのようにスレッドの停止再開によるコンテキストスイッチが発生しないので高速です(ただしマルチプロセッサで並行に処理されている場合に限る)。

一方で、spin waitが長くなればなるほど無駄にCPUを使ってしまうため非効率になります。

使い所としてはlockする時間が非常に少ない場合に有効です。


しかしOSSpinLockではスレッドの優先度の異なるスレッド間で用いた場合に深刻な問題(https://lists.swift.org/pipermail/swift-dev/Week-of-Mon-20151214/000372.html)があるので使わないほうがよいと思います。

os_unfair_lock

iOS10でOSSpinLockの置き換えとして登場しました。OSSpinLockの上記の問題を解決しています。

     class Sample {
        private var lock = os_unfair_lock_s()
        func doSomething() {
            defer { os_unfair_lock_unlock(&lock) }
            os_unfair_lock_lock(&lock)
            // do something
        }
    }

ただ、os_unfair_lockのコメントをみると、spin lockはしてないようなので中身がどうなっているのかは不明です。

Does not spin on contention but waits in the kernel to be woken up by an unlock.

どういうデメリットがあるのかはちょっと謎なので、使うときはよく検証した上で使ったほうがよいかと思います。

pthread_mutex

pthread_mutexを直接叩くことで、NSLockを使うよりは多少オーバーヘッドを減らすことはできますが、あえて使う必要はなく普通に使う場合はNSLockかNSRecursiveLockを使うとよいと思います。

ただNSLockの場合だとMutexのプロトコル属性がデフォルトのPTHREAD_PRIO_NONEなので、優先度の逆転を避けるためにPTHREAD_PRIO_INHERITとか使いたい場合はpthread_mutexのほうを使う必要があります。

semaphore

こちらも同様にsemaphoreを直接呼ぶことで、DispatchSemaphoreよりは多少オーバーヘッドを減らすことはできますが、あえて使う必要はなくDispatchSemaphoreを使うとよいと思います。

ただsemaphoreのpolicyがデフォルトのSYNC_POLICY_FIFOになってしまう(ソース)ので、もし別のpolicyを適用したいときはsemaphoreのほうを使う必要があります。

DispatchQueue.sync

GCDのSerialQueueとDispatchQueueのsync(execute block: () → Void)を使う方法です。

SerialQueueで一つのスレッドで順番に実行し、その実行をsyncで同期的に待つことで、一つのスレッドからしか操作されないようにすることで競合を避けています。(排他制御かというと微妙な部類ですが)

    class Sample {
        fileprivate let serialQueue = DispatchQueue(label: "jp.supership.sample")
    
        private var _counter: Int = 0
        var counter: Int {
            get {
                return serialQueue.sync { _counter }
            }
            set {
                serialQueue.sync { _counter = newValue }
            }
        }
    }

メリットとしては、lock/unlockなどと書かなくて良いのでシンプルに書けます

NSLockなどと同様に同じスレッドで再帰的にsyncを呼ぶとデッドロックを起こすので下の例のsayEvenNumber()のようなことをすると駄目です。(EXEC_BAD_INSTRUCTIONが発生します)

    extension Sample {
        func sayEvenNumber() {
            serialQueue.sync {
                if counter % 2 == 0 {
                    print(counter)
                }
            }
        }
    }

また、このようにConcurrentQueueを使って読み込みは並列に行って、書き込みはbarrier asyncでブロックして書き込みすることで、読み込みのほうが多い場合にパフォーマンスをあげることができます。

    class Sample {
        private let concurrentQueue = DispatchQueue(label: "jp.supership.sample")
    
        private var _counter: Int = 0
        var counter: Int {
            get {
                return concurrentQueue.sync { _counter }
            }
            set {
                concurrentQueue.async(flags: .barrier) { self._counter = newValue }
            }
        }
    }

まとめ

全体的に言えることですが、排他制御を行うと、デッドロックが発生したり、場合によっては優先度の高いスレッド処理が優先度の低いスレッドの処理によってブロックされて処理が遅くなってしまうといった優先度の逆転が発生したりと、様々な問題を引き起こす可能性があります。

できるだけ排他制御は入れずにすむような設計にしたほうがよいです。


どうしても排他制御が必要な場合は以下のような感じで使い分けるとよいかと思います。

  • 再帰的なロックが不要な場合
    • NSLock, DispatchQueue.sync, DispatchSemaphore
  • 再帰的なロックが必要な場合
    • objc_sync_enter()/objc_sync_exit(), NSRecursiveLock
  • spin lockを使いたい場合
    • やめたほうがよい
    • iOS10以降ならos_unfair_lockがあるけどspin lockはしてない


さいごに、弊社ではいっしょに働いてくださる仲間を募集しています。ご興味がある方はぜひ下にある採用情報をご覧ください。

Supership's job postings
12 Likes
12 Likes

Weekly ranking

Show other rankings