見出し画像

Swiftでプログラミング - Concurrency

Swiftには、構造化された方法で非同期コードと並列コードを記述するためのサポートが組み込まれています。非同期コードは、一度に1つのプログラムしか実行されませんが、後で一時停止および再開できます。プログラムでコードを一時停止および再開すると、UIの更新などの短期的な操作を継続しながら、ネットワーク経由でのデータのフェッチやファイルの解析などの長時間実行される操作を継続できます。並列コードとは、複数のコードを同時に実行することを意味します。たとえば、4コアプロセッサを搭載したコンピュータでは、4つのコードを同時に実行でき、各コアが1つのタスクを実行します。並列および非同期コードを使用するプログラムは、一度に複数の操作を実行します。外部システムを待機している操作を一時停止し、このコードをメモリセーフな方法で簡単に記述できるようにします。

並列コードまたは非同期コードによるスケジューリングの柔軟性の向上には、複雑さが増すというコストも伴います。 Swiftを使用すると、コンパイル時のチェックを可能にする方法で意図を表現できます。たとえば、アクターを使用して可変状態に安全にアクセスできます。ただし、低速またはバグのあるコードに同時実行性を追加しても、高速化または正確化が保証されるわけではありません。実際、同時実行性を追加すると、コードのデバッグがさらに難しくなる可能性があります。ただし、並行する必要があるコードでの並行性に対するSwiftの言語レベルのサポートを使用することは、Swiftがコンパイル時に問題をキャッチするのに役立つことを意味します。

この章の残りの部分では、並行性という用語を使用して、非同期コードと並列コードのこの一般的な組み合わせを指します。

以前に並行コードを記述したことがある場合は、スレッドの操作に慣れている可能性があります。 Swiftの同時実行モデルはスレッドの上に構築されていますが、スレッドと直接対話することはありません。 Swiftの非同期関数は、実行中のスレッドを放棄する可能性があります。これにより、最初の関数がブロックされている間、そのスレッドで別の非同期関数を実行できます。

Swiftの言語サポートを使用せずに並行コードを作成することは可能ですが、そのコードは読みにくい傾向があります。たとえば、次のコードは写真名のリストをダウンロードし、そのリストの最初の写真をダウンロードして、その写真をユーザーに表示します。

    listPhotos(inGallery: "Summer Vacation") { photoNames in
       let sortedNames = photoNames.sorted()
       let name = sortedNames[1]
       downloadPhoto(named: name) { photo in
           show(photo)
       }
   }

この単純な場合でも、コードは一連の完了ハンドラーとして作成する必要があるため、ネストされたクロージャーを作成することになります。 このスタイルでは、ネストが深い、より複雑なコードはすぐに扱いにくくなる可能性があります。

Defining and Calling Asynchronous Functions 非同期関数の定義と呼び出し

非同期関数または非同期メソッドは、実行の途中で一時停止できる特殊な種類の関数またはメソッドです。 これは、完了するまで実行されるか、エラーをスローするか、または戻らない通常の同期関数およびメソッドとは対照的です。 非同期関数またはメソッドは、これら3つのことのいずれかを実行しますが、何かを待機しているときに途中で一時停止することもあります。 非同期関数またはメソッドの本体内で、実行を一時停止できるこれらの各場所にマークを付けます。

関数またはメソッドが非同期であることを示すには、スローを使用してスロー関数をマークする方法と同様に、パラメーターの後に宣言にasyncキーワードを記述します。 関数またはメソッドが値を返す場合は、戻り矢印(->)の前にasyncを記述します。 たとえば、ギャラリー内の写真の名前を取得する方法は次のとおりです。

    func listPhotos(inGallery name: String) async -> [String] {
       let result = // ... some asynchronous networking code ...
       return result
   }

非同期とスローの両方の関数またはメソッドの場合、スローの前に非同期を記述します。

非同期メソッドを呼び出すと、そのメソッドが戻るまで実行が一時停止します。 通話の前に待機を書き込み、停止の可能性のあるポイントをマークします。 これは、スロー関数を呼び出すときにtryを記述して、エラーが発生した場合にプログラムのフローに変更が加えられる可能性があることを示すのと似ています。 非同期メソッド内では、別の非同期メソッドを呼び出した場合にのみ実行フローが一時停止されます。つまり、一時停止が暗黙的またはプリエンプティブになることはありません。つまり、可能なすべての一時停止ポイントが待機としてマークされます。

たとえば、次のコードは、ギャラリー内のすべての画像の名前を取得してから、最初の画像を表示します。

   let photoNames = await listPhotos(inGallery: "Summer Vacation")
   let sortedNames = photoNames.sorted()
   let name = sortedNames[1]
   let photo = await downloadPhoto(named: name)
   show(photo)

listPhotos(inGallery :)関数とdownloadPhoto(named :)関数はどちらもネットワーク要求を行う必要があるため、完了するまでに比較的長い時間がかかる可能性があります。戻り矢印の前にasyncを記述して両方を非同期にすると、このコードが画像の準備ができるまで待機している間、アプリの残りのコードが実行され続けます。

上記の例の同時性を理解するために、実行の可能な順序は次のとおりです。

1.  コードは最初の行から実行を開始し、最初の待機まで実行されます。 listPhotos(inGallery :)関数を呼び出し、その関数が戻るのを待つ間、実行を一時停止します。
2.  このコードの実行が一時停止されている間、同じプログラム内の他のいくつかの並行コードが実行されます。たとえば、長時間実行されるバックグラウンドタスクが、新しいフォトギャラリーのリストを更新し続ける場合があります。そのコードは、 awaitでマークされた次の一時停止ポイントまで、または完了するまで実行されます。
3. listPhotos(inGallery :)が戻った後、このコードはその時点から実行を継続します。 photoNamesに返された値を割り当てます。
4.  sortNamesとnameを定義する行は、通常の同期コードです。これらの行には awaitマークがないため、停止ポイントの可能性はありません。
5.  次のawaitは、downloadPhoto(named :)関数の呼び出しをマークします。このコードは、その関数が戻るまで実行を再び一時停止し、他の並行コードを実行する機会を与えます。
downloadPhoto(named :)が戻った後、その戻り値はphotoに割り当てられ、show(_ :)を呼び出すときに引数として渡されます。

awaitとマークされたコード内の停止ポイントの可能性は、非同期関数またはメソッドが戻るのを待っている間、現在のコードが実行を一時停止する可能性があることを示しています。これは、スレッドの生成とも呼ばれます。これは、バックグラウンドで、Swiftが現在のスレッドでのコードの実行を一時停止し、代わりにそのスレッドで他のコードを実行するためです。 awaitのあるコードは実行を一時停止できる必要があるため、プログラム内の特定の場所でのみ非同期関数またはメソッドを呼び出すことができます。

・ 非同期関数、メソッド、またはプロパティの本体のコード。
・ @mainでマークされた構造体、クラス、または列挙体の静的main()メソッドのコード。
・ 以下の非構造化同時実行に示すように、切り離された子タスクのコード。

Task.sleep(_ :)メソッドは、並行性がどのように機能するかを学ぶための簡単なコードを書くときに役立ちます。 このメソッドは何もしませんが、戻る前に少なくとも指定されたナノ秒数だけ待機します。 これは、sleep()を使用してネットワーク操作の待機をシミュレートするlistPhotos(inGallery :)関数のバージョンです。
    func listPhotos(inGallery name: String) async -> [String] {
       await Task.sleep(2 * 1_000_000_000)  // Two seconds
       return ["IMG001", "IMG99", "IMG0404"]
   }

Asynchronous Sequences 非同期シーケンス

前のセクションのlistPhotos(inGallery :)関数は、配列のすべての要素の準備ができた後、配列全体を一度に非同期的に返します。 別のアプローチは、非同期シーケンスを使用して、コレクションの1つの要素を一度に待機することです。 非同期シーケンスの反復は次のようになります。

   import Foundation
   let handle = FileHandle.standardInput
   for try await line in handle.bytes.lines {
       print(line)
   }

上記の例では、通常のfor-inループを使用する代わりに、その後にawaitを付けて書き込みます。 非同期関数またはメソッドを呼び出すときと同様に、awaitを書き込むと、中断ポイントの可能性が示されます。 for-await-inループは、次の要素が使用可能になるのを待機しているときに、各反復の開始時に実行を一時停止する可能性があります。

Sequenceプロトコルに適合性を追加することでfor-inループで独自の型を使用できるのと同じように、AsyncSequenceプロトコルに適合性を追加することでfor-await-inループで独自の型を使用できます。

Calling Asynchronous Functions in Parallel

awaitを使用して非同期関数を呼び出すと、一度に1つのコードしか実行されません。 非同期コードの実行中、呼び出し元はそのコードが終了するのを待ってから、次のコード行を実行します。 たとえば、ギャラリーから最初の3枚の写真をフェッチするには、次のようにdownloadPhoto(named :)関数への3回の呼び出しを待つことができます。

   let firstPhoto = await downloadPhoto(named: photoNames[0])
   let secondPhoto = await downloadPhoto(named: photoNames[1])
   let thirdPhoto = await downloadPhoto(named: photoNames[2])
   let photos = [firstPhoto, secondPhoto, thirdPhoto]
   show(photos)

このアプローチには重要な欠点があります。ダウンロードは非同期であり、進行中に他の作業が発生しますが、downloadPhoto(named :)への呼び出しは一度に1回だけ実行されます。 次の写真のダウンロードが開始される前に、各写真が完全にダウンロードされます。 ただし、これらの操作を待つ必要はありません。各写真は個別にダウンロードすることも、同時にダウンロードすることもできます。

非同期関数を呼び出して、その周りのコードと並行して実行するには、定数を定義するときにletの前にasyncを記述し、定数を使用するたびにawaitを記述します。

   async let firstPhoto = downloadPhoto(named: photoNames[0])
   async let secondPhoto = downloadPhoto(named: photoNames[1])
   async let thirdPhoto = downloadPhoto(named: photoNames[2])
   let photos = await [firstPhoto, secondPhoto, thirdPhoto]
   show(photos)

この例では、downloadPhoto(named :)への3つの呼び出しはすべて、前の呼び出しが完了するのを待たずに開始されます。利用可能なシステムリソースが十分にある場合は、それらを同時に実行できます。これらの関数呼び出しはいずれも、関数の結果を待機するためにコードが一時停止しないため、待機としてマークされていません。代わりに、写真が定義されている行まで実行が続行されます。その時点で、プログラムはこれらの非同期呼び出しの結果を必要とするため、3つの写真すべてのダウンロードが完了するまで実行を一時停止するようにawaitを書き込みます。

これら2つのアプローチの違いについて考える方法は次のとおりです。

・ 次の行のコードがその関数の結果に依存する場合は、awaitを使用して非同期関数を呼び出します。これにより、順次実行される作業が作成されます。
・ コードの後半まで結果が必要ない場合は、async-letを使用して非同期関数を呼び出します。これにより、並行して実行できる作業が作成されます。
・ awaitとasync-letの両方で、他のコードが一時停止されている間に実行できるようにします。
・ どちらの場合も、非同期関数が返されるまで、必要に応じて実行が一時停止することを示すために、待機の可能性のある一時停止ポイントをマークします。

これらのアプローチの両方を同じコードに混在させることもできます。

Tasks and Task Groups

タスクは、プログラムの一部として非同期で実行できる作業単位です。 すべての非同期コードは、いくつかのタスクの一部として実行されます。 前のセクションで説明したasync-let構文は、子タスクを作成します。 タスクグループを作成し、そのグループに子タスクを追加することもできます。これにより、優先度とキャンセルをより細かく制御でき、動的な数のタスクを作成できます。

タスクは階層に配置されます。 タスクグループ内の各タスクには同じ親タスクがあり、各タスクには子タスクを含めることができます。 タスクとタスクグループの間の明示的な関係のため、このアプローチは構造化同時実行と呼ばれます。 正確さについてはあなたが責任を負いますが、タスク間の明示的な親子関係により、Swiftはキャンセルの伝播などの動作を処理し、コンパイル時にSwiftがいくつかのエラーを検出できるようになります。

    await withTaskGroup(of: Data.self) { taskGroup in
       let photoNames = await listPhotos(inGallery: "Summer Vacation")
       for name in photoNames {
           taskGroup.async { await downloadPhoto(named: name) }
       }
   }

Unstructured Concurrency   非構造化並行性

前のセクションで説明した並行性への構造化アプローチに加えて、Swiftは非構造化並行性もサポートします。 タスクグループの一部であるタスクとは異なり、非構造化タスクには親タスクがありません。 プログラムが必要とする方法で非構造化タスクを管理する完全な柔軟性がありますが、それらの正確さについても完全に責任があります。 現在のアクターで実行される非構造化タスクを作成するには、async(priority:operation :)関数を呼び出します。 現在のアクターの一部ではない非構造化タスク(より具体的にはデタッチタスクと呼ばれる)を作成するには、asyncDetached(priority:operation :)を呼び出します。 これらの関数は両方とも、タスクを操作できるタスクハンドルを返します。たとえば、結果を待つか、タスクをキャンセルします。

   let newPhoto = // ... some photo data ...
   let handle = async {
       return await add(newPhoto, toGalleryNamed: "Spring Adventures")
   }
   let result = await handle.get()

Task Cancellation タスクのキャンセル

Swiftの同時実行性は、協調的なキャンセルモデルを使用します。 各タスクは、実行の適切な時点でキャンセルされたかどうかを確認し、適切な方法でキャンセルに応答します。 行っている作業に応じて、通常は次のいずれかを意味します。

・CancellationErrorのようなエラーをスローする
・nilまたは空のコレクションを返す
・部分的に完成した作品を返却する

キャンセルを確認するには、タスクがキャンセルされた場合にCancellationErrorをスローするTask.checkCancellation()を呼び出すか、Task.isCancelledの値を確認して、独自のコードでキャンセルを処理します。 たとえば、ギャラリーから写真をダウンロードするタスクでは、ダウンロードの一部を削除してネットワーク接続を閉じる必要がある場合があります。

キャンセルを手動で伝達するには、Task.Handle.cancel()を呼び出します。

Actors

classと同様に、Actorは参照型であるため、「classは参照型」の値型と参照型の比較は、classだけでなくActorにも適用されます。 classとは異なり、Actorは一度に1つのタスクのみが可変状態にアクセスできるため、複数のタスクのコードがアクターの同じインスタンスと安全に対話できます。 たとえば、気温を記録する俳優は次のとおりです。

    actor TemperatureLogger {
       let label: String
       var measurements: [Int]
       private(set) var max: Int
       init(label: String, measurement: Int) {
           self.label = label
           self.measurements = [measurement]
           self.max = measurement
       }
   }

Actorキーワードを使用してアクターを紹介し、その後に中かっこで定義します。 TemperatureLoggerアクターには、Actor外の他のコードがアクセスできるプロパティがあり、アクター内のコードのみが最大値を更新できるようにmaxプロパティを制限します。

構造体やクラスと同じ初期化構文を使用して、Actorのインスタンスを作成します。 Actorのプロパティまたはメソッドにアクセスするときは、待機を使用して潜在的な一時停止ポイントをマークします。次に例を示します。

   let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
   print(await logger.max)
   // Prints "25"

この例では、logger.maxへのアクセスが一時停止の可能性のあるポイントです。 Actorは一度に1つのタスクのみがその可変状態にアクセスできるため、別のタスクのコードがすでにロガーと対話している場合、このコードはプロパティへのアクセスを待機している間中断します。

対照的に、Actorの一部であるコードは、アクターのプロパティにアクセスするときに待機を記述しません。 たとえば、TemperatureLoggerを新しい温度で更新するメソッドは次のとおりです。

    extension TemperatureLogger {
       func update(with measurement: Int) {
           measurements.append(measurement)
           if measurement > max {
               max = measurement
           }
       }
   }

update(with :)メソッドはすでにActorで実行されているため、maxなどのプロパティへのアクセスをawaitでマークしません。この方法は、Actorが一度に1つのタスクのみを変更可能な状態と対話できるようにする理由の1つも示しています。Actorの状態を更新すると、一時的に不変条件が壊れます。 TemperatureLoggerアクターは、温度と最高温度のリストを追跡し、新しい測定値を記録すると最高温度を更新します。更新の途中で、新しい測定値を追加した後、最大値を更新する前に、温度ロガーは一時的に一貫性のない状態になっています。複数のタスクが同じインスタンスと同時に対話するのを防ぐことで、次の一連のイベントのような問題を防ぐことができます。

1.  コードはupdate(with :)メソッドを呼び出します。最初に測定配列を更新します。
2.  コードがmaxを更新する前に、他の場所のコードが最大値と温度の配列を読み取ります。
3.  コードは、maxを変更して更新を終了します。

この場合、他の場所で実行されているコードは、データが一時的に無効である間、update(with :)の呼び出しの途中でアクターへのアクセスがインターリーブされたため、誤った情報を読み取ります。 Swift Actorアクターを使用する場合、一度に1つの操作しか許可せず、待機が一時停止ポイントをマークする場所でのみコードを中断できるため、この問題を防ぐことができます。 update(with :)には一時停止ポイントが含まれていないため、更新の途中で他のコードがデータにアクセスすることはできません。

クラスのインスタンスの場合のように、Actorの外部からこれらのプロパティにアクセスしようとすると、コンパイル時エラーが発生します。例えば:

print(logger.max)  // Error

ActorのプロパティはそのActorの分離されたローカル状態の一部であるため、 awaitを書かずにlogger.maxにアクセスすると失敗します。 Swiftは、アクター内にコードを書くことがActorのローカル状態にアクセスすうることができます。 これはactor isolationと呼ばれます。


この記事が気に入ったらサポートをしてみませんか?