コルーチンの原理と応用、C ++実数コルーチン

コルーチンの原理

コルーチンは、オペレーティングシステムの概念を持つスレッドとは異なります。実際、コルーチンは関数のようなプログラムコンポーネントです。数十万の関数呼び出しと同じように、1つのスレッドで数十万のコルーチンを簡単に作成できます。関数のみが呼び出しエントリの開始点を1つだけ持ち、戻った後に終了します。コルーチンエントリは開始点になることも、前の戻り点から実行を継続することもできます。つまり、実行権は、yieldを介してコルーチン間で転送できます。 。、関数のように上位レベルと下位レベルの関係を呼び出すのではなく、対称的かつ同じレベルで相互に呼び出します。もちろん、コルーチンは関数をシミュレートして、非対称コルーチンと呼ばれる上位レベルと下位レベルの間の呼び出し関係を実現することもできます。

対称的なコルーチン呼び出しシナリオ、最もよく知られている「生産者/消費者」イベント駆動型モデルを見てみましょう。一方のコルーチンは製品の生成とキューへの追加を担当し、もう一方は製品のキューからの削除を担当します。キュー。そしてそれを使用します。効率を上げるには、一度に複数の製品を追加または削除する必要があります。擬似コードは次のようになります。

# producer coroutine
loop
while queue is not full
  create some new items
  add the items to queue
yield to consumer
 
# consumer coroutine
loop
while queue is not empty
  remove some items from queue
  use the items
yield to producer

生産者/消費者モデルの実装に複数のスレッドを使用する場合は、グローバルリソースの競合状態を回避するために、スレッド間で同期メカニズムを使用する必要があります。これにより、スリープ、スケジューリング、コンテキストスイッチングなどのシステムオーバーヘッドが必然的に発生し、スレッドのスケジューリングはまた、タイミングに不確実性が生じます。

コルーチンの場合、「ハング」の概念は、コード実行権を転送して別のコルーチンを呼び出すことです。転送されたコルーチンがしばらく終了すると、再度呼び出され、一時停止の時点から「ウェイクアップ」されます。プログラムは論理的に制御可能で時系列であり、すべてが制御されていると言えます。

今日では、C#、erlang、golang、軽量のpython、lua、javascript、ruby、機能的なscala、schemeなどのコルーチンセマンティクスを持つ一部の言語は比較的重いです。対照的に、元の生態学的言語であるCは、厄介な位置にあります。その理由は、Cがスタックフレームと呼ばれるルーチン呼び出しに依存しているためです。ルーチンの内部状態と戻り値はスタックに保持されます。つまり、プロデューサーです。もちろん、プロデューサーをメインルーチンとして使用し、製品を渡されたパラメーターとして使用してコンシューマールーチンを呼び出すように書き直すことはできます。この種のコードは、記述や外観が不快です。特に、コロの数が10万のオーダーに達すると、この書き方は厳しすぎます。

各コルーチンのコンテキスト(プログラムカウンターなど)がスタックではなく他の場所に保存されている場合、コルーチンが相互に呼び出すと、呼び出されたコルーチンは、外部の場所からの最後の転送ポイントの前にコンテキストを復元するだけで済みます。スタック。ただし、これはCPUコンテキストスイッチングに少し似ています。C標準ライブラリは2つのコルーチンスケジューリングプリミティブを提供します。1つはsetjmp / longjmpで、もう1つはucontextコンポーネントであり、内部で(もちろんアセンブリ言語で)実装されます。コルーチンのコンテキスト切り替えにより、前者はアプリケーションにかなりの不確実性があり(たとえば、パッケージ化するのは簡単ではありません。具体的な手順についてはオンラインドキュメントを参照してください)、後者がより広く使用されます。ほとんどのCコルーチンインターネット上ライブラリもucontextコンポーネントに基づいて実装されています。

Pythonのyieldセマンティック関数は、反復ジェネレーターに似ていることがわかっています。この関数は、最後の呼び出しの状態を保持し、次に呼び出されたときに最後の戻り点から実行を継続します。次に例を示します。

def cols():
    for i in range(10):
        yield i

g=cols()
for k in g:
    print(k) 

C言語のyiledセマンティクスがどのように実装されているかを見てみましょう。

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: goto LABEL0;
    case 1: goto LABEL1;
  }
  LABEL0: /* start of function */
  for (i = 0; i < 10; i++) {
    state = 1; /* so we will come back to LABEL1 */
    return i;
    LABEL1:; /* resume control straight after the return */
  }
}

これは、静的変数とgotoジャンプを使用することで実現されます。gotoを使用しない場合は、switchのジャンプ機能を直接使用できます。

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: /* start of function */
    for (i = 0; i < 10; i++) {
      state = 1; /* so we will come back to "case 1" */
      return i;
      case 1:; /* resume control straight after the return */
    }
  }
} 

__LINE__マクロを使用して、より一般的にすることもできます。

int function(void) {
  static int i, state = 0;
  switch (state) {
    case 0: /* start of function */
    for (i = 0; i < 10; i++) {
      state = __LINE__ + 2; /* so we will come back to "case __LINE__" */
      return i;
      case __LINE__:; /* resume control straight after the return */
    }
  }
}

このようにして、マクロを使用してパラダイムを抽出し、それをコンポーネントにカプセル化できます。

#define Begin() static int state=0; switch(state) { case 0:
#define Yield(x) do { state=__LINE__; return x; case __LINE__:; } while (0)
#define End() }
int function(void) {
  static int i;
  Begin();
  for (i = 0; i < 10; i++)
    Yield(i);
  End();
}

この種のコルーチンの実装方法には使用上の制限があります。つまり、コルーチンのスケジューリング状態の保存は、スタック上のローカル変数ではなく静的変数に依存します。実際、ローカル変数(スタック)を使用して状態を保存することはできません。 、これにより、コードには再入可能性とマルチスレッドアプリケーションがありません。ローカル変数が関数パラメーターによって渡された架空のコンテキスト構造ポインターにラップされ、動的に割り当てられたヒープを使用してスタックを「シミュレート」すると、スレッドの再入可能性の問題が解決されます。ただし、これはコードの明確さを損なうことになります。たとえば、すべてのローカル変数はオブジェクトメンバーへの参照として記述する必要があります。特に、ローカル変数が多い場合は面倒です。たとえば、マクロ定義malloc / freeのゲームプレイは大きすぎて制御が難しい。

コルーチン自体はシングルスレッドソリューションであるため、アプリケーション環境はシングルスレッドであり、コードの再入力の問題はないと想定する必要があります。静的変数を大胆に使用して、コードの単純さと読みやすさを維持できます。実際、マルチスレッド環境でこのような単純なコルーチンを使用することを検討するべきではありません。使用する必要がある場合は、前述のglibcのucontextコンポーネントも実行可能な代替手段であり、コルーチンのプライベートスタックのコンテキストを提供します。もちろん、この使用法はクロススレッドで無制限ではありません。ドキュメントを注意深く読んでください。

コルーチンの古典的なトレーニングキャンプビデオをあなたと共有します、説明はまだ非常に詳細です、それは学ぶ価値があります、そしてビデオリンクが添付されています:コルーチンフレームワークの実現、基礎となる原理とパフォーマンス分析、インタビューブレード-学習ビデオ

クリックしてqunに入ります:ドキュメント取得するためにジャンプします

 

コルーチンの同時適用

コルーチンは、単一スレッドで同期プログラミングのアイデアを使用して非同期処理フローを実現し、単一スレッドが数百または数千の要求を同時に処理でき、各要求の処理プロセスが使用せずに線形であることを実現することです。あいまいなコールバックの処理フローを接続するメカニズム。

イベント駆動型ステートマシン

従来のWebサーバー(nginx、squidなど)はすべて、EDSM(イベント駆動型ステートマシン)メカニズムを使用して要求を同時に処理します。これは、コールバックメソッドを使用してスレッドのブロックを回避する非同期処理メソッドです。

EDSMの最も一般的な方法は、I / Oイベントの非同期コールバックです。基本的に、ディスパッチャーと呼ばれるシングルスレッドのメインループ(イベントループとも呼ばれます)があり、ユーザーはコールバック関数(イベントハンドラーとも呼ばれます)をディスパッチャーに登録することで非同期通知を実装できるため、その場でリソースを浪費します。ディスパッチャのメインループで、select()/ epoll()などのシステムコールを通じてさまざまなI / Oイベントが発生するのを待ちます。カーネルがイベントがトリガーされ、データが利用可能または利用可能であることを検出したら、select()/ epoll ()は、ディスパッチャが対応するコールバック関数を呼び出してユーザーの要求を処理するように戻ります。

プロセス全体がシングルスレッドです。この種の処理は、基本的に、一連の互いに素なコールバックを、それらが順次リンクリスト上で直列に接続されているかのように同期することです。次の図に示すように、黒い二重矢印はI / Oイベントの多重化を示します。コールバックは、さまざまな要求の処理を含むバスケットです(もちろん、すべての要求にコールバックがあるわけではありません。要求は別のコールバックに対応することもできます) )、各コールバック直列に接続され、ディスパッチャによってアクティブ化されます。ここでの要求は、(オペレーティングシステムのスレッドではなく)スレッドの概念と同等ですが、「コンテキストスイッチ」(コンテキストスイッチ)は各コールバックの最後に発生します(異なる要求が異なるコールバックに対応すると仮定)。イベントを待機する次のコールバックトリガーされたときに他のリクエストの処理を再開します。ディスパッチャの実行状態(実行状態)は、コールバック関数のパラメータとして保存して渡すことができます。

非同期コールバックの欠点は、実装と拡張が難しいことです。DeanGaudet(Apache開発者)が述べたように、libeventなどの一般的なライブラリや、他のアクター/リエーターのデザインパターンやフレームワークはすでに存在します。 -線形思考をコールバックのバケットロードに分割します-それはまだ存在しています。」上の図からわかるように、コールバック間のリクエストルーチンは連続的ではありません。たとえば、コールバックを切り替えると一部のリクエストが中断されたり、再登録が必要な新しいリクエストがあったりします。

コルーチンは本質的にEDSMモデルに基づいていますが、従来の非同期コールバックメソッドを置き換えることを目的としています。コルーチンは、要求をスレッドの概念に抽象化して、自然プログラミングモデルに近づけます(いわゆる線形思考は、オペレーティングシステムのスレッドを切り替えるのと同じくらい自然です)。

以下に、コルーチンの実装について説明します。StateThreadsライブラリ。

STライブラリ

ST(State Threads)ライブラリは、高性能でスケーラブルなサーバー(Webサーバー、プロキシサーバー、メールエージェントなど)の実装スキームを提供します。

STライブラリは、マルチスレッドプログラミングパラダイムを簡素化します。各リクエストはスレッドに対応します。ここでのスレッドは実際にはコルーチン(コルーチン)であり、pthreadのようなカーネルスレッドとは異なります。

STスケジューリングの動作原理について少し説明します。STオペレーティング環境は、IOQ(待機キュー)、RUNQ(実行キュー)、SLEEPQ(タイムアウトキュー)、およびZOMBIEQの4つのキューを維持します。各スレッドが異なるキューにある場合、それは異なる状態に対応します(STは、名前が示すように、いわゆるスレッドステートマシンです)。たとえば、ポーリング要求が行われると、現在のスレッドは待機中のイベントを示すためにIOQを追加します(タイムアウトがある場合はSLEEPQに配置されます)。イベントがトリガーされると、スレッドはIOQから削除されます(ある場合)。はタイムアウトであり、SLEEPQからも削除され、RUNQに転送されてスケジュールされるのを待ち、現在実行中のスレッドになります。これは、オペレーティングシステムの準備完了キューに相当します。従来のEDSMに対応して、登録コールバックです。およびアクティベーションコールバック。別の例として、待機/スリープ/ロックの同期制御をシミュレートする場合、現在のスレッドは、アウェイクされるかタイムアウトになるまでSLEEPQに入れられ、スケジュールのために再びRUNQに入ります。

STのスケジューリングには、パフォーマンスとメモリの2つの利点があります。パフォーマンスの観点から、STは独自のsetjmp / longjmpを実装して、システムオーバーヘッドなしでスケジューリングをシミュレートし、コンテキスト(つまり、jmp_buf)は、さまざまなプラットフォームの基盤となる言語で実装されます。アーキテクチャ、および移植性がありますlibcに匹敵します。スケジューリングの実装を説明するために、以下のコードを入力してください。

/*
 * Switch away from the current thread context by saving its state 
 * and calling the thread scheduler
 */
#define _ST_SWITCH_CONTEXT(_thread)       \
    ST_BEGIN_MACRO                        \
    if (!MD_SETJMP((_thread)->context)) { \
      _st_vp_schedule();                  \
    }                                     \
    ST_END_MACRO
 
/*
 * Restore a thread context that was saved by _ST_SWITCH_CONTEXT 
 * or initialized by _ST_INIT_CONTEXT
 */
#define _ST_RESTORE_CONTEXT(_thread)   \
    ST_BEGIN_MACRO                     \
    _ST_SET_CURRENT_THREAD(_thread);   \
    MD_LONGJMP((_thread)->context, 1); \
    ST_END_MACRO
 
void _st_vp_schedule(void)
{
    _st_thread_t *thread;
 
    if (_ST_RUNQ.next != &_ST_RUNQ) {
        /* Pull thread off of the run queue */
        thread = _ST_THREAD_PTR(_ST_RUNQ.next);
        _ST_DEL_RUNQ(thread);
    } else {
        /* If there are no threads to run, switch to the idle thread */
        thread = _st_this_vp.idle_thread;
    }
    ST_ASSERT(thread->state == _ST_ST_RUNNABLE);
 
    /* Resume the thread */
    thread->state = _ST_ST_RUNNING;
    _ST_RESTORE_CONTEXT(thread);
}

setjmp / longjmpの使用法に精通している場合は、現在のスレッドがMD_SETJMPを呼び出してシーンコンテキストをjmp_bufに保存し、0を返し、次に_st_vp_schedule()を呼び出してそれ自体をスケジュールすることを知っています。スケジューラーは最初にRUNQでそれを見つけます。キューが空の場合、アイドルスレッドを見つけます。これはST全体が初期化されたときに作成される特別なスレッドであり、現在のスレッドをそれ自体に設定してから、MD_LONGJMPを呼び出してその場所に切り替えます。ここで、MD_SETJMPは前回呼び出されました。、スレッド->コンテキストからシーンを復元し、1を返すと、スレッドは実行を継続します。プロセス全体は、EDSMと同様にオペレーティングシステムのシングルスレッドで実行されるため、システムのオーバーヘッドやブロッキングは発生しません。

実際、実際のブロッキングは、I / Oイベントの再利用、つまり、ST全体で唯一のシステムコールであるselect()/ epoll()を待機しているときに発生します。STの現在の状態は、環境全体がアイドル状態にあり、スレッドのすべての要求処理が完了している、つまりRUNQが空であるということです。このとき、メインループ(イベントループと同様)は_st_idle_thread_startで維持され、主に3つのタスクを担当します。1。IOQのすべてのスレッドでI / O多重化検出を実行します。2。SLEEPQでタイムアウトチェックを実行します。3。アイドルスレッドを設定します。ディスパッチされます。コードは次のとおりです。

void *_st_idle_thread_start(void *arg)
{
    _st_thread_t *me = _ST_CURRENT_THREAD();
 
    while (_st_active_count > 0) {
        /* Idle vp till I/O is ready or the smallest timeout expired */
        _ST_VP_IDLE();
 
        /* Check sleep queue for expired threads */
        _st_vp_check_clock();
 
        me->state = _ST_ST_RUNNABLE;
        _ST_SWITCH_CONTEXT(me);
    }
 
    /* No more threads */
    exit(0);
 
    /* NOTREACHED */
    return NULL;
}

_st_idle_thread_startがアイドルスレッドを作成するための開始点であるため、ここでの私はアイドルスレッドです。最後の_ST_SWITCH_CONTEXT()から戻るたびに、_ST_VP_IDLE()でI / Oイベントの発生をポーリングし、検出された場合SLEEPQで別のスレッドイベントが発生するか、タイムアウトが発生した場合は、_ST_SWITCH_CONTEXT()を使用して自分自身を切り替えます。この時点でRUNQが空でない場合は、キューの最初のスレッドに切り替わります。ここでは、メインループは終了しません。

メモリに関しては、STの実行状態は、コールバックのように動的に割り当てられるのではなく、ローカル変数としてスタックに格納されます。ユーザーは、次のようにスレッドモードとコールバックモードを使用できます。

/* thread land */
int foo()
{
    int local1;
    int local2;
    do_some_io();
}
 
/* callback land */
struct foo_data {
    int local1;
    int local2;
};
 
void foo_cb(void *arg)
{
    struct foo_data *locals = arg;
    ...
}
 
void foo()
{
    struct foo_data *locals = malloc(sizeof(struct foo_data));
    register(foo_cb, locals);
} 

他に注意すべき点が2つあります。1つは、STのスレッドが非優先非プリエンプティブスケジューリングであるということです。つまり、STはEDSMに基づいています。各スレッドはイベント駆動型またはデータ駆動型です。遅かれ早かれ、それ自体がディスパッチされます。 、およびスケジューリングポイントスレッド管理を簡素化するタイムスライスではなく明らかです。次に、STはすべての信号処理を無視し、sigact.sa_handlerは_st_io_initでSIG_IGNに設定されます。これはスレッドリソースが最小化されて回避されるためです。シグナルマスクとそのシステム呼び出し(ucontextでは回避できません)。ただし、これはSTが信号を処理できないことを意味するものではありません。実際、STは、パイプへの信号の書き込み方法を通常のI / Oイベント処理に変換することを推奨しています。例については、ここを参照してください。

マルチスレッドプログラミングパラダイム

Posix Thread(以下、PThreadと呼びます)は、一般的なスレッドライブラリであり、ユーザーレベルのスレッドをカーネル実行エンティティ(カーネル実行エンティティ、一部の本では軽量プロセスとも呼ばれます)と1:1またはm:nでマッピングします。スレッドモード。たとえば、ApacheサーバーはPThreadを使用して同時リクエスト処理を実装します。各スレッドはリクエストを処理します。スレッドは同期的かつブロック的な方法でリクエストを処理します。スレッドの現在のリクエスト処理が完了するまで、他のリクエストを受け入れません。

STはシングルスレッド(n:1マッピング)であり、そのスレッドは実際にはコルーチンです。一般的なネットワークアプリケーションでは、マルチスレッドパラダイムはオペレーティングシステムをバイパスできませんが、特定のサーバー領域では、スレッド間の共有リソースにより、ロック、競合状態、同時実行性、ファイルハンドル、グローバル変数、パイプなどの複雑さが増します。 、シグナルなど、これらのPthreadの柔軟性は大幅に低下します。STのスケジューリングは正確であり、クリアI / Oと同期関数呼び出しの時点でのみコンテキスト切り替えが発生します。これはコルーチンの特性であるため、STは相互排除保護を必要とせず、使用しても安心です。静的変数と非再入可能ライブラリ関数(これは、スタックがなく、コンテキストを保存できないため、コルーチンでもあるProtothreadsでは許可されていません)。これにより、パフォーマンスが向上しながらプログラミングとデバッグが大幅に簡素化されます。

ちなみに、私が知る限り、C言語でコルーチンを実装する方法は3つしかありません。

1. Protothreadは、代表としてswitch-caseセマンティックジャンプを使用します。

2.STで表されるlibcに依存しないSetjmp / longjmpコンテキストスイッチング。

3. glibc(Yunfengのコルーチン)のucontextインターフェースに依存します。

その中で、Protothreadは最も軽いですが、最も制限があり、ucontextはリソースを消費し、パフォーマンスが遅くなります。現在、STが最適のようです。

総括する

STの核となるアイデアは、マルチスレッドのシンプルでエレガントなパラダイムを使用して、従来の非同期コールバックの複雑であいまいな実装よりも優れたパフォーマンスを発揮し、EDSMのパフォーマンスとデカップリングアーキテクチャを使用して、システムでのマルチスレッドのオーバーヘッドとリーフを回避することです。 。

STの主な制限は、アプリケーションのすべてのI / O操作でSTが提供するAPIを使用する必要があることです。これは、この方法でのみ、スケジューラーがスレッドを管理し、ブロッキングを回避できるためです。

実際、最後の文では、ngx_luaモジュールもコルーチンを使用してNginxプロセスの処理フローを簡素化します。各リクエストはluaコルーチンに対応しているため、リクエストはコルーチン内で線形に処理され、非同期書き込みを回避します。コールバックの。

おすすめ

転載: blog.csdn.net/Linuxhus/article/details/114088289