12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

macOS/iOSスレッドプログラミング(排他制御とキャンセル)

Last updated at Posted at 2020-06-12

シリーズ(予定)

  1. macOS/iOSスレッドプログラミング(ThreadとRunLoop)
  2. macOS/iOSスレッドプログラミング(排他制御とキャンセル) :point_left:
  3. macOS/iOSスレッドプログラミング(OperationQueue / Grand Central Dispatch)
  4. macOS/iOSスレッドプログラミング(Swift + Dispatch / Combine)

前回は Objective-C しか書かずこのまま書き続けても誰得になるので、 Swift を使っていこうと思います。

排他制御

プログラマを悩ませる具体的な予測が不能な謎の挙動 :bomb:

突っ込みどころ満載 :100: ですが、こんな仕様を実現する必要があるとします:

  • 最大4つまで同時に発生するかもしれない非同期処理
  • ↑が完了した時それぞれ、 "Foo" と "Bar" を ループで100回繰り返してひとつずつ データを配列に格納する
  • 格納する配列は各非同期処理で共有される領域であること(一つのインスタンスにまとめる)
  • 同時に触る可能性があるので、最後の項目が "Foo" なのか "Bar" なのかをみて全体が "Foo", "Bar", "Foo", "Bar", ... の並びになるようにすること
  • 非同期処理から別スレッドを呼び出してはいけない

:warning: ここではあらかじめ100個の配列を作っておいてそれを1回足して終わりにしてはいけません!配列を操作する回数が増えれば増えるほどおかしな出来事が起きやすくなります

前回Threadを扱ったので、今回も今だけThreadメインで書きます(DispatchQueueでもいいです)

class FooBar { // 各スレッド間で共有するには参照として渡す必要があるのでclassでラップする
    var values = [String]()
}

class FooBarThread : Thread { // Mac OS X 10.5 から継承できるようになった
    
    let fooBar: FooBar
    
    init(fooBar: FooBar) {
        self.fooBar = fooBar
    }
    
    override func main() { // スレッドで実行する内容はここに書く
        for _ in 0..<100 {
            if self.fooBar.values.last == "Foo" {
                self.fooBar.values.append("Bar")
            } else {
                self.fooBar.values.append("Foo")
            }
        }
    }
}

let test = FooBar() // 共有するデータのインスタンス
// スレッドは4つ走らせる
FooBarThread(fooBar: test).start()
FooBarThread(fooBar: test).start()
FooBarThread(fooBar: test).start()
FooBarThread(fooBar: test).start()
// 結果を待つ必要があるので1秒くらいウェイト入れる
RunLoop.current.schedule(after: RunLoop.current.now.advanced(by: 1.0)) {
    print("count = \(test.values.count), result = \(test.values)")
}

前回はスレッドに入力値を渡して結果を受け取るというシンプルな仕様だったのに対し、今回は一つの資源を共有して変更を加えるという内容になっています。

さてさて Playground などで動かしてみましょう。結果はうまく動きましたか?そもそも動いて正常にコンソールに何かが出たら御の字でクラッシュしまくるかと思います。スレッドを一つにすれば動くはずです(要件は満たせませんが)。 シングルコアの端末ならクラッシュしないのに!(PPC MacかA4以前のiOSデバイスだけですね)

スレッドセーフな扱い方 :lock:

スレッドセーフ というキーワードは聞いたことがあるでしょうか。ここでのスレッドセーフとは2つ以上のスレッドが同時にAPIの関数やオブジェクトのインスタンスを操作しても安全性が保証されているかどうかを指します。もし、スレッドセーフでない場合、試してみた通りデータが壊れてしまったり不正なメモリアクセスによりアプリがクラッシュしてしまう恐れがあります。

今回の場合、実際に共有される FooBar インスタンス "test" がスレッドセーフかどうかによって結果が期待通りになるかが変わります。 Swift で DispatchQueue などを使わなかった場合、 値型かつ変更が加わる場合はスレッドごとに(クロージャならキャプチャしつつ)コピーされる状態ならセーフ=安全ということが言えます :

var arr = [String]()
DispatchQueue.global().async { // これはダメ (スレッドを跨いで同じインスタンスを操作してしまう)
    arr.append("")
}

DispatchQueue.global().async { // これはOK
    var arr2 = arr // キャプチャされるのでこの async の直後に arr が変更されても影響を受けない
    arr2.append("")
}

変更されないイミュータブルな参照型も安全です(ただしメインスレッドでのみ実行可能だったりするケースもあるので必ず調べましょう)

参考: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html

まとめると

  • Int, String などのSwiftのプリミティブは基本的には安全
    • ただし mutating を使う場合は下記ルールが適用されます
  • Array, Dictionary, 構造体 は変更されないか、変更の恐れがある場合はキャプチャしつつスレッドのスコープにコピーされるようにしておけば安全
  • NSArray, NSDictionary といった参照型も変更されない場合は基本的に安全(例外あるので必ず確認すること)

です。ということで先ほどのコードは意図的にスレッドセーフでない状態を作り出したため不可解な現象が発生しました。

スレッドセーフにするには :construction:

先ほどの安全な状態にすればよいので、スレッドセーフではないインスタンスを適切に扱うには

  • 同一インスタンスをシェアして変更させない (先ほどのコピーするテクニック)
  • インスタンスの変更をするスレッドを統一する
  • スレッドがインスタンスにアクセスしている間に他スレッドが割り込みできないようにする

といったことが挙げられます。自分で実装したクラスがスレッドセーフかどうかについてはまた複雑な話になりそうなのでここではいったん考えないこととします。

同一インスタンスをシェアして変更させない (コピーする)

すでに上でコードを挙げていますが、クロージャーでスレッドが異なる場合はキャプチャさせておくことで変更に対するロックをかけることができます(うっかり忘れそうになるのを自動で防げると良いのですが)。NS系の配列などは copy / mutableCopy メソッドを使ってしまえばよいでしょう。これ自体はシャローコピー(配列の各項目への参照自体はコピーされるがオブジェクト自体は同じインスタンスを参照する)ので、実行コストは安い反面まだまだ危険を伴います(必要に応じてディープコピーするか、次の方法を使って並列性を排除してください)。

インスタンスの変更をするスレッドを統一する

// ワーカーキューを作ってスレッド間でシェア
let workerQueue = DispatchQueue(label: "MyAppWorkerQueue")

DispatchQueue.global().async {
    (...何かの処理...)
    workerQueue.async {
        (クリティカルセクション)
    }
}

このようにシリアルなキューを作っておいてasyncを呼び出すと常に同じスレッドで実行されるので同時にいろんなスレッドが動く、ということが防げます。

排他制御 :lock:

これこそ今必要なもので、可変な配列を読み取ったり変更を加えるスレッドセーフではない部分… クリティカルセクション を守るために必要な概念です。

排他制御については個室トイレ=クリティカルセクション(可変な配列)でスレッド=人だとすると、排他制御がない状態は鍵のない個室トイレで中に人がいるのに誰でも入れてしまう状態です。先ほどまでのテクニックを同じように例えると、コピーは個室を増やしてあげて干渉させない、スレッドの統一は複数人ではなく個室に入る人を1人だけに決めて対応する方法です。

排他制御はドアに鍵を設けることです。入っている(読み書きしている)間ロックをかけて触れないようにすることでスレッドセーフではない操作を安全に行うことができます。

Objective-Cでは @synchronized(<obj>) {} という構文があり、ロックをつける対象を引数に渡してそのスコープ内は安全に対象を操作できる仕組みがありました。

    - (void)main {
        for (int i = 0; i < 100; ++i) {
            @synchronized(self.fooBar) { // 追加、ループの外のほうが効率はよいが他をブロックしやすくなる
                if ([@"Foo" isEqualToString:self.fooBar.values.lastObject]) {
                    [self.fooBar.values addObject:@"Bar"];
                } else {
                    [self.fooBar.values addObject:@"Foo"];
                }
            }
        }
    }

これで :key: を設けることができたので、複数のスレッドが @synchronized の中のコードを同一インスタンスに対して触ることがなくなります。Swiftだとこんな感じ

objc_sync_enter(self.fooBar)
if self.fooBar.values.last == "Foo" {
    self.fooBar.values.append("Bar")
} else {
    self.fooBar.values.append("Foo")
}
objc_sync_exit(self.fooBar)

Objective-Cのように文法レベルで用意されていないので簡単なラッパーを作って気軽に使える状態にすると良さそうですね。
これで冒頭の Foo / Bar はめでたく?動くようになりました。この辺りマルチスレッドが難しいと言われる由縁ですね。

ただし mutating メソッドには使えない :hole:

によると仕組み上排他制御自体不可能だそうで、 mutating メソッド内にロックを見かけたらレビューで弾く必要がありそうです。

NSLock :lock:

せっかくなので古くから伝わるクラスもご紹介しておきます。 objc_sync_enter / objc_sync_exit と同じように使えますが、ロック対象のオブジェクトを指定する代わりに NSLock のインスタンスをスレッド間で使い回す必要があります。

 class FooBar {
     var values = [String]()
+    let lock = NSLock()
 }

 class FooBarThread : Thread {
    
     let fooBar: FooBar
    
     init(fooBar: FooBar) {
         self.fooBar = fooBar
     }
    
     override func main() {
         for _ in 0..<100 {
+            self.fooBar.lock.lock()
             if self.fooBar.values.last == "Foo" {
                 self.fooBar.values.append("Bar")
             } else {
                 self.fooBar.values.append("Foo")
             }
+            self.fooBar.lock.unlock()
         }
     }
 }

これでも同じように動きます。ただし NSLock には落し穴があって例えばこんなコードを書いたとします:

// スレッドセーフなイケてる配列!
class AtomicArray<T> : CustomStringConvertible {

    private let lock = NSLock()
    private var _items = [T]()
    
    var count: Int {
        return acquireLock { _items.count }
    }
    
    var description: String {
        return acquireLock { _items.description }
    }
    
    subscript(index: Int) -> T {
        return acquireLock { _items[index] }
    }
    
    private func acquireLock<U>(block: () -> U) -> U {
        defer {
            lock.unlock()
        }
        lock.lock()
        return block()
    }
    
    func add(item: T) {
        acquireLock { _items.append(item) }
    }
    
    func remove(at index: Int) {
        acquireLock { _items.remove(at: index) }
    }
}

ここまでは良くて( imo: Use struct / nits: Collectionプロトコルを適合すべき とかはサンプルなので勘弁してください :pray: )例えば良かれと思ってこんなコードを追加してしまうと…

extension AtomicArray : CustomDebugStringConvertible {
    var debugDescription: String { // 空の時は親切な表示にしてあげたい
        acquireLock {
            if _items.count > 0 {
                return description // <--
            } else {
                return "空だよ"
            }
        }
    }
}

実は動かなくなります。なぜかというと return description のところですでに acquireLock しているのに、descriptionプロパティでさらに acquireLock してしまうと NSLock は永久にロックしたままになります。

You should not use this class to implement a recursive lock. Calling the lock method twice on the same thread will lock up your thread permanently. Use the NSRecursiveLock class to implement recursive locks instead.

これを解消するにはそもそも description_items.description にして不要な再ロックを避ければ良いのですが、こういったメソッドを入れ子にしたい場合は NSLock の代わりに先ほど紹介した objc_sync〜 を使うか、 NSRecursiveLock を使えば解決できます※

排他制御は他にもいろんな方法があるのですが、いったん次の話題へ進みたいと思います。

キャンセル (スレッドの中断) :stopwatch:

これは次で紹介する Operation / OperationQueue などでも使える一般的なテクニックです。
標準で用意されている、時間がある程度かかることが予想される非同期処理のクラスには cancel メソッドが用意されていることが少なくありません。例えば URLSession / AVAssetReader (AVAssetWriter) あたりでしょうか。

ここに指定した範囲の素数を抽出して返すコードがあります(ごめんなさい、時間なくてStackOverflowから引用してます :pray: )。iOSの場合メインスレッドで実行すると :dog2: ウォッチドッグタイマー :eyes: ...

Decreasing app launch time improves the user experience, and reduces the chances of the iOS watchdog timer terminating the app.

...にタスクキルされてしまうかもしれないのでスレッドを使います。

// https://stackoverflow.com/a/49787123
func isPrime(_ n: Int) -> Bool {
    guard n >= 2     else { return false }
    guard n != 2     else { return true  }
    guard n % 2 != 0 else { return false }
    return !stride(from: 3, through: Int(sqrt(Double(n))), by: 2).contains { n % $0 == 0 }
}

class PrimeNumberThread : Thread {
    
    let testRange: ClosedRange<Int>
    let handler: ([Int]) -> Void
    
    init(testRange: ClosedRange<Int>, handler: @escaping ([Int]) -> Void) {
        self.testRange = testRange
        self.handler = handler
        super.init()
    }
    
    override func main() {
        var primeNumbers = [Int]()
        for i in testRange {
            if i % 100 == 0 {
                print("Now processing... \(i)")
            }
            if isPrime(i) {
                primeNumbers.append(i)
            }
        }
        RunLoop.main.perform { // Dispatch.main.async と同じはず
            self.handler(primeNumbers)
        }
    }
}

// 100000まで探索
PrimeNumberThread(testRange: 0...100000) {
    print($0)
}.start()

とまあこんな感じで、Swift Playgrounds で実行すると結構時間かかりますので好都合です。

cancel を確認せよ :flag_white:

ずばり primeNumberThread.exit() みたいなことはFoundationでは実現できません。できることは

  • 外部から停止信号を受けたら自前で break する

のが基本です。 Thread (や Operation / OperationQueue ) の場合は cancel メソッドが用意されており、これが何をするかというと Thread / Operation インスタンスの isCancelled プロパティを true に変更します(それだけです)。なのでループ内で isCancelled == true なら break すればキャンセル実現できることになります。

     override func main() {
         var primeNumbers = [Int]()
         for i in testRange {
+            if isCancelled {
+                break
+            }
             if i % 100 == 0 {
                 print("Now processing... \(i)")
             }
             if isPrime(i) {
                 primeNumbers.append(i)
             }
         }
         RunLoop.main.perform {
             self.handler(primeNumbers)
         }
    }

こんなコードを試してきちんと止まれば成功です(中途半端な結果を受け取りたくない場合は break の代わりに return を使えば良いですね)

        let th = PrimeNumberThread(testRange: 0...100000000) {
            print("DONE!", $0)
        }
        th.start()
        
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            th.cancel()
        }

おまけ:パフォーマンス計測 :stopwatch:

高頻度で回るループにこのようなチェックをいれたりする場合は必ずパフォーマンス計測をして悪影響を及ぼしていないことを確認しましょう。Xcode上で Run の代わりに Profile を選び Instruments が立ち上がったら Time Profiler を選択。レコードボタンを押してアプリを動かしサンプルを収集します。記録を止めたら左下のコールツリーからスレッドの main 関数を見つけて(Swiftの場合 @objc とついていないメソッド)ダブルクリックすると、各行毎のパフォーマンスが表示されます:

image.png

よかった、 isCancelled は相対的に占めるウェイトがかなり低いので最適化の第一候補からは外れています(もしパフォーマンスに問題があるならば isPrime を最適化するのが最も効果的であることが一目瞭然ですね)。

おすすめリンク :link:

Special Thanks

社内のメンバーに mutating に関するツイートを共有してもらいました。感謝。

12
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?