10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift Concurrency まとめ

Last updated at Posted at 2021-08-16

WWDC

Meet async/await in Swift

非同期処理をコールバックやデリゲートで書いていましたが、async/awaitキーワードを使って自然にかけるようになりました。

非同期の処理を行う関数、変数やイニシャライザの宣言部分にasyncをつけます。これを呼び出す時、awaitキーワードをつけて呼ぶだけです。エラー処理のあるような関数ではthrowsも同時につけることが多いと思いますが、async throwsの順で、呼び出し時はtry awaitの順でつけます。

hoge.swift
func someAsyncfunction() async throws String {
...
}

let result = try await someAsyncfunction()

awaitで他の関数呼び出している関数自体も、asyncキーワードをつけて宣言し直す必要があります。

async をつけると、そこで一旦処理がストップし、先に進まなくなります。しかしスレッドを占有はしないため、待っている間も他の処理(例えばユーザーのUI操作への対応など)は挟まることができます。asyncをつけた処理が終わると、一旦ストップしていた処理が再開し、次の業に進んでいくことになります。

  • awaitは、次の行以降でその関数の結果が必要な時に使う
  • async letは、次の行では直ちにその関数の結果は必要なく、もっと後になってからで良いという時に使う
  • awaitでもasync letでも、アプリの他の箇所で行われている非同期処理は止まらない

というのが区別のポイントです。

.hoge.swift
func someAsyncfunction() async throws String {
...
}

func anotherAsyncfunction() async throws String? {
   let result = try? await someAsyncfunction()
   return result
}

非同期処理の関数は、XCTestでテストするときexpectationを使ってわざわざ待ち受けするなど、面倒な書き方をしなくてはいけませんでしたが、これも必要なくなり、普通の関数とほとんど同じように書けるようになります。違うのはasyncをつけるだけです。
截屏2021-07-01 20.09.38.png

なんらかのクロージャの中でasyncを使おうとすると、そのクロージャ自体がawaitに対応していないためそのままだとうまく使えない場合があります。その場合、async { }クロージャで囲うと対応できます。

截屏2021-07-01 20.07.34.png

既存で、コールバックを使っている非同期処理関数を、continuationを使って、awaitの関数に置き換えることができます。
continuation.resume()を呼んだところで結果が返されますが、全てのパスでresume()を一回必ず呼んでいることを確かめる必要があります。呼んでいないとコンパイラから警告が来るとのこと。

なお、このようなコンパイラの警告がないUnsafeContinuationもあります。これはパフォーマンス重視の場合に使用しますが、resumeの呼び方を間違っていないかどうか入念なテストがいると思われます。

截屏2021-07-01 20.22.11.png

また、逆に一つのパスでresume()を複数回呼ばないように気をつけましょう。これも警告が出るとのこと。
截屏2021-07-01 20.24.53.png

既存でデリゲートを使っている非同期処理も、continuationをメンバー変数とすることで、awaitの関数に置き換えることができます。

スクリーンショット 2021-08-16 18.58.59.png

Explore structured concurrency in Swift

https://developer.apple.com/videos/play/wwdc2021/10134/
https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html

上のセッションでも述べられていましたが、
async をつけると、そこで一旦処理がストップし、先に進まなくなります。asyncをつけた処理が終わると、一旦ストップしていた処理が再開し、次の行に進んでいくことになります。複数のasyncをつけた非同期処理があるとなると、そのままでは、一つ目が終わるまでストップし、その後初めて二つ目のasyncに進むことになります。↓

.hoge.swift
let (data, _) = try await URLSession.shared.data(for: imageRequest) //終わるまで次の行に行かない
let (metadata, _) = try await URLSession.shared.data(for: metadataRequest) //終わるまで次の行に行かない
async let

ここで普通のasyncではなくasync letというものを使うと、async letのところで処理が一旦ストップせず、子タスクが作られてそちらで処理される一方、メインの処理はすぐ次の行の処理に進んでいくことになります。そこで処理がサスペンドするわけではないのでawaitないしtry awaitは不要となります。のちに変数の中身を参照するとき初めてawaitをつけていくことになります。
例えば以下では、画像データのダウンロードが終わるのを待たず、メタデータのダウンロードも始まり、二つの非同期処理が同時に走ることになります。

.hoge.swift

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
   async let (data, _) = URLSession.shared.data(for: imageRequest) //子タスクを作り、直ちに次の行に進む
   async let (metadata, _) = URLSession.shared.data(for: metadataRequest) //こタスクを作り、直ちに次の行に進む

   guard 
      let size = parseSize(from: try await metadata), // この時点で初めてtry awaitをつける
      let image = try await UIImage(data?).byPreparingThumbnail(ofSize: size) // この時点で初めてtry awaitをつける
   else {
   //throw some error
   }
}

この時、内部的には、Taskというクラスのオブジェクトが内部的には作られています。
関数fetchOneThumbnailが親タスクとなり、その下に子タスクdata metadataがぶら下がる形となるのです。
Taskキャンセル可能です。しかしキャンセルした時も自動的にTaskがストップするわけではないので、キャンセルがありうるようなケースでは、キャンセルされたかどうかをチェックする必要があります。

checkCancellation
isCancelled

キャンセルされてた時に何かしたい場合は、withTaskCancellationHandlerが利用できます。

  • 以下、引用
Download.swift
import Foundation

class Wrapper<Wrapped> {
    var value: Wrapped
    init(_ value: Wrapped) { self.value = value }
}

func download(url: URL) async throws -> Data? {
    let urlSessionTask: Wrapper<URLSessionTask?> = Wrapper(nil)

    return try await withTaskCancellationHandler {
        try await withUnsafeThrowingContinuation { continuation in
            urlSessionTask.value = URLSession.shared.dataTask(with: url) { data, _, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: data)
                }
            }
            urlSessionTask.value?.resume()
        }
    } onCancel: {
        urlSessionTask.value?.cancel()
    }
}

Storeという会社さんが導入した例が以下になります。open-ai generatorにより生成した内容だそうです。

  • 以下、引用
open class func session(sessionParams: SessionParams? = nil) async throws -> SessionToken {
    var requestTask: RequestTask?
    return try await withTaskCancellationHandler {
        try Task.checkCancellation()
        return try await withCheckedThrowingContinuation { continuation in
            guard !Task.isCancelled else {
                continuation.resume(throwing: CancellationError())
                return
            }

            requestTask = sessionWithRequestBuilder(sessionParams: sessionParams).execute { result in
                switch result {
                case let .success(response):
                    continuation.resume(returning: response.body)
                case let .failure(error):
                    continuation.resume(throwing: error)
                }
            }
        }
    } onCancel: { [requestTask] in
        requestTask?.cancel()
    }
}
Group Tasks

動きとしてはasync letと同じく複数の非同期処理を同時並行でやるものとしてgroup tasksがあります。

withTaskGroup関数やwithThrowingTaskGroup関数でGroupTaskを開始します。

GroupTaskのクロージャ内でasyncを呼ぶと、子タスクを手動で開始できます。

截屏2021-07-03 19.43.25.png

複数の子タスクから結果を集約していくことになると思いますが、実は上のようにthumnailsという辞書型にデータを集めていくようなやり方だとコンパイルエラーが出ます。
截屏2021-07-03 19.44.55.png
子タスクからは変数に同時平行的にアクセスが走る可能性があるので、値型の変数や、actor、同時平行アクセスに対する対処を実装したクラス以外にはアクセスすべきではありません。物によってはこのようにコンパイルエラーが出ます。

下のようにfor awaitを使って子タスクの結果を集約すれば、コンパイルエラーは出ません。

截屏2021-07-03 19.48.15.png

Unstructured Tasks

以上までのタスク・子タスクの関係性を利用しての同時平行処理をStructured Tasksと呼んでいますが、タスクを利用しない同時平行処理をUnstructured Tasksと呼びます。

具体的には、例えばApple のUIKitでさまざまに定義されているデリゲートのメソッドなどはasyncで定義されていないので、そのままだと内部でawaitを呼ぶことができません。その際、async {}関数を使うと、どこからでもawaitが使えるようになります。

以下は、コレクションビューにおいて、あるアイテムが画面に出現したら画像ダウンロードを開始するが、画面から消えたらキャンセルする一例になります。コレクションビューのデリゲートがasyncなしであるためそのままではawaitを中で使うことができないので、async {}関数を用いて使っています。

截屏2021-07-03 20.11.36.png
截屏2021-07-03 20.12.42.png

Meet AsyncSequence

for-await-inループまたはfor-try-await-inで、連続した非同期処理を行うことができます。
截屏2021-07-10 15.07.18.png

for-await-inループを使うには対象がAsyncSequenceプロトコルに準拠している必要があります。しかし、既存のコールバックやデリゲートを使う処理など、AsyncSequenceプロトコルに準拠していない物でも、AsyncStreamでラップしてしまうと、比較的簡単にfor-await-inループに適合させられるようになります。

截屏2021-07-10 15.23.20.png

実際に使ってみると、以下のようになります。

截屏2021-07-10 15.24.10.png

Protect mutable state with Swift actors

  • Actorとはshared mutable stateを隔離し、データ競合を防ぐもの

データ競合というのが発生することがあります。二箇所(2スレッド)以上の箇所から同じデータにアクセスし、しかも読み込みではなく書き込みが一つ以上含まれる場合は、データ競合が起こり得ます。

値型(基本型や構造体など)のデータであれば、複数の箇所から参照されるということが起こり得ない(常にローカルでコピーが作られる)ので、データ競合は起こり得ません。が、他方で複数の場所から参照できる共有の状態というのは、値型では実現できないということになります。

というわけで今までは、複数箇所から同時並行的に参照・変更され得るような、共有のデータ(Shared mutable state in concurrent programs)を実現するには、参照型(クラスなど)で競合が起きないようDispatchQueueなどを用いるなどして注意深くコードを書くしかありませんでした。

今回導入されるActorでは、Shared Mutable Stateを簡単に実現できるように設計されたものです。Actor自身の状態を他のプログラム全ての部分から隔離する設計となっています。

Actorタイプはクラスと同様参照型で、クラスや構造体と同様変数・関数・イニシャライザ・エクステンションなどを定義することができます。

以下のように、データ競合を避けるため、Actorへアクセスする際はawaitキーワードで待受をする必要があります。
スクリーンショット 2021-08-19 18.54.47.png

他方Actor自身がActor自身にアクセスするときは特に待受する必要はありません。
スクリーンショット 2021-08-19 18.55.52.png

  • データが想定と異なる変更をされる可能性があることに注意

注意点として、Actorは複数箇所から(awaitで)アクセスされることから、awaitで待っている間に他の箇所からのアクセスによりActorの状態が当初の予想から変わってしまっていることがあります。そのためそれをあらかじめ想定しておく必要があります。

例えば以下は、画像をダウンロードするためのActorです。

まず、キャッシュをチェックし、キャッシュがある場合はダウンロードをせずにキャッシュを返しています。(if let cached = chche[url] { return cached })

もしキャッシュがなかったら初めてダウンロードをし、ダウンロードした画像をキャッシュに入れていくわけです。

最初の段階でキャッシュがないことをチェックしてはいます。しかし、ダウンロードが時間がかかる上、他の箇所からもこの部分のコードを通って同時にダウンロードをしている可能性があります。すると他の箇所から実行されているダウンロードが先に完了し、キャッシュを埋めている可能性があります。つまり最初の行でキャッシュがないことを確認していたとしても、自分の画像ダウンロードが終わった時には、他の画像でキャッシュが埋められている可能性があるわけです。

そのため、ダウンロードが終わった後(時間がかかる処理が終わった後)も、もう一度キャッシュをチェックする処理を入れています。そうして、キャッシュが入っていた時は元のキャッシュを尊重してダウンロードしてきた画像は破棄する、という例が紹介されています。
(cache[url] = cache[url, default: image]; return cache[url])

スクリーンショット 2021-08-19 18.56.58.png
スクリーンショット 2021-08-19 18.59.54.png

  • nonisolated キーワードを使ったプロトコルへの適合

actorをプロトコルに適合させる際問題が発生することがあります。何かactor内にある状態をにアクセスするようなメソッドを実装しなければいけないのに、awaitキーワードをつけることができないので、結果プロトコルに適合できないというような場合です。

スクリーンショット 2021-08-19 19.04.03.png

この場合は、そのメソッドだけnonisolatedキーワードをつけると対応できます。nonisolatedキーワードをつけるということは、その部分はActorの外側として扱われるので、Actorの内側にある状態(mutable shared state)にアクセスすることはできません。下の例では、定数のidNumberにアクセスしているだけなので問題は生じません。

スクリーンショット 2021-08-19 19.04.24.png

もしnonisolatedをつけているところからActorの変数にアクセスしようとすると、mutable shared stateにアクセスしようとしていることになるので、コンパイルエラーになります。
スクリーンショット 2021-08-19 19.05.18.png

  • Actorが所有できるのはSendableプロトコルに適合した型のみ

もしActorが何かのクラス(以下ではBookクラス)の変数を所有していたとしましょう。するとクラスは参照型なので、他の箇所からもアクセスできてしまいます。つまり、Actorの内部の状態がActorを経ずに参照されてしまうということになります。
スクリーンショット 2021-08-19 19.10.57.png

これを防ぐために、Actorが保有できるのは新しく導入されたSendableプロトコルに適合した型のみとされています。Sendableは複数スレッドからの実行の文脈で安全に共有できるかどうかの性質を表しています。値型(構造体・基本型など)や、Actorや、immutableなクラスが適合しています。言い換えれば、普通のクラスはSendableに適合していません。従って、Actorは変数として普通のクラスを持つことはできません。
スクリーンショット 2021-08-19 19.12.22.png

なおSendableに適合するには、ある型の持っている変数が全てSendableに適合している必要があります。また、ジェネリックを用いる型をSendableに適合したい場合は、ジェネリックで導入する型が全てSendableに適合している必要があります。
スクリーンショット 2021-08-19 19.13.48.png

メソッド・クロージャをSendableに適合させるには@Sendableでマーカーをつけます。@Sendableメソッドは値型の変数しか使うことができません。メソッドがキャプチャする対象もSendableプロトコルに適合している必要があります。以下では、@Sendableのクロージャの中でmutable関数を使っているのでエラーとなっています。

スクリーンショット 2021-08-19 19.16.57.png

  • main actor

メインスレッド(UIの更新などを行うスレッド)というものがありました。今まで、例えばAPIなどでデータを持ってきた後、そのデータを用いてUIを更新する場合は、明示的にメインスレッドで行うようにするため、DispatchQueue.main.async {}等を使っていたかと思います。

今回導入されたmain actorはこれを簡単にできるようになっています。関数や変数に@MainActorという文字をつけると、それらの実行は常にメインスレッド上で行われることが保証されます。クラスの宣言などに@MainActorをつけた場合は、そのクラスの全変数・メソッドに@MainActorをつけたのと同じになります。

スクリーンショット 2021-08-19 19.21.27.png
スクリーンショット 2021-08-19 19.22.14.png

プログラムの中で、データを扱う部分の中でUIと接する部分(データを用いてUIに影響を与える部分)に@MainActorを使っていくと良いでしょう。

Discover concurrency in SwiftUI

async マークの付いた非同期メソッド(サーバーから画像をダウンロードする) を持っている、ObservableObject@MainActorマークを つける例が紹介されています。
截屏2021-09-14 22.26.24.png

これにより、該当の オブジェクトのメソッドが常にメインスレッド上で実行されることが保証されます。async キーワードをつけていることにより、非同期処理と実行中は一旦メインスレッドが明け渡しされるので、UIが止まってしまう心配はありません。非同期処理の終了次第、画面の更新の処理がメインスレッドで確実に行われることになります。

截屏2021-09-14 21.28.23.png

※ObservableObjectについてはこちら whats-the-difference-between-observedobject-state-and-environmentobject参照

Swift Concurrency

公式解説ページ: Swift Concurrency
Swift Migration Guide

Data Isolation(隔離)

データ競合が起きないことを保証する仕組みのことで、コンパイラがあらゆる可変な状態をチェックしている。

Isolatioin Domain(隔離ドメイン)

全ての変数・関数について、隔離を行うために三種の隔離ドメインの内のどれかに属することになる。どの隔離ドメインに属するかは明示的に示すこともできるし、宣言されている場所のコンテキストによって暗黙的に決まっている場合もある。

  1. 非隔離(non-isolated)
  2. アクター隔離(actor-isolated)
  3. グローバルアクター隔離(global-actor-isolated) 典型的には、MainActor-isolated

actor内部であれば2, @MainActorなどグローバルアクターの内部であれば3, それ以外は全て1となる。

ある隔離ドメインはそれぞれ保護されており(Isolation Boundary 隔離境界),ある隔離ドメインのコンテキストの中から、別の隔離ドメインに属する変数や関数にアクセスしようとするとコンパイラによってエラーとなる。これにより、データ競合を防いでいる。

Sendable

Sendable Types
複数スレッドからの実行の文脈で安全に共有できることを表しているプロトコル。これに適合している型は隔離境界を越えることができる。

Sending

Sendableに適合していない型であっても、場合によっては安全に実行できる場合があり、その場合Sendable キーワードをつけることになる。一々Sendableに適合させなくても良い点が便利。

関数のパラメータにつける場合と、関数の戻り値につける場合がある。

関数のパラメータの場合、その値を関数に渡した後一才変更されない必要がある。

Swift 6 Migration Guide - Flow-Sensitive-Isolation-Analysis
swift-evolution
【Swift Concurrency】引数に付与するsendingキーワードについて

その他参照

Swift Documentation - Task Cancellation
Swift Documentation - Task and Task Group
Swift Documentation - Actors

10
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?