しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

DispatchQueueによる非同期処理を見直す

他言語から入ると、一見取っつきづらいDispatchQueueですが、だいぶ浸透してきた気がします。
一方で、簡単にマルチスレッドで非同期処理ができるようになった結果、処理フローの制御がしづらくなったり、 知らないうちに、CPUやメモリリソースを異常に消費したりなど、副次的な問題が出てくることがあります。

それらの問題に対する、解決策を考えてみます。


TL;DR

半分くらい当時のチームへの愚痴で自分で読み返して何が言いたいかわからなかったので、当時の気持ちを思い出しながら言いたいことベスト3を加筆しました。

  • UIの更新など必要な場所以外ではmain.asyncやめよう、マジで
  • 時間のかかる処理はサブスレッドをうまく活用しよう
  • 何でもかんでも非同期処理(特に並列処理)に回してるとメンテナンスコストかかるぜ!

GCD復習

Grand Central Disptach(GCD)は、従来のマルチスレッドのAPIとは異なり、Dispatch Queueというキューによって、OSが適切な優先度や実行スレッドを決めて、タスクを実行する仕組みです。

開発者は、実行したいブロックを、DispatchQueueに追加するだけで、細かいスレッドの区分を意識することなく、メインスレッドとサブスレッドの区別だけで、並列処理や非同期処理を書くことができます。

問題

ここで生じる問題は、

  • asyncの使いすぎによって、非同期処理の実行や完了の流れが制御しきれなくなる
  • 実行のタイミングの制御が見えないので、過剰にリソースを使う処理を書いても気づかない場合がある

ということです。
(実際、ライブラリに依存せずDispatchQueueを使っているチームや、RxSwiftをメインに使っているチームでも、メインスレッドで非同期処理が走りまくりパフォーマンスが落ちるというケースはありました。)

方針

本当にasyncが必要なのか考える

まず、非同期な並列処理は、処理が複雑になり完了のタイミングや順番は事前に予測しづらいので、いつどのような順番で終わってもいいような処理を書かなくてはなりません。

これは、上記AppleのDefine Your Application’s Expected Behaviorのセクションにも書かれています。

Before you even think about adding concurrency to your application, you should always start by defining what you deem to be the correct behavior of your application. Understanding your application’s expected behavior gives you a way to validate your design later. It should also give you some idea of the expected performance benefits you might receive by introducing concurrency.

非同期の並列処理を増やせば増やすほど、処理の完了の順番の組み合わせが増え、正しい振る舞いを気にしたプログラムやテストを書かなくてはなりません。
どうしてもパフォーマンス的にasyncでなければいけない処理以外は、同期処理や直列処理の方が実装、運用コストも減るはずです。

非同期処理自体の数を減らしたりサブスレッド上の直列処理にすることで、結果を利用するコードもシンプルになります。

main.asyncに気をつける

iOSではUIの処理は、メインスレッドで実行しなくてはなりません。

逆にそれ以外の処理で、DispatchQueue.main.asyncを使うと、UIの処理をブロックしてしまうことがあります。
したがって、サブスレッドのタスク内でのUIの更新などを除き、本当に限られた場面でしか、わざわざmain.asyncを使う場面はないはずです。

サブスレッドのタスク内でUIの更新を内包している場合は、そこだけメインスレッド(メインキュー)で行なったほうが良いです。 それ以外の重い処理は、グローバルキューなどで行います。

DispatchQueue.global(qos: .userInitiated).async {
    // UI以外の処理...
    DispatchQueue.main.async {
        // UIの更新(それ以外は書かない)
    }
}

ここを適当にしてしまうと、意図せずUIの処理が重くなり、アプリの見栄えも悪くなります。
ライブラリを使っている場合、暗黙のうちに使ってしまっている場合もあります。

QoSを指定する

ブラックボックスの部分なので、どのくらい効果があるのかわかりませんが、グローバルキューのQoS(Quality of Service)はきちんと指定しておきましょう。

QualityOfService - Foundation | Apple Developer Documentation

指定できるものとしては、4種類あります。

優先度 QoS 説明 Int値
userInteractive ユーザーにただちに処理の結果を返す必要がある UI用の計算 33
userInitiated ユーザーに数秒以内に処理の結果を返す必要がある メールのフェッチ 25
utility 数十秒〜数分に一度動かすくらい 定期的なリスト更新 17
background 終わらなくてもユーザーが気にならない バックアップ処理 9

globalキューや、定義したキューを使う際は、指定しない場合defaultとなってしまいます。
特に急ぎでない処理は、utilitybackgroundを指定しましょう。

DispatchQueue.global(qos: .utility).async
// ついでにラベルもつけよう
let queue = DispatchQueue(label: "com.hogehoge.fuga", qos: .utility)

DispatchGroupも使う

DispatchGroupを使うことで、複数のキューのタスクが全て終わるのを検知することができます。
どうしてもキューが立ちすぎた時は、できるだけまとめましょう。

DispatchGroup - Dispatch | Apple Developer Documentation

let dispatchGroup = DispatchGroup()
let queue = DispatchQueue(label: "com.hogehoge.fuga", qos: .userInteractive)
let anotherQueue = DispatchQueue(label: "com.hogehoge.foo", qos: .userInteractive)

queue.async(group: dispatchGroup) {
    // 処理
}
anotherQueue.async(group: dispatchGroup) {
    // 処理
}

dispatchGroup.notify(queue: .main) {
    // タスク完了後の処理(mainキューで実行)
}

ダメそうだったら

どうしてもダメそうだったら、各種非同期ライブラリの導入を検討した方がいいかもしれません。

github.com

github.com

github.com

BoltsやHydraは直列処理が多い部分に強いです。逆にRxSwiftは複数の並列処理の結果を組み合わせるのに強いです。
中の実装はDispatchQueueに依存しているものが多いので、メインスレッドとサブスレッドをしっかり使い分ける必要があります。

ただ、ライブラリは記述の助けにしかならないので、処理を非同期にするかどうかはしっかり吟味した上で、設計はした方が良いと思います。(膨大な負債になります😭)