『Goならわかるシステムプログラミング第2版』読み
何気なく書いているアプリケーションコードの裏でがんばってくれる OS のはたらきについて納得感を得ていきたい。[1]
はじめに
本書では、OS が開発者にどのような機能を提供してくれているかを見て、それらを使う「システムプログラミング」 の方法を学びます。プログラミングを支えている下位のレイヤーをプログラマーの視点で知ることが、本書の目的です。プログラマーがこのような OS の機能を使うときは、提供されているAPIを呼び出すだけです。しかし、そのAPIの裏ではOSが多くの仕事をしています。この API の裏側で、OS がいかに一生懸命に仕事をしているのかを知る、というのが本書のゴールです。
第1章 Go 言語で覗くシステムプログラミングの世界
本書では、システムプログラミング := OS の提供する機能を使ったプログラミング
Hello World するコードを題材に fmt.Println の実装を追っていく。
fmt.Println("Hello World!")
最終的にシステムコールのひとつ syscall.Write() を呼び出していることがわかった。システムコールの詳しい説明は第 5 章「システムコール」にて。
第2章 低レベルアクセスへの入り口1:io.Writer
- ファイルディスクリプタ: OS がカーネルのレイヤーで用意している抽象化のしくみ
- 通常のファイルの他にも標準入出力などにもファイルディスクリプタとして識別子 (数値) が割り当てられる
- システムコールの呼び出しはファイルディスクリプタを指定しておこなう
-
io.Writerはファイルディスクリプタを言語レベル (Go) でまねしたもの-
io.Writerインタフェースを満たす構造体:os.File,os.Stdout, ...
-
net.Dial 関数は net.Conn インタフェース (とエラー) を返すのか。構造体を返すのかと思っていた。そういうもの?
第3章 低レベルアクセスへの入り口2:io.Reader
3.4
net.Conn は io.Reader と io.Writer の両方を満たす構造体。
他にも読み書き両方のインタフェースを満たす構造体はいろいろある。
func main() {
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
panic(err)
}
// ここでは conn が io.Writer として使われる
io.WriteString(conn, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
// ここでは conn が io.Reader として使われる
io.Copy(os.Stdout, conn)
}
3.5
PNG ファイル https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png を解析する例! オリジナルのチャンクを挿入するなど。
第4章 低レベルアクセスへの入り口3:チャネル
4.1
- goroutine: 並列処理を書きやすくするための仕組み
- 他言語でのスレッドとファイバー (軽量スレッド) のいいとこどりのような使い勝手 (?)
4.2
- チャネル: 並列でアクセスされても正しく処理されるキュー
- 複数の goroutine から同時にチャネルにデータを投入したりデータを読み込んだりしようとしても、ちゃんとひとつの goroutine だけができるようになっている
- 読み込み・書き込みは準備ができるまでブロックされる
- チャネルにデータがない状態で読み込もうとすると待たされる
- チャネルのバッファに空きがない状態で書き込もうとすると待たされる
- クローズされたチャネルからはデフォルト値が返ってくる
- 数値を入れるチャネルだと
0が返ってくる- → 受信側では、データとしての
0かどうか分からない
- → 受信側では、データとしての
- ちゃんとするときは「処理が全部終わった」という情報を知らせるためのチャネルを別に用意する
- 数値を入れるチャネルだと
4.3 & 4.5
標準ライブラリの中では time.After がチャネルを使う API のひとつ。
たとえば 5 秒で処理をタイムアウトさせたいときはこう書ける。
func main() {
ch := make(chan int)
go func() {
result := 0
for i := 0; i < 1000; i++ {
for j := 0; j < 2000; j++ {
for k := 0; k < 3000; k++ {
result ^= i*j + k
}
}
}
ch <- result
}()
select {
case result := <-ch:
fmt.Printf("finish %v\n", result)
case <-time.After(3 * time.Second):
fmt.Println("timed out")
}
}
第5章 システムコール
5.1
- システムコール: 特権モードで OS の機能を呼ぶこと
- CPU の動作モードに「特権モード」「ユーザーモード」がある
- 特権モードではメモリ確保などのより強い機能を使える
- システムコールを使うと、ユーザーモードのアプリケーションから特権モードの機能を一部使える
5.4
CPU の特別なレジスタに「アプリケーションからこのシステムコール番号が渡されたらカーネルのこの関数を呼び出す」という情報を登録しておく
第11章 コマンドシェル101
11.2
(macOS の Terminal.app など) 端末エミュレータ ↔ 擬似端末 ↔ シェル (bash など)
11.4
Windows ↔ WSL 間で伝播される環境変数はデフォルトでは PATH のみ
11.5
- シェルの実装! https://github.com/shibukawa/tish
- 変数展開
echo "hello ${USER}"・ワイルドカードcp *.txt dest・パイプ|・リダイレクト>,>>などが実装されているらしい
第12章 プロセスの役割と Go 言語による操作
12.1
-
cat sample.go | echoを実行するとcat,echoがひとつのプロセスグループ (=ジョブ) になる - 同じターミナルから起動したアプリケーションは同じセッショングループになる
- ひとつのプロセスで作業フォルダはひとつだけ
- プロセスの中で複数スレッドを作ってもスレッドごとに別の作業フォルダを設定することはできない
12.7
- fork/exec はサブプロセス (?) を起動する
- Go では goroutine によって OS スレッドが (ブラックボックス的に) 融通されているので fork/exec は簡単には使えない
- Python では
multiprocessingパッケージで fork/exec を効率的に使って並行処理ができる
- デーモン (daemon) はシェルを閉じたりログアウトしても終了しないような細工が施されたプロセス
第13章 シグナルによるプロセス間の通信
13.1
- システムコール (プロセス → OS カーネル) とは逆方向で OS カーネル → プロセスに情報を送るのがシグナル
- 0 除算エラーや、メモリの範囲外アクセスが起きると CPU レベルでシグナルが生成される
- プロセスは、シグナルを受け取ると、現在の処理を中断して受け取ったシグナルの処理をおこなう
- なにを実行するかは事前に登録しておける
-
SIGHUP: サーバーアプリケーションでは、設定ファイルの再読み込みを外部から指示する用途で使われることがデファクトスタンダードになっているらしい (そうなんだ)- 通常はコンソールアプリケーション用のシグナル
13.4
- プロセスを外部から停止するお作法: いきなり
SIGKILLを送ると受け取り側のプロセスはシグナルをハンドリングできずに強制終了 - → まずは
SIGTERMを送信して様子を見るのがよい (例:docker stopコマンド)
13.6
- マルチスレッドのプログラムだと、シグナルはその中のどれかのスレッドに届けられる
- シグナル処理用のスレッドとそれ以外のスレッドを分ける
-
runtime. LockOSThread()で goroutine が必ず特定の OS スレッドで実行されるようにできる
-
- わかりそうでよくわからなかった: https://github.com/golang/go/blob/release-branch.go1.17/src/runtime/signal_unix.go
第14章 Go 言語と並列処理
14.1
- 並行処理・並列処理で得られる特性
- 並行: CPU 数、コア数の限界を超えて複数の仕事を同時に行う
- 並列: 複数の CPU、コアを効率よく扱って計算速度を上げる
14.3
- プログラムから見たスレッドは「メモリにロードされたプログラムの現在の実行状態を持つ仮想 CPU」
- OS や CPU から見たスレッドは、「時間が凍結されたプログラムの実行状態」
- OS は、凍結状態のプログラムの実行状態を復元して、各スレッドの処理を短期間ずつ適当な順番でまわす
- 1 回に実行する時間 (=タイムスライス) はスレッドに設定されている優先度による
- 複数のプログラムは、このように時間分割して CPU コアにマッピングされて実行される (?)
- ↔ goroutine は OS のスレッドにマッピングされる
- Go ランタイム/goroutine と OS/スレッド は相似の構造になっている!(図 14.2)
14.7
-
sync.Mutexは、実行パスに入ることが可能な goroutine を、排他制御によって制限するのに使う- 「実行パス」はソースコードの 1 行みたいな意味?🤔 「クリティカルセクション」と同じ意味かも
-
sync.WaitGroupは多数の goroutine の終了待ちに使う
第15章 並行・並列処理の手法と設計のパターン
GPU 用プログラム (シェーダー言語) での並列処理すごそう
第16章 Go 言語のメモリ管理
16.1
- 物理メモリでは細切れの空き領域 1GB × 4 でも、プロセスから見える仮想メモリではフラットな 4GB に見える
- パフォーマンスのために、大きめのメモリの塊を OS からもらって、その中での細かなメモリのやりくりはユーザーランドでおこなう
- 関数を呼ぶとスタックフレームと呼ばれるメモリブロックが確保される
- スレッド新規作成時にスタックフレーム用のメモリ (Linux ではだいたい 8MB) をまとめて確保している
- ↔ goroutine では 4KB!
- Go ではデータをヒープに置くかスタックに置くかコンパイラが判断している
- 構造体を
newで作ってもその関数内でしか使わないならスタック領域にメモリ確保される、など
- 構造体を
16.5
- 単純なマーク & スイープによるガベージコレクタだとストップ・ザ・ワールドが起きる
- 「未使用」のマークを付けた直後に参照が復活したオブジェクトを消してしまうと最悪なのでプログラム全体を止めることになる (たぶん)
- インクリメンタルかつ並行にマーク & スイープができる tri-color GC すごそう
第17章: 実行ファイルが起動するまで
17.2
- ランタイムライブラリ
- Go ではチャネルや goroutine もランタイムに含まれる
- C では標準のランタイム libc を除いてビルドすることもできる
第19章: Go 言語とコンテナ
???
第9章: ファイルシステムの基礎と Go 言語の標準パッケージ
9.1
- Unix 系のシステムでは /proc 以下は仮想的なファイルシステムになっている
- → シェルなどから各プロセスの情報を見られる!
- Linux では VFS という API で、各ファイルシステムの違いを気にせず扱える
9.2
- Go で
File.Write()を実行してすぐにレスポンスが返ってくるのは、この段階では OS カーネル内部のバッファメモリへの書き込みが終了しただけであるため - 実際のファイル変更は重いので遅延される
9.3
ストレージが HDD → SSD になってランダムアクセスがミリ秒 → マイクロ秒と短縮されたことで、OS 側が SSD の性能を活かすようにチューニングされた話おもしろい!
第10章: ファイルシステムの最深部を扱う Go 言語の関数
10.3
mmap
- ファイルの中身をそのままメモリ上に展開し、内容を同期させる
- コピーオンライトモードだと、複数プロセスが同じファイルを mmap したとき、メモリ書き換えがあるまでは同じ領域を参照してコピーを節約する
- 書き換えが発生したときに初めて本当にコピーする
-
File.Read()と比べて mmap が速いとは限らない- 前から順番に読み込む処理なら
File.Read()でも十分
- 前から順番に読み込む処理なら
10.4
- 同期・非同期 / ブロッキング・ノンブロッキング の組合せややこしい
- 非同期かつブロッキングの処理は I/O 多重化 と呼ばれる
- Go では 4 章のチャネルで出てきた
selectを使うことで実現できる
- Go では 4 章のチャネルで出てきた
- Go は I/O のスタイルをあとから切り替えやすい
- 他言語だと非同期化するために大規模な変更が必要になることもある
10.5
- ネットワーク I/O については、すでに select 属のシステムコールが Go 言語のランタイム内部に組み込まれている (!)
-
select,pollシステムコールは遅いので、多重 I/O を実現する場合、各 OS が提供している効率のよいシステムコー ルを使うのが定石
10.6
- クラウドのストレージサービスをマウントしてローカルフォルダにあるかのように見せるファイルシステムの自作!
- FUSE を使って作る
- 本来 OS カーネルに投げられるシステムコールを FUSE がユーザーランドに転送してくれる
- それに応じて、ファイル一覧取得やファイルの内容読み込みなどを実装すればよい
第6章: TCP ソケットと HTTP の実装
6.5
net/http 以下の高機能な API ではなく net.Conn を使って HTTP 通信 (サーバー/クライアント) を実装していく
6.6
- HTTP/1.1 の Keep-Alive に対応
- TCP コネクションを一定時間維持することで速度低下を防げる
-
net.Connにタイムアウトを設けて実装できる
-
6.9
- パイプライニング
- レスポンスが返ってくる前にリクエストを多重で飛ばす
- サーバー側はリクエストの順序を保ってレスポンスを返さなければいけない
- リクエストが来た順に「『リクエストから生成されたレスポンス』のチャネル」をバッファ付きチャネルに入れる
- バッファがあるのでブロックされない
- レスポンスを生成する処理は並列に実行される
- 各処理の中では「生成完了したレスポンス」をチャネルに入れる
第7章: UDP ソケットを使ったマルチキャスト通信
7.1
- QUIC では UDP が利用されている
- QUIC のレイヤーで UDP にまつわるエラー処理 (パケットロスなど) をきちんとやりきっている
7.2
Go の UDP 関係の API (通信のために TCP と同様の準備が必要となる API) は C とだいぶ様子が違うらしい
7.3
- マルチキャスト
- 同じ「グループ」であれば、受信するコンピューターが 100 台でも送信側の負担は 1 台分
第8章: 高速な Unix ドメインソケット
8.1
- Unix ドメインソケットはカーネル内部で完結する高速なネットワークインタフェースを作る
- TCP や UDP のように外部のネットワークにはつながらない
- ソケットファイルは特殊なファイル
- プロセス間の高速な通信としてファイルというインタフェースを利用するだけ