プログラミング+ Go言語で知るプロセス(1)

プログラミング+ Go言語で知るプロセス(1)

  • 沿って huawei-accessories
  • 04/06/2022

オペレーティングシステムが実行ファイルを読み込んで実行するには、そのためのリソース(メモリやCPUなど)を用意しなければなりません。 そのようなリソースをまとめたプログラムの実行単位がプロセスです。 プロセスは、オペレーティングシステムが実行ファイルを読み込んで実行するときに新しく作られます。

コンピュータシステムの低レイヤをGo言語で覗いてみるこの連載では、今回から数回に分けてプロセスを見ていきます。 今回の記事で扱うのは次の内容です。

これまでの連載で登場したプロセス

プロセスはコンピュータシステムの中心となる概念なので、その存在をまったく無視してシステムに関するプログラムを書くことはできません。 そのため、これまでの連載記事でも、プロセスに関連する情報は小出しにしてきました。 まずは、これまでの連載記事でどんな場面にプロセスが出てきたかを思い出してみましょう。

ファイルディスクリプタとプロセス

連載の第2回では、外部との入出力を汎用化するための仕組みとして、ファイルディスクリプタについて触れました。 カーネルは、新しくプロセスを作るたびに、各プロセスでどういった入出力が行われるかの管理テーブルを作ります。 そのインデックス値がファイルディスクリプタです。

入出力とプロセス

これまでの連載では、Go言語でファイルやソケット、標準入出力、標準エラー出力といった外部との入出力を行う方法をたくさん見てきました。 ソケットを使った入出力については第6回から第9回、ファイルの入出力については第10回から第12回で触れました。io.Readerio.Writerといったインタフェースは、プロセスが外部との入出力に使います。 それらのインタフェースの裏に、ファイルやソケットや標準入出力、標準エラー出力があります。

プロセスと外界のやり取りはシステムコール経由

連載の第5回では、システムコールについて触れました。 プロセスはとってもシャイで、自分から他のプロセスに「データをくれ」とか「これを処理しておいて」とは言えないので、 やり取りはすべてシステムコールを介してOS経由で行います。 ファイルやソケットからのデータ読み込みも、現在の時刻の取得も、すべてシステムコール経由です。 プロセスが自分の力でできるのは、単純な数値計算ぐらいです。

プロセスに含まれるもの(Go言語視点)

プロセスにはプログラムの実行に必要なものや、外部プロセスとの入出力に必要なものまで、いろいろな情報が含まれています。

Go言語の関数を使って、これらの情報にアクセスしてみましょう。

プロセスには必ず、プロセスごとにユニークな識別子があります。それがプロセス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を取得すると常に定数値が返ります。

プロセスIDのAPI実装状況
OSプロセスID親のプロセスID
APIos.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実装状況
プロセスグループ取得指定プロセスのプロセスグループ取得指定プロセスのプロセスグループ設定
APIsyscall.Getpgrp()syscall.Getpgid()syscall.Setpgid()
Linux/BSD系OS
Solaris
Windows/Plan9/NaCl
セッショングループのAPI実装状況
指定プロセスのセッショングループ取得指定プロセスのセッショングループ設定
APIsyscall.Getsid()syscall.Setsid()
Linux/BSD系OS
Solaris
Windows/Plan9/NaCl

プロセスは誰かしらのユーザー権限で動作します。 また、ユーザーはいくつかのグループに所属しています (名前が紛らわしいのですが、このグループはさきほどのプロセスグループとは別の概念です)。 ユーザーは、メインのグループには1つだけしか所属できませんが、 サブのグループには複数入れます。

ユーザーとグループの権限は、ファイルシステムの読み書きの権限を制限するのに使われます。 ファイルシステムの読み書きの権限には、「読み」「書き」「実行」の3種類の権限があり、それぞれの権限が「所有者」「同一グループ」「その他」の3セットあります。 3種類の権限をrwxの文字で表すことで、権限を表現する9桁の「記号表記」と、それぞれの権限を4、2、1の数値の足し算として3桁の8進数表記があります。

権限の設定例
記号表記8進数表記意味
-rwxr-xr-x0755所有者は全操作。それ以外のユーザーは実行を許可
-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で権限の管理を行っています。GetTokenInformation2というAPIで詳細情報が取得できますが、Go言語では実装されていません。

また、他のOSでも、ファイル以外の読み書きの権限管理は別の仕組みが用意されています。 macOSでは、10.5のLeopardからはApple Open Directory3という仕組みを利用していて、こちらでOSの権限管理を行っています。 GUIのシステム環境設定のユーザーやグループ、あるいはdsclコマンドによる管理が行われます。

ユーザーIDとグループIDの設定は一部のOSでsyscallパッケージで提供されています。Linuxは他のOSと違い「プロセスではなく、現在のスレッドにしか効果がない」という理由で1.44からエラーを返す実装になっています。Setgroups()も同じ理由で無効な気がしますが、こちらはそのままとなっています。

ユーザーID、グループIDのAPI実装状況
ユーザーID取得ユーザーID設定グループID取得グループID設定
APIos.Getuid()syscall.Setuid()os.Getgid()syscall.Setgid()
BSD系OS/Solaris
Linuxエラーを返すエラーを返す
Windows定数値(-1)を返す定数値(-1)を返す
Plan9定数値(-1)を返す定数値(-1)を返す
NaCl定数値(1)を返す定数値(1)を返す
補助グループID一覧のAPI実装状況
補助グループID一覧取得補助グループID一覧設定
APIos.Getgroups()syscall.Setgroups()
BSD系OS/Solaris
Linux
Windowsエラーを返す
Plan9長さゼロの配列を返す
NaCl定数1が入った配列を返す

プロセスのユーザーのIDやグループIDは、通常は親プロセスのものを引き継ぎます。 しかしPOSIX系OSでは、SUIDSGIDフラグを付与することで、実行ファイルに設定された所有者(実効ユーザーID)と所有グループ(実効グループID)でプロセスが実行されるようになります5。 これらのフラグがないときは、実効ユーザーIDも実効グループIDも、元のユーザーIDとグループIDと同じです。 これらのフラグが付与されているときは、ユーザーIDとグループIDはそのままですが、実効ユーザーIDと実効グループIDが変更されます。

プログラミング+ Go言語で知るプロセス(1)

実効ユーザー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のAPI実装状況
実効ユーザーID取得実効ユーザーID設定実効グループID取得実効グループID設定
APIos.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実装状況
作業フォルダ取得
APIos.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は全環境で使えます。

環境変数の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種類あります(waitwaitpidwaitidwait3wait4)が、 このうちwaitidを使えば32ビットの範囲で扱えるはずです。 それ以外の関数は、シグナル受信状態とセットで同じ数値の中にまとめられて返され、そのときに8ビットの範囲にまとめられてしまうため、255までしか使えません。

シェルやPythonなどを親プロセスにして試した限りでは256以上は扱えなかったので、ポータビリティを考えると255までにしておくのが無難でしょう。

なお、wait3wait4は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言語に足りない機能は次のどちらか、または両方に集約されます。

gopsutilのような高機能なライブラリで弱点がカバーされる可能性もありますが、まずはGo言語が得意な部分でメリットを享受しながら少しずつリーチを広げていくのが最善といえるでしょう。

次回はプロセス編の第二弾として、外部プロセスの実行を取り上げます。

脚注

ツイートする

カテゴリートップへ

ASCII倶楽部

■2019年7月6日 (土) 13:00~19:00

東京都千代田区五番町3-1

今年3月23日に行われたセミナー「量子コンピュータで学ぶ量子プログラミング入門」を好評につき、リピート開催。これまでの「紙と鉛筆で学ぶ量子コンピュータ入門」と異なり、今回のハンズオンは量子コンピュータで計算させるために必要な「量子プログラミング」の基礎を学ぶことが目標。IBMの協力を得て、基本的なゲートを組み合わせて量子回路を構成するプログラミング手法と、IBMのQiskitにある多くのシミュレータの使い方を学ぶ。


株式会社角川アスキー総合研究所 お問い合わせフォーム

プログラミング+編集部では、法人向けセミナーや企業内研修の企画・実施、商品や出版物のレビュー記事作成、イベント取材のご依頼など、プログラミングや教育にまつわる幅広いご要望・ご相談を承っております。個人・法人・団体を問わず、ご興味のある方は上記フォームよりお気軽にお問い合わせください。


© KADOKAWA ASCII Research Laboratories, Inc. 2022

表示形式: PC ⁄ スマホ