Skip to content

非同期プログラミングの手法

何十年もの間、開発者はアプリケーションのブロッキングをどう防ぐかという問題に直面してきました。デスクトップ、モバイル、あるいはサーバーサイドのアプリケーションを開発しているかどうかにかかわらず、ユーザーを待たせることや、さらに悪いことにアプリケーションのスケーリングを妨げるボトルネックが発生することは避けたいものです。

この問題を解決するために、以下のような多くの手法が取られてきました:

コルーチンとは何かを説明する前に、他の解決策をいくつか簡単におさらいしましょう。

スレッド

スレッドは、アプリケーションのブロッキングを回避するための手法として、おそらく最もよく知られているものです。

kotlin
fun postItem(item: Item) {
    val token = preparePost()
    val post = submitPost(token, item)
    processPost(post)
}

fun preparePost(): Token {
    // リクエストを行い、結果としてメインスレッドをブロックする
    return token
}

上記のコードにおいて、preparePost が長時間実行されるプロセスであり、その結果ユーザーインターフェースをブロックしてしまうと仮定しましょう。私たちができることは、それを別のスレッドで起動することです。そうすれば、UIのブロッキングを回避できます。これは非常に一般的な手法ですが、一連の欠点があります:

  • スレッドは安価ではない。 スレッドはコストのかかるコンテキストスイッチを必要とします。
  • スレッドは無限ではない。 起動できるスレッドの数は、基盤となるオペレーティングシステムによって制限されます。サーバーサイドアプリケーションでは、これが大きなボトルネックになる可能性があります。
  • スレッドが常に利用可能とは限らない。 JavaScriptなどの一部のプラットフォームでは、スレッドをサポートさえしていません。
  • スレッドは簡単ではない。 スレッドのデバッグやレースコンディション(競合状態)の回避は、マルチスレッドプログラミングにおいて私たちが苦しむ一般的な問題です。

コールバック

コールバックの考え方は、ある関数を別の関数のパラメータとして渡し、プロセスが完了した時点でその関数を呼び出してもらうというものです。

kotlin
fun postItem(item: Item) {
    preparePostAsync { token -> 
        submitPostAsync(token, item) { post -> 
            processPost(post)
        }
    }
}

fun preparePostAsync(callback: (Token) -> Unit) {
    // リクエストを行い、すぐに戻る
    // 後で呼び出されるようにコールバックを配置する
}

これは原理的にははるかにエレガントな解決策に感じられますが、やはりいくつかの問題があります:

  • 入れ子になったコールバックの難しさ。 通常、コールバックとして使用される関数は、それ自体がさらに独自のコールバックを必要とすることがよくあります。これにより一連の入れ子になったコールバックが発生し、理解不能なコードにつながります。このパターンは、深く入れ子になったコールバックによるインデントが三角形の形を作ることから、コールバック地獄(callback hell)や ピラミッド・オブ・ドゥーム(死のピラミッド) と呼ばれることがよくあります。
  • エラーハンドリングが複雑。 入れ子モデルでは、エラーハンドリングやエラーの伝搬がいくらか複雑になります。

コールバックはJavaScriptのようなイベントループ・アーキテクチャでは非常に一般的ですが、そこでも一般的にはPromiseやReactive Extensionsなどの他の手法へと移行が進んでいます。

Future、Promise、その他

FutureやPromise(言語やプラットフォームによって他の用語が使われることもあります)の背後にある考え方は、呼び出しを行う際に、いつかの時点で Promise オブジェクトが返されることを「約束(promise)」され、そのオブジェクトに対して操作を行うというものです。

kotlin
fun postItem(item: Item) {
    preparePostAsync() 
        .thenCompose { token -> 
            submitPostAsync(token, item)
        }
        .thenAccept { post -> 
            processPost(post)
        }
         
}

fun preparePostAsync(): Promise<Token> {
    // リクエストを行い、後で完了するPromiseを返す
    return promise 
}

このアプローチでは、プログラミングの方法に一連の変化が必要になります。具体的には以下の通りです:

  • 異なるプログラミングモデル。 コールバックと同様に、プログラミングモデルがトップダウンの命令的なアプローチから、連鎖的な呼び出しによる合成モデルへと移行します。ループや例外処理などの伝統的なプログラム構造は、通常このモデルでは有効ではなくなります。
  • 異なるAPI。 通常、thenComposethenAccept のような、プラットフォームによっても異なる全く新しいAPIを学ぶ必要があります。
  • 特定の戻り値の型。 戻り値の型は、私たちが必要とする実際のデータではなく、イントロスペクション(内省)が必要な新しい型である Promise に変わります。
  • エラーハンドリングが複雑になる可能性がある。 エラーの伝搬や連鎖が常に単純であるとは限りません。

Reactive Extensions

Reactive Extensions (Rx) は、Erik Meijer によって C# に導入されました。.NETプラットフォームで間違いなく使用されていましたが、NetflixがそれをJavaに移植し RxJava と命名するまでは、主流の採用には至りませんでした。それ以来、JavaScript (RxJS) を含む様々なプラットフォーム向けに多数の移植版が提供されています。

Rxの背後にある考え方は、「Observableストリーム(観察可能なストリーム)」と呼ばれるものへと移行することです。これにより、データをストリーム(無限のデータ量)として考え、それらのストリームを観察できるようになります。実用的な観点では、Rxは単にデータに対して操作を行うための一連の拡張機能を備えた オブザーバー・パターン(Observer Pattern) です。

アプローチとしてはFutureに非常に似ていますが、Futureが離散的な要素を返すと考えるのに対し、Rxはストリームを返すと考えることができます。しかし、前述の手法と同様に、これも私たちのプログラミングモデルについて全く新しい考え方を導入します。有名なフレーズに次のようなものがあります:

「すべてはストリームであり、それは観察可能である」

これは、問題へのアプローチ方法が異なることを意味し、同期コードを書く際に慣れ親しんでいるものからかなり大きな転換を必要とします。Futureと比較した一つの利点は、非常に多くのプラットフォームに移植されているため、C#、Java、JavaScript、あるいはRxが利用可能な他のどの言語を使用していても、一般的に一貫したAPI体験が得られることです。

さらに、Rxはエラーハンドリングに対していくらか優れたアプローチを導入しています。

コルーチン

非同期コードを扱うためのKotlinのアプローチはコルーチンを使用することです。これは「中断可能な計算(suspendable computations)」という考え方であり、すなわち、ある時点で実行を中断し、後で再開できる関数の概念です。

コルーチンの利点の一つは、開発者にとって、ノンブロッキングなコードを書くことが基本的にブロッキングなコードを書くことと同じであるという点です。プログラミングモデル自体は、実際には変わりません。

例えば、次のコードを見てください:

kotlin
fun postItem(item: Item) {
    launch {
        val token = preparePost()
        val post = submitPost(token, item)
        processPost(post)
    }
}

suspend fun preparePost(): Token {
    // リクエストを行い、コルーチンを中断する
    return suspendCoroutine { /* ... */ } 
}

このコードは、メインスレッドをブロックすることなく、長時間実行される操作を開始します。preparePost は「中断可能な関数(suspendable function)」と呼ばれるもので、そのため先頭に suspend キーワードが付いています。これが意味するのは、前述の通り、関数が実行され、実行を一時停止し、ある時点で再開するということです。

  • 関数のシグネチャは全く同じままです。 唯一の違いは suspend が追加されることだけです。戻り値の型は、私たちが返してほしい型そのものです。
  • コードは依然として同期コードを書くかのように、トップダウンで書かれます。 コルーチンをキックオフする launch と呼ばれる関数の使用(他のチュートリアルで解説)以外に、特別な構文は必要ありません。
  • プログラミングモデルとAPIは変わりません。 ループや例外処理などを引き続き使用でき、全く新しいAPIセットを学ぶ必要もありません。
  • プラットフォームに依存しません。 JVM、JavaScript、あるいはその他のプラットフォームをターゲットにしているかに関わらず、書くコードは同じです。内部的には、コンパイラが各プラットフォームへの適応を処理します。

コルーチンは新しい概念ではなく、ましてやKotlinによって発明されたものでもありません。コルーチンは何十年も前から存在しており、Goなどの他のいくつかのプログラミング言語でも人気があります。注目すべき重要な点は、Kotlinにおける実装方法では、機能の大部分がライブラリに委譲されていることです。実際、suspend キーワード以外に、言語にキーワードは追加されていません。これは、asyncawait が構文の一部となっているC#のような言語とは多少異なります。Kotlinでは、これらは単なるライブラリ関数です。

詳細については、コルーチンの概要 を参照してください。