Hero Image
スレッドと並行処理

はじめに バックエンドエンジニアのロードマップに沿ってエンジニアとしての自己肯定感を養うシリーズです。 スレッド プロセスが最低1つは持っている実行単位のことです。 こんな言い方をするのは、プロセスが複数のスレッドを管理できるからです。 実行単位という視点でプロセスとの違いは、「アドレス空間」を共有できるという点です。 尾を引くようにプロセス管理の話に繋がりますが、プロセスにはそれぞれ1つのアドレス空間が割り当てられます。 そして別のプロセスからアドレス空間へのアクセスは原則できません。(これを可能にするために共有メモリという方法を使います) それに対して、スレッドは1つのプロセスの実行単位を分けたものですから、同じアドレス空間を共有できるというわけです。 そういうわけで、スレッドとプロセスをそれぞれ複数起動する場合は、スレッドの方がアドレス空間を1つで済ませることができるため省コストになります。 では、複数のスレッドを起動してやることは?というと並行処理です。 並行処理 これもすでに出てきている話ではあります。プロセス管理の記事で出した複数アプリを同時に起動させるという部分です。 「同時に」というのは私たちユーザがそう解釈しているだけで、アプリはカーネルが割り当てた非常に短い処理時間ごとに切り替えているのでしたよね。これが並行処理です。 スレッドでも同じように短い処理時間ごとに切り替えて「同時に」処理させることができます。 並列処理との違い 私自身、再三調べては納得 → 忘れるを繰り返していましたが、プロセス管理(3 度目)をまとめることでやっと理解できたと思います。 並行処理では処理時間ごとに切り替えると言いましたが、並列処理では CPU 1つは言わず2つで処理してしまえばいいじゃないという考え方です。 図で見ると非常にわかりやすいのですが、並行処理だとパン食べてチーズ食べてハム食べてレタス食べて、、を繰り返して食べ切る作戦なのに対して、並列処理はミックスサンドとして食べ切るようなイメージです。 そんなの絶対ミックスサンドとして処理したら無限じゃんと思われますが、並列処理にも上限があるようです。 アムダールの法則といって複数のプロセッサ(CPU のことですね)を使って並列化による高速化を行う場合、そのプログラムの中で逐次的に実行される処理部分(並列)の時間によって、高速化が制限されるというものです。 出典:wikipedia「アムダールの法則」より引用 まあ、上限があるといっても高速するのに変わりはないわけです。 今回はその中でも比較的面白い実装を見つけたのでそれを紹介します。 ワーカープール スレッドプールとも呼ばれるものです。並行処理でたくさんのスレッドを起動して、、というのももちろん可能ですが、それには代償が伴います。 ワーカープールはそのようにいくつもスレッドを起動させるのではなく、すでに起動したスレッドを使い回そうの精神で実装される並行処理です。 以下のような実装です。 こちらを参考にさせていただきました。 (ほぼコメントつけただけですが) package main import ( "fmt" "time" ) // 使い回し用のワーカー func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Println("worker", id, "started job", j) time.Sleep(time.Second) // 1秒待ち(重い処理を想定) fmt.Println("worker", id, "finished job", j) results <- j * 2 } } func main() { // タスクの数 const numJobs = 5 // こなさなければいけないタスク jobs := make(chan int, numJobs) // タスクの成果物 results := make(chan int, numJobs) for w := 1; w <= 3; w++ { // 使い回し用のワーカーだけ生成しておく(この状態ではまだタスクをもらってないのでブロック) go worker(w, jobs, results) } // タスク数だけjobsに渡す for j := 1; j <= numJobs; j++ { // チャネルへの書き込みを契機にワーカー起動 jobs <- j } // タスク数だけ格納されたらチャネルを閉じる close(jobs) for a := 1; a <= numJobs; a++ { <-results } } // 結果 worker 3 started job 1 worker 1 started job 2 worker 2 started job 3 worker 3 finished job 1 worker 3 started job 4 worker 1 finished job 2 worker 1 started job 5 worker 2 finished job 3 worker 1 finished job 5 worker 3 finished job 4 実行するとわかりますが、順番がごっちゃになって処理されているのがわかります。

Hero Image
メモリ管理

はじめに バックエンドエンジニアのロードマップに沿ってエンジニアとしての自己肯定感を養うシリーズです。 仮想メモリ プロセス管理でもあったように、メモリはアドレス空間ごとにプロセスを管理します。 アドレス空間は 4KB/8KB 単位のページに分割して管理されています。 ページはそれぞれ論理アドレス、物理アドレスを対応づける単位でもあります。 論理アドレスと物理アドレスは常に紐づけられているわけではなく、そのページが必要になった時点で割り当てることも可能です。 そのため、論理アドレスを実際の物理アドレスの容量より大きく確保することができます。 (実際に使えるメモリの量よりも大きなメモリを想定できるということです。) 仮装メモリとして使う仕組みには次の3つが挙げられます。 ページング 仮想メモリといえばこれ、という風に教えられるものの筆頭かと思います。 ハードディスクを物理メモリの代わりに使うといったものです。 物理メモリが不足すると、OS のコアであるカーネルは使われていないページをハードディスクに移して論理アドレスを解放します。 そしてプロセスがハードディスクに移されたページにアクセスしようとすると、カーネルがプロセスを停止し、ハードディスクのページを再度物理メモリに読み込み、論理アドレスを対応づけます。 また、プロセス全体を単位にする場合はスワッピングと呼ばれます。 メモリマップトファイル ファイルをメモリとしてアクセスすることができるものです。 アクセスがあった瞬間に、カーネルがファイルをメモリに読み込みます。プロセスがメモリを使い終わると、論理アドレスと物理アドレスを解放して、メモリの内容をファイルに保存します。 共有メモリ 1つの物理アドレスを、複数のプロセスの論理アドレスに対応づけるものです。 アドレス空間をまたぐと危険では?!という見方もありますが、複数プロセスで処理できるため、巨大な画像データを編集するときには都合が良いみたいです。 ※Go では共有メモリを使わずに Message Passing を使っています。 メモリ管理 API malloc(3) メモリをヒープ領域に割り当てます。プログラム実行時に決まるサイズのメモリはヒープ領域から確保します。 ヒープは「何かを積み重ねた山」のことで、その名の通り、プログラムを実行してから決定する量だけメモリを確保しておく領域なので納得です。 malloc で確保したメモリはfreeで解放しなければいけません。 calloc(3) メモリをヒープ領域に割り当てます。malloc と異なる点は、割り当てたメモリをゼロクリアすることです。 こちらも malloc 同様、確保したメモリはfreeで解放しなければいけません。 realloc(3) malloc で割り当てたメモリのサイズを拡大、縮小します。こちらも確保したメモリはfreeで解放しなければいけません。 free 割り当てたメモリを開放します。いったん開放したアドレスにはアクセスしてはいけません。 メモリの開放漏れを防ぐために、malloc で確保したメモリは常に free で開放されるべきです。 brk(2) malloc や realloc が割り当てるためのメモリを探してくるものです。 物理アドレスが割り当てられていないページに物理アドレスを対応づけます。 余談 メモリはエラーでもかなりお世話になる部分なので、次回以降、実際のエラーやプログラミング言語(Go か Java)に絡めた記事を書きたいです。 備考 ふつうの Linux プログラミング 第 2 版 Linux の仕組みから学べる gcc プログラミングの王道

Hero Image
プロセス管理

はじめに バックエンドエンジニアのロードマップに沿ってエンジニアとしての自己肯定感を養うシリーズです。 プロセスとは プロセスという概念は Linux において、ファイルシステム、ストリームに並んで重要な構成要素の1つです。 プログラマが作成したソースコードはファイルに保存されます。そしてファイルの保存先はハードディスクです。 プログラムの実行時、プログラムはハードディスクからメモリへと読み込まれます。 CPU はメモリに読み込まれたプログラムを順次処理していきます。このとき、メモリに読み込まれて CPU に処理されているプログラムをプロセスといいます。 1つのプロセスを処理できるのは1つの CPU のみです。 そのため、同じプロセスしか一度に実行できなくなるといったことを避けるために、CPU はプロセスごとに処理時間を決めて次々に切り替えます。 普段使っている PC やスマホは Youtube や Line や Twitter など、複数アプリを同時に起動して使用しています。 あれは CPU が処理時間を決めて順に処理しているために実現されています。 OS のコアであるカーネルはプロセスの優先順位を考慮して、各プロセスに処理時間を割り当てます。 (この機能をスケジューラ、またはディスパッチャといいます。) アドレス空間 プロセス1つに対して、CPU とメモリがそれぞれ1つ必要です。CPU は前述の通り、処理時間を割り当てるのに対し、メモリはプロセスごとにアドレス空間を割り当てます。 メモリにプログラムを書き込む際にはアドレスが必要です。 しかしプロセスには 0 番地から始まるメモリが必要なため、1つのプロセスしか使えなくなってしまいます。 そこでプロセスから見えるアドレス(論理アドレス)と実際のアドレス(物理アドレス)を分けてしまいます。 こうすることで、カーネルと CPU によって論理アドレス → 物理アドレスと変換された実際のアドレスに対して書き込むことができます。 1つのプロセスの論理アドレス、物理アドレスを全体としてアドレス空間といいます。 アドレス空間はプロセスごとに割り当てられるので他のプロセスにアクセスできなくなります。 プロセス API fork(2) 自分のプロセスを複製して新しいプロセスを作ります。 Github でも fork がありますが、意味合いは同じです。既存のリポジトリを複製します。複製したリポジトリは自由に更新できますが、fork した元のリポジトリに対しては更新はできません。 プロセスの fork は元からあるプロセスを親プロセス、複製されたプロセスを子プロセスと呼びます。 子プロセスの fork 実行時の戻り値は 0 です。 (戻り値 0 は正常終了のステータスコード)そして親プロセスの fork 実行時の戻り値は子プロセスのプロセス ID です。