GCDを使った非同期処理について改めて調べてみた

大阪オフィスの山田です。久しぶりの執筆です。iOSアプリを開発する際のGCDを使った非同期処理について、理解があやふやだったので改めて調べて色々と試してみました。備忘録です。

GCDの基礎知識

GCDについて

GCDとはGrand Central Dispatchの略です。ディスパッチキューにタスクを詰めると、タスクを実行してくれます。

キューのタスク処理について

キューのタスク処理の方法は2種類あります。

  • Serial(直列)
  • Concurrent(並列)

Serialは、前のタスクが完了次第、次のタスクが実行されます。そのため、同時に実行されるタスクは1つです。Concurrentは、前のタスクの処理状況に関わらず、次のタスクが実行されます。

キューの種類について

キューの種類は3つに分けられます。

  • Main Queue
    • Serial(直列)
  • Global Queue
    • Concurrent(並列)
  • Private Queue
    • Serial, Concurrent

このうち、Main QueueGlobal Queueはシステムによってすでに作成されています。Private Queueでは開発者が自由に設定したQueueを作成することができます。

タスクの実行

タスクの実行をする際には同期か非同期かを指定します。

  • sync
  • async

syncを使う場合には、呼び出し元のスレッドをブロックするため、デッドロックが発生することがあるので注意が必要です。

実際に動かしてみる

開発環境

本ブログに記載のソースコードは、以下の環境で動かしています。

OS: 10.14.3
Xcode: 10.2
Swift: 5

Global Queueを使う

Global Queueを使ってログを吐き出してみます。

let queue = DispatchQueue.global(qos: .default)
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.async {
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
    }
}
print("----- \(queue.label) end -----")
----- com.apple.root.default-qos start -----
----- com.apple.root.default-qos end -----
com.apple.root.default-qos: 0
com.apple.root.default-qos: 8
== 省略 ==
com.apple.root.default-qos: 84
com.apple.root.default-qos: 91

ログを見るとまず、startとendのログが出力されていて、タスクが非同期で実行されていることがわかります。その後、queueに渡したタスクからログが出力されています。Global Queueは並列で処理を行うため、ログに出力されている数字が、順序よく昇順には並んでいません。

ブレークポイントを作成し、デバッグしてみると、スレッドがたくさん生成されていることがわかります。また、この例だとMain ThreadからEnqueueされていることもわかります。

Global Queueの優先度について

Global Queueを使用する際には優先度を指定することができます。以下が優先度の一覧で、上に位置するほど、優先度が高くなります。

  • userInteractive
  • userInitiated
  • default
  • utility
  • background
  • unspecified

Private Queueを使う

次はPrivate Queueを使います。今回は直列にタスクを処理するQueueを用意します。

let queue = DispatchQueue(label: "com.yamada.queue")
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.async {
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
    }
}
print("----- \(queue.label) end -----")
----- com.yamada.queue start -----
----- com.yamada.queue end -----
com.yamada.queue: 0
com.yamada.queue: 1
com.yamada.queue: 2
== 省略 ==
com.yamada.queue: 96
com.yamada.queue: 97
com.yamada.queue: 98

今回用意したQueueは、直列にタスクを処理するQueueなのでログに表示されている数字が順序よく昇順に並んでいます。Private Queueは直列、並列どちらも用意することができます。並列のPrivate Queueを用意するには以下のようにattributeの値を指定します。

let queue = DispatchQueue(label: "com.yamada.queue", attributes: DispatchQueue.Attributes.concurrent)

syncを使ってみる

先ほどの例はasyncを使っていたため、非同期でタスクが実行されていました。今度はsyncを使ってタスクを実行してみます。

let queue = DispatchQueue(label: "com.yamada.queue")
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.sync {
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
    }
}
print("----- \(queue.label) end -----")
----- com.yamada.queue start -----
com.yamada.queue: 0
com.yamada.queue: 1
com.yamada.queue: 2
== 省略 ==
com.yamada.queue: 96
com.yamada.queue: 97
com.yamada.queue: 98
----- com.yamada.queue end -----

ログを見るとわかる通り、endのログが出力される前に、全てのタスクのログが出力されています。呼び出し元はブロッキングされてタスクが終わるのを待っていることがわかります。メインスレッドからsyncを使うとメインスレッドがブロッキングされるため、UIが固まります。

Dead Lockしてみる

syncを使うとDead Lockが発生する可能性がある旨を記述しましたが、実際にDead Lockをさせるコードを書いて動かしてみました。

let queue = DispatchQueue(label: "com.yamada.deadlock")
for i in 0..<99 {
    queue.sync {    // 1.
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
        queue.sync {    // 2.
            print("hogehoge")
        }
    }
}

上記のコードを動かすと、1, 2の処理と進みますが、2の処理は1の処理が終わるまで開始されず、1の処理は2の処理が開始された際にブロッキングされて終わるのを待つため、お互いのタスクが終わるのを待つ状態になってしまいDead Lockとなります。実際に動かすと2の部分で実行時エラーが発生します。

Main Queueに戻してUIに反映する

非同期で処理を行い、その結果をUIに反映するコードは以下のように書けます。

let queue = DispatchQueue(label: "com.yamada.queue")
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.async { [weak self] in
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
        DispatchQueue.main.async { [weak self] in
            self?.logLabel.text = log
        }
    }
}
print("----- \(queue.label) end -----")

Private Queueを作成し、非同期で処理をさせ、処理が終わり次第、Main Queueに戻してUILabelにテキストを入れています。UIの更新はメインスレッドで行う必要があります。

UIの描画にMain Queueを使わなかった場合はどうなるか

以下のようなコードを書いて動かしてみます。

let queue = DispatchQueue(label: "com.yamada.queue")
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.async { [weak self] in
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
        self?.logLabel.text = log  // メインスレッド以外からUIを更新
    }
}
print("----- \(queue.label) end -----")

動かしてみると、落ちはしませんでしたが以下のようなログが出ました。

Main Thread Checker: UI API called on a background thread: -[UILabel setText:]

Xcode上にもその旨が表示されました。Xcodeに怒られないようにしましょう。

並列に処理を走らせて全て完了するのを待つ

DispatchGroupを使用します。DispatchGroupはいくつかのタスクを集約的に扱って同期させることができます。具体的には完了時に任意のコードを実行することや、一連のタスクが完了するのを待つことができます。

let group = DispatchGroup()
let queue = DispatchQueue(label: "com.yamada.queue", attributes: DispatchQueue.Attributes.concurrent)
print("----- \(queue.label) start -----")
for i in 0..<99 {
    queue.async(group: group) {
        sleep(1)
        let log = "\(queue.label): \(i)"
        print(log)
    }
}

group.notify(queue: DispatchQueue.main) { [weak self] in
    print("----- notify -----")
    self?.logLabel.text = "notify"
}
print("----- \(queue.label) end -----")
----- com.yamada.queue start -----
----- com.yamada.queue end -----
com.yamada.queue: 4
com.yamada.queue: 7
== 省略 ==
com.yamada.queue: 83
com.yamada.queue: 88
----- notify -----

上記の例では、並列処理を実行するキューを用意しています。startとendのログ出力が先に実行され、queueに詰めたタスクが全て終わった際にgroupのnotifyメソッドに通知がされ、指定したクロージャが実行されています。その際に、実行するタスクのキューはMain Queueを指定しています。

リンク集

執筆する上で、下記のドキュメントと記事を参考にさせて頂きました。