😺

【翻訳】Concurrency & Multithreading in iOS

2023/07/22に公開

並行処理とマルチスレッドは、 iOS 開発の核となる部分です。何がこれらを強力にしているのか、そして私たち自身の Cocoa Touch アプリケーションでこれらをどのように活用できるのか、掘り下げてみましょう。

並行処理とは、複数のことが同時に起こるという概念です。

これは一般的に、タイムスライシング (https://en.wikipedia.org/wiki/Preemption_(computing)#Time_slice) によって実現されるか、ホスト・オペレーティング・システムで複数の CPU コアが利用可能であれば、本当に並列で実現されます。私たちは皆、同時実行性の欠如を経験したことがあり、重いタスクを実行しているときにアプリがフリーズするという形で経験したことがあるでしょう。 UI のフリーズは、必ずしも同時実行性の欠如が原因で発生するわけではありません - バグの多いソフトウェアの症状である可能性もあります。 - しかし、自由に使えるすべての計算能力を活用しないソフトウェアは、リソースを大量に必要とする処理を行うたびに、このようなフリーズを発生させることになります。このようにハングアップしているアプリをプロファイリングした場合、おそらく次のようなレポートが表示されるでしょう。

ファイルI/O、データ処理、ネットワークに関連するものは通常、バックグラウンド・タスクにする必要があります(プログラム全体を停止させるようなやむを得ない理由がない限り)。

これらのタスクが、ユーザーがアプリケーションの残りの部分とやりとりするのを妨げなければならない理由は、それほど多くありません。もし、プロファイラーがこのような報告をしたら、あなたのアプリのユーザー体験がどれだけ良くなるか考えてみてください。

画像の分析、文書やオーディオの処理、ディスクへの大量のデータ書き込みなどは、バックグラウンド・スレッドに委譲することで大きな利益を得ることができるタスクの例です。 iOS アプリケーションにこのような動作を強制する方法を掘り下げてみましょう。

略史

その昔、コンピューターが実行できる CPU サイクルあたりの最大仕事量は、クロック速度によって決定されていました。プロセッサの設計がよりコンパクトになるにつれ、熱や物理的な制約が高クロック化の制限要因となり始めました。その結果、チップメーカーは総パフォーマンスを向上させるため、各チップにプロセッサコアを追加し始めました。コアの数を増やすことで、1つのチップは、速度、サイズ、熱出力を増加させることなく、1サイクルにより多くの CPU 命令を実行できるようになりました。ただ、ひとつ問題があります。

余ったコアを活用するには?マルチスレッド

マルチスレッディングは、n個のスレッドの作成と使用を可能にするために、ホスト・オペレーティング・システムによって処理される実装です。その主な目的は、プログラムの2つ以上の部分を同時に実行し、利用可能な CPU 時間をすべて活用することです。マルチスレッディングはプログラマーのツールベルトの中に入れておくと強力なテクニックですが、それなりの責任が伴います。よくある誤解は、マルチスレッドにはマルチコア・プロセッサが必要だというものですが、そうではありません。シングルコア CPU でも多くのスレッドで動作することは十分に可能ですが、そもそもなぜスレッド処理が問題になるのか、もう少し見ていきましょう。本題に入る前に、簡単な図を使って concurrency と parallelism が意味するニュアンスを見てみましょう。

上で紹介した最初の状況では、タスクは並行して実行されることはあっても、並列に実行されることはありません。これは、チャットルームで複数の会話をし、その間をインターリーブする(コンテキストを切り替える)ことに似ていますが、同時に2人と会話することはありません。これを私たちは同時並行性と呼んでいます。複数の物事が同時に起こっているように見えますが、実際には非常に速く切り替わっています。並行処理とは、同時にたくさんのことを処理することなのです。これと対照的なのが並列実行モデルで、両方のタスクが同時に実行されます。どちらの実行モデルもマルチスレッディングを示します。マルチスレッディングとは、1つの共通の目標に向かって複数のスレッドが働くことです。マルチスレッディングは、プログラムに並行性と並列性の組み合わせを導入するための一般化されたテクニックです。

スレッドの負担

iOS のような最新のマルチタスク・オペレーティング・システムでは、常に何百ものプログラム(またはプロセス)が実行されています。しかし、これらのプログラムのほとんどは、システムデーモンかバックグラウンドプロセスであり、メモリフットプリントは非常に小さいです。アプリケーション(プロセス)は、共有メモリー上で動作する多くのスレッド(サブプロセス)を持つことができます。我々の目標は、これらのスレッドを制御し、有利に使えるようにすることです。

歴史的に、アプリケーションに並行性を導入するには、1つ以上のスレッドを作成する必要がありました。スレッドは低レベルの構成要素であり、手動で管理する必要があります。 Apple の 『Threaded Programming Guide』 をざっと読むだけで、スレッドコードがコードベースにどれだけ複雑さを加えるかが分かります。アプリを作るだけでなく、開発者は次のことをしなければなりません。

・ 責任を持って新しいスレッドを作成し、システム状況の変化に応じてその数を動的に調整します。

・ スレッドを慎重に管理し、実行が終了したらメモリから解放します。

・ ミューテックス、ロック、セマフォなどの同期メカニズム (https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafety/ThreadSafety.html) を活用してスレッド間のリソース・アクセスをオーケストレーションし、アプリケーション・コードにさらにオーバーヘッドを追加します。

・ 使用するスレッドの作成と維持に関連するコストのほとんどを、ホスト OS ではなくアプリケーションが負担するコーディングに関連するリスクを軽減します。

これは残念なことで、パフォーマンスの向上が保証されないまま、膨大なレベルの複雑さとリスクが追加されることになります。

Grand Central Dispatch

iOS は、スレッド管理という並行処理の問題を解決するために、非同期アプローチを採用しています。非同期関数はほとんどのプログラミング環境で一般的で、ディスクからファイルを読み込んだり、ウェブからファイルをダウンロードしたりするような、時間がかかりそうなタスクを開始するためによく使われます。呼び出されると、非同期関数はバックグラウンド・タスクを開始するために舞台裏でいくつかの作業を実行しますが、元のタスクが実際に完了するのにかかる時間に関係なく、すぐに戻ります。

iOS が提供する非同期タスク開始のコア技術は、 Grand Central Dispatch (略して GCD )です。 GCD はスレッド管理コードを抽象化し、システムレベルまで下げ、タスクを定義して適切なディスパッチキューで実行するための軽い API を公開します。 GCD は全てのスレッド管理とスケジューリングを行い、タスク管理と実行への全体的なアプローチを提供すると同時に、従来のスレッドよりも優れた効率を提供します。

GCDの主な構成要素を見てみましょう。

これは?左から見ていいきましょう。

DispatchQueue.main

メイン・スレッド(UIスレッド)は、1つのシリアル・キューにバックアップされています。全てのタスクは連続して実行されるので、実行順序が保たれることが保証されます。全てのUI更新がこのキューに指定されるようにすること、そしてこのキューでブロッキングタスクを実行しないことが重要です。最高のフレームレートを維持するために、アプリの実行ループ(https://bou.io/RunRunLoopRun.html)( CFRunLoop と呼ばれる)が決してブロックされないようにしたい。続いて、メイン・キューは最高の優先度を持ち、このキューにプッシュされたタスクは即座に実行されます。

DispatchQueue.global

グローバルな同時キューのセットで、それぞれがスレッドのプールを管理します。タスクの優先度に応じて、どのキューでタスクを実行するかを指定することができますが、たいていの場合はデフォルトを使用することになるでしょう。これらのキュー上のタスクは同時並行的に実行されるため、タスクがキューイングされた順序の保持は保証されません。

個々のスレッドを扱わなくなっていることにお気づきでしょうか?私たちが扱っているのは、内部でスレッドのプールを管理するキューであり、なぜキューがマルチスレッディングに対してより持続可能なアプローチなのかは、すぐに分かるでしょう。

シリアルキュー:メインスレッド

練習として、ユーザーがアプリのボタンを押したときに実行される以下のコードを見てみましょう。高価な計算関数は何でもいいです。デバイスに保存されている画像を後処理していることにしましょう。

import UIKit

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        compute()
    }

    private func compute() -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

一見、これは無害に見えるかもしれませんが、実際のアプリの中でこれを実行すると、ループが終了するまでUIが完全にフリーズしてしまいます。 Instruments でこのタスクをプロファイリングすれば、それが証明できます。 Instruments の Time Profiler モジュールを起動するには、 Xcode のメニュー・オプションからXcode > Open Developer Tool > Instruments と進んでください。プロファイラの Threads モジュールを見て、 CPU 使用率が最も高いところを見てみましょう。

メインスレッドの処理能力が100%であることがほぼ5秒間分かります。これは、UIをブロックするのに十分な時間です。チャートの下のコールツリーを見ると、 Main Thread は99.9%の処理能力を4.43秒間保っていることが分かります!シリアル・キューがFIFO方式で動作することを考えると、タスクは常に挿入された順番に完了します。 compute() メソッドが原因であることは明らかです。ボタンをクリックしただけで UI がフリーズしてしまうことを想像できるでしょうか?

バックグランドスレッド

これを改善するには? DispatchQueue.global() で解決!ここでバックグラウンド・スレッドの出番です。上の GCD アーキテクチャ図を参照すると、 iOS ではメイン・スレッド以外のものがバックグラウンド・スレッドであることが分かります。バックグラウンド・スレッドはメイン・スレッドと並行して実行することができ、メイン・スレッドは完全に占有されずに、スクロール、ユーザー・イベントへの応答、アニメーションなどのような他の UI イベントを処理する準備ができます。上のボタン・クリック・ハンドラを少し変更してみましょう。

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
            self.compute()
        }
    }

    private func compute() -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

指定がない限り、コードのスニペットは通常メイン・キューで実行されるのがデフォルトなので、別のスレッドで実行させるために、 DispatchQueue.global キューにサブミットされる非同期クロージャの中に計算コールをラップします。ここではスレッドを管理しているわけではないことに注意してください。ある時点での実行が保証されているという前提で、(クロージャやブロックの形で)タスクを目的のキューに投入しているのです。キューは、どのスレッドにタスクを割り当てるかを決定し、システム要件の評価と実際のスレッドの管理という大変な作業を全て行います。これが Grand Central Dispatch のマジックです。古い格言にあるように、測定できないものは改善できません。というわけで、本当にひどいボタンクリックハンドラーを測定してみました。

プロファイラーをもう一度見てみると、これが大きな改善であることは一目瞭然です。タスクには同じ時間がかかりますが、今回はUIをロックすることなくバックグラウンドで実行されています。私たちのアプリは同じ量の仕事をしているにもかかわらず、アプリが処理をしている間、ユーザーは自由に他のことをすることができるので、知覚されるパフォーマンスははるかに向上しています。

.userInitiated 優先度のグローバルキューにアクセスしたことに気づいたかもしれません。これは、タスクに緊急性を与えるために使える属性です。同じタスクを .userInitiated priority のグローバル・キューで実行し、 background の qos 属性を渡すと、iOSはそれをユーティリティ・タスクだと考え、実行に割り当てるリソースを少なくします。つまり、タスクがいつ実行されるかはコントロールできないですが、優先順位はコントロールできるということです。

メインスレッドとメインキューの違いについて

プロファイラに "Main Thread" と表示され、なぜ "Main Queue" と呼ぶのか不思議に思うかもしれません。上で説明した GCD アーキテクチャを参照すると、メイン・スレッドを管理するのはもっぱらメイン・キューです。同時実行プログラミングガイドのディスパッチキューセクションには、「メインディスパッチキューは、アプリケーションのメインスレッド上でタスクを実行するグローバルに利用可能なシリアルキューである」と書かれています。これはアプリケーションのメインスレッド上で実行されるため、メインキューはしばしばアプリケーションの重要な同期ポイントとして使用されます。"

メイン・スレッド上で実行する "と "メイン・キュー上で実行する "という用語は、
同じ意味で使うことができます。

同時実行キュー

これまでのところ、タスクはもっぱらシリアルで実行されてきました。 DispatchQueue.main はデフォルトでシリアル・キューであり、 DispatchQueue.global はあなたが渡した優先度パラメータに応じて4つの同時ディスパッチ・キューを提供します。

例えば、5枚の画像を撮影し、バックグラウンドのスレッドで並列処理させたいとします。そのためにはどうすればいいでしょうか?カスタム並行キューを任意の識別子で立ち上げ、そこにタスクを割り当てることができます。必要なのは、キューの作成時に .concurrent 属性を指定することだけです。

class ViewController: UIViewController {
    let queue = DispatchQueue(label: "com.app.concurrentQueue", attributes: .concurrent)
    let images: [UIImage] = [UIImage].init(repeating: UIImage(), count: 5)

    @IBAction func handleTap(_ sender: Any) {
        for img in images {
            queue.async { [unowned self] in
                self.compute(img)
            }
        }
    }

    private func compute(_ img: UIImage) -> Void {
        // Pretending to post-process a large image.
        var counter = 0
        for _ in 0..<9999999 {
            counter += 1
        }
    }
}

これをプロファイラーにかけると、
アプリが for ループを並列化するために5つのスレッドを回転させていることが分かります。

N個のタスクの並列化

ここまでは、 UI スレッドを詰まらせることなく、計算量の多いタスクをバックグラウンドスレッドにプッシュする方法について見てきました。しかし、並列タスクを制限付きで実行するのはどうでしょう? Spotify が複数の曲を並列にダウンロードし、その最大数を3つまでに制限するにはどうすればいいでしょうか?これにはいくつかの方法がありますが、マルチスレッドプログラミングにおけるもう一つの重要な構成要素であるセマフォ (https://en.wikipedia.org/wiki/Semaphore_(programming)) を探求する良い機会です。

セマフォは信号伝達メカニズムです。セマフォは、共有リソースへのアクセスを制御するためによく使われます。
あるスレッドがコードのあるセクションへのアクセスをロックし、実行が終わるとロックを解除して他のスレッドに
そのセクションを実行させるというシナリオを想像してみてください。このような動作は、たとえばデータベースの書き込みや読み込みで見られます。1つのスレッドだけがデータベースに書き込み、その間の読み取りは禁止したい場合はどうすればいいでしょうか?これは、リーダーズ・ライター・ロック (https://en.wikipedia.org/wiki/Readers–writer_lock) と呼ばれるスレッドセーフティにおける一般的な懸念事項です。セマフォは、n個のスレッドをロックできるようにすることで、アプリの並行性を制御するのに使うことができます。

let kMaxConcurrent = 3 // Or 1 if you want strictly ordered downloads!
let semaphore = DispatchSemaphore(value: kMaxConcurrent)
let downloadQueue = DispatchQueue(label: "com.app.downloadQueue", attributes: .concurrent)

class ViewController: UIViewController {
    @IBAction func handleTap(_ sender: Any) {
        for i in 0..<15 {
            downloadQueue.async { [unowned self] in
                // Lock shared resource access
                semaphore.wait()

                // Expensive task
                self.download(i + 1)

                // Update the UI on the main thread, always!
                DispatchQueue.main.async {
                    tableView.reloadData()

                    // Release the lock
                    semaphore.signal()
                }
            }
        }
    }

    func download(_ songId: Int) -> Void {
        var counter = 0

        // Simulate semi-random download times.
        for _ in 0..<Int.random(in: 999999...10000000) {
            counter += songId
        }
    }
}

ダウンロード・システムが、ダウンロード数kに制限されるように効果的に制限していることに注目してください。1つのダウンロードが終了した瞬間(あるいはスレッドが実行を終了した瞬間)、セマフォをデクリメントし、管理キューが別のスレッドを起動して別の曲のダウンロードを開始できるようにします。同じようなパターンを、同時読み取りと同時書き込みを扱うデータベース・トランザクションに適用することができます。

セマフォは通常、この例のようなコードには必要ありませんが、非同期APIを消費しながら同期動作を強制する必要がある場合に威力を発揮します。上記は、 maxConcurrentOperationCount を持つカスタムの NSOperationQueue でも同じように動作するでしょうが、いずれにしても有意義な余談です。

OperationQueue による より細かい制御

GCD は、単発のタスクやクロージャを "set-it-and-forget-it" 方式でキューにディスパッチしたいときには最適で、非常に軽量な方法を提供してくれます。しかし、反復可能で構造化され、関連するステートやデータを生成するような長期的なタスクを作りたい場合はどうでしょう?そして、この一連のオペレーションをモデル化し、キャンセル、中断、追跡が可能で、なおかつクロージャフレンドリーな API を使いたいとしたらどうでしょう?次のような操作を想像してみてください。

これを GCD で実現するのはかなり面倒です。もっとモジュール化された方法で、読みやすさを保ちながら、より多くの制御を公開できるタスク・グループを定義したい。この場合、 Operation オブジェクトを使い、 DispatchQueue の上位ラッパーである OperationQueue にキューイングすることができます。これらの抽象化を使用する利点と、低レベルの GCI API と比較して提供されるものを見てみましょう。

・ タスク間の依存関係を作成したいかもしれません。 GCD を使用してこれを行うこともできますが、Operation オブジェクト、または仕事の単位として具体的に定義し、独自のキューにプッシュする方が良いでしょう。そうすれば、アプリケーションの別の場所で同じパターンを使うことができるので、再利用性を最大限に高めることができます。

・ Operation クラスと OperationQueue クラスには、 KVO(Key Value Observing) を使って観察できるプロパティがいくつかあります。これは、オペレーションやオペレーション・キューの状態を監視したい場合に、もう1つの重要な利点です。

・ オペレーションは一時停止、再開、キャンセルが可能です。 Grand Central Dispatch を使用してタスクをディスパッチすると、そのタスクの実行を制御したり洞察したりすることはできなくなります。その点、 Operation API はより柔軟で、開発者はオペレーションのライフサイクルを制御できます。

・ OperationQueue では、キューに入れられたオペレーションを同時に実行できる最大数を指定することができ、同時実行の側面をより細かく制御することができます。

Operation と OperationQueue の使用法については、ブログ記事全体を埋め尽くすことができますが、依存関係のモデリングがどのようなものか、簡単な例を見てみましょう。( GCD でも依存関係を作成することはできますが、大きなタスクを一連の構成可能なサブタスクに分割する方がよいでしょう)。互いに依存し合う操作の連鎖を作るには、次のようにします。

class ViewController: UIViewController {
    var queue = OperationQueue()
    var rawImage = UIImage? = nil
    let imageUrl = URL(string: "https://example.com/portrait.jpg")!
    @IBOutlet weak var imageView: UIImageView!

    let downloadOperation = BlockOperation {
        let image = Downloader.downloadImageWithURL(url: imageUrl)
        OperationQueue.main.async {
            self.rawImage = image
        }
    }

    let filterOperation = BlockOperation {
        let filteredImage = ImgProcessor.addGaussianBlur(self.rawImage)
        OperationQueue.main.async {
            self.imageView = filteredImage
        }
    }

    filterOperation.addDependency(downloadOperation)

    [downloadOperation, filterOperation].forEach {
        queue.addOperation($0)
     }
}

では、なぜより高度な抽象化を選択し、 GCD を完全に使用しないのでしょうか? GCD はインラインの非同期処理に理想的ですが、 Operation は、アプリケーション内の構造化された反復可能なタスクの周りの全てのデータをカプセル化するための、より包括的な、オブジェクト指向の計算モデルを提供します。開発者は、与えられた問題に対して可能な限り高い抽象度を使用する必要があり、一貫性のある繰り返し作業のスケジューリングには、その抽象度が Operation となります。一方、単発のタスクやクロージャのために GCD を使うこともあります。 OperationQueue と GCD の両方を混ぜることで、両方の長所を生かすことができます。

同時実行のコスト

DispatchQueue とその仲間たちは、アプリケーション開発者がコードを簡単に同時実行できるようにするためのものです。しかし、これらの技術は、アプリケーションの効率や応答性の向上を保証するものではありません。効果的で、かつ他のリソースに過度な負担をかけないような方法でキューを使用するかどうかは、あなた次第です。例えば、10,000個のタスクを作成し、それをキューに投入することは十分に可能ですが、そうすることは、自明ではない量のメモリを割り当て、オペレーション・ブロックの割り当てと割り当て解除のために多くのオーバーヘッドを導入することになります。これは、あなたが望むこととは正反対です!アプリを徹底的にプロファイリングして、同時実行がアプリのパフォーマンスを向上させ、劣化させないことを確認するのが最善です。

これまで、並行処理には複雑さやシステム・リソースの割り当てといったコストがかかることを説明してきましたが、並行処理を導入することで、以下のようなリスクも発生します。

・ デッドロック

スレッドがコードの重要な部分をロックし、アプリケーションの実行ループを完全に停止させてしまう状況。GCDのコンテキストでは、 dispatchQueue.sync { } コールを使用する際は、2つの同期処理が互いに待ち状態になる可能性があるため、十分に注意する必要があります。

・ 優先順位の逆転

優先順位の低いタスクが優先順位の高いタスクの実行をブロックすること。 GCD はバックグラウンドキューで異なるレベルの優先度を許すので、このようなことは簡単に起こりえます。

・ Producer-Consumer問題

あるスレッドがデータリソースを作成している間に、別のスレッドがそれにアクセスするという競合状態。これは同期の問題であり、ロック、セマフォ、シリアル・キュー、または GCD で並行キューを使用している場合はバリア・ディスパッチを使って解決できます。

・ ......その他、デバッグが困難なロックやデータ競合状態がたくさんあります!スレッドの安全性は、並行処理を行う際に最も重要な関心事です。

お別れの言葉+続きの読み物

ここまでたどり着いたなら、拍手を送りたい。この記事で、 iOS のマルチスレッド技術と、そのいくつかをあなたのアプリでどのように使うことができるかについて、一通り理解していただけたと思います。ロックやミューテックスといった低レベルのコンストラクトの多くや、それらが同期を実現するのにどのように役立つのか、また同時実行がアプリにどのようなダメージを与えるかについての具体的な例については、まだカバーできていません。そのようなことはまた別の日にするとして、より深く知りたいのであれば、追加の本やビデオを読むといいでしょう。

記事のリンク

Building Concurrent User Interfaces on iOS (WWDC 2012)
https://developer.apple.com/videos/all-videos/

Concurrency and Parallelism: Understanding I/O
https://blog.risingstack.com/concurrency-and-parallelism-understanding-i-o/

Apple's Official Concurrency Programming Guide
https://developer.apple.com/library/archive/documentation/General/Conceptual/ConcurrencyProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008091-CH1-SW1

Mutexes and Closure Capture in Swift
https://www.cocoawithlove.com/blog/2016/06/02/threads-and-mutexes.html

Locks, Thread Safety, and Swift
https://www.mikeash.com/pyblog/friday-qa-2017-10-27-locks-thread-safety-and-swift-2017-edition.html

Advanced NSOperations (WWDC 2015)
https://developer.apple.com/videos/wwdc2015

NSHipster: NSOperation
https://nshipster.com/nsoperation/

【翻訳元の記事】

https://www.viget.com/articles/concurrency-multithreading-in-ios/

Discussion