1. 概要

このチュートリアルでは、並行性の高いアプリケーションを構築するために時間をかけて確立されてきた設計原則とパターンのいくつかについて説明します。

ただし、並行アプリケーションの設計は幅広く複雑なトピックであるため、その取り扱いを網羅していると主張できるチュートリアルはありません。 ここで取り上げるのは、よく使用される人気のあるトリックの一部です。

2. 並行性の基本

先に進む前に、基本を理解するために少し時間をかけましょう。 まず、並行プログラムとは何であるかについての理解を明確にする必要があります。 複数の計算が同時に行われている場合、同時実行されているプログラムを指します

ここで、同時に発生する計算について説明したことに注意してください。つまり、計算は同時に進行中です。 ただし、同時に実行されている場合とされていない場合があります。 同時に実行される計算は並列と呼ばれるため、違いを理解することが重要です。

2.1. 並行モジュールを作成する方法は?

並行モジュールを作成する方法を理解することが重要です。 多数のオプションがありますが、ここでは2つの一般的な選択肢に焦点を当てます。

  • プロセス:プロセスは、同じマシン内の他のプロセスから分離された実行中のプログラムのインスタンスです。 マシン上の各プロセスには、独自の分離された時間とスペースがあります。 したがって、通常、プロセス間でメモリを共有することはできず、プロセスはメッセージを渡すことによって通信する必要があります。
  • スレッド:一方、スレッドはプロセスの単なるセグメントです。 同じメモリスペースを共有するプログラム内に複数のスレッドが存在する可能性があります。 ただし、各スレッドには固有のスタックと優先順位があります。 スレッドは、ネイティブ(オペレーティングシステムによってネイティブにスケジュールされる)またはグリーン(ランタイムライブラリによってスケジュールされる)にすることができます。

2.2. 並行モジュールはどのように相互作用しますか?

並行モジュールが通信する必要がない場合は非常に理想的ですが、そうでない場合もよくあります。 これにより、並行プログラミングの2つのモデルが生まれます。

  • 共有メモリ:このモデルでは、コンカレントモジュールは、メモリ内の共有オブジェクトの読み取りと書き込みによって相互作用します。 これにより、並行計算がインターリーブされ、競合状態が発生することがよくあります。 したがって、非決定論的に誤った状態につながる可能性があります。

  • メッセージパッシング:このモデルでは、コンカレントモジュールは、通信チャネルを介して相互にメッセージを渡すことによって相互作用します。 ここで、各モジュールは着信メッセージを順番に処理します。 共有状態がないため、プログラミングは比較的簡単ですが、それでも競合状態から解放されるわけではありません。

2.3. 並行モジュールはどのように実行されますか?

ムーアの法則がプロセッサのクロック速度に関して壁にぶつかってからしばらく経ちました。 代わりに、成長する必要があるため、マルチコアプロセッサと呼ばれることが多い同じチップに複数のプロセッサを搭載し始めました。 しかし、それでも、32コアを超えるプロセッサについて聞くことは一般的ではありません。

これで、1つのコアが一度に実行できるスレッドまたは命令のセットは1つだけであることがわかりました。 ただし、プロセスとスレッドの数は、それぞれ数百と数千になる可能性があります。 それで、それは実際にどのように機能しますか? これは、オペレーティングシステムが私たちの同時実行をシミュレートする場所です。 オペレーティングシステムは、タイムスライシングによってこれを実現します。これは、プロセッサがスレッドを頻繁に、予測できない形で、非決定的に切り替えることを効果的に意味します。

3. 並行プログラミングの問題

並行アプリケーションを設計するための原則とパターンについて説明するときは、最初に典型的な問題が何であるかを理解するのが賢明です。

非常に大部分の場合、並行プログラミングの経験には、共有メモリを備えたネイティブスレッドの使用が含まれます。 したがって、そこから発生する一般的な問題のいくつかに焦点を当てます。

  • 相互排除(同期プリミティブ):インターリーブスレッドは、プログラムの正確性を確保するために、共有状態またはメモリへの排他的アクセスを持っている必要があります。 共有リソースの同期は、相互排除を実現するための一般的な方法です。 使用できる同期プリミティブはいくつかあります。たとえば、ロック、モニター、セマフォ、ミューテックスなどです。 ただし、相互排除のプログラミングはエラーが発生しやすく、パフォーマンスのボトルネックにつながることがよくあります。 デッドロックやライブロックなど、これに関連するいくつかのよく議論された問題があります。
  • コンテキストスイッチング(ヘビーウェイトスレッド):すべてのオペレーティングシステムは、プロセスやスレッドなどの並行モジュールを、さまざまではありますが、ネイティブにサポートしています。 説明したように、オペレーティングシステムが提供する基本的なサービスの1つは、タイムスライシングを通じて限られた数のプロセッサで実行するスレッドをスケジュールすることです。 これは事実上、スレッドが異なる状態間で頻繁に切り替えられることを意味します。 その過程で、現在の状態を保存して再開する必要があります。 これは、全体的なスループットに直接影響する時間のかかるアクティビティです。

4. 同時実行性の高いデザインパターン

並行プログラミングの基本とその中の一般的な問題を理解したので、これらの問題を回避するための一般的なパターンのいくつかを理解する時が来ました。 並行プログラミングは多くの経験を必要とする難しい作業であることを繰り返し述べなければなりません。 したがって、確立されたパターンのいくつかに従うと、タスクが簡単になります。

4.1. アクターベースの並行性

並行プログラミングに関して説明する最初の設計は、アクターモデルと呼ばれます。 これは同時計算の数学モデルであり、基本的にすべてをアクターとして扱います。 アクターは互いにメッセージを渡すことができ、メッセージに応答して、ローカルの決定を行うことができます。 これは最初にCarlHewittによって提案され、多くのプログラミング言語に影響を与えました。

並行プログラミングのためのScalaの主要な構成要素は、アクターです。 アクターは、Actorクラスをインスタンス化することで作成できるScalaの通常のオブジェクトです。 さらに、 Scalaアクターライブラリは、多くの便利なアクター操作を提供します。

class myActor extends Actor {
    def act() {
        while(true) {
            receive {
                // Perform some action
            }
        }
    }
}

上記の例では、無限ループ内で receive メソッドを呼び出すと、メッセージが到着するまでアクターが一時停止されます。 到着すると、メッセージはアクターのメールボックスから削除され、必要なアクションが実行されます。

アクターモデルは、並行プログラミングの基本的な問題の1つである共有メモリを排除します。 アクターはメッセージを介して通信し、各アクターは専用メールボックスからのメッセージを順番に処理します。 ただし、スレッドプールを介してアクターを実行します。 また、ネイティブスレッドは重量があり、そのため数が制限される可能性があることがわかりました。

もちろん、ここで私たちを助けることができる他のパターンがあります—それらについては後で説明します!

4.2. イベントベースの並行性

イベントベースの設計は、ネイティブスレッドの生成と操作にコストがかかるという問題に明示的に対処します。 イベントベースの設計の1つは、イベントループです。 イベントループは、イベントプロバイダーと一連のイベントハンドラーで機能します。 この設定では、イベントループはイベントプロバイダーでブロックし、到着時にイベントハンドラーにイベントをディスパッチします

基本的に、イベントループはイベントディスパッチャに他なりません。 イベントループ自体は、単一のネイティブスレッドで実行できます。 では、イベントループで実際に何が発生するのでしょうか。 例として、非常に単純なイベントループの擬似コードを見てみましょう。

while(true) {
    events = getEvents();
    for(e in events)
        processEvent(e);
}

基本的に、イベントループが実行しているのは、イベントを継続的に検索し、イベントが見つかった場合はそれらを処理することだけです。 アプローチは本当に単純ですが、イベント駆動型設計のメリットを享受します。

この設計を使用して同時アプリケーションを構築すると、アプリケーションをより細かく制御できます。 また、デッドロックなど、マルチスレッドアプリケーションの一般的な問題のいくつかを排除します。

JavaScriptは、非同期プログラミングを提供するイベントループを実装しますは、実行するすべての関数を追跡するための呼び出しスタックを維持します。 また、処理のために新しい関数を送信するためのイベントキューを維持します。 イベントループは常に呼び出しスタックをチェックし、イベントキューから新しい関数を追加します。 すべての非同期呼び出しは、通常はブラウザによって提供されるWebAPIにディスパッチされます。

イベントループ自体は単一のスレッドで実行できますが、WebAPIは個別のスレッドを提供します。

4.3. 非ブロッキングアルゴリズム

非ブロッキングアルゴリズムでは、1つのスレッドを一時停止しても、他のスレッドは一時停止されません。 アプリケーションに含めることができるネイティブスレッドの数は限られていることがわかりました。 現在、スレッドをブロックするアルゴリズムは、明らかにスループットを大幅に低下させ、高度な同時実行アプリケーションを構築できなくなります。

非ブロッキングアルゴリズムは常に、基盤となるハードウェアによって提供されるコンペアアンドスワップアトミックプリミティブを利用します。 これは、ハードウェアがメモリ位置の内容を指定された値と比較し、それらが同じである場合にのみ、値を新しい指定された値に更新することを意味します。 これは単純に見えるかもしれませんが、そうでなければ同期を必要とするアトミック操作を効果的に提供します。

これは、この不可分操作を利用する新しいデータ構造とライブラリを作成する必要があることを意味します。 これにより、いくつかの言語での待機やロックのない実装の膨大なセットが得られました。 Javaには、 AtomicBoolean AtomicInteger AtomicLong AtomicReferenceなどのいくつかの非ブロッキングデータ構造があります。

複数のスレッドが同じコードにアクセスしようとしているアプリケーションについて考えてみます。

boolean open = false;
if(!open) {
    // Do Something
    open=false;
}

明らかに、上記のコードはスレッドセーフではなく、マルチスレッド環境での動作は予測できない可能性があります。 ここでのオプションは、このコードをロックと同期するか、アトミック操作を使用することです。

AtomicBoolean open = new AtomicBoolean(false);
if(open.compareAndSet(false, true) {
    // Do Something
}

ご覧のとおり、 AtomicBoolean のような非ブロッキングデータ構造を使用すると、ロックの欠点に甘んじることなく、スレッドセーフなコードを記述できます。

5. プログラミング言語でのサポート

並行モジュールを構築する方法は複数あることがわかりました。 プログラミング言語は違いを生みますが、それは主に、基盤となるオペレーティングシステムが概念をサポートする方法です。 ただし、ネイティブスレッドでサポートされるスレッドベースの同時実行性は、スケーラビリティに関して新しい壁にぶつかっているため、常に新しいオプションが必要です。

前のセクションで説明した設計手法のいくつかを実装することは、効果的であることが証明されています。 ただし、プログラミング自体が複雑になることを覚えておく必要があります。 私たちが本当に必要としているのは、スレッドベースの並行性の力を、それがもたらす望ましくない影響なしに提供するものです。

私たちが利用できる解決策の1つは、グリーンスレッドです。 グリーンスレッドは、基盤となるオペレーティングシステムによってネイティブにスケジュールされるのではなく、ランタイムライブラリによってスケジュールされるスレッドです。 これはスレッドベースの同時実行性のすべての問題を取り除くわけではありませんが、場合によっては確かにパフォーマンスを向上させることができます。

現在、使用するプログラミング言語がグリーンスレッドをサポートしていない限り、グリーンスレッドを使用するのは簡単ではありません。 すべてのプログラミング言語にこの組み込みのサポートがあるわけではありません。 また、私たちが大まかにグリーンスレッドと呼んでいるものは、さまざまなプログラミング言語によって非常にユニークな方法で実装できます。 私たちが利用できるこれらのオプションのいくつかを見てみましょう。

5.1. GoのGoroutines

Goプログラミング言語のゴルーチンは軽量スレッドです。 これらは、他の関数またはメソッドと同時に実行できる関数またはメソッドを提供します。 ゴルーチンは、から始めて、スタックサイズが数キロバイトしかないため非常に安価です。

最も重要なことは、ゴルーチンが少数のネイティブスレッドと多重化されていることです。 さらに、ゴルーチンはチャネルを使用して相互に通信するため、共有メモリへのアクセスを回避できます。 私たちは必要なものをほぼすべて手に入れ、何もせずに何を推測します!

5.2. Erlangでのプロセス

Erlang では、実行の各スレッドはプロセスと呼ばれます。 しかし、これまでに説明したプロセスとはまったく異なります。 Erlangプロセスは軽量で、メモリフットプリントが小さく、スケジューリングのオーバーヘッドが少なく、の作成と廃棄が高速です。

内部的には、Erlangプロセスはランタイムがスケジューリングを処理する関数に他なりません。 さらに、Erlangプロセスはデータを共有せず、メッセージパッシングによって相互に通信します。 これが、そもそもこれらを「プロセス」と呼ぶ理由です。

5.3. Javaのファイバー(提案)

Javaとの並行性の話は、絶え間なく進化してきました。 Javaは、そもそも、少なくともSolarisオペレーティングシステムではグリーンスレッドをサポートしていました。 ただし、このチュートリアルの範囲を超えるハードルのため、これは中止されました。

それ以来、Javaでの並行性は、ネイティブスレッドと、それらをスマートに操作する方法がすべてです。 しかし、明らかな理由から、Javaには、ファイバーと呼ばれる新しい並行性の抽象化が間もなく登場する可能性があります。 Project Loom は、継続をファイバーと一緒に導入することを提案しています。これにより、Javaでの並行アプリケーションの記述方法が変わる可能性があります。

これは、さまざまなプログラミング言語で利用できるもののほんの一部です。 他のプログラミング言語が並行性を処理しようとしたはるかに興味深い方法があります。

さらに、前のセクションで説明したデザインパターンの組み合わせと、グリーンスレッドのような抽象化のプログラミング言語サポートは、並行性の高いアプリケーションを設計するときに非常に強力になる可能性があることに注意してください。

6. 同時実行性の高いアプリケーション

実際のアプリケーションでは、多くの場合、複数のコンポーネントがネットワークを介して相互作用します。 通常、インターネット経由でアクセスし、プロキシサービス、ゲートウェイ、Webサービス、データベース、ディレクトリサービス、ファイルシステムなどの複数のサービスで構成されています。

このような状況で高い同時実行性を確保するにはどうすればよいですか? これらのレイヤーのいくつかと、並行性の高いアプリケーションを構築するためのオプションについて見ていきましょう。

前のセクションで見たように、同時実行性の高いアプリケーションを構築するための鍵は、そこで説明されている設計概念のいくつかを使用することです。 私たちはその仕事に適したソフトウェアを選ぶ必要があります—これらの慣行のいくつかをすでに組み込んでいるものです。

6.1. Webレイヤー

Webは通常、ユーザーリクエストが到着する最初のレイヤーであり、ここでは高い同時実行性のプロビジョニングが避けられません。 オプションのいくつかを見てみましょう:

  • Node (NodeJSまたはNode.jsとも呼ばれます)は、ChromeのV8JavaScriptエンジン上に構築されたオープンソースのクロスプラットフォームJavaScriptランタイムです。 ノードは、非同期I/O操作の処理で非常にうまく機能します。 Nodeがこれをうまく実行する理由は、単一のスレッドでイベントループを実装するためです。 コールバックを使用したイベントループは、I/Oなどのすべてのブロッキング操作を非同期で処理します。
  • nginx は、オープンソースのWebサーバーであり、他の用途の中でもリバースプロキシとして一般的に使用されています。 nginxが高い同時実行性を提供する理由は、非同期のイベント駆動型アプローチを使用しているためです。 nginxは、単一スレッドのマスタープロセスで動作します。 マスタープロセスは、実際の処理を行うワーカープロセスを維持します。 したがって、ワーカープロセスは各要求を同時に処理します。

6.2. アプリケーション層

アプリケーションを設計する際、高い同時実行性を実現するためのツールがいくつかあります。 私たちが利用できるこれらのライブラリとフレームワークのいくつかを調べてみましょう。

  • Akka は、JVM上で高度な並行および分散アプリケーションを構築するためにScalaで記述されたツールキットです。 並行性の処理に対するAkkaのアプローチは、前に説明したアクターモデルに基づくです。 Akkaは、アクターと基盤となるシステムの間にレイヤーを作成します。 フレームワークは、スレッドの作成とスケジューリング、メッセージの受信とディスパッチの複雑さを処理します。
  • Project Reactor は、JVM上で非ブロッキングアプリケーションを構築するためのリアクティブライブラリです。 これはReactiveStreams仕様に基づいており、効率的なメッセージパッシングとデマンド管理(バックプレッシャー)に重点を置いています。 リアクターのオペレーターとスケジューラーは、メッセージの高いスループット率を維持できます。 Spring WebFluxやRSocketなど、いくつかの一般的なフレームワークがリアクターの実装を提供します。
  • Netty は、非同期のイベント駆動型ネットワークアプリケーションフレームワークです。 Nettyを使用して、高度な同時プロトコルサーバーとクライアントを開発できます。 Nettyは、バッファとチャネルを介した非同期データ転送を提供するJavaAPIのコレクションであるNIOを活用します。 スループットの向上、レイテンシの短縮、リソース消費の削減、不要なメモリコピーの最小化など、いくつかの利点があります。

6.3. データレイヤー

最後に、データなしで完全なアプリケーションはありません。データは永続ストレージから取得されます。 データベースに関して高い同時実行性について説明する場合、ほとんどの焦点はNoSQLファミリーにとどまります。 これは主に、NoSQLデータベースが提供できる線形スケーラビリティによるものですが、リレーショナルバリアントでは実現が困難です。 データレイヤーの2つの一般的なツールを見てみましょう。

  • Cassandra は、無料のオープンソースNoSQL分散データベースであり、コモディティハードウェアで高可用性、高スケーラビリティ、およびフォールトトレランスを提供します。 ただし、Cassandraは複数のテーブルにまたがるACIDトランザクションを提供しません。 したがって、アプリケーションが強力な一貫性とトランザクションを必要としない場合、Cassandraの低レイテンシー操作の恩恵を受けることができます。
  • Kafkaは分散ストリーミングプラットフォームです。 Kafkaは、トピックと呼ばれるカテゴリにレコードのストリームを保存します。 これは、レコードのプロデューサーとコンシューマーの両方に線形の水平スケーラビリティを提供すると同時に、高い信頼性と耐久性を提供します。 パーティション、レプリカ、およびブローカーは、大規模に分散された同時実行性を提供する基本的な概念の一部です。

6.4. キャッシュレイヤー

さて、高い同時実行性を目指す現代の世界では、毎回データベースにアクセスする余裕のあるWebアプリケーションはありません。 そのため、キャッシュを選択する必要があります。できれば、同時実行性の高いアプリケーションをサポートできるメモリ内キャッシュを選択してください。

  • Hazelcast は、分散型のクラウド対応のメモリ内オブジェクトストアであり、 Map[などのさまざまなデータ構造をサポートするコンピューティングエンジンです。 X176X]、 Set List MultiMap RingBuffer 、およびHyperLogLog。 レプリケーションが組み込まれており、高可用性と自動パーティショニングを提供します。
  • Redis は、主にキャッシュとして使用するメモリ内のデータ構造ストアです。 オプションの耐久性を備えたメモリ内のKey-Valueデータベースを提供します。 サポートされているデータ構造には、文字列、ハッシュ、リスト、およびセットが含まれます。 Redisにはレプリケーションが組み込まれており、高可用性と自動パーティショニングを提供します。 永続性が必要ない場合、Redis は、優れたパフォーマンスを備えた、機能が豊富でネットワーク化されたメモリ内キャッシュを提供できます。

もちろん、高度な並行アプリケーションを構築するために利用できるものの表面をかろうじてかじっただけです。 利用可能なソフトウェアよりも、私たちの要件が適切な設計を作成するための指針となるはずであることに注意することが重要です。 これらのオプションのいくつかは適切かもしれませんが、他のオプションは適切でないかもしれません。

そして、私たちの要件により適しているかもしれない利用可能なオプションがもっとたくさんあることを忘れないでください。

7. 結論

この記事では、並行プログラミングの基本について説明しました。 並行性の基本的な側面と、それが引き起こす可能性のある問題のいくつかを理解しました。 さらに、並行プログラミングの一般的な問題を回避するのに役立ついくつかのデザインパターンを調べました。

最後に、非常に並行性の高いエンドツーエンドのアプリケーションを構築するために利用できるフレームワーク、ライブラリ、およびソフトウェアのいくつかを確認しました。