Closed25

ゼロからOS自作入門 13章

ackyacky

マルチタスク(1)

おぉついに複数のアプリを動かすための基盤を作るのか

ackyacky

マルチタスクとコンテキスト

マルチタスク
コンピュータにおいて複数のタスク(プロセス)を切り替えて実行できるシステムのことである。マルチプログラミングという語は複数のプログラムを動かすという点に着目した語である。逆に、同時に一つのタスクしか実行できない方式をシングルタスクという。

マルチタスクはいろいろな実現方法がある

  • 並列処理:実行するCPUコアを分けることで完全に並列処理を行う
  • 並行処理:実行するCPUコアは同じだが、実行する時間を分離することで仮想的に並列処理を行う
    • 時分割方式
並列処理:CPUコア分割 並行処理:時分割方式

【図解】CPUのコアとスレッドとプロセスの違い,コンテキストスイッチ,マルチスレッディングについてから引用

並行処理:時分割方式

第3回 プロセス・スケジューリングから引用


CPUコア分割で完全に分離させていれば、(メモリの問題はあるが)管理は非常に簡単である。一方、時分割方式の場合はリソースの管理だけでなく、優先順位や処理が重いタスクへの対応など多くの課題がある。

ackyacky

この2つの違いはコンテキストで説明できる

コンテキスト
ある作業をするために必要なデータや変数をまとめたもの

  • アプリの実行バイナリ
  • コマンドライン引数
  • 環境変数
  • スタックメモリ
  • 各レジスタの値など

アプリケーションはそれぞれ独自のコンテキストを持ち、その中で動作する。あるコンテキストの中では他のコンテキストのことは考慮することはない。そのため、複数のアプリケーションが同時並行で動くことが出来る。

並行処理と並列処理はどちらか一方を使うのではなく、両方を組み合わせて仕様する

ackyacky

コンテキストの切り替えに挑戦

コンテキストスイッチを実装する

コンテキストスイッチ
複数のプロセスが1つのCPUを共有できるように、CPUの状態(コンテキスト)を保存したり復元したりする過程のこと

コンテキストスイッチは以下の順で処理を行う

  1. タスクAの処理を行う
  2. タスクAのコンテキストをCPUレジスタからタスクAのスタックに退避する
  3. タスクBのコンテキストをCPUレジスタに復帰させる
  4. タスクBの処理を行う

さらに3.では次の設定が必要(参考:X86アセンブラ/x86アーキテクチャ)

  • RIP(プログラムカウンタ)をタスクBの先頭アドレスに書き換える
  • RSPをタスクBのスタックポインタに書き換える
  • タスクBに引数がある場合にはレジスタに値を設定する
    • 今回はRDI/RSI
    • RSI/ESI/SI
      • ソースレジスタ」と呼ばれる
      • ストリーム操作コマンド(たとえば MOV命令)でのソース(入力元)へのポインタとして使用される
    • RDI/EDI/DI
      • デスティネーションレジスタと呼ばれる
      • ストリーム操作コマンドでの転送先へのポインタとして使用される

値を退避する場合には「RIPの値を保存すること」がポイントである。これによって元の処理に戻ることが出来る

これがマルチスレッドにおけるリソース競合問題が起きる原因かと

ここでのポイントは「CPUはRIPで指定されているアドレスの処理を実行しているに過ぎない」ということである。つまり、CPUからするとコンテキストの切り替わりが起ころうと起こらまいとRIPで指定されている処理を実行しているに過ぎない

ackyacky

main関数で InitializeTaskBWindow(); を呼び忘れていた。そりゃダメだ。

今日は眠いので、明日はここの部分を理解する

ackyacky
struct TaskContext {
  uint64_t cr3, rip, rflags, reserved1; // offset 0x00
  uint64_t cs, ss, fs, gs; // offset 0x20
  uint64_t rax, rbx, rcx, rdx, rdi, rsi, rsp, rbp; // offset 0x40
  uint64_t r8, r9, r10, r11, r12, r13, r14, r15; // offset 0x80
  std::array<uint8_t, 512> fxsave_area; // offset 0xc0
} __attribute__((packed));

alignas(16) TaskContext task_b_ctx, task_a_ctx;

これがタスクコンテキストか。つまり、状態を復元するためのスタックに詰め込むデータ。今回は2つのタスクなので、それぞれ領域を確保する

ackyacky
void TaskB(int task_id, int data) {
  printk("TaskB: task_id=%d, data=%d\n", task_id, data);
  char str[128];
  int count = 0;
  while (true) {
    ++count;
    sprintf(str, "%010d", count);
    FillRectangle(*task_b_window->Writer(), {24, 28}, {8 * 10, 16}, {0xc6, 0xc6, 0xc6});
    WriteString(*task_b_window->Writer(), {24, 28}, str, {0, 0, 0});
    layer_manager->Draw(task_b_window_layer_id);

    SwitchContext(&task_a_ctx, &task_b_ctx);
  }
}

SwitchContext関数のシグニチャはこう

void SwitchContext(void* next_ctx, void* current_ctx);

次のスタックの領域と退避先の現在のスタックを与える

ackyacky

ただし、コンテキストスイッチを切り替える処理はC++にはないのでアセンブリ言語で表現する

global SwitchContext
SwitchContext:  ; void SwitchContext(void* next_ctx, void* current_ctx);
    mov [rsi + 0x40], rax
    mov [rsi + 0x48], rbx
    mov [rsi + 0x50], rcx
    mov [rsi + 0x58], rdx
    mov [rsi + 0x60], rdi
    mov [rsi + 0x68], rsi

    lea rax, [rsp + 8]
    mov [rsi + 0x70], rax  ; RSP
    mov [rsi + 0x78], rbp

    mov [rsi + 0x80], r8
    mov [rsi + 0x88], r9
    mov [rsi + 0x90], r10
    mov [rsi + 0x98], r11
    mov [rsi + 0xa0], r12
    mov [rsi + 0xa8], r13
    mov [rsi + 0xb0], r14
    mov [rsi + 0xb8], r15

    mov rax, cr3
    mov [rsi + 0x00], rax  ; CR3
    mov rax, [rsp]
    mov [rsi + 0x08], rax  ; RIP
    pushfq
    pop qword [rsi + 0x10] ; RFLAGS

    mov ax, cs
    mov [rsi + 0x20], rax
    mov bx, ss
    mov [rsi + 0x28], rbx
    mov cx, fs
    mov [rsi + 0x30], rcx
    mov dx, gs
    mov [rsi + 0x38], rdx

    fxsave [rsi + 0xc0]

    ; iret 用のスタックフレーム
    push qword [rdi + 0x28] ; SS
    push qword [rdi + 0x70] ; RSP
    push qword [rdi + 0x10] ; RFLAGS
    push qword [rdi + 0x20] ; CS
    push qword [rdi + 0x08] ; RIP

    ; コンテキストの復帰
    fxrstor [rdi + 0xc0]

    mov rax, [rdi + 0x00]
    mov cr3, rax
    mov rax, [rdi + 0x30]
    mov fs, ax
    mov rax, [rdi + 0x38]
    mov gs, ax

    mov rax, [rdi + 0x40]
    mov rbx, [rdi + 0x48]
    mov rcx, [rdi + 0x50]
    mov rdx, [rdi + 0x58]
    mov rsi, [rdi + 0x68]
    mov rbp, [rdi + 0x78]
    mov r8,  [rdi + 0x80]
    mov r9,  [rdi + 0x88]
    mov r10, [rdi + 0x90]
    mov r11, [rdi + 0x98]
    mov r12, [rdi + 0xa0]
    mov r13, [rdi + 0xa8]
    mov r14, [rdi + 0xb0]
    mov r15, [rdi + 0xb8]

    mov rdi, [rdi + 0x60]

    o64 iret
ackyacky

で、タスクBは自分でタスクAに切り替えている。だから、タスクA、つまり、メインスレッドではタスクBに切り替える必要がある

    __asm__("cli");
    if (main_queue->size() == 0) {
      __asm__("sti");
      SwitchContext(&task_b_ctx, &task_a_ctx);
      continue;
    }
ackyacky

ページ機構のエントリポイント、すなわち、4-levelページングの場合でいう、PML4 tableのアドレスは、CR3レジスタに入っている(キャッシュの項にCR3レジスタのビット割り当て例がある)。そして、このことは、CR3レジスタの値を書き換えることで、ページ機構をまるまる別のものに入れ替える事ができることを意味する。実際にOSは、プロセス毎に仮想アドレス空間(ie. リニアアドレス)を用意するためにこの仕組みを用いている。プロセス毎にページ機構を用意して、プロセスの切り替えのたびにCR3レジスタを書き換えているのだ。こうすることで、アプリケーションから見れば(ie. 論理アドレスでは)同一のアドレスのメモリを、アプリケーション毎に別のページで保持する事が可能になる。

ackyacky

Basic program execution registers
汎用レジスタが16に増加しました。汎用レジスタは64bit幅でWord, Dword, Qword整数の演算をサポートします。バイトレジスタへのアクセスは、一番下の8ビットに一律に行われます。 命令ポインタレジスタは64ビットになります。EFLAGSレジスタは64ビット幅に拡張され、RFLAGSレジスタと呼ばれます。 RFLAGSの上位32ビットは予約されています。 RFLAGSの下位32ビットはEFLAGSと同じです。

ackyacky

次はSwitchContext()の処理と

SwitchContext()の直前のコンテキスト構造体

ここに書いたように第一引数がCPUレジスタにロードするスタックのアドレス、第二引数が現在のCPUレジスタを保存する先

直前の状態はCPUレジスタのRIP/RSPはMainの処理を指している

SwitchContext()の直後のコンテキスト構造体

直後はTaskBの処理を指している

ackyacky

細かいアセンブリの処理は難しい

ackyacky

コンテキストスイッチの自動化

協調的マルチタスク(ノンプリエンプティブマルチタスク)
一つの処理装置(CPU)で並行して複数の処理を進め、OSがCPUを管理しない方式。タスク制御をアプリケーション側に依存してます。1つのアプリケーションが実行している間は他のアプリケーションの実行が制限されます。昔の性能の低いコンピュータで実用的に使用できますがそのアプリケーションの処理を終了しなければ他のタスクを実行出来ません。その為、そのアプリの処理中にエラー等が発生するとシステム全体が停止する事も有りました。

プリエンプティブルマルチタスク
OSがCPUやシステム資源を管理し、CPU使用時間や優先度などによりタスクを実行状態や実行可能状態に切り替える方式。OS側の処理が複雑になりますが、前記の様にアプリケーションがエラーで停止しても他のアプリに影響を与えずに処理を行います。

ackyacky

ノンプリエンプティブマルチタスクは実行するタスクがコンテキストを切り替えあう 協調動作 をおこなう。これではタスクを追加する毎に呼びあう必要があるので拡張性が低い。

そこで、このコンテキストスイッチをOSに任せるプリエンプティブルマルチタスクに移行することで拡張性が向上させる。

ackyacky

NotifyEndOfInterrupt() でイベントハンドラを作って、SwitchTask()でコンテキストを切り替えるってやつか。

で、このコンテキスト切り替えをする関数をタイマの割り込みに入れ込むのか

ackyacky

マルチタスクの検証

切り替えの周期を観察できるレベルにするのか

  • 修正前(timer.hpp)
// define task timer
const int kTaskTimerPeriod = static_cast<int>(kTimerFreq * 0.02);
const int kTaskTimerValue = std::numeric_limits<int>::min();
  • 修正前(timer.hpp)
// define task timer
const int kTaskTimerPeriod = static_cast<int>(kTimerFreq * 1.0);
const int kTaskTimerValue = std::numeric_limits<int>::min();

切り替えの周期を変えるとで可視化か。なるほど。

ackyacky

タスクを増やす

確かにタスクを増やす毎に TaskContext の変数を増やすのは面倒

ackyacky

個々のタスクをクラスで表現するのか

class Task {
 public:
  static const size_t kDefaultStackBytes = 4096;

  Task(uint64_t id);
  Task& InitContext(TaskFunc* f, int64_t data);
  TaskContext& Context();

 private:
  uint64_t id_;
  std::vector<uint64_t> stack_;
  alignas(16) TaskContext context_;
};

確かにこの3つのプロパティがあればタスク自体は表現できる

ackyacky
using TaskFunc = void (uint64_t, int64_t);

へぇusing宣言で関数ポインタみたいの作れるんだ

ackyacky
class TaskManager {
 public:
  TaskManager();
  Task& NewTask();
  void SwitchTask();

 private:
  std::vector<std::unique_ptr<Task>> tasks_{};
  uint64_t latest_id_{0};
  size_t current_task_index_{0};
};

TaskManager は面白い。個別の変数からVectorにすることで可変長のタスクを扱えるようにしているんだ。

ackyacky

タスク切換えの関数も良いな。楽しい。

void TaskManager::SwitchTask() {
  size_t next_task_index = current_task_index_ + 1;
  if (next_task_index >= tasks_.size()) {
    next_task_index = 0;
  }

  Task& current_task = *tasks_[current_task_index_];
  Task& next_task = *tasks_[next_task_index];
  current_task_index_ = next_task_index;

  SwitchContext(&next_task.Context(), &current_task.Context());
}
ackyacky
  InitializeTask();
  task_manager->NewTask().InitContext(TaskB, 45);
  task_manager->NewTask().InitContext(TaskIdle, 0xdeadbeef);
  task_manager->NewTask().InitContext(TaskIdle, 0xcafebabe);

ここでタスクを3つ生成して、計4つのタスクにしているんだ

  1. メインタスク
  2. TaskB
  3. TaskIdle1
  4. TaskIdle2
このスクラップは2021/08/25にクローズされました