【翻訳】Parallel Programming with Swift — Part 1/4

2023/07/21に公開

並列プログラミングと聞くと、2つの紛らわしい用語が頭に浮かびます。並「行」処理と並「列」処理です。まず、この2つの用語の実際の役割を見てみましょう。

並行処理

私たちの日常業務はマルチタスクで行われています。多くの場合、マルチタスクは同時に1つのタスクに終わりますが、理想的にはコンテキスト・スイッチングです。マルチタスクには限界もあります。例えば、毎日100の仕事をこなさなければなりませんが、これを1000に増やすことはできません。

並列処理

もっと多くの物理的なものを手に入れることができれば、2つ以上の仕事を同時に行うことができます。腕が4本あれば、この記事は半分の時間で仕上げることができます。

コンピュータの世界でこの2つの用語を見てみましょう。

並行処理

並行処理とは、アプリケーションが複数のタスクを同時に(同時並行的に)処理することです。コンピュータにCPUが1つしかない場合、アプリケーションは同時に複数のタスクを処理することはできませんが、アプリケーション内部では一度に複数のタスクが処理されています。1つのタスクが完全に終了してから次のタスクが始まるわけではありません。

並行処理は基本的に、最低2つ以上のタスクについて話すときに適用されます。アプリケーションが2つのタスクを仮想的に同時に実行できる場合、それを並行アプリケーションと呼びます。ここではタスクは同時に実行されているように見えますが、本質的にはそうではありません。オペレーティング・システムのCPUタイム・スライシング機能を利用し、各タスクがそのタスクの一部を実行した後、待機状態になります。

最初のタスクが待機状態にあるとき、CPUは2番目のタスクに割り当てられ、そのタスクの一部を完了させます。オペレーティング・システムはタスクの優先順位に基づいて動作するため、CPUやメモリなどの計算資源を全てのタスクに順番に割り当て、完了するチャンスを与えます。エンドユーザーには、すべてのタスクが並行して実行されているように見えます。

並行処理における複雑さ

ある家に5人の友人が引っ越してきて、それぞれがベッドメイキングをするとしましょう。この場合、どちらが複雑な構造になるでしょうか?

・ 5人が同時に1つのベッドを組み立てる または
・ 各自が自分のベッドを組み立てる

何人かの友だちが一緒にベッドを組み立てる方法を、お互いを邪魔したり、道具を待ったりすることなく説明する方法を考えてみましょう。ベッドのパーツを完成したベッドに組み立てるには、適切なタイミングで行動を調整する必要があります。その説明書は本当に複雑で、書くのも大変だし、おそらく読むのも大変でしょう。

各自が自分のベッドを作るのであれば、指示はとてもシンプルで、他の人が完成するのを待つ必要も、道具が揃うのを待つ必要もありません。

ロブ・パイクによる「並行処理は並列処理ではない」という講演があります。

並列処理

並列処理では、2つのタスクが存在する必要はありません。各タスクまたはサブタスクに1つのコアを割り当てることで、CPUのマルチコアインフラストラクチャを使って、タスクの一部または複数のタスクを物理的に同時に実行します。並列処理には、基本的に複数の処理ユニットを持つハードウェアが必要です。シングルコアのCPUでは、並行処理はできても並列処理はできません。

並行処理とは、独立に実行されるプロセスの合成であり、並列処理とは計算の同時実行です。並行処理とは、アプリケーションがそのタスクをより小さなサブタスクに分割し、例えば複数のCPUでまったく同時に並列処理することを意味します。

並行処理とは、一度に多くのことを処理することであり、構造により重点を置きます。並列処理とは、一度に多くのことを処理することであり、その焦点は実行にあります。

・ アプリケーションは同時並行にはできますが、並列にはできません。つまり、複数のタスクを同時に処理しますが、2つのタスクが同時に実行されることはありません。

・ アプリケーションは並列処理することができますが、同時並行処理ではありません。これは、マルチコアCPUで1つのタスクの複数のサブタスクを同時に処理することを意味します。

・ アプリケーションは並列でも並行でもなく、すべてのタスクを1つずつ順次処理します。

・ アプリケーションは並列処理と並行処理の両方を行うことができ、これはマルチコアCPUで複数のタスクを同時に処理することを意味します。

CPUが誕生して以来、CPU技術の最大の進歩のひとつは、複数のコアを搭載し、複数のスレッドを実行できるようになったことです。iOSでは、同時実行を実現する2つの方法があります:Grand Central DispatchとOperationQueueです。

どちらのトピックも膨大なので、この記事を4つのパートに分けています。

Concurrency & GCD — Parallel Programming with Swift — Part 1/4

GCD — Parallel Programming with Swift — Part 2/4

アジェンダ:

Grand Central Dispatch

  1. 同期実行と非同期実行
  2. シリアルキューとコンカレントキュー
  3. システム提供のキュー
  4. カスタムキュー
  5. 参考文献

Grand Central Dispatch

Grand Central Dispatch (GCD)はキューベースのAPIで、先入れ先出しの順番でワーカープールのクロージャを実行することができます。完了順序は各ジョブの継続時間に依存します。

ディスパッチキューは、タスクを連続的または同時並行的に実行しますが、常に FIFO 順で実行されます。アプリケーションは、同期的または非同期的に、ブロックの形でタスクをキューに投入することができます。ディスパッチ・キューは、システムが提供するスレッド・プール上でこのブロックを実行します。投入されたタスクがどのスレッドで実行されるかは保証されていません。

GCD APIはSwift 3でいくつかの変更があり、SE-0088はその設計を近代化し、よりオブジェクト指向にしました。

このフレームワークは、システムによって管理されるディスパッチキューにタスクを投入することで、マルチコアシステム上でコードを同時に実行することを容易にします。

同期実行と非同期実行

各タスクやワークアイテムは、同期的に実行することも、非同期的に実行することもできます。同期の場合、最初のタスクが終了するのを待ってから次のタスクを開始します。タスクが非同期に実行されると、メソッド呼び出しはすぐに戻り、次のタスクが進行を開始します。

シリアルキューとコンカレントキュー

ディスパッチ待ち行列は、作業項目が一度に一つずつ実行される直列待ち行列と、 作業項目が順番に待ち行列から外される並行待ち行列とがあります。シリアルキューと並行キューはどちらも、先入れ先出し(FIFO)の順序で作業項目を処理します。

シリアルキュー

タスクを非同期にメイン・キューにディスパッチする例を見てみましょう。

import Foundation

var value: Int = 2

DispatchQueue.main.async {
    for i in 0...3 {
        value = i
        print("\(value) ✴️")
    }
}

for i in 4...6 {
    value = i
    print("\(value) ✡️")
}

DispatchQueue.main.async {
    value = 9
    print(value)
}

このタスクは非同期に実行されるので、最初は現在のキューのforループが実行され、次にディスパッチ・キュー内のタスクが実行され、さらに別のタスクが実行されます。

この実験をしているうちに、DispatchQueue.main.syncは使えないことが分かりました。

メイン・キュー上の作業項目を同期的に実行しようとすると、デッドロックが発生します。

dispatch_sync関数を、関数呼び出しに渡した同じキュー上で実行中のタスクから呼び出さないでください。そうするとキューがデッドロックします。

現在のキューにディスパッチする必要がある場合は、dispatch_async 関数を使用して非同期にディスパッチしてください。

  • アップル・ドキュメント

メイン・スレッドがシリアル・キュー(つまり、1つのスレッドしか使わない)であることを考えると、次のように記述します。

DispatchQueue.main.sync {}

は以下のイベントを引き起こします。

  1. syncはブロックをメイン・キューにキューイングします。
  2. syncはブロックの実行が終わるまでメインキューのスレッドをブロックします。
  3. ブロックが実行されるはずのスレッドがブロックされているため、syncは永遠に待ち続けます。

これを理解する鍵は、DispatchQueue.main.syncはブロックを実行するのではなく、キューに入れるだけだということです。実行は実行ループの将来の繰り返しで行われます。

最適化として、この関数は可能な限り現在のスレッド上でブロックを起動します。

並行キュー:

このメソッドにQoSを渡すことで、グローバル同時キューを取得することができます。

DispatchQueue.global(qos: .default)

システムが提供するキュー

アプリが起動すると、システムは自動的にメイン・キューと呼ばれる特別なキューを作成します。メイン・キューにエンキューされたワーク・アイテムは、アプリのメイン・スレッドでシリアルに実行されます。メイン・キューには、DispatchQueue.mainを使用してアクセスできます。

メイン・キューとは別に、システムはいくつかのグローバル同時実行キューを提供しています。グローバルな同時実行キューにタスクを送信する際には、QoS(Quality of Service)クラスのプロパティを指定します。

** プライマリQoS:

User-interactive

快適なユーザー体験を提供するために即座に完了しなければならないタスクを表します。UIの更新、イベント処理、小さな作業負荷に使用します。アプリの実行中にこのクラスで行われる作業の総量は小さくなければなりません。メイン・スレッドで実行する必要があります。

User-initiated

ユーザーがUIからこれらの非同期タスクを開始します。ユーザーが即時の結果を待っているときや、ユーザーとのインタラクションを継続するために必要なタスクに使用します。優先度の高いグローバル・キューで実行されます。

Utility

これは長時間実行タスクを表します。計算、I/O、ネットワーキング、継続的なデータフィード、および同様のタスクに使用します。このクラスは、エネルギー効率が良いように設計されています。ユーティリティ・タスクは通常、ユーザーに見えるプログレス・バーを持っています。これは優先度の低いグローバル・キューにマッピングされます。実行される作業には数秒から数分かかります。

Background

これは、プリフェッチやバックアップなど、ユーザーが直接意識しないタスクを表します。これは、バックグラウンド優先のグローバル・キューにマッピングされます。数分や数時間など、かなりの時間を要する作業に有効です。

** 特別なQoS:

Default

このQoSの優先レベルは、ユーザ起動とユーティリティの間に位置します。QoS情報が割り当てられていない作業はデフォルトとして扱われ、GCDグローバルキューはこのレベルで実行されます。

Unspecified

これは QoS 情報がないことを表し、環境 QoS が推論されるべきであることをシステムに知らせます。スレッドが QoS から外れるレガシー API を使用している場合、スレッドは未指定の QoS を持つことができます。

グローバル並行キュー:

これまでGCDは、作業の優先順位付けのために、高、デフォルト、低、バックグラウンドのグローバル同時キューを提供してきました。現在では、対応するQoSクラスがこれらのキューの代わりに使用されるべきです。

カスタムキュー

GCDはメインキュー、グローバルキュー、カスタムキューの3種類のキューを提供します。

独自のキューを作成する場合、3つのinitメソッドが利用可能です。

・ init("queueName")
・ init("queueName", attributes: {attributes})
・ init("queueName", qos:{QoS class}, attributes:attribute: {attributes}, autoReleaseFrequency:autoReleaseFrequency: {autoreleaseFrequency}, target: {queue})

最初のイニシャライザは、暗黙的にシリアルキューを作成します。

2番目と3番目のイニシャライザで言及されている attributes は、DispatchQueue.Attributes を参照しています。これは、同時実行キューを作成するために使用できる .concurrent と、非アクティブキューを作成できる .initiallyInactive の2つのオプションを持つオプションセットです。これは、非アクティブなキューを作成するためのものです。非アクティブなキューは、activate() を呼び出してアクティブにするまで、そのキュー内のアイテムの実行を開始しません。 autoReleaseFrequency は、DispatchQueue.AutoreleaseFrequency を参照しています。

.inherit

この自動解放頻度を持つディスパッチキューは、ターゲットキューからの振る舞いを継承します。これは、手動で作成されたキューのデフォルトの動作です。

.workItem

この自動解放頻度を持つディスパッチキューは、非同期に投入された全てのブロックの実行時に、自動解放プールをプッシュ/ポップします。キューが (直接またはターゲットキューから継承した) ワークアイテムごとの自動解放頻度を使用している場合、このキューに (async() や .barrier、.notify() などによって) 非同期に投入されたブロックは、あたかも個々の自動解放プールに囲まれているかのように実行されます。自動解放の頻度は、(sync() や .barrier によって) キューに同期的に投入されたブロックには影響しません。

.never

この自動解放頻度を持つディスパッチキューは、非同期に投入されたブロックの実行時に、個別の自動解放プールを設定することはありません。これは、グローバル同時キューの動作です。

これらのキューが同期または非同期でどのように機能するかを見てみましょう。

非同期にタスクを実行するシリアルキュー

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

var value: Int = 20
let serialQueue = DispatchQueue(label: "com.queue.Serial")

func doAsyncTaskInSerialQueue() {
        for i in 1...3 {
            serialQueue.async {
            if Thread.isMainThread{
                print("task running in main thread")
            }else{
                print("task running in other thread")
            }
            let imageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/0/07/Huge_ball_at_Vilnius_center.jpg")!
            let _ = try! Data(contentsOf: imageURL)
            print("\(i) finished downloading")
        }
    }
}

doAsyncTaskInSerialQueue()

serialQueue.async {
    for i in 0...3 {
        value = i
        print("\(value) ✴️")
    }
}

print("Last line in playground 🎉")

GCDでasyncを使うと、タスクはメイン・スレッドとは別のスレッドで実行されます。非同期とは、次の行を実行することであり、ブロックが実行されるまで待ちません。シリアルキューなので、すべてのタスクはシリアルキューに追加された順に実行されます。シリアルに追加されたタスクは、キューに関連付けられたスレッドによって常に1つずつ実行されます。

同期的にタスクを実行するシリアルキュー

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

var value: Int = 20
let serialQueue = DispatchQueue(label: "com.queue.Serial")

func doSyncTaskInSerialQueue() {
        for i in 1...3 {
            serialQueue.sync {
            if Thread.isMainThread{
                print("task running in main thread")
            }else{
                print("task running in other thread")
            }
            let imageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/0/07/Huge_ball_at_Vilnius_center.jpg")!
            let _ = try! Data(contentsOf: imageURL)
            print("\(i) finished downloading")
        }
    }
}

doSyncTaskInSerialQueue()

serialQueue.async {
    for i in 0...3 {
        value = i
        print("\(value) ✴️")
    }
}

print("Last line in playground 🎉")

GCDでsyncを使用すると、タスクがメインスレッドで実行されることがあります。syncは指定されたキューでブロックを実行し、それが完了するのを待ちますが、その結果メインスレッドやメインキューがブロックされます。メイン・キューはディスパッチされたブロックが完了するまで待つ必要があるため、メイン・スレッドはメイン・キュー以外のキューからのブロックを処理できるようになります。したがって、バックグラウンド・キューで実行されているコードが、実際にはメイン・スレッドで実行されている可能性があります。 シリアル・キューなので、全てが追加された順に実行されます(FIFO)。

タスクを非同期に実行する並行キュー

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

var value: Int = 20
let concurrentQueue = DispatchQueue(label: "com.queue.Concurrent", attributes: .concurrent)

func doAsyncTaskInConcurrentQueue() {
        for i in 1...3 {
            concurrentQueue.async {
            if Thread.isMainThread{
                print("task running in main thread")
            }else{
                print("task running in other thread")
            }
            let imageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/0/07/Huge_ball_at_Vilnius_center.jpg")!
            let _ = try! Data(contentsOf: imageURL)
            print("\(i) finished downloading")
        }
    }
}

doAsyncTaskInConcurrentQueue()

concurrentQueue.async {
    for i in 0...3 {
        value = i
        print("\(value) ✴️")
    }
}

print("Last line in playground 🎉")

GCDでasyncを使うと、タスクは別のスレッドで実行されます。asyncは次の行を実行することを意味し、ブロックが実行されるまで待たず、その結果メインスレッドはノンブロッキングになります。並行キューと同様に、タスクはキューに追加された順番に処理されますが、キューには異なるスレッドがアタッチされています。スレッドはキューに追加された順番にタスクを完了させるわけではありません。スレッドはシステムによって処理され割り当てられるため、タスクの順序は毎回異なります。すべてのタスクは並行して実行されます。

タスクを同期的に実行する同時実行キュー

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

var value: Int = 20
let concurrentQueue = DispatchQueue(label: "com.queue.Concurrent", attributes: .concurrent)

func doSyncTaskInConcurrentQueueQueue() {
        for i in 1...3 {
            concurrentQueue.sync {
            if Thread.isMainThread{
                print("task running in main thread")
            }else{
                print("task running in other thread")
            }
            let imageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/0/07/Huge_ball_at_Vilnius_center.jpg")!
            let _ = try! Data(contentsOf: imageURL)
            print("\(i) finished downloading")
        }
    }
}

doSyncTaskInConcurrentQueueQueue()

concurrentQueue.async {
    for i in 1...3 {
        value = i
        print("\(value) ✴️")
    }
}

print("Last line in playground 🎉")

GCDでsyncを使用すると、タスクがメインスレッドで実行されることがあります。syncは指定されたキューでブロックを実行し、それが完了するのを待ちますが、その結果メインスレッドやメインキューがブロックされます。メイン・キューはディスパッチされたブロックが完了するまで待つ必要があるため、メイン・スレッドはメイン・キュー以外のキューからのブロックを処理できるようになります。したがって、バックグラウンド・キューで実行されているコードが、実際にはメイン・スレッドで実行されている可能性があります。並行キューであるため、タスクはキューに追加された順番に終了するとは限りません。しかし同期処理では、異なるスレッドによって処理されるかもしれませんが、そうなります。そのため、これはシリアル・キューとして動作します。

asyncAfter:

何らかの遅延の後にキュー上でタスクを実行したい場合は、 sleep() を使用する代わりに asyncAfter() で遅延を指定することができます。

参考記事及び参考動画

https://www.youtube.com/watch?v=VLq9DfL4g8w

https://www.youtube.com/watch?v=cN_DpYBzKso

https://stackoverflow.com/questions/19179358/concurrent-vs-serial-queues-in-gcd#35810608

https://blog.krzyzanowskim.com/2016/06/03/queues-are-not-bound-to-any-specific-thread/

https://medium.com/@brunorochaesilva/understanding-dispatchqueues-9dd089c88367

【翻訳元の記事】

Parallel Programming with Swift — Part 1/4
https://medium.com/swift-india/parallel-programming-with-swift-part-1-4-df7caac564ae

Discussion