「Go ならわかるシステムプログラミング」という本を読みました。 感想と学んだことを簡単に書き残します。
本の概要と全体の感想
本書の内容はこんな感じです。
- Go の io.Writer と io.Reader インタフェースによるストリーム
- goroutine, channel の使い方
- Go の内部でのシステムコール呼び出し
- TCP ソケット、UDP ソケット、Unix ドメインソケットの使い方
- ファイルシステムの扱い方
- プロセスとシグナル
- 並行処理
- Go のメモリ管理
- 実行ファイルが起動するまでの流れ
- 時間と時刻
- 仮想化(コンテナ)
「Go ならわかるシステムプログラミング」というタイトルから、OS 寄りの知識が得られそうだなと期待を抱いていました。
ですが実際に読んでみると思ったよりも Go 言語寄りで、低レイヤで何が起きているのかを理解しながら、Go 言語の機能やライブラリの使い方を学ぶといった内容でした。
最初の 4 章でデバッガの使い方と Go 言語の IO 周り、goroutine, channel の仕様が説明され、使い方を理解したところで「さあ準備ができた。これから OS レイヤに飛び込んでいくぞ」という気持ちになってくるのですが、それ以降の章も「Go 言語から XX を扱うには」が続き、そのまま deep dive の準備をし続けていると読了した、そんな感覚です。
この記事を読んでくださっている方に伝わるくらい言語化できている気がしませんが、本書のスタンスと自分の期待がほんの少し違ったな、というのが総じての感想です。
他の書評記事ではどんな風にコメントされているのか気になって調べてみたところ、やはりシステムプログラミングと聞いてイメージする内容とは異なるという感想もちらほらありました 1 2。
その中のある記事で知ったのですが、本書の「はじめに」でシステムプログラミングという言葉の定義がされていたようです。 自分は読み飛ばしていたところですが、確かに書かれていますね。
たとえば、ファイルを開くのも、メモリを確保するのも、ネットワークにアクセスするのも、すべて OS が提供する機能を利用します。本書では、OS が開発者にどのような機能を提供してくれているかを見て、それらを使う「システムプログラミング」の方法を学びます。プログラミングを支えている下位のレイヤーをプログラマーの視点で知ることが、本書の目的です。
あくまでプログラマーの視点で、OS が提供する機能を扱うことを、システムプログラミングと呼んでいるみたいです。 「はじめに」を読み飛ばさず、そういうスタンスの本だと理解・納得したうえで読めていたら、モヤモヤもなく読了できていただろうと思います。
…と、ここまで残念だったかのような感想ばかり書いてしまいましたが、今までよりも OS レイヤに近い部分で Go 言語を知ることができ、今後のプログラミングに役立てられそうな知識が得られたという点も、しっかりアピールしておきたいです。
学んだこと
読書中のメモ書きですが、ブログ用に少し書き直して紹介します。
IO 周りの使い方 (第 2 章、第 3 章)
- Go の IO 周りの関数は、io, bufio, os と様々なモジュールに散らばっていて、どれを使えばいいかパッと思い浮かばないイメージだった
- これまではエディタで
<モジュール名>.
と入力して出てくる関数の候補と説明ポップアップを読みながら、型エラーが出ないようになんとなくで書いていた
- これまではエディタで
- 本書では IO 周りの関数の組み合わせ方が整理されていて、なおかつ練習問題も用意されており、これまでよりは多少理解して書けるようになった
- それでもまだまだ理解が浅い。色々書いてみないと使いこなせそうにないが、そもそも IO 周りの処理を書く機会が滅多にない…
os.Stat()
によるファイルの存在チェックは不要 (第 9 章)
- ファイルの存在チェックを
os.Stat()
でやって、その結果でどうこうするのはイケてない - 存在チェックそのものが不要
- ファイル操作関数を直接使い、それで発生したエラーを正しく扱うようにコードを書くことが推奨されている
- これまで、存在しないファイルに対して操作してしまうことへの不安から
os.Stat()
でチェックしていたので、目からウロコだった - たしかに、チェックした時点では存在していても、次にするファイル操作の瞬間までにファイルが消滅している可能性だってあるし、結局そういうケースでもファイル操作関数が「ファイルなし」というエラーを返すのであれば、(エラーハンドリングさえすれば)存在チェック自体いらない
SIGKILL と SIGTERM の使い分け (第 13 章)
- SIGKILL を受け取ったプロセスはそのシグナルをハンドリングできず、強制終了させられる。また、子プロセスがいた場合は子プロセスが生き続ける
- そのため、プロセスを終了したい場合はまず SIGTERM を送信し、プログラム側に終了処理を行わせるのが良い作法
- そのうえで、一定時間が経ってもプロセスが終了しないときに SIGKILL を送るようにする
runtime.LockOSThread()
(第 14 章)
- この関数は、現在実行中の OS スレッドでのみ goroutine が実行されるように束縛できる
- さらに、そのスレッドが他の goroutine によって使用されなくなる
- https://go.dev/wiki/LockOSThread
- スレッドのキャッシュをフルで効かせるために使えるのかと思ったが、そういうことは書かれていない
- 「メインスレッドでの実行が強制されているライブラリ(GUI のフレームワークや、OpenGL とその依存ライブラリなど)を Go 言語で利用する場合」に必要になる
runtime.GOMAXPROCS()
の調整 (第 14 章)
- 同時に実行する OS スレッド数を制御する関数
- デフォルトで
runtime.NumCPU()
(コア数)に設定されている - 本書で興味深いと思ったのは、最速を狙おうとすると、このデフォルト値の半分に設定するほうがスループットが上がる場合がある、ということ
- 1 コアで 2 つ以上のスレッドを同時に実行する機構(ハイパースレッディングや SMT)を利用している場合、
GOMAXPROCS
が物理コア数ではなく論理コア数で設定されてしまい、1 コアで 2 つのヘビーな計算を同時に実行すると、CPU コアのリソースを食い合ってパフォーマンスが上がらないことがあるため
sync/atomic
の使いどころ (第 14 章)
- int 型の ID 生成など、単純なデータ読み書きに使える
- 途中でコンテキストスイッチが発生しないため、
sync.Mutex
による排他制御よりも性能が良い - ISUCON では ID 生成に使えるかもしれない
- ただ、自分たちのチームでは
sync.Mutex
を使った ID 生成の方が慣れているし、sync/atmic
は汎用性の面で劣りそう - 使うとしても、
sync.Mutex
がボトルネックになるほどチューニングされた状態でないと出番がないかな…
- ただ、自分たちのチームでは
channel-in-channel という並行処理パターン (第 15 章)
- よく知っている通り、早く終了したものから順番に後ろの処理へ処理結果を渡す場合は、単に channel を使えば良い
- 早く開始したものから順番に後ろの処理へ処理結果を渡したい場合は、channel-in-channel を使う
- そういうケースを考えたことはなかったが、意識していなかっただけで、使える場面は多いかも?
スタックとヒープの違い (第 16 章)
- Go では、ヒープに置くかスタックに置くかはコンパイラが自動的に判断する
- ある関数内で作られ、そこでしか使われない変数は、スタックに確保される
- ある関数内で作られた変数の寿命がその関数よりも長い場合、変数はヒープに確保される
- スタックの方が高速
GC のアルゴリズム:マーク・アンド・スイープ (第 16 章)
- 2 つのフェーズで実行される
- 必要・不要のマークを付けるフェーズ → 不要なものを消すフェーズ
- 不要なメモリを削除する間、プログラム全体を停止する必要がある(ストップ・ザ・ワールド)
- ただ Go の GC は改良が重ねられ、無視できるくらいの停止時間になっている
- 参考ブログ:https://deeeet.com/writing/2016/05/08/gogc-2016
- GC に関連する話として、Go 1.20 からメモリアリーナが追加された
- メモリアリーナとは、GC 対象外にできる、ユーザープログラム側で管理するメモリ領域のこと
まとめ
これまで Go を書くときはアプリケーションのロジックやミドルウェアの操作が多かったですが、本書を読んで低レイヤの視点も加わったことで、自分の Go レベルが少し上がった気がします。
次に読む本ですが、現在「Go 言語でつくるインタプリタ」を同期と読み進めているところです。 こちらも読了後に感想記事を書こうと思います。