オペレーティングシステムが実行ファイルを読み込んで実行するには、そのためのリソース(メモリやCPUなど)を用意しなければなりません。 そのようなリソースをまとめたプログラムの実行単位がプロセスです。 プロセスは、オペレーティングシステムが実行ファイルを読み込んで実行するときに新しく作られます。
コンピュータシステムの低レイヤをGo言語で覗いてみるこの連載では、今回から数回に分けてプロセスを見ていきます。 今回の記事で扱うのは次の内容です。
- Go言語から見たプロセス
- プロセスの入出力
- プロセスの情報にアクセスする外部ライブラリ
- OSから見たプロセス
これまでの連載で登場したプロセス
プロセスはコンピュータシステムの中心となる概念なので、その存在をまったく無視してシステムに関するプログラムを書くことはできません。 そのため、これまでの連載記事でも、プロセスに関連する情報は小出しにしてきました。 まずは、これまでの連載記事でどんな場面にプロセスが出てきたかを思い出してみましょう。
ファイルディスクリプタとプロセス
連載の第2回では、外部との入出力を汎用化するための仕組みとして、ファイルディスクリプタについて触れました。 カーネルは、新しくプロセスを作るたびに、各プロセスでどういった入出力が行われるかの管理テーブルを作ります。 そのインデックス値がファイルディスクリプタです。
入出力とプロセス
これまでの連載では、Go言語でファイルやソケット、標準入出力、標準エラー出力といった外部との入出力を行う方法をたくさん見てきました。 ソケットを使った入出力については第6回から第9回、ファイルの入出力については第10回から第12回で触れました。 io.Reader
や io.Writer
といったインタフェースは、プロセスが外部との入出力に使います。 それらのインタフェースの裏に、ファイルやソケットや標準入出力、標準エラー出力があります。
プロセスと外界のやり取りはシステムコール経由
連載の第5回では、システムコールについて触れました。 プロセスはとってもシャイで、自分から他のプロセスに「データをくれ」とか「これを処理しておいて」とは言えないので、 やり取りはすべてシステムコールを介してOS経由で行います。 ファイルやソケットからのデータ読み込みも、現在の時刻の取得も、すべてシステムコール経由です。 プロセスが自分の力でできるのは、単純な数値計算ぐらいです。
プロセスに含まれるもの(Go言語視点)
プロセスにはプログラムの実行に必要なものや、外部プロセスとの入出力に必要なものまで、いろいろな情報が含まれています。
- プロセスID
- プロセスグループID、セッショングループID
- ユーザーID、グループID
- 実効ユーザーID、実効グループID
- カレントフォルダ
- ファイルディスクリプタ
Go言語の関数を使って、これらの情報にアクセスしてみましょう。
プロセスID
プロセスには必ず、プロセスごとにユニークな識別子があります。それがプロセスIDです。 Go言語では、os.Getpid()
を使って現在のプロセスのプロセスIDを取得できます。
また、ほとんどのプロセスはすでに存在している別のプロセスから作成された子プロセスとなっているので、親のプロセスIDを知りたい場合もあります。 親のプロセスIDはos.Getppid()
で取得できます。
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("プロセスID: %d\n", os.Getpid())
fmt.Printf("親プロセスID: %d\n", os.Getppid())
}
プロセスIDは、Windowsならタスクマネージャ(デフォルトではオフになっているので表示メニューからPIDを追加する必要があります)、macOSならアクティビティモニター、POSIX系OSであればps
コマンドで出てくるものと同じです。 なお、Google Native Client(NaCl)の場合には、プロセスIDを取得すると常に定数値が返ります。
OS | プロセスID | 親のプロセスID |
---|---|---|
API | os.Getpid() |
os.Getppid() |
NaCl以外 | ◯ | ◯ |
NaCl | 定数(3)を返す | 定数(2)を返す |
プロセスグループ・セッショングループ
プロセスには親子関係があることを紹介しました。 プロセス間の関係は親子だけではありません。
プロセスを束ねたグループというものがあり、プロセスはそのグループを示すID情報を持っています。 次のようにパイプでつなげて実行された仲間が、1つのプロセスグループ(別名ジョブ)になります。
$ cat sample.go | echo ⏎
上記の例では、cat
コマンドとecho
コマンドが同じプロセスグループになります。 プロセスグループに対するIDは、Linuxの場合、グループ内に含まれるコマンドの代表のプロセスIDになっています。
プロセスグループと似た概念として、セッショングループがあります。 同じターミナルから起動したアプリケーションであれば、同じセッショングループになります。 同じキーボードにつながって同じ端末に出力するプロセスも同じセッショングループとなります。
プロセスグループとセッショングループのIDをGo言語で見るには次のようにします。
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
sid, _ := syscall.Getsid(os.Getpid())
fmt.Fprintf(os.Stderr, "グループID: %d セッションID: %d\n", syscall.Getpgrp(), sid)
}
Windowsは、プロセスグループとセッショングループに対応する情報を持っていません。 Solarisのプロセスグループ取得はなぜかテスト用のスタブとして実装されており1、標準ライブラリには実装されていません。
プロセスグループ取得 | 指定プロセスのプロセスグループ取得 | 指定プロセスのプロセスグループ設定 | |
---|---|---|---|
API | syscall.Getpgrp() |
syscall.Getpgid() |
syscall.Setpgid() |
Linux/BSD系OS | ◯ | ◯ | ◯ |
Solaris | ◯ | ||
Windows/Plan9/NaCl |
指定プロセスのセッショングループ取得 | 指定プロセスのセッショングループ設定 | |
---|---|---|
API | syscall.Getsid() |
syscall.Setsid() |
Linux/BSD系OS | ◯ | ◯ |
Solaris | ◯ | ◯ |
Windows/Plan9/NaCl |
ユーザーIDとグループID
プロセスは誰かしらのユーザー権限で動作します。 また、ユーザーはいくつかのグループに所属しています (名前が紛らわしいのですが、このグループはさきほどのプロセスグループとは別の概念です)。 ユーザーは、メインのグループには1つだけしか所属できませんが、 サブのグループには複数入れます。
ユーザーとグループの権限は、ファイルシステムの読み書きの権限を制限するのに使われます。 ファイルシステムの読み書きの権限には、「読み」「書き」「実行」の3種類の権限があり、それぞれの権限が「所有者」「同一グループ」「その他」の3セットあります。 3種類の権限をr
、w
、x
の文字で表すことで、権限を表現する9桁の「記号表記」と、それぞれの権限を4、2、1の数値の足し算として3桁の8進数表記があります。
記号表記 | 8進数表記 | 意味 |
---|---|---|
-rwxr-xr-x |
0755 |
所有者は全操作。それ以外のユーザーは実行を許可 |
-rw-r--r-- |
0644 |
所有者は読み書き、それ以外のユーザーは読み込みのみ許可 |
ユーザーIDとグループID、サブグループを表示するには次のようにします。
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("ユーザーID: %d\n", os.Getuid())
fmt.Printf("グループID: %d\n", os.Getgid())
groups, _ := os.Getgroups()
fmt.Printf("サブグループID: %v\n", groups)
}
子プロセスを起動すると、その子プロセスは親プロセスのユーザーIDとグループIDを引き継ぎます。
WindowsはPOSIXのグループとは多少異なる、セキュリティIDというシステムのセキュリティのデータベースのIDで権限の管理を行っています。 GetTokenInformation
2というAPIで詳細情報が取得できますが、Go言語では実装されていません。
また、他のOSでも、ファイル以外の読み書きの権限管理は別の仕組みが用意されています。 macOSでは、10.5のLeopardからはApple Open Directory3という仕組みを利用していて、こちらでOSの権限管理を行っています。 GUIのシステム環境設定のユーザーやグループ、あるいはdscl
コマンドによる管理が行われます。
ユーザーIDとグループIDの設定は一部のOSでsyscall
パッケージで提供されています。Linuxは他のOSと違い「プロセスではなく、現在のスレッドにしか効果がない」という理由で1.44からエラーを返す実装になっています。Setgroups()
も同じ理由で無効な気がしますが、こちらはそのままとなっています。
ユーザーID取得 | ユーザーID設定 | グループID取得 | グループID設定 | |
---|---|---|---|---|
API | os.Getuid() |
syscall.Setuid() |
os.Getgid() |
syscall.Setgid() |
BSD系OS/Solaris | ◯ | ◯ | ◯ | ◯ |
Linux | ◯ | エラーを返す | ◯ | エラーを返す |
Windows | 定数値(-1)を返す | 定数値(-1)を返す | ||
Plan9 | 定数値(-1)を返す | 定数値(-1)を返す | ||
NaCl | 定数値(1)を返す | 定数値(1)を返す |
補助グループID一覧取得 | 補助グループID一覧設定 | |
---|---|---|
API | os.Getgroups() |
syscall.Setgroups() |
BSD系OS/Solaris | ◯ | ◯ |
Linux | ◯ | ◯ |
Windows | エラーを返す | |
Plan9 | 長さゼロの配列を返す | |
NaCl | 定数1が入った配列を返す |
実効ユーザーIDと実効グループID
プロセスのユーザーのIDやグループIDは、通常は親プロセスのものを引き継ぎます。 しかしPOSIX系OSでは、SUID
、SGID
フラグを付与することで、実行ファイルに設定された所有者(実効ユーザーID)と所有グループ(実効グループID)でプロセスが実行されるようになります5。 これらのフラグがないときは、実効ユーザーIDも実効グループIDも、元のユーザーIDとグループIDと同じです。 これらのフラグが付与されているときは、ユーザーIDとグループIDはそのままですが、実効ユーザーIDと実効グループIDが変更されます。
実効ユーザーIDと実効グループIDも、次のようにしてGo言語で取得できます。
package main
import (
"fmt"
"os"
)
func main() {
fmt.Printf("ユーザーID: %d\n", os.Getuid())
fmt.Printf("グループID: %d\n", os.Getgid())
fmt.Printf("実効ユーザーID: %d\n", os.Geteuid())
fmt.Printf("実効グループID: %d\n", os.Getegid())
}
このコードをそのまま実行しても、ユーザーIDと実効ユーザーID、グループIDと実効グループIDには特に変化がないことがわかります。 次の実行例は筆者のmacOS上での結果です(他のOSによってはグループIDの桁がだいぶ小さいことがあります)。
# 実行ファイルを作る
$ go build -o uid uid.go ⏎
# そのまま実行してみる
$ ./uid ⏎
ユーザID: 755476792
グループID: 1522739515
実効ユーザID: 755476792
実効グループID: 1522739515
今度はSUIDを付けて実行してみましょう (macOSでは、フラグの付与とオーナーの変更にsudo
が必要です)。 実効ユーザーが変わっていることが確認できます。
# SUIDフラグをつける
$ sudo chmod u+s uid ⏎
# オーナーを別のユーザーに変える
$ sudo chown test uid ⏎
# 再実行してみる
$ ./uid ⏎
ユーザID: 755476792
グループID: 1522739515
実効ユーザID: 507
実効グループID: 1522739515
POSIX系OSでは、ケーパビリティ(capability)という、権限だけを付与する仕組みが提案されました。 それまで、ルート権限が必要な情報の設定・取得を行うツールでは、SUIDを付けてルートユーザーの所有にしたプログラムを用意し、ユーザー権限からも利用可能にする、といったことが行われてきました。 しかし、これでは与えられる権限が大きすぎるため、ツールにセキュリティホールがあって任意のプログラムの実行ができると、ルート権限を得るための踏み台として悪用されて被害を拡大してしまいます。 ケーパビリティは、スーパーユーザーのみが利用できた権限を細かく分け、必要なツールに必要なだけの権限を与える仕組みであり、そうしたリスクを減らします。 Linuxでは2.4から、FreeBSDでは9.0からケーパビリティが導入されました6。
実効ユーザーIDや実効グループIDも、ファイルシステム上のリソースのアクセス権の制御に限定すれば便利ですし、今後はそれ以外の用途は減ってくると思われます。
実効ユーザーID取得 | 実効ユーザーID設定 | 実効グループID取得 | 実効グループID設定 | |
---|---|---|---|---|
API | os.Geteuid() |
syscall.Seteuid() |
os.Getegid() |
syscall.Setegid() |
BSD系/Solaris | ◯ | ◯ | ◯ | ◯ |
Linux | ◯ | ◯ | ||
Windows/Plan9 | 定数値(-1)を返す | 定数値(-1)を返す | ||
NaCl | 定数値(1)を返す | 定数値(1)を返す |
作業フォルダ
現在の作業フォルダもプロセスにおける大事な実行環境のひとつです。 作業フォルダは、次のようにos.Getwd()
関数を使って取得できます。
package main
import (
"fmt"
"os"
)
func main() {
wd, _ := os.Getwd()
fmt.Println(wd)
}
作業フォルダ取得 | |
---|---|
API | os.Getwd() |
全OS | ◯ |
ファイルディスクリプタ
ファイルディスクリプタは、連載の第2回で紹介したように、ファイルやソケットなどを抽象化した仕組みです。 どのリソースも「ファイル」として扱えます。 プロセスは、これらのリソースをファイルディスクリプタと呼ばれる識別子で識別します。 カーネルはプロセスごとに、プロセスが関与しているファイル情報のリストを持っています (Linuxの場合、各要素はfile
構造体です)。 ファイルディスクリプタはこのリストのインデックス値です。
OSがプロセスを起動した時点で、すでに3つのファイルがオープンされています。 それぞれ、標準入力、標準出力、標準エラー出力に対応するファイルです。
Go言語には、すでにオープン済みのファイルディスクリプタの数値をio.ReadWriter
インタフェースでラップする、os.NewFile()
という関数があります。 Go言語での標準入出力の初期化は下記のようになっています。
Stdin = os.NewFile(0, "/dev/stdin")
Stdout = os.NewFile(1, "/dev/stdout")
Stderr = os.NewFile(2, "/dev/stderr")
子プロセスを起動したときに、他のプロセスの標準入力にデータを流し込んだり、他のプロセスが出力する標準出力や標準エラー出力の内容を読み込むこともできます。 この方法は次回の記事で紹介する予定です。
プロセスの入出力
プロセスには入力があって、プログラムがそれを処理し、最後出力を行います。 その意味では、プロセスはGo言語や他の言語の「関数」や「サブルーチン」のようなものだとも言えます。
すべてのプロセスは、次の3つの入出力データを持っています。
- コマンドライン引数
- 環境変数
- 終了コード
プログラムによっては、実行中にファイルや標準入出力の読み書きをしたり、ソケット通信などもできますが、 この3つのデータは必ずどのプロセスにも含まれています。
コマンドライン引数
コマンドライン引数は、プログラムに設定を与える一般的な手法として使われています。 Go言語では、os.Args
引数の文字列の配列として、コマンドライン引数が格納されています。
この配列をプログラムで直接利用してもいいのですが、通常は「オプションパーサー」と呼ばれる種類のライブラリを利用してパースします。 コマンドライン引数には、-o ファイル名
のような組み合わせのオプションがあったり、-o=ファイル名
と--output ファイル名
のような等価な表現があったり、自分で実装するとなると面倒なルールがたくさんあります。 オプションパーサーはそのような複雑なルールの解釈とバリデーションを引き受けてくれるライブラリです。
オプションパーサーとして代表的なライブラリは標準のflag
パッケージです。 これ以外にも、多くのパッケージがあります7。
環境変数
環境変数は、ユーザーごとの固有の設定が含まれた配列です。 ユーザー名やホームディレクトリ、言語設定などのカレントのユーザーの情報、実行ファイルのパス、プログラム用の設定など、さまざまな情報を含みます。 以下の表に示すAPIは全環境で使えます。
os.Environ() |
文字列のリストで全取得 |
---|---|
os.ExpandEnv() |
環境変数が埋め込まれた文字列を展開 |
os.Setenv() |
キーに対する値を設定 |
os.LookupEnv() |
キーに対する値を取得(有無をboolで返す) |
os.Getenv() |
キーに対する値を取得 |
os.Unsetenv() |
指定されたキーを削除する |
os.Clearenv() |
全部クリアする |
ウェブアプリケーションと環境変数は切っても切れない関係にあります。 古のCGIでは、クライアントからのリクエストヘッダー情報やGETメソッドのクエリー、サーバー情報が環境変数としてプログラムに渡されました。 最近でも、本番環境と開発環境とでモードの切り替えを行うのに環境変数を使います。 また、コンテナを使ったウェブサービスでは、サーバーに固有の情報やクレデンシャルを環境変数で渡します。
環境変数は、キー=値
という形式の文字列の配列です。 Go言語内部では、このままの形式配列と、これをマップ型にマッピングしたものを両方持っています。
少し特殊で他の言語であまり見かけない機能として、os.ExpandEnv()
があります。 これは、環境変数をそのまま使うのではなく、GOBIN=${HOME}/bin
のようにして他の環境変数を組み合わせた文字列が欲しい場合に使います。
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println(os.ExpandEnv("${HOME}/gobin"))
}
終了コード
プロセス終了時にはos.Exit()
関数を呼びます。この関数は引数として数値を取り、この数値がプログラムの返り値として親プロセスに返されます。 この数値が終了コードです。
package main
import (
"os"
)
func main() {
os.Exit(1)
}
終了コードは非負の整数です。 一般的な慣習として、0が正常終了、1以上がエラー終了ということになっています。 安心して使える数値の上限については諸説ありますが、Windowsではおそらく32ビットの数値の範囲で使えます。 POSIX系OSでは、子プロセスの終了を待つシステムコールが5種類あります(wait
、waitpid
、waitid
、wait3
、wait4
)が、 このうちwaitid
を使えば32ビットの範囲で扱えるはずです。 それ以外の関数は、シグナル受信状態とセットで同じ数値の中にまとめられて返され、そのときに8ビットの範囲にまとめられてしまうため、255までしか使えません。
wait
: 子プロセスどれか(選択できない)の終了を待つwaitpid
: 指定されたプロセスIDを持つ子プロセスの終了を待つwaitid
: プロセスグループ内といった柔軟なプロセス指定ができ、32ビット対応
シェルやPythonなどを親プロセスにして試した限りでは256以上は扱えなかったので、ポータビリティを考えると255までにしておくのが無難でしょう。
なお、wait3
とwait4
はBSD系OS由来の関数で、子プロセスのメトリックも返す高機能関数になっています。 ただし、Linuxのmanによると将来削除される予定になっていますし、この関数はPOSIXの規格外の関数です。 Go言語はwait4
を使っています。
プロセスの名前や資源情報の取得
タスクマネージャのようなツールでは、プロセスIDと一緒にアプリケーション名が表示されています。 しかし、あるプロセスIDが何者なのかを知る方法は標準APIにありません。
LinuxやBSD系OSの場合、/proc
ディレクトリの情報が取得できます。 このディレクトリは、カーネル内部の情報をファイルシステムとして表示したものです。 GNU系のps
コマンドは、このディレクトリをパースして情報を得ています。 以下に示すように、/proc/プロセスID/cmdline
というテキストファイルの中にコマンドの引数が格納されているように見えます。
$ cat /proc/2862/cmdline ⏎
bash
macOSの場合は、オープンソースになっているdarwin用のps
コマンド8の中でsysctl
システムコールを使っています。 このシステムコールはLinuxにも存在していますが、カーネルの中の情報を取り出すシステムコールという性格上、OSごとに互換性はありません。
Windowsの場合はGetModuleBaseName()
を使います9。
このあたりはプロセスモニターのようなツールを実装するときには便利ですが、現時点で多くの機能がまとまっていてクロスプラットフォームで使えるのが、@r_rudi氏作のgopsutil10です。 このパッケージを使ったサンプルを以下に紹介します。
package main
import (
"fmt"
"github.com/shirou/gopsutil/process"
"os"
)
func main() {
p, _ := process.NewProcess(int32(os.Getppid()))
name, _ := p.Name()
cmd, _ := p.Cmdline()
fmt.Printf("parent pid: %d name: '%s' cmd: '%s'\n", p.Pid, name, cmd)
}
上記のサンプルでは、プロセスの実行で使われた実行ファイル名と、実行時のプロセスの引数情報を表示しています。 これ以外にも、ホストのOS情報、CPU情報、プロセス情報、ストレージ情報など、数多くの情報が取得できます。
OSから見たプロセス
プロセスから見た世界と比べると、OSから見た世界のほうが、やっていることが少し複雑です。
OSから見たプロセスは、CPU時間を消費してあらかじめ用意してあったプログラムに従って動く「タスク」です。 OSの仕事は、たくさんあるプロセスに効率よく仕事をさせることです。
Linuxではプロセスごとにtask_struct
型のプロセスディスクリプタと呼ばれる構造体を持っています。 プロセスを構成するすべての要素は、この構造体に含まれています。 基本的にはプロセスから見た各種属性と同じ内容ですが、それには含まれていない要素もいくつかあります。
連載の第5回では、現代のOS上のプロセスは自分の仕事だけに集中し、他のプロセスに干渉できないようになっていると紹介しました。 プロセスは、いわば水槽の中の魚のようなものです。 自分の水槽の中だけで自由に泳ぎ回れます。 プロセスが知れる自分の情報は魚の情報だけですが、OS側にはこの水槽の定義も含まれます。
例えば、プロセスはファイルシステムに関するコンテキストとして「カレントフォルダ」を持っていると説明しましたが、ルートディレクトリがどこかもプロセスごとに設定できます。 どこからどこまでが自分のメモリ領域かを定義する、メモリブロックの情報もあります。 スタック領域がどこにあり、プログラムが静的に確保するデータや、動的に確保するデータがどのようにレイアウトされるかもOSが持つプロセス情報の中にあります。
ファイルディスクリプタも、プロセス視点だと単なる一次元の配列(のインデックス値)に見えますが、ファイルはプロセス間で共有されることがあります。 OSではマスターとなるファイルのリストを持っており、参照カウントで参照数をカウントしています。
まとめと次回予告
今回は、プロセス編の第一弾として、プロセスが持つ情報を取得するGo言語の機能紹介を中心に解説しました。 紹介した機能の一部はsyscall
以下にしかなかったり、OSによっては関数は存在するけれど正しく実装されていないものもあります。 いざとなればsyscall
パッケージを使ってシステムコールを呼び出したり、cgoを使ってOSの機能を使うことはできますが、 システムコール周りのカバレッジは限定的で、Windowsも含めた多くの環境の最大公約数の機能しか提供されていません。
Go言語は、makeのような同一権限内で実行するジョブランナーだったり、入出力の負荷が大きいサーバーの実行では力を発揮します。 しかしGo言語も万能ではなく、ユーザー権限を細かく設定しながらタスクを管理するようなインフラ系のジョブ管理には機能が足りません。
Go言語に足りない機能は次のどちらか、または両方に集約されます。
- そもそもあまり使われていないので、なくてもそれほど困らなかった機能
- Go言語のプログラミングモデルと合わず、シェルスクリプトとかGoのコードを実行する側で吸収するほうがよい機能
gopsutilのような高機能なライブラリで弱点がカバーされる可能性もありますが、まずはGo言語が得意な部分でメリットを享受しながら少しずつリーチを広げていくのが最善といえるでしょう。
次回はプロセス編の第二弾として、外部プロセスの実行を取り上げます。
脚注
- https://github.com/golang/go/blob/master/src/syscall/exec_solaris_test.go↩
- GetTokenInformation: https://msdn.microsoft.com/en-us/library/aa446671(VS.85).aspx↩
- https://en.wikipedia.org/wiki/Apple_Open_Directory↩
- https://golang.org/doc/go1.4#minor_library_changes↩
- このあたりの概念は、ITmediaエンタープライズ: UNIX処方箋「SUIDとは」: http://www.itmedia.co.jp/enterprise/articles/0804/08/news014.html によくまとまっています。↩
- IPA「情報セキュリティ技術動向調査(2011年下期): https://www.ipa.go.jp/security/fy23/reports/tech1-tg/b_01.html↩
- オプションパーサーの数々: https://github.com/avelino/awesome-go#standard-cli↩
- macOSのPSコマンドのソース: https://opensource.apple.com/source/adv_cmds/adv_cmds-158/ps/ps.c↩
- GetModuleBaseName: https://msdn.microsoft.com/ja-jp/library/cc429400.aspx↩
- https://github.com/shirou/gopsutil↩
この連載の記事
-
第20回
プログラミング+
Go言語とコンテナ -
第19回
プログラミング+
Go言語のメモリ管理 -
第18回
プログラミング+
Go言語と並列処理(3) -
第17回
プログラミング+
Go言語と並列処理(2) -
第16回
プログラミング+
Go言語と並列処理 -
第15回
プログラミング+
Go言語で知るプロセス(3) -
第14回
プログラミング+
Go言語で知るプロセス(2) -
第12回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(3) -
第11回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(2) -
第10回
プログラミング+
ファイルシステムと、その上のGo言語の関数たち(1) - この連載の一覧へ