WWDC21で発表され、もはやどこでも発表されつくされた感のあるasync / awit。
遅ればせながら私も async/awaitをやっときちんと調べましたので、記事の形にしてみようと思います。
1. 非同期処理をclosure(もしくはdelegate)を利用して書く場合の注意点
例えば、下記のようなコードを書きたいとします。よくある非同期処理ですね。
func someLongLongTimeTask(completion: @escaping ((Data) -> Void), errorHandler: @escaping ((Error) -> Void)) {
precheck()
guard isReady else {
errorHandler(LongLongFuncError.setupFailure)
return
}
someLongLongLong(completion: { data in
guard self.validate(of: data) else {
errorHandler(LongLongFuncError.dataValidationFailure)
return
}
guard self.updated else {
errorHandler(LongLongFuncError.selfUpdateFailure)
return
}
completion(data)
})
}
上記のコードでは、処理の完了やエラーの発生を引数として渡しているclosureで管理しようとしています。
ですが、closureは引数として渡されているため、Swiftのコンパイラ側で発火を保証することができません。
例えばこのコードは下記のように書き換えたとしても正常に動作します。
func someLongLongTimeTask(completion: @escaping ((Data) -> Void), errorHandler: @escaping ((Error) -> Void)) {
precheck()
guard isReady else {
return
}
someLongLongLong(completion: { data in
guard self.validate(of: data) else {
return
}
guard self.updated else {
return
}
completion(data)
})
}
上記コードはerrorHandler
の呼び出しをサボっただけのコードですが、この場合呼び出し元はエラーの発生を検知できません。その結果プログレスバーが表示されたままになるなどのバグが生まれる可能性があります。
またエラーの発生が極めて限定的な状況下で発生する場合、動作テストや社内テストで検知することができず、お客様トラブルへと発展する恐れがあります。
上記のテストコードはclosureを利用したコードでしたが、これをdelegateを利用して読み替えたとしても同様にdelegateの発火を強制することはできませんし、コンパイラに確認させることもできません。
このように、closure(もしくはdelegate)を利用した非同期処理はその性質上注意深く実装し、またテストを十二分に行わないと容易にバグを生む可能性がある コードになります
この問題を解決するのが async/awaitです。
2. 上記のコードをasync/awaitを利用して書き換えてみる
上記のコードをasync/awaitを利用して書き換えてみると下記のようになります。
func someLongLongTimeTask() async throws -> Data {
precheck()
guard isReady else { throw LongLongFuncError.setupFailure }
let data = try await someLongLongLong()
guard validate(of: data) else { throw LongLongFuncError.dataValidationFailure}
guard updated else { throw LongLongFuncError.selfUpdateFailure}
return data
}
上記のようにasync/awiatを利用してコードを書き換えると下記のようなメリットが生まれます
- closureを利用することによるコードのネスト構造はなくなり、直線的なコードになりました。
- また、guard節を利用したvalidationも明快になり、同期関数と似たような見た目になりました
- そしてさらに、Swiftのコンパイラがエラーのthrowかデータの返却を確認するようになりました。
例えば、下記のようなコードはコンパイルが通りません。これはつまり、SwiftコンパイラによってErrorのThrowを保証できているということになります。
func someLongLongTimeTask() async throws -> Data {
precheck()
guard isReady else { return }
let data = try await someLongLongLong()
guard validate(of: data) else { return }
guard updated else { return }
return data
}
このように、非同期処理にasync/awaitを利用することで、closureやdelegateを利用した非同期処理より、よりかんたんに、そしてより安全にコードを記述することができます。
3. async/awaitの注意点
async/awaitはこのように安全にコードを書くことができますが、いつ処理が発火されるかわからないため、状態管理やスレッドには十分に注意を払う必要がある という点には注意が必要です。
async/awaitを利用すると、それぞれの処理がいつ行われるかはOSによって決定されるため、実行されるThreadは多くの場合呼び出しスレッドとは違うでしょうし、もしかしたら更新中の配列に同時アクセスしてデータ競合や内部状態の破損をもたらしてしまうかもしれません。
このような問題の多くはasync/awaitと同時に発表されたactorという概念を利用することで解決できます
(後日こちらについても記事を書く予定です)
4. おわりに
このようにasync/awaitを利用して非同期処理を記述すると、closure /delegateを利用して書く場合に比べてよりかんたんに、そして安全にコードを書くことができます。
データの非同期処理をかんたんにするためにSingleを利用していたプロジェクトなどは、async/awaitに切り替えることで外部ライブラリの仕様を減らすことができ、良いのではないでしょうか。
(私が参画しているプロジェクトでも、十分に検討した上で随時async/awaitへの移行を進めようと考えています)