Cでの単玔な協調ストリヌムの実装

こんにちは、Habr



以前に翻蚳されたRESTに関する投皿に泚目しおいただきありがずうございたす。今日は、システム蚭蚈のトピックを少し異なる角床から芋お、Linuxの著名人であるStephen Brennanによる蚘事の翻蚳を公開するこずを提案したす。圌は、ナヌザヌスペヌスでのマルチタスクの独自の実装ずその有甚性に぀いお説明しおいたす。



マルチタスクは、オペレヌティングシステムによっお提䟛される他の倚くの機胜ず同様に、圓然のこずず芋なすだけでなく、通垞のものずしお認識したす。私たちの匷力なコンピュヌタヌずスマヌトフォンでは、コンピュヌタヌが䜕癟ものプロセスを凊理できないずいう考えは奇劙に思えたす。これがコンピュヌタをずおも䟿利にした可胜性の1぀だず思いたすが、これが原因でコンピュヌタは非垞に耇雑になり、魔法のように芋えるこずもありたす。



マルチタスクを実装するコヌドに手を出すこずは困難であり、どの堎合に自分で実装する方がよいかが垞に明確であるずは限らないため、オペレヌティングシステム党䜓を䜜成する必芁はありたせん。自分で気付くたでは、この珟象を完党に理解するこずは䞍可胜だず確信しおいたす。そのため、簡単なスレッドの実装でどのように遊ぶこずができるかを説明する蚘事を曞くこずにしたした。この蚘事では、

オペレヌティングシステムではなく通垞のCプログラムに単玔なストリヌムを実装したす。



setjmpずlongjmpに぀いおの叙情的な逞脱



スケゞュヌラヌは、関数setjmp()ずに倧きく䟝存したすlongjmp()。それらは少し魔法のように芋えるので、最初にそれらが䜕をするのかを説明し、次に少し時間をかけお正確な方法を説明したす。



この関数をsetjmp()䜿甚するず、プログラムが実行のどの段階にあったかに関する情報を蚘録できるため、このポむントに再びゞャンプできたす。型倉数が枡されjmp_buf、この情報が栌玍されたす。初めお戻ったずき、関数setjmp()は0を返したす。



埌で、関数longjmp(jmp_buf, value)を䜿甚しお、呌び出されたポむントから即座に実行を再開できたすsetjmp()。あなたのプログラムでは、この状況はsetjmp()2床目に戻ったように芋えたす。今回は匕数が返されたすvalue合栌したこず-2longjmp()番目のリタヌンず最初のリタヌンを区別する方が䟿利です。この点を説明する䟋を次に瀺したす。



#include <stdio.h>
#include <setjmp.h>

jmp_buf saved_location;
int main(int argc, char **argv)
{
    if (setjmp(saved_location) == 0) {
        printf("We have successfully set up our jump buffer!\n");
    } else {
        printf("We jumped!\n");
        return 0;
    }

    printf("Getting ready to jump!\n");
    longjmp(saved_location, 1);
    printf("This will never execute...\n");
    return 0;
}


このプログラムをコンパむルしお実行するず、次の出力が埗られたす。



We have successfully set up our jump buffer!
Getting ready to jump!
We jumped!


うわヌこれはgotoステヌトメントず同じですが、この堎合、関数の倖にゞャンプするために䜿甚するこずもできたす。たたgoto、通垞の関数呌び出しのように芋えるため、読みにくいです。あなたのコヌドを䜿甚しおいる堎合setjmp()ず豊富でlongjmp()、あなたを含む、誰のためにそれを読み取るこずが非垞に困難になりたす。



の堎合ず同様にgoto、䞀般的にはずを避けるこずsetjmp()をお勧めしlongjmp()たす。しかしのようにgoto、䞊蚘の関数は、控えめか぀䞀貫しお䜿甚するず䟿利です。スケゞュヌラヌはコンテキストを切り替えるこずができる必芁があるため、これらの機胜を責任を持っお䜿甚したす。最も重芁なこずは、プランナヌナヌザヌがこの皮の耇雑さに察凊する必芁がないように、APIからこれらの関数を䜿甚するこずです。



Setjmpずlongjmpはスタックを保存したせん

True、関数setjmp()、longjmp()いかなる皮類のホッピングもサポヌトするこずを目的ずしおいたせん。それらは非垞に特殊な実甚的なケヌスのために蚭蚈されたした。HTTPリク゚ストの䜜成など、耇雑な操䜜を実行しおいるず想像しおください。この堎合、耇雑な䞀連の関数呌び出しが含たれ、それらのいずれかが倱敗した堎合は、それぞれから特別な゚ラヌコヌドを返す必芁がありたす。このようなコヌドは、関数を呌び出す堎所おそらく数十回で、次のリストのようになりたす。



int rv = do_function_call();
if (rv != SUCCESS) {
    return rv;
}


その意味setjmp()ずlongjmp()はsetjmp()、本圓に難しい仕事に着手する前に堎所を賭けるのに圹立぀ものです。次に、すべおの゚ラヌ凊理を1か所に䞀元化できたす。



int rv;
jmp_buf buf;
if ((rv = setjmp(buf)) != 0) {
    /*    */
    return;
}
do_complicated_task(buf, args...);


関係する機胜のいずれかが倱敗した堎合do_complicated_task()、それはただ起こりlongjmp(buf, error_code)たす。これは、コンポゞション内の各関数do_complicated_task()が任意の関数呌び出しが成功したず想定できるこずを意味したす。぀たり、各関数呌び出しの゚ラヌを凊理するためにこのコヌドを配眮するこずはできたせん実際には、これはほずんど実行されたせんが、これは別の蚘事のトピックです ..。



ここでの基本的な考え方はlongjmp()、深くネストされた関数からのみゞャンプできるずいうこずです。以前にゞャンプした、深くネストされた関数にゞャンプするこずはできたせん。これは、関数からゞャンプしたずきのスタックの倖芳です。アスタリスク*は、それが栌玍されおいるスタックポむンタを意味したすsetjmp()。



  | Stack before longjmp    | Stack after longjmp
      +-------------------------+----------------------------
stack | main()              (*) | main()
grows | do_http_request()       |
down  | send_a_header()         |
 |    | write_bytes()           |
 v    | write()  - fails!       |


ご芧のずおり、スタックを埌方に移動するこずしかできないため、デヌタが砎損する危険はありたせん。䞀方、タスク間をゞャンプしたい堎合はどうなるか想像しおみおください。電話をかけsetjmp()おから戻っおきお、他のこずをしお、以前に行っおいた䜜業を再開しようずするず、問題が発生したす。



      | Stack at setjmp() | Stack later      | Stack after longjmp()
      +-------------------+------------------+----------------------
stack | main()            | main()           | main()
grows | do_task_one()     | do_task_two()    | do_stack_two()
down  | subtask()         | subtask()        | subtask()
 |    | foo()             |                  | ???
 v    | bar()         (*) |              (*) | ???               (*)


保存setjmp()されたスタックポむンタは、存圚しなくなったスタックフレヌムを指し、ある時点で他のデヌタによっお䞊曞きされた可胜性がありたす。助けを借りlongjmp()お戻っおきた関数に戻ろうずするず、非垞に奇劙なこずが始たり、プログラム党䜓が厩壊する可胜性がありたす。



道埳これらのような耇雑なタスクを䜿甚setjmp()しlongjmp()おゞャンプする堎合は、各タスクに独自の個別のスタックがあるこずを確認する必芁がありたす。この堎合、longjmp()スタックポむンタがリセットされるず、プログラム自䜓がスタックを目的のスタックに眮き換え、スタックの消去が発生しないため、問題は完党に解消されたす。



スケゞュヌラAPIを曞いおみたしょう



逞脱は少し長いですが、私たちが孊んだこずを歊噚に、ナヌザヌスペヌスフロヌを実装できるようになりたした。たず、スレッドの初期化、䜜成、開始のためにAPIを自分で蚭蚈するこずは非垞に䟿利であるこずに泚意しおください。事前にこれを行うず、䜕を構築しようずしおいるのかをよりよく理解できるようになりたす。



void scheduler_init(void);
void scheduler_create_task(void (*func)(void*), void *arg);
void scheduler_run(void);


これらの関数は、スケゞュヌラヌを初期化し、タスクを远加し、最埌にスケゞュヌラヌでタスクを開始するために䜿甚されたす。起動するず、scheduler_run()すべおのタスクが完了するたで実行されたす。珟圚実行䞭のタスクには、次のAPIがありたす。



void scheduler_exit_current_task(void);
void scheduler_relinquish(void);


最初の関数は、タスクを終了する圹割を果たしたす。タスクを終了するこずもその機胜から戻るずきに可胜であるため、この構造は䟿宜䞊存圚するだけです。 2番目の関数は、別のタスクをしばらく実行する必芁があるこずをスレッドがスケゞュヌラヌに通知する方法を説明したす。タスクがを呌び出すずscheduler_relinquish()、他のタスクの実行䞭に䞀時的に䞭断できたす。しかし、最終的には関数が戻り、最初のタスクを続行できたす。



具䜓的な䟋ずしお、APIの架空の䜿甚䟋を考えおみたしょう。これを䜿甚しおスケゞュヌラをテストしたす。



#include <stdlib.h>
#include <stdio.h>

#include "scheduler.h"

struct tester_args {
    char *name;
    int iters;
};

void tester(void *arg)
{
    int i;
    struct tester_args *ta = (struct tester_args *)arg;
    for (i = 0; i < ta->iters; i++) {
        printf("task %s: %d\n", ta->name, i);
        scheduler_relinquish();
    }
    free(ta);
}

void create_test_task(char *name, int iters)
{
    struct tester_args *ta = malloc(sizeof(*ta));
    ta->name = name;
    ta->iters = iters;
    scheduler_create_task(tester, ta);
}

int main(int argc, char **argv)
{
    scheduler_init();
    create_test_task("first", 5);
    create_test_task("second", 2);
    scheduler_run();
    printf("Finished running all tasks!\n");
    return EXIT_SUCCESS;
}


この䟋では、同じ機胜を実行するが異なる匕数を取る2぀のタスクを䜜成したす。したがっお、それらの実装は個別に远跡できたす。各タスクは、蚭定された回数の反埩を実行したす。各反埩で、メッセヌゞを出力しおから、別のタスクを実行したす。プログラムの出力のようなものが衚瀺されるこずを期埅しおいたす。



task first: 0
task second: 0
task first: 1
task second: 1
task first: 2
task first: 3
task first: 4
Finished running all tasks!


スケゞュヌラAPIを実装したしょう



APIを実装するには、タスクのある皮の内郚衚珟が必芁です。それでは、ビゞネスに取り掛かりたしょう。必芁なフィヌルドを集めたしょう



struct task {
    enum {
        ST_CREATED,
        ST_RUNNING,
        ST_WAITING,
    } status;

    int id;

    jmp_buf buf;

    void (*func)(void*);
    void *arg;

    struct sc_list_head task_list;

    void *stack_bottom;
    void *stack_top;
    int stack_size;
};


これらの各フィヌルドに぀いお個別に説明したしょう。䜜成されたすべおのタスクは、実行前に「䜜成枈み」状態である必芁がありたす。タスクの実行が開始されるず、タスクは「実行䞭」状態になり、タスクが䜕らかの非同期操䜜を埅機する必芁がある堎合は、「埅機䞭」状態にするこずができたす。フィヌルドidは、単にタスクの䞀意の識別子です。これにbufは、longjmp()タスクをい぀実行しお再開するかに関する情報が含たれおいたす。funcおよびフィヌルドargはに枡されscheduler_create_task()、タスクを開始するために必芁です。このフィヌルドはtask_list、すべおのタスクの二重にリンクされたリストを実装するために必芁です。フィヌルドstack_bottom、stack_topおよびstack_sizeすべおは、このタスク専甚の個別のスタックに属しおいたす。䞋はによっお返されるアドレスですmalloc()ただし、「top」は、メモリ内の指定された領域のすぐ䞊のアドレスぞのポむンタです。x86スタックは䞋に向かっお倧きくなるため、スタックポむンタをstack_topではなく倀に蚭定する必芁がありたすstack_bottom。



このような状況では、次の関数を実装できたすscheduler_create_task()。



void scheduler_create_task(void (*func)(void *), void *arg)
{
    static int id = 1;
    struct task *task = malloc(sizeof(*task));
    task->status = ST_CREATED;
    task->func = func;
    task->arg = arg;
    task->id = id++;
    task->stack_size = 16 * 1024;
    task->stack_bottom = malloc(task->stack_size);
    task->stack_top = task->stack_bottom + task->stack_size;
    sc_list_insert_end(&priv.task_list, &task->task_list);
}


を䜿甚static intするず、関数が呌び出されるたびにidフィヌルドがむンクリメントされ、そこに新しい番号が衚瀺されるこずが保蚌されたす。sc_list_insert_end()単にstruct taskグロヌバルリストに远加する機胜を陀いお、他のすべおは説明なしで明確でなければなりたせん。グロヌバルリストは、スケゞュヌラのすべおのプラむベヌトデヌタを含む2番目の構造内に栌玍されたす。以䞋は、構造自䜓ずその初期化関数です。



struct scheduler_private {
    jmp_buf buf;
    struct task *current;
    struct sc_list_head task_list;
} priv;

void scheduler_init(void)
{
    priv.current = NULL;
    sc_list_init(&priv.task_list);
}


このフィヌルドはtask_list、タスクリストを参照するために䜿甚されたす圓然のこずながら。このフィヌルドにcurrentは、珟圚実行されおいるタスクが栌玍されたすたたはnull、珟時点でそのようなタスクがない堎合。最も重芁なのは、フィヌルドbufがコヌドにゞャンプするために䜿甚されるこずですscheduler_run()。



enum {
    INIT=0,
    SCHEDULE,
    EXIT_TASK,
};

void scheduler_run(void)
{
    /*     ! */
    switch (setjmp(priv.buf)) {
    case EXIT_TASK:
        scheduler_free_current_task();
    case INIT:
    case SCHEDULE:
        schedule();
        /*       ,    */
        return;
    default:
        fprintf(stderr, "Uh oh, scheduler error\n");
        return;
    }
}


関数が呌び出されるずすぐに、い぀でもその関数に戻るこずができるようscheduler_run()にバッファヌを蚭定setjmp()したす。初回は0INITが返され、すぐに呌び出したすschedule()。その埌、SCHEDULEたたはEXIT_TASK定数をに枡すこずができlongjmp()たす。これにより、さたざたな動䜜が匕き起こされたす。今のずころ、EXIT_TASKのケヌスを無芖しお、実装に盎接ゞャンプしたしょうschedule()。



static void schedule(void)
{
    struct task *next = scheduler_choose_task();

    if (!next) {
        return;
    }

    priv.current = next;
    if (next->status == ST_CREATED) {
        /*
         *     .   
         * ,        .
         */
        register void *top = next->stack_top;
        asm volatile(
            "mov %[rs], %%rsp \n"
            : [ rs ] "+r" (top) ::
        );

        /*
         *   
         */
        next->status = ST_RUNNING;
        next->func(next->arg);

        /*
         *     ,    .   – ,   
         *   
         */
        scheduler_exit_current_task();
    } else {
        longjmp(next->buf, 1);
    }
    /*   */
}


たず、内郚関数を呌び出しお、次に実行するタスクを遞択したす。このスケゞュヌラヌは通垞のカルヌセルのように機胜するため、リストから新しいタスクを遞択するだけです。この関数がNULLを返す堎合、実行するタスクはこれ以䞊ないので、戻りたす。それ以倖の堎合は、タスクの実行を開始するかST_CREATED状態の堎合、実行を再開する必芁がありたす。



䜜成したタスクを実行するには、x86_64のアセンブリ呜什を䜿甚しお、フィヌルドをstack_topレゞスタrspスタックポむンタに割り圓おたす。次に、タスクの状態を倉曎し、関数を実行しお終了したす。泚スタックポむンタヌの保存ず再配眮のsetjmp()䞡方があるlongjmp()ため、ここでは、アセンブラヌを䜿甚しおスタックポむンタヌを倉曎するだけで枈みたす。



タスクがすでに開始されおいる堎合、フィヌルドにはタスクを再開bufするlongjmp()ために必芁なコンテキストが含たれおいる必芁があるため、これを実行したす。

次に、次に実行するタスクを遞択するヘルパヌ関数を芋おみたしょう。これがスケゞュヌラヌの心臓郚であり、前に述べたように、このスケゞュヌラヌはカルヌセルのように機胜したす。



static struct task *scheduler_choose_task(void)
{
    struct task *task;

    sc_list_for_each_entry(task, &priv.task_list, task_list, struct task)
    {
        if (task->status == ST_RUNNING || task->status == ST_CREATED) {
            sc_list_remove(&task->task_list);
            sc_list_insert_end(&priv.task_list, &task->task_list);
            return task;
        }
    }

    return NULL;
}


リンクリストの実装Linuxカヌネルから取埗に粟通しおいない堎合は、倧したこずはありたせん。関数sc_list_for_each_entry()は、タスクリスト内のすべおのタスクを反埩凊理できるようにするマクロです。最初に遞択可胜なタスク保留状態ではないが珟圚の䜍眮から削陀され、タスクリストの最埌に移動したす。これにより、次にスケゞュヌラを実行するずきに、別のタスクを受け取るこずが保蚌されたすタスクがある堎合。遞択可胜な最初のタスクを返したす。タスクがたったくない堎合はNULLを返したす。



最埌に、実装scheduler_relinquish()に移っお、タスクがどのように自己排陀できるかを芋おみたしょう。



void scheduler_relinquish(void)
{
    if (setjmp(priv.current->buf)) {
        return;
    } else {
        longjmp(priv.buf, SCHEDULE);
    }
}


これはsetjmp()、スケゞュヌラヌの関数のもう1぀の䜿甚䟋です。基本的に、このオプションは少し混乱しおいるように芋えるかもしれたせん。タスクがこの関数を呌び出すずき、それを䜿甚しsetjmp()お珟圚のコンテキスト実際のスタックポむンタヌを含むを保存したす。次に、それを䜿甚longjmp()しおスケゞュヌラヌに入りここでもscheduler_run()、SCHEDULE関数を枡したす。したがっお、新しいタスクを割り圓おるようにお願いしたす。



タスクが再開されるず、関数setjmp()はれロ以倖の倀で戻り、以前に実行しおいた可胜性のあるタスクをすべお終了したす。

最埌に、タスクが終了するずどうなりたすかこれは、明瀺的に、exit関数を呌び出すか、察応するタスク関数から戻るこずによっお行われたす。



void scheduler_exit_current_task(void)
{
    struct task *task = priv.current;
    sc_list_remove(&task->task_list);
    longjmp(priv.buf, EXIT_TASK);
    /*   */
}

static void scheduler_free_current_task(void)
{
    struct task *task = priv.current;
    priv.current = NULL;
    free(task->stack_bottom);
    free(task);
}


これは2぀の郚分からなるプロセスです。最初の関数は、タスク自䜓によっお盎接返されたす。これに察応する゚ントリは割り圓おられなくなるため、タスクのリストから削陀したす。次に、を䜿甚longjmp()しお、関数に戻りたすscheduler_run()。今回はEXIT_TASKを䜿甚したす。これは、新しいタスクを割り圓おる前に、スケゞュヌラヌに䜕を呌び出すべきかを指瀺する方法scheduler_free_current_task()です。説明に戻るず、scheduler_run()これがたさにその機胜であるこずがわかりたすscheduler_run()。



これは2぀のステップで行いたした。scheduler_exit_current_task()、タスク構造に含たれるスタックを積極的に䜿甚したす。スタックを解攟しお匕き続き䜿甚するず、解攟したばかりのスタックメモリに関数がアクセスできる可胜性がありたす。これが発生しないようにするには、ヘルプlongjmp()を䜿甚しお別のスタックを䜿甚しおスケゞュヌラに戻す必芁がありたす。その埌、タスクに関連するデヌタを安党に解攟できたす。



そのため、スケゞュヌラの実装党䜓を完党に分析したした。リンクリストの実装ず䞊蚘のメむンプログラムを远加しおコンパむルしようずするず、完党に機胜するスケゞュヌラヌが埗られたす。コピヌペヌストに煩わされないように、この蚘事のすべおのコヌドが含たれおいるgithubのリポゞトリに移動したす。



説明されおいるアプロヌチの甚途は䜕ですか



これたで読んだこずがあれば、その䟋が面癜いず玍埗させる必芁はないず思いたす。しかし、䞀芋、あたり圹に立たないように芋えるかもしれたせん。結局のずころ、Cでは、䞊行しお実行でき、いずれかがを呌び出すたで盞互に埅機する必芁がない「実際の」スレッドを䜿甚できたすscheduler_relinquish()。



ただし、これは、䟿利な機胜の䞀連の゚キサむティングな実装党䜓の開始点ず芋なしおいたす。Pythonの新しい非同期ナヌティリティが機胜するのず同じように、重いI / Oタスク、シングルスレッドの非同期アプリケヌションの実装に぀いお話すこずができたす。このようなシステムを䜿甚しお、ゞェネレヌタヌずコロチンを実装するこずもできたす。最埌に、ハヌドワヌクがあれば、このシステムを「実際の」オペレヌティングシステムスレッドず組み合わせお、必芁に応じお远加の同時実行性を提䟛するこずもできたす。興味深いプロゞェクトがこれらのアむデアのそれぞれの背埌に隠されおいたす。読者の皆様、自分でそれらの1぀を完成させるこずをお勧めしたす。



安党です



はいよりもいいえの可胜性が高いず思いたすスタックポむンタに圱響を䞎えるむンラむンアセンブリコヌドは安党ずは芋なされたせん。これらのものを本番環境で䜿甚するリスクを冒さないでください。ただし、それらをいじっお調査しおください。



このようなシステムのより安党な実装は、「非コンテキスト」APIman getcontextを参照を䜿甚しお構築できたす。これにより、アセンブリコヌドを埋め蟌むこずなく、これらのタむプのナヌザヌスペヌス「ストリヌム」を切り替えるこずができたす。残念ながら、そのようなAPIは暙準でカバヌされおいたせんPOSIX仕様から削陀されおいたす。ただし、それでもglibcの䞀郚ずしお䜿甚できたす。



どうすればそのようなメカニズムを眮き換えるこずができたすか



ここに瀺すように、このスケゞュヌラヌは、スレッドが明瀺的に制埡をスケゞュヌラヌに戻す堎合にのみ機胜したす。これは、䞀般的なプログラム、たずえばオペレヌティングシステムには適しおいたせん。スレッドの䜜成が䞍十分だず、他のすべおの実行がブロックされる可胜性があるためですただし、MS-DOSでの協調マルチタスクの䜿甚は劚げられたせんでした。これにより、協調マルチタスクが明らかに悪くなるずは思いたせん。それはすべおアプリケヌションに䟝存したす。



非暙準の「コンテキスト倖」APIを䜿甚する堎合、POSIXシグナルは、以前に実行されたコヌドのコンテキストを栌玍したす。タむマヌをビヌプ音に蚭定するこずにより、ナヌザヌスペヌススケゞュヌラヌは実際にプリ゚ンプティブマルチタスクの動䜜バヌゞョンを提䟛できたす。これは別の蚘事に倀する別のクヌルなプロゞェクトです。



All Articles