47
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

学生AndroiderAdvent Calendar 2022

Day 10

コルーチンを1から理解する

Last updated at Posted at 2022-12-09

はじめに

みなさんこんにちは、たかっしーと申します。

この記事は、Corutineとは何者なのかを1から理解するという記事になっています。

今回必要なコンピュータサイエンスの知識

それでは早速、コルーチンを理解する上で必要な知識の解説を行います。

CPUについて

私たちが普段使用しているPC、スマホにはCPUが入っています。
このCPUには、コアスレッドというものが用意されており、これらによって処理が実行されています。

ここで大事なことが、CPUの一つのスレッドはある瞬間に1つのタスクしか行うことができないということです。
それゆえ、CPUは一つずつ書かれた処理を順番に実行していくことで、複雑なタスクを行なっています。

マルチプロセス

しかし私たちは、パソコンを用いて同時に様々な処理を行なっています。
Chromeのタブを数十個開きながらコードを書いたり、音楽を流したりしています。

最近のCPUがマルチコアであると言っても、せいぜい8個程度の処理しか同時に扱えないはずです。
では一体なぜ、私たちのPCは同時に複数の処理を行うことができるのでしょうか。

スケジューラー

その答えは、スケジューラーです。
スケジューラーが、様々な処理を分割して微小時間ごとに切り替えながらCPUに行わせることで、あたかも複数の処理を同時に行なっているかのように見せることができます。

スケジューラーの具体的な説明の前に、「プロセス」、「スレッド」を解説します。

プロセス

プロセスとは、「プログラムの実行単位」のことです。
次の画像のように、アプリケーションごとにプロセスが存在しています。
process.png

スレッド(軽量プロセス)

スレッドとは、プロセスをさらに細かくした「CPUコアを利用する単位」のことです。
複数のスレッドをプロセスが持つことで、並列処理を行うことができるというメリットがあります。

スケジューラーとは

OSは、スケジューラーを用いてCPUに様々な処理を同時に実行させており、次の画像のように、ソフトウェア上で複数のスレッドを立ち上げて処理を行わせると、スケジューラーによってCPUでは次のように処理が行われます。
scheduler.png
このようにして、私たちは大量の処理をパソコン上で行わせているわけです。

マルチタスクの扱い方

CPUに関する知識を学んだ上で、複数の処理を同時に行うにはどうすればよいか、具体的に考えてみましょう。

同期処理

まず考えられるのが、複数の処理を書いた順番に実行する方法です。
この方法は同期処理と呼ばれ、特に意識せずにコードを書くと、通常はこの同期処理が行われます。

例えば、タスクA, B, Cの順番でコードを書いた時は、次のように同期処理が実行されます。
douki.png

同期処理は書いた順番に処理が実行されるため、処理を追いやすいというメリットがありますが、1つのスレッドで同期処理を行った場合に問題が生じる場合があります。

CPUについての解説で、CPUのスレッドは瞬間的に1つのタスクしか行うことができないと説明しました。
それゆえ、1つのスレッドで時間のかかる処理を行うと、そのスレッドは時間のかかる処理が終わるまで他の処理を受け付けなくなるため、フリーズしているように見えてしまいます

同期処理
メリット 書いた順番に確実に処理されるため、分かりやすい
デメリット 重い処理が入ると固まってしまう場合もある

これはユーザーにとって良くないため、時間のかかる処理をシングルスレッドで同期処理させるのは良くないことが分かります。

非同期処理

複数の処理をさまざまな順番で実行する非同期処理というものもあります。
コルーチンは非同期処理をサポートする仕組みでもあるので、なんとなく覚えておいてください。
hidouki.png

マルチスレッドによる並列処理

それでは、1つのスレッドだけではなく、複数のスレッドを並列して複数の処理を行うマルチスレッドによる並列処理はどうでしょうか。
multi.png

利点

複数のスレッドを用いることで、時間のかかる処理と入力を受け付ける処理を別のスレッドで同時に行うことができ、フリーズを防ぐことが可能になります。また、マルチコアを最大限活用することができるため、処理速度向上にも繋がります。
multi.png

問題点

しかし、このマルチスレッドによる並列処理には問題点があります。
それは、適切なメモリの管理が求められるということです。その理由について説明を行います。

スレッドは同じメモリ空間を共有する

スレッドにはそれぞれメモリ空間が割り当てられており、プロセス内のメモリ空間を共有することができます
それゆえ、同時にメモリ領域を書き換えようとしたときにバグが発生してしまうことがあります。
このバグは、コルーチンのCodelabに以下のように記載されています。

複数のスレッドを操作するとき、競合状態という状態になることもあります。これは、複数のスレッドがメモリ内の同じ値に同時にアクセスしようとした場合に発生します。競合状態は、再現の難しいランダムに見えるバグを引き起こす可能性があり、アプリの頻繁かつ予期しないクラッシュの原因になることがあります。

スレッドの作成にはコストを要する

スレッドはメモリ領域を確保するため、作成や削除を行うと、メモリを消費したり、メモリの確保にCPUの処理を持っていかれるため、頻繁な作成や削除はよろしくないとされています。

スレッドの作成、切り替え、管理は、システム リソースを消費し、同時に管理できるスレッド数の制限に時間がかかります。作成のコストが大幅に増えることがあります。

このようにマルチスレッドで並列処理を行うには、適切なメモリの管理が求められることが分かります。

コルーチンとは

マルチタスクを行う方法を見てきましたが、どの方法もメリットデメリットがあり、適切な状態に応じて使い分けることが必要となります。

コルーチンもマルチタスクを扱う方法の1つであり、
シングルスレッドやマルチスレッドの非同期処理をサポートするための仕組みとなっているため、マルチタスクを行う際には有力な選択肢となるでしょう。

コルーチンの仕組み

それでは、コルーチンがどのような仕組みになっているかを見ていきましょう。

コルーチンの概念として、メモリの管理を意識することなく、並列処理を用いた非同期処理を行うというものがあり、この概念は、KotlinのドキュメントであるCortoutines guideにも以下のように記載されています。

Moreover, Kotlin's concept of suspending function
 provides a safer and less error-prone abstraction for asynchronous operations than futures and promises.

この概念を達成するため、コルーチンには以下のような実装がなされています。

  • suspend function(中断可能な関数)
  • スレッドの切り替えをサポートする

suspend function

suspend functionとは、その名の通り「中断可能な関数」です。
suspend functionによって、コルーチンは関数を中断してスレッドの使用を他の関数に譲ることができます。

この譲るという処理を実際のコードで確認してみましょう。

まず、delayという指定した時間だけスレッドを解放するsusupend関数を用いたコードを書いてみます。

例1
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        // 1つ目のコルーチンを起動
        launch {
            delay(1000L)
            println("1")
        }
        // 2つ目のコルーチンを起動
        launch {
           println("2")
        }
    }
}

このコードを実行すると、2→1の順でコードが出力されます。

実行結果
2
1

これは、delayによって1つ目のコルーチンが関数を中断し、他のコルーチンに処理を譲った結果、2つ目のコルーチンの処理である2が先に出力されたということを意味しています。

このように、suspend関数によってコルーチンが関数を中断し、処理を譲るという仕組みとなっています。

それでは、なぜこの実装がシングルスレッドやマルチスレッドの非同期処理をサポートするのでしょうか。

スレッドの切り替えをサポートする

その核となるのが、スレッドの切り替えをサポートするdispatcherという機能です。

dispathcerは以下のコードのように書くことで、スレッドの切り替えを行うことができます。

また、dispathcerは以下の3種類が用意されており、それぞれ用途に応じで切り替えることができます。
公式ドキュメントには以下のような使い分けが明記されています。

  • Dispatchers.Main - このディスパッチャを使用すると、コルーチンはメインの Android スレッドで実行されます。UI を操作して処理を手早く作業する場合にのみ使用します。たとえば、suspend 関数の呼び出し、Android UI フレームワーク オペレーションの実行、LiveData オブジェクトのアップデートを行う場合などです。
  • Dispatchers.IO - このディスパッチャは、メインスレッドの外部でディスクまたはネットワークの I/O を実行する場合に適しています。たとえば、Room コンポーネントの使用、ファイルの読み書き、ネットワーク オペレーションの実行などです。
  • Dispatchers.Default - このディスパッチャは、メインスレッドの外部で CPU 負荷の高い作業を実行する場合に適しています。ユースケースの例としては、リストの並べ替えや JSON の解析などがあります。

dispathcerはスレッドに類似していますが、違う点があります。
それは、スレッドは外部から中断されるが、dispatcherは協調的に中断されるという点です。

           スレッドの切り替え
通常のスレッド 外部から中断される
dispatcher 協調的に中断される

dispatcher内で動いている関数を中断させることで別のコルーチンを再開することができるため、複雑な待ち合わせのコードを書くことなく、非同期処理を書くことが可能となっています。

スレッドの切り替えを最小限に抑える

また、コルーチンではスレッドの切り替えを最小限に抑えるような仕組みが備わっているため、スレッドの頻繁な作成、削除によるコストを削減することができます。

withContext()を複数回使用しても、それは同じディスパッチャにとどまり、スレッドの切り替えは回避されます。1

それでは、このdispatcherを用いて非同期処理を書いてみましょう。
コルーチンを起動する際に必要なスコープも実は、何も明示しないと親のディスパッチャーが起動されるようになっています。
またdispatcherの切り替えは、withContext()を用いて行うことができます。

スレッドの切り替え(公式ドキュメントより)
suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

コルーチンのエラー処理

しかし、このコルーチンにも弱点があります。
それは、複数のコルーチンを起動している際に不要になったコルーチンのキャンセルを行うことが難しいという点です。
特にコルーチンの中でコルーチンを起動しているといった複雑な構造を持っている場合、コルーチンのキャンセルによるエラーがさまざまな場所に伝播してしまうということが考えられます。

Structured concurrency

そこでコルーチンに導入されているのがStructured concurrency(構造化された並行性) という概念です。
Structured concurrencyは、大まかに以下のキーワードで説明することができます。

  • 親のコルーチンスコープの中で子のコルーチンを呼び出すという、根付き木の構造をとっている
  • エラーが親のコルーチンへと伝搬され確実に子コルーチンの処理を完了させることができる
    それぞれについて解説をします。

根付き木の構造をとっている

根付き木とは、グラフ理論における用語で、特定の一つの頂点である根から葉が伸びている構造のことを言います。
tree.png

根付き木において重要なのが、親と子という概念です。
図における、橙と緑色の関係が親子関係となるように、上流にある頂点が親、そこから伸びている頂点が子と呼ばれます。
コルーチンもこの根付き木の構造をとっているとみなすことができ、全てのコルーチンはスコープ内でしか呼び出すことができないようになっています。
このような構造を取ることによって得られるメリットが、2つ目のキーワードであるエラーが親のコルーチンへと伝搬され、確実に子コルーチンの処理を完了させることができるというものになります。
error.png
子コルーチンのどこかでエラーが発生した際、再帰関数を用いることで簡単にエラーは親に確実に伝搬することができるようになり、安全に子コルーチンの処理を完了させることができるようになっています。

この概念が存在しているお陰で、以下のようなコードを書くだけで簡単にコルーチンのキャンセルを行うことができるようになっています。

キャンセル処理
fun main() {
    val scope = CoroutineScope(EmptyCoroutineContext)
    scope.launch {
        println("1")
    }
    scope.launch {
       println("2")
    }
    scope.cancel()
}

scope.cancelによって、すべてのscope内の関数がすべてキャンセルされます。

まとめ

  • コルーチンはシングルスレッドやマルチスレッドの非同期処理をサポートするための仕組みである。
  • suspend functionとdispatcherによって、メモリの管理を意識することなくシングルスレッドやマルチスレッドの非同期処理を行えるようにしている。
  • キャンセル処理を行うことが難しくなっているため、Structured concurrencyという概念が導入されている。

最後まで読んでいただきありがとうございました。よければtwitterのフォローよろしくお願いします。

参考文献

  1. Kotlin コルーチンでアプリのパフォーマンスを改善する

47
30
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
47
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?