Task.initのクロージャーに[weak self]はいらない。Task.detachedとTaskGroup.addTaskも同様
2022年7月29日、インプレスR&D社よりSwift Concurrencyの解説本をリリースしました。
一冊でマスター!Swift Concurrency入門です。
こちらの本は一冊でSwift Concurrencyの機能をほぼ網羅したConcurrency機能の解説本です。
日本語でSwift Concurrencyを学べる解説本はまだ少ないので、Swift 5.5からの非同期処理をうまく書きたい方には必見の本となっています。
詳しい内容はこちらをご覧ください。
同人誌として先にリリースしましたが、商業版も同じ内容となっています。
同人版はBOOTHで販売中です。
さて、せっかくリリースしたばかりなのですが、一部のサンプルコードがあまり良いコードではありませんでした。
すでにサンプルコードのリポジトリは修正済みですが、どのような修正があったのかをこの記事で説明したいと思います。
Task.initのクロージャーに[weak self]はいらない
もともとのサンプルコードではこのようにTask.init
のクロージャーに[weak self]
をつけていました。
[weak self]
をして素朴に循環参照を回避しようとしたコードにしていました。
Task { [weak self] in
let mypageData = await self?.fetchMyPageData()
print(mypageData ?? "")
}
ところが、Task.init
のクロージャー内でself
を参照しても循環参照の恐れがありませんでした。
しかも@_implicitSelfCaptureという特別な属性が機能していて、クロージャー内に明示的にself
と書かなくてもコンパイルが通ります。
Task {
let mypageData = await fetchMyPageData() // self.fetchMyPageData()でなくてよい
print(mypageData)
}
なぜこの書き方ができるのかを順を追って説明します。
エスケープクロージャのおさらい
Task.init
の定義をXcodeの定義ジャンプで見てみます。
extension Task where Failure == Never {
public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)
}
operation
引数はクロージャーになっていて@escaping
属性がついていることが分かります。
@escaping
属性をおさらいしてみましょう。
@escaping
属性は関数の引数として渡されたクロージャが、関数本文が終了した後に呼び出される場合につけられる属性です。
@escaping
属性をつけられたクロージャー内ではself
インスタンスのアクセスは明示的にself
をつけないとコンパイルエラーになります。
これは誤って循環参照が発生しないように開発者に注意を促す目的があります。
例えば、次のようなコードがあるとします。
class SampleClouser {
var value: Int = 0
var completion: () -> Void = {}
func add(value: Int, completion: @escaping () -> Void) {
self.value += value
completion()
}
func addOne() {
add(value: 1) {
print(value) // Reference to property 'value' in closure requires explicit use of 'self' to make capture semantics explicit
}
}
init() {}
}
add
メソッドの引数completion
は@escaping
属性がついたクロージャーです。
別のメソッドaddOne
でadd
メソッドを呼び出した際にクロージャー内でself
のインスタンス、ここではvalue
を参照しています。
しかし、このままですと循環参照の危険があるためコンパイルエラーとなります。
Reference to property 'value' in closure requires explicit use of 'self' to make capture semantics explicit
// [筆者訳] クロージャー内の`value`プロパティの参照は明示的にselfを使用し、明示的なキャプチャを作成してください。
ただし、そのままvalue
プロパティにself
をつけると循環参照が起こり、メモリリークが発生します。
func addOne() {
add(value: 1) {
print(self.value)
}
}
理由は、self
であるSampleClouser
がcompletion
クロージャーをプロパティとして強参照している一方で、completion
クロージャーもクロージャー内でself
を強参照しており、これが循環参照を引き起こすからです。
@escaping
属性がついたクロージャーでよく行う循環参照の解消方法は[weak self]
でself
をキャプチャする方法です。
これによって、循環参照を起こさずself
のインスタンスにクロージャー内でアクセスができるようになります。
func addOne() {
add(value: 1) { [weak self] in
print(self?.value ?? "")
}
}
エスケープクロージャ自体を解説はThe Swift Programming Language(日本語版)を参照ください。
https://www.swiftlangjp.com/language-guide/closures.html#エスケープクロージャescaping-closures
Task.initのクロージャーのself参照
ところが、Task.init
のクロージャー内では@escaping
属性があるにも関わらず、明示的なself
参照を考慮しなくても循環参照の問題が発生しません。
Taskが提案されたSE-304 Structured concurrencyのプロポーザルを見てみましょう。
Implicit "self" という節に書かれています。
The intent behind requiring self. when capturing self in an escaping closure is to warn the developer about potential reference cycles. The closure passed to Task is executed immediately, and the only reference to self is what occurs in the body. Therefore, the explicit self. isn't communicating useful information and should not be required.
[筆者訳]エスケープクロージャ内でselfをキャプチャする際にself.が必須な背景は開発者に循環参照の可能性を警告するためです。一方でTaskに渡されたクロージャーは即時に実行され、selfへの唯一の参照はクロージャー本文で発生するだけです。したがって、selfを明示することは有効な情報を伝えておらず、必須にすべきでもありません。
Task.init
のクロージャーは即時に実行され、self
インスタンスへの循環参照の恐れがないので暗黙的にself
参照しても問題ないという記載がされています。
更にSwiftのリポジトリからTask.init
の定義をみてみましょう。
extension Task where Failure == Error {
@discardableResult
@_alwaysEmitIntoClient
public init(
priority: TaskPriority? = nil,
@_inheritActorContext @_implicitSelfCapture operation: __owned @Sendable @escaping () async throws -> Success
)
operation
クロージャーの引数の前に@_implicitSelfCapture
という属性がついています。
アンダースコアがついている属性は正式な言語機能としてはまだ議論中の属性です。
@_implicitSelfCapture
はself
が参照型であってもクロージャー内でキャプチャをしなくてもself
にアクセスができるようになる属性です。
class C {
func f() {}
func g(_: @escaping () -> Void) {
g({ f() }) // error: call to method 'f' in closure requires explicit use of 'self'
}
func h(@_implicitSelfCapture _: @escaping () -> Void) {
h({ f() }) // ok
}
}
Task.init
のクロージャーには@_implicitSelfCapture
がつけられていたため、明示的にself
をつけなくてもself
のメソッドやプロパティにアクセスができたということです。
冒頭のサンプルコードをもう一度掲載します。
Task {
let mypageData = await fetchMyPageData() // self.fetchMyPageData()でなくてよい
print(mypageData)
}
fetchMyPageData
メソッドはself
に定義されているメソッドです。
通常の@escaping
がつけられたクロージャーではself.fetchMyPageData()
としなければコンパイルエラーになりますが、Task.init
は@_implicitSelfCapture
もつけられているため、self
をつけなくてもコンパイルエラーが起こらないのです。
Xcodeの定義ジャンプでは@_implicitSelfCaptureが確認できない
ただ、奇妙なことはXcodeの定義ジャンプではTask.init
の定義に@_implicitSelfCapture
のついていることは確認できませんでした。
手元で確認したのは次のバージョンです。
- Xcode 13.4.1
- Xcode 14 beta4
このようにoperation
引数は@escaping
がつけられた通常のクロージャーにみえます。
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
extension Task where Failure == Never {
public init(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Success)
}
アンダースコアがつけられた属性は正式な言語機能として決定したものではないので、定義ジャンプからでは隠されてしまっているのかもしれません。
Task.init
でクロージャー内にself
キャプチャが不要な理由は@_implicitSelfCapture
が付与されていることを覚えておきましょう。
そのうち正式な属性になった際にはXcodeからでも確認ができるようになるでしょう。
Task.detachedとTaskGroup.addTaskのクロージャーも[weak self]いらない
SE-304のImplicit "self"節では、Task.init
の他にもTask.detached
とTaskGroup.addTask
のクロージャーにもself
の明示的なキャプチャは必要ないと書かれています。
つまりこの2つのクロージャー引数も[weak self]
はいりません。
なので次のようにTask.detached
で[weak self]
でクロージャー内でself
キャプチャを弱参照にする必要もありません。
// ✗ クロージャーで[weak self]する必要なし
Task.detached(priority: .low) { [weak self] in
guard let self = self else { return }
async let _ = await self.sendLog(name: "didTapButton")
}
// ○ クロージャー内でselfに直接アクセスしてよい
Task.detached(priority: .low) {
async let _ = await self.sendLog(name: "didTapButton")
}
しかし、Task.init
とは異なり暗黙的なself
キャプチャはできないようです。
SwiftのリポジトリでTask.detached
の定義をみてみます。
@discardableResult
@_alwaysEmitIntoClient
public static func detached(
priority: TaskPriority? = nil,
operation: __owned @Sendable @escaping () async -> Success
) -> Task<Success, Failure> {
Task.init
とは異なりoperation
クロージャーの引数に@_implicitSelfCapture
属性はつけられていませんでした。
ですので、クロージャー内では明示的にself
を記述が必要です。
// 暗黙的なselfキャプチャはできない。
Task.detached(priority: .low) {
async let _ = await sendLog(name: "didTapButton") // Call to method 'sendLog' in closure requires explicit use of 'self' to make capture semantics explicit
}
上記のコードではsendLog
メソッドにself.
を消すとCall to method 'sendLog' in closure requires explicit use of 'self' to make capture semantics explicit
のコンパイルエラーが表示されます。
明示的にself.sendLog
とself
のキャプチャが必要です。
同じことがTaskGroup.addTask
とThrowingTaskGroup.addTask
にも言えます。
TaskGroup.addTask
の定義はこちらです。
@_alwaysEmitIntoClient
public mutating func addTask(
priority: TaskPriority? = nil,
operation: __owned @Sendable @escaping () async -> ChildTaskResult
) {
@_implicitSelfCapture
属性がありません。
ThrowingTaskGroup.addTask
の定義はこちらです。
@_alwaysEmitIntoClient
public mutating func addTask(
priority: TaskPriority? = nil,
operation: __owned @Sendable @escaping () async throws -> ChildTaskResult
) {
同じく@_implicitSelfCapture
属性はありません。
なのでTaskGroup.addTask
とThrowingTaskGroup.addTask
のクロージャー内でもself
の記述は省略できません。
しかし循環参照の恐れはないので明示的にself.
と書いて問題ないでしょう。
Task.init
では@_implicitSelfCapture
がつけられ、Task.detached
、TaskGroup.addTask
、ThrowingTaskGroup.addTask
ではつけられていないのは単純に一貫性がないと思われるので、Swiftのバージョンがあがれば修正されるかもしれません。
非同期処理のキャンセル
[weal self]とguard文で早期リターン
クロージャーでのコールバックで非同期処理を書く場合は、[weak self]
でキャプチャした後、guard
文で早期リターンをすることが多いです。
request { [weak self] in
// 早期リターン
guard let self = self else { return }
// selfがなかったら以降の処理は実行されない
print(self.number)
}
処理をキャンセルすることを目的に書いている方もいるかと思います。
しかし、Task.init
のクロージャー内ではこの早期リターンが意味がある場面は多くはないでしょう。
次のコードはView ControllerのviewDidLoad
メソッドでTask.init
を呼び出すコードです。
override func viewDidLoad() {
super.viewDidLoad() // ①
Task { [weak self] in
guard let self = self else { return } // ③
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC) // ④
}
print(number) // ②
}
コードが呼ばれる順番は①、②、③、④の順番になります。
②が終わったらすぐ Task.init
のクロージャーが呼ばれ③が実行されます。
②から③の実行間隔はとても短いので、その間にself
がなくなることは多くの場合はないでしょう。
先ほどの例では、ほとんどの場合④に到達して、非同期処理を開始してしまいます。
④は3秒待つという重い処理です。
③で早期リターンしてもself
がなくなる場面が限られているので、ほとんどの場合④の重い処理が開始されてしまいます。
Task.init
のクロージャーに早期リターンをしてもTask.init
のクロージャー処理をキャンセルされる場合は限定的です。
ではどうすればいいでしょうか?
Task.cancelを使おう
Task.cancel
を使いましょう。
今回の例ではself
がView Controllerでした。
画面が非表示になったらTask
をキャンセルをするコードは次のとおりです。
var task: Task<Void, Error>?
override func viewDidLoad() {
super.viewDidLoad()
// taskを保持
task = Task {
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
print(number)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 画面非表示でキャンセル
task?.cancel()
}
Task
のインスタンスをtask
として保持します。
viewDidAppear
でtask
のcancel
メソッドを呼び出すことでキャンセルができます。
cancelメソッドでTask.initクロージャーの処理が必ず停止するわけではない
ただし、cancel
メソッドを呼び出せば、必ずTask.init
クロージャーの処理が必ず停止するわけではありません。
Task
の処理を実際に止めるにはCancellationError
というエラーをスローする必要があり、エラーをスローしない関数を呼んでいる場合はcancel
しても処理は止まりません。
例えば、先ほどの例ではスリープ処理にTask.sleep(nanoseconds:)
を使っていました。
これはTask
がキャンセルされるとCancellationError
をスローするのですぐに処理が停止します。
task = Task {
// Taskがキャンセルされるとすぐに停止する
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
}
Task
を使ったもう一つのスリープ処理はTask.sleep()
です。
これはTask
がキャンセルされても停止しません。
画面が閉じても、きっちり3秒処理を待たないといけません。
task = Task {
// Taskがキャンセルされても停止しない
await Task.sleep(3 * NSEC_PER_SEC)
}
キャンセルされた場合に処理を適切に停止したい場合はTask.checkCancellation()
やTask.isCancelled
が利用できます。
Task.checkCancellation()
:Task
がキャンセルされた場合にCancellationError
をスローするメソッド- とりあえず処理を止める場合に使える
Task.isCancelled
:Task
がキャンセルされた場合にtrue
を返す- キャンセルされた場合に独自処理が記述できる
使い方としてはCancellationError
スローしない処理の前後に呼び出すことで、すでにキャンセルしていた場合に処理を止めることができます。
task = Task {
// すでにキャンセルされていたら処理を停止
try Task.checkCancellation()
// CancellationErrorスローしない処理
await Task.sleep(3 * NSEC_PER_SEC)
// すでにキャンセルされていたら処理を停止
try Task.checkCancellation()
}
上の例ではTask.sleep()
が始まる前にTask.checkCancellation
を呼び出して、すでにキャンセルされていたら処理を停止します。
Task.sleep()
が実行されると、その間にキャンセルされても3秒待ってしまいます。
が、その後に再びTask.checkCancellation
を呼び出すことでなるべく無駄な時間を作らずに処理を停止できます。
Task.isCancelled
を使った例も見ていきましょう。
task = Task {
await Task.sleep(3 * NSEC_PER_SEC)
if Task.isCancelled {
// 独自のキャンセル処理
// 最後にエラーをスローすると処理が停止
throw CancellationError()
}
キャンセルされた場合はTask.isCancelled
がtrue
になるので、if文のブロックに独自のキャンセル処理を実装できます。
最後にエラーをスローすることで処理を停止できます。
ここでは便宜上CancellationError
をスローしています。
独自のError
型でも問題ないです。
Task.initクロージャーのメモリ解放タイミング
Task.init
クロージャーのメモリ解放タイミングはクロージャーの処理が全て完了してからです。
その間はself
インスタンスの参照が残っています。
self
を消したと思っても処理が継続する可能性があるので、注意が必要です。
例えば、viewDidLoad
で3秒かかる処理をTask.init
で実行した場合、3秒未満に画面を非表示する場合を考えます。
画面非表示で特にTask.cancel
の処理は行わない想定です。
override func viewDidLoad() {
super.viewDidLoad()
Task {
// 3秒かかる処理。
// 3秒以内画面が非表示になっても処理は継続する
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
print(number)
}
}
override func viewDidAppear(_ animated: Bool) {
// Taskのキャンセル処理はしていない
super.viewDidAppear(animated)
}
viewDidLoad
で呼んでいるTask.init
の処理は3秒待つ処理があります。
viewDidLoad
が呼ばれて3秒未満に画面を消す動作をしても、Task.init
のクロージャーの処理は終わらず、最後まで実行され続けます。
つまり、3秒経ってクロージャーの処理がすべて終わってからself
を解放します。
この例ではTask.sleep(nanoseconds:)
を使っていたのでスリープする秒数間待てばself
は解放されますが、中には処理が無限に終わらない場合もあります。
次のようにNotification
のイベントをfor await in
で待ち合わせるコードを書いた場合、for await in
のコードはキャンセルを明示的にしない限り無限にイベントを待ち受けます。
Task.init
にクロージャーの処理は終わらず、self
インスタンスはいつまで経っても解放されません。
override func viewDidLoad() {
super.viewDidLoad()
Task {
// イベントを待ち受ける
// キャンセルしない限り、この処理は終わらない。つまりselfも解放されない
for await notification in NotificationCenter.default.notifications(named: UIScene.willEnterForegroundNotification) {
print(notification)
}
}
}
このコードは、UIScene.willEnterForegroundNotification
のイベントをfor await in
で待ち受けています。
キャンセルのコードは書いていないので、無限にイベントを待ち合わせます。
self
のViewControllerが非表示になってもself
インスタンスは解放されません。
Task.cancel
を使ってタスクをキャンセルしましょう。
Task
をインスタンスとして保持しておいて、Task.cancel
を行う
「Task.cancelを使おう」の章でお伝えした通りで、self
がなくなるタイミングで確実にTask.cancel
を行い、タスクをキャンセルする方法です。
特にfor await in
では無限にイベントを待ち合わせるので明示的にキャンセルをして、無駄なメモリを解放することが重要です。
override func viewDidLoad() {
super.viewDidLoad()
// taskインスタンスを保持しておく
task = Task { [weak self] in
for await notification in NotificationCenter.default.notifications(named: UIApplication.willEnterForegroundNotification) {
print(notification)
}
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// 画面非表示でtaskをキャンセルする
// for await inの処理もキャンセルされる -> メモリの無駄がなくなる
task?.cancel()
}
重い処理が終わった後にself
インスタンスの有無を確認する
一応重い処理が終わった後にself
インスタンスの有無を確認することでもキャンセルに似た動きをすることができます。
先ほど「非同期処理のキャンセル」の章の「[weal self]とguard文で早期リターン」では、Task.init
のクロージャーの最初でself
インスタンスの有無を確認するのは不要と話しました。
一方で、重い処理が終わった後にself
インスタンスの有無を確認するのは場合によっては処理をキャンセルする意味合いを持たせられます。
次のコードでは3秒待つ処理の後にself
の有無をチェックしています。
override func viewDidLoad() {
super.viewDidLoad()
task = Task { [weak self] in
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
// 3秒のスリープの後にselfの有無チェック
guard let self = self else { return }
// selfがなければこの処理は行わない
print(self.number)
}
}
こうすることで処理が終わった後にself
が解放されている場合は、後続の処理は行わないことを表現できます。
guard
文を使わなくてもself?
を使って、その都度self
の有無をチェックできます。
override func viewDidLoad() {
super.viewDidLoad()
task = Task { [weak self] in
try await Task.sleep(nanoseconds: 3 * NSEC_PER_SEC)
// 3秒のスリープの後にselfの有無チェック
// selfがnilならprint文は実行されない
print(self?.number)
}
}
ただし、個人的には、Task.cancel
を利用したほうが読みやすいとは思います。
Task.initで[weak self]がいらなくなるのはとても嬉しい
Task.init
、Task.detached
、TaskGroup.addTask
、ThrowingTaskGroup.addTask
でクロージャーでself
の循環参照を気にする必要がなくなるのはとてもよいですね。
Swift Concurrencyが解決した非同期処理コードの問題点の一つに、コールバックが呼ばれない問題というものがあります。
例えば、次のrequest
関数ですが、エラー発生時にコールバックが呼ばれていません。
func request(url: URL, completionHandler: @escaping (Result<UIImage, Error>) -> ()) {
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard error == nil else { return } // コールバック呼んでいない
downloadImage(data: data) { result in
let image = try? result.get()
resizeImage(image: image) { result in
completionHandler(result)
}
}
}
task.resume()
}
このままではエラー発生時に呼び出し側で後続の処理が進まずにバグの原因になる可能性があります。
Swift Concurrencyではasync
で非同期関数を定義することで必ず戻り値かエラーをスローさせる関数を定義できます。
非同期関数:
async
がつけられた関数・メソッドのことを便宜的にこう呼びます。async
がない従来の関数・メソッドは対比のために同期関数と呼んでいます。
func request(url: URL) async throws -> UIImage {
let (data, response) = try await URLSession.shared.data(from: url, delegate: nil)
let image = try await downloadImage(data: data)
let resizedImage = try await resizeImage(image: image)
return resizedImage
}
呼び出し元では戻り値かエラーをキャッチすることで全てのパスをハンドリングが可能になります。
つまりSwift Concurrencyでは非同期処理でも戻り値がある関数・メソッドの定義が重要になります。
今まではクロージャーを引数に渡して関数・メソッド自体の戻り値はVoidにすることが多かったと思いますが、それとは異なるということです。
戻り値が重要になることが多くなると、[weak self]
でself
をオプショナルで扱うと書きづらくなる場面が多々出てきます。
例えば、Task.init
を[weak self]
で書いてみます。
Task { [weak self] in
let mypageData = await self?.fetchMyPageData() // 戻り値がオプショナル
print(mypageData ?? "")
}
func fetchMyPageData() async -> MypageInfo {
...
}
struct MypageInfo { ... }
fetchMyPageData
メソッドはMypageInfo
というstructを返すメソッドです。
せっかく通常の型を戻り値としているのに、self?.fetchMyPageData
としているがために戻り値mypageData
もオプショナルになり、アンラップしないと中身を取り出せなくなります。
またTask.init
のクロージャーは値を返すことで後でその値を利用できます。
// taskを保持しておいて
let task = Task { () -> MypageInfo in
let mypageData = await fetchMyPageData()
return mypageData
}
Task {
// 後で使う
let value = await task.value
}
その際に[weak self]
を書いてしまうとオプショナルのアンラップ処理をどこかで入れる必要が出てきてしまいます。
// self?.で記述
let task = Task { [weak self] () -> MypageInfo? in // クロージャーの戻り値をオプショナルにする?
let mypageData = await self?.fetchMyPageData()
return mypageData
}
// guard letで記述
let task = Task { [weak self] () -> MypageInfo? in
guard let self = self else { return nil }
let mypageData = await self.fetchMyPageData()
return mypageData
}
これは面倒ですね。
なのでself
の循環参照を気にする必要がないのはとてもありがたいことです。
動作環境
下記の環境で動作を確認しました。
- Xcode 13.4.1
- Xcode 14beta4
サンプルコードの修正状況
本書のサンプルコードはすでに修正しています。
修正内容は下記からご確認をお願いします。
- pull request
- release tag
本ブログのサンプルコード
Task.initでselfキャプチャを強参照するコードを次のgistに残しました。
動きの確認に使ってください。
https://gist.github.com/SatoTakeshiX/c3cb6fe57ff1424931c21d97c5f1496a
参考記事
Task.init
に渡すクロージャが暗黙的にself
をキャプチャすることの背景と注意点Task.init
で[weak self]
が有効でないことを丁寧に解説していて参考になります。
- Taskで[weak self]を使わないで!
- エスケープクロージャーの循環参照が起こる仕組みが参考になります。
- iOSDC 2022 Swift Concurrency Next Step
- Swift Concurrencyの新情報や、実装で気をつけるべき点がまとまっています。
一冊でマスター!Swift Concurrency入門
改めて、「一冊でマスター!Swift Concurrency入門」はAmazonで販売中です。
Swift Concurrencyを網羅した解説書はまだ他にはないです。
ご興味あればぜひ手にとってご覧いただければ幸いです。
一冊でマスター!Swift Concurrency入門です。