プロセス
このページの見出し
このページでは、OpenVessel のカーネルのプロセスを定めます。始まりから終わりまでの流れ、プログラムの見つけ方と起動のしかた、ファイル記述子と標準入出力、パイプとソケット、シグナル、プロセスグループ、端末(TTY)です。ソースは src/kernel/kernel.ts・src/kernel/proc.ts・src/kernel/io.ts・src/kernel/syscalls.ts です。
カーネル全体は カーネル、シェルは シェル、プロセスの中の Node.js のランタイムは Node.js のランタイム で説明しています。
プロセスの記録
| 項目 | 意味 |
|---|---|
pid | プロセスの番号。カーネル全体で起動した順に 1, 2, 3, …。使い回さない |
ppid | 親の pid。ホストが起動したプロセスと、親を失ったプロセスは 0 |
pgid | プロセスグループ(プロセスグループ) |
argv | 引数。node のプロセスの argv[0] は node(argv0) |
env | 環境変数(文字列のふつうのオブジェクト) |
cwd | 作業ディレクトリ(chdir のあとは実際のパス) |
umask | 起動時は 0o022。どのプロセスも同じ |
nofile | RLIMIT_NOFILE。1024 で、親から受け継ぐ。シェルの ulimit -n で変える |
fds | ファイル記述子の表 |
tty | 制御端末。親から受け継ぐ |
kind | builtin(カーネルの中で動く)または node(Worker で動く) |
title | node、または組み込みのプログラムの名前 |
state・status | running か exited。終了状態 { code, signal } |
children | 子プロセスの pid |
handledSignals | プロセスが受け手を登録したシグナル |
threads | node のプロセスの Node のスレッド(メインスレッドとワーカースレッド) |
ホストの ps() と ps のプログラムは、動いているプロセスだけを出します(PID・PPID・argv)。
始まりから終わりまで
起動
kernel.spawn() は、次のことを同期で行います。
- 環境変数(渡されたもの、無ければ親の写し、それも無ければ 既定の環境変数)と、作業ディレクトリ(渡されたもの、無ければ親のもの、それも無ければ
/home/user)を決めます。 argvをどう動かすかを調べます(プログラムの見つけ方)。エラーは、pid を取る前のここで投げます:ENOENT(<cmd>: command not found)、EACCES(<cmd>: Permission denied)、ELOOP(インタプリタのつながりが深すぎる)、EINVAL(argvが空)。- 次の pid を取り、プロセスグループ・制御 TTY・
nofileを決め、渡された標準入出力の記述子を fd 0, 1, 2, … に置きます(それぞれ参照を 1 つ増やします)。 - プログラムを始めます。組み込みならカーネルのイベントループの上で、node のプログラムなら Worker で動かします(node のプロセスの起動)。
spawn のシステムコールは、さらに、渡された cwd があってディレクトリであることを調べます(ENOENT、 spawn: cwd <path> does not exist)。ホストの spawn は、cwd が無ければ /home/user を使い、そのディレクトリが無ければ作ります。
argv と env と cwd
- 直接起動した node のプログラム(
node app.js、または中身が#!/ov/builtin nodeのファイルのパス)は、呼び出し側のargv[0](process.argv0。たとえばnode)を保ちます。execve/spawnは別のもの(argv0)を渡せます。スクリプトの#!の行を通して起動したときは、argv[0]はnodeです。移植ランタイムはprocess.execPathとprocess.argv[0]を/usr/bin/nodeとします。 - 環境変数はまるごと渡します。子は、親の環境変数と渡されたものを混ぜません。
chdirはシンボリックリンクを解決します。新しいcwdは実際のパスです。失敗するとENOENTかENOTDIRです。
終了
プロセスは次のどれかで終わります。
| どう終わるか | 終了状態 |
|---|---|
node のプロセスが exit を呼ぶ(process.exit、イベントループの終わり) | code & 0xff(process.exit(300) は 44) |
組み込みのプログラムが値を返す、または ctx.exit(n) を呼ぶ | n |
| 組み込みのプログラムが例外を投げる | 標準エラーに <name>: <message> を出し、1 |
組み込みのプログラムが、誰も読まないパイプに書く(EPIPE) | 141 |
| シグナルで終わる | { code: null, signal } |
node のプロセスのメインの Worker が失敗する(onerror) | 1 |
process.abort()(移植ランタイム) | 134 |
node のプロセスが exit を呼ぶと、カーネルは片付ける前に、読む側が追いつくのを待ちます。そのプロセスが書いたパイプをほかのプロセスがまだ読んでいるなら、空になるまで(最大 250 ミリ秒)待ちます。そのプロセスが書くソケットをほかのプロセスが読んでいるなら、そのプロセスに ping を送り、pong を(最大 250 ミリ秒)待ちます。終わろうとしているスレッドは、二度と動きません。
そのあと、この順に進みます: ワーカースレッドをすべて止める、fd をすべて閉じる(パイプの読み手は EOF を見る)、待っているものに知らせる、そのプロセスの TCP の待ち受けと Unix ソケットの待ち受けを放す(ホストに port-closed が届く)、監視と UDP のポートを無くす、子の親を pid 0 にする。
数としての終了状態($? やホストの exit のイベントが示すもの)は、コード、またはシグナルなら 128 + シグナルの番号 です(SIGTERM は 143、SIGINT は 130)。カーネルの知らないシグナルの名前は 15 として数えます。
終わったプロセスの記録は、プロセスの表に 60 秒残ります。そのとき子プロセスを持っていなければ、消します。waitpid はありません。spawn のシステムコールで子を起動した親は、起動したスレッドで child-exit のメッセージ(pid・code・ signal)を受け取ります。終わったプロセスにシグナルを送ると ESRCH です。
execve
execve(argv, env, argv0)(Node の process.execve。移植ランタイム)は、プロセスのプログラムを入れ替えます。
- まず新しいプログラムを探します。
ENOENT・EACCES・ELOOPのときは、プロセスに何もしません。 - 2 より大きい fd をすべて閉じ、監視を外し、古いプログラムのスレッドを止め、受け手を登録したシグナルの記録を消し、そのプロセスの TCP と Unix の待ち受けを放します。
- pid・ppid・プロセスグループ・
cwd・fd 0〜2・制御 TTY・nofileはそのままです。 - 新しいプログラムは、渡された
argvとenvで動きます。
プログラムの見つけ方
argv[0] は次のように解決します(kernel.planExec)。
/を含む名前はパスで、cwdからの相対です。あって、ディレクトリでないことが必要です。/を含まない名前は、PATHの項目を順に探します(相対の項目はcwdから、空の項目は飛ばします)。xのビットを 1 つでも持つ最初の通常のファイルを使います(シンボリックリンクはたどります)。- 見つからない: その名前の組み込みのプログラムが登録されていれば、それを動かします(
/binにファイルが無くても)。そうでなければENOENT、<cmd>: command not foundです(シェルは 127 で終わります)。 - 見つかった: ファイルは
xのビットを持つ必要があります。無ければEACCES、<cmd>: Permission deniedです(シェルは 126 で終わります)。カーネルは先頭の 256 バイトを読みます。
| 最初の行・ファイル | 動かし方 |
|---|---|
#!/ov/builtin node | node のプロセス(これが /usr/bin/node) |
#!/ov/builtin <name> | 組み込みの <name>(登録が無ければ ENOENT、<name>: builtin missing) |
#!/usr/bin/env <interp> [args] または #!/bin/env … | #!<interp> [args] と同じ。<interp> は PATH で探す。-S は行の残りを単語に分ける。先頭の NAME=value の単語は捨てる(環境変数には入れない) |
#!<interp> [args] で、<interp> の最後の名前が node・nodejs・bun・deno | node のプロセス: node [args] <file> <引数…> |
#!<interp> [args](それ以外) | <interp> [args] <file> <引数…> を、手順 1 から解決し直す |
#! が無く、ファイル名が .js・.cjs・.mjs で終わる | node <file> <引数…> |
#! が無く、それ以外 | sh <file> <引数…>(POSIX のシェルが ENOEXEC のときにするのと同じ) |
#!の行の引数は空白で分けます(Linux は 1 つの引数として渡します)。- インタプリタのつながりは、ファイル 9 つ(プログラムとインタプリタ 8 つ)までです。超えると
ELOOPです。 - 無いインタプリタ(
#!/usr/bin/python3)はENOENTです。 node_modules/.binのパッケージのコマンドは、パッケージの bin のファイルへのシンボリックリンクで、インストーラーが実行できるようにしています。#!/usr/bin/env nodeの行で動きます。.jsの決まりが見るのは、動かした名前で、シンボリックリンクの行き先ではありません。
printf '#!/usr/bin/env node\nconsole.log(process.argv.slice(2))\n' > hello
chmod +x hello
./hello a b # [ 'a', 'b' ]
組み込みのプログラムと node のプロセス
| 組み込みのプログラム | node のプロセス | |
|---|---|---|
| 例 | sh・ls・grep・npm・curl | node・スクリプト・パッケージのコマンド |
| 動く場所 | カーネルの Worker の中の async 関数 | 自分の Web Worker |
| カーネルへの触れ方 | 直接(ProgramContext: fd・VFS・spawn) | システムコール を通して |
| 待つとき | カーネルを止めない。await で待つ | システムコールのあいだ、自分のスレッドを Atomics.wait で止める |
| シグナル | onSignal で受ける。受けなければ終わる(シグナル) | 受け手に届く。受け手が無ければ終わる |
| 起動にかかるもの | マイクロタスク 1 つ | 待機列の Worker と、Node.js の起動処理 |
node のプロセスの起動
- カーネルは、
/etc/openvessel/nodeで選んだランタイムの Worker を取ります(port→ 移植ランタイムのnode-process.js、それ以外か無いとき → 旧ランタイムのprocess.js。起動のたびに読みます)。ランタイムごとに、何もしていない Worker を 2 つ読み込んでおきます(温めた Worker の待機列)。 - そのスレッドのシステムコールのために、4 MiB の
SharedArrayBufferの通信路を作ります。 initを送ります。
| 項目 | 値 |
|---|---|
t | init |
kind | main(ワーカースレッドなら thread) |
pid・ppid・tid | 番号 |
argv・env・cwd | そのプロセスのもの |
sab | システムコールの通信路 |
ttys | fd 0, 1, 2 について、TTY なら { cols, rows }、そうでなければ null |
hostname | openvessel |
| スレッドの項目 | file・eval・evalType・execArgv・workerData・envData・port・name・threadId・resourceLimits・envRings・trackUnmanagedFds・sharedEnv |
そのあとランタイムが Node.js を起動し、プログラムを動かします。それ以外のこと(イベントループ、モジュール、 process のオブジェクト)はランタイムの受け持ちです(Node.js のランタイム)。
ワーカースレッド
worker_threads は、同じプロセスの Worker を増やしたものです。pid も fd の表も cwd も同じです。
- ワーカースレッドの tid は
threadReserveで取り、100000 から数えます。Node のthreadIdはプロセスごとに 1, 2, … です。 threads × 8 + 30 > nofileのとき、新しいスレッドをEMFILE(initFailed)で断ります(Node のスレッドは、それぞれ自分の記述子を持つためです)。既定のnofile1024 では、1 つのプロセスのスレッドは 125 までです。ulimit -nは、そのあとシェルが起動するプロセスの上限を変えます。threadKill(worker.terminate())は、スレッドをすぐに止めます(コード 1、terminated: trueのthread-exit)。- カーネルの返事は、頼んだスレッドへ返します。このため、メインスレッドが
Atomics.waitで止まっているあいだも、ワーカースレッドは動き続けます。
ファイル記述子と標準入出力
- 表はプロセスごとです。記述子は、参照の数を持つ共有のオープンファイル記述を指します。最後の参照が無くなると、そのオープンファイル記述は閉じます。
- 新しい記述子(
open、親の側のパイプの端、受け付けた接続、受け取ったハンドル)は、空いている 3 以上の最小の番号を取ります。nofileは記述子の数を縛りません。 - 子が受け継ぐのは、標準入出力として渡したものだけです。close-on-exec のフラグはありません。
- 移植ランタイムの標準出力・標準エラー(fd 0〜2)への書き込みは、Linux の Node がパイプとファイルにするのと同じく、バッファ全体を同期で書きます。
spawn のシステムコールの stdio
stdio は、子の fd の番号で並べた一覧です(既定は ['pipe', 'pipe', 'pipe'])。
| 項目 | 子の fd | 親が受け取るもの |
|---|---|---|
位置 0 の 'pipe'・'overlapped' | 新しいパイプの読む端 | 書く端(3 以上の fd) |
位置 1・2 の 'pipe'・'overlapped' | 新しいパイプの書く端 | 読む端 |
位置 3 以上の 'pipe'・'overlapped' | socketpair の片方(双方向) | もう片方 |
'ipc' | socketpair の片方 | もう片方 |
'inherit' | 親の同じ番号の fd(開いていなければ無し) | — |
数 n | 親の fd n | — |
'ignore'・null・undefined | /dev/null(位置 0 は読み取り専用、ほかは書き込み専用) | — |
| それ以外 | fd 無し | — |
プログラムを起動できなかったときは、子の側の端を閉じます。keepStdioOnError(移植ランタイムが使う)があれば、 ENOENT/EACCES を { pid: 0, fds, error } として返し、親の側の端は開いたままにするので、libuv と同じく親のストリームは EOF を読みます。移植ランタイムは Node のオプションを次のように変えます: pipe と overlapped → pipe(または ipc)、inherit → inherit、{ fd } → その数、ハンドルを持つストリーム → その fd、それ以外 → ignore。
spawnSync
spawnSync は、子が終わり、出力の 2 つのパイプが閉じるまで、呼んだスレッドを止めます。
- 位置 0 の
'pipe'は、渡した入力を受け取り、そのあと EOF です。位置 1 と 2 は出力を集めて返します。位置 3 以上はパイプを作り、読む端は捨てます。 'inherit'と数は上と同じです。それ以外('ipc'も)は/dev/nullです。timeout(ミリ秒): 子にkillSignal(既定SIGTERM)を送り、結果にerror: 'ETIMEDOUT'を付けます。maxBufferは、あとでランタイムが当てはめます(ENOBUFS)。
パイプ
createPipe() は、読む端と書く端を持つ一方向のバイトの通り道を作ります(src/kernel/io.ts)。
| 性質 | ふるまい |
|---|---|
| バッファ | 大きさに上限の無いかたまりの列。ブロックする書き込みは、いつも全部のバイトを一度に受け取り、待たない |
| 容量 | 65536 バイト。ブロックしない書き手(nonblock 付きの write)にだけ効く。入る分だけ書き、いっぱいなら 0 |
| 背圧 | 移植ランタイムは、標準入出力以外のパイプとソケットにブロックせずに書き、残りを列に入れて fd-writable を頼む。fd-writable はバッファがまた空になったとき(または読む人がいなくなったとき)に来る |
| 読み取り | たまっている分を、求めた大きさまで返す。何も無ければ待つ |
| EOF | 書く端がすべて閉じ、バッファが空のとき |
| 読み手がいない | 書き込みは EPIPE で失敗する。SIGPIPE は出さない。node のプロセスは EPIPE のエラーを受け取り(Node はもともと SIGPIPE を無視する)、組み込みのプログラムは 141 で終わる |
| 最後の読み手が閉じる | たまっていたデータは捨てる |
| プロセスへの送り出し | readStart のあと、データが来るたびに最大 64 KiB の fd-data を送り、EOF では data: null を送る |
| 番号 | パイプはそれぞれ番号を持ち、readlink('/proc/self/fd/N') で pipe:[N] と見える |
yes | head -2 # head が終わると、yes は EPIPE を受けて 141 で終わる
socketpair
socketpair('ipc' の標準入出力、追加の標準入出力のパイプ、TCP と Unix ソケットの接続)は、2 本のパイプを交差させたものです。
- ブロックしない書き手への容量: 向きごとに 212992 バイト。
shutdown()はこちらの端の書く側を閉じます。相手は EOF を読み、こちらはまだ読めます。- 端を閉じると、両方の向きが閉じます。
- ハンドル(ソケット、サーバー、パイプ)は、データと一緒に送れます(
child.send(msg, handle))。その書き込みの最初の 1 バイトに添えられ、そのバイトを読んだ読み手が受け取ります。ハンドルを求めずにそのバイトを読んだ読み手は、ハンドルを捨てます。
FIFO は ファイルシステム で説明しています。
シグナル
シグナルの表
| シグナル | 番号 | 受け手の無い node のプロセス | 組み込みのプログラム |
|---|---|---|---|
SIGHUP | 1 | 終わる | 終わる |
SIGINT | 2 | 終わる | 終わる |
SIGQUIT | 3 | 終わる | 終わる |
SIGILL | 4 | 終わる | 終わる |
SIGTRAP | 5 | 終わる | 終わる |
SIGABRT | 6 | 終わる | 終わる |
SIGBUS | 7 | 終わる | 終わる |
SIGFPE | 8 | 終わる | 終わる |
SIGKILL | 9 | 終わる(受け手があっても) | 終わる |
SIGUSR1 | 10 | 終わる | 終わる |
SIGSEGV | 11 | 終わる | 終わる |
SIGUSR2 | 12 | 終わる | 終わる |
SIGPIPE | 13 | 終わる | 終わる |
SIGALRM | 14 | 終わる | 終わる |
SIGTERM | 15 | 終わる | 終わる |
SIGCHLD | 17 | 無視 | 無視 |
SIGCONT | 18 | 無視 | 無視 |
SIGSTOP | 19 | 終わる | 終わる |
SIGTSTP | 20 | 終わる | 終わる |
SIGTTIN | 21 | 終わる | 終わる |
SIGTTOU | 22 | 終わる | 終わる |
SIGWINCH | 28 | 無視(メッセージは送る) | 無視 |
止まった状態はありません。SIGSTOP・SIGTSTP・SIGTTIN・SIGTTOU も、ほかのシグナルと同じく終わらせます。表に無い番号(16、23〜27、29〜64)は SIGTERM として扱います。移植ランタイムはシグナルをいつも番号で送るので、 process.kill(pid, 'SIGPWR') は SIGTERM として届きます。旧ランタイムは名前で送り、知らない名前もそのまま通します。
届け方
- シグナルの受け手を持つ node のプロセス(
sigactionを呼んだもの。process.on('SIGINT', …))は、{ t: 'signal', sig }を受け取り、動き続けます。SIGKILLは受け手に届きません。 - 旧ランタイムが受け手を登録するのは
SIGINT・SIGTERM・SIGHUP・SIGQUIT・SIGUSR1・SIGUSR2・SIGWINCH・SIGBREAK・SIGPIPE・SIGCONT・SIGTSTPです。移植ランタイムは、Node のprocess.onが受け付けるすべてのシグナルです。 - node のプロセスを終わらせると、その Worker をすぐに止めます。JavaScript はそれ以上動きません(
exitのイベントもありません)。 - 組み込みのプログラムは
onSignalを置いて、シグナルを飲み込めます(対話のシェルはSIGINT・SIGTSTP・SIGQUIT・SIGWINCHを飲み込みます)。そうでなければ終わります。AbortSignalが発火し、終了状態はすぐに決まり、プログラムがあとで返す値は無視します。
kill
kill { pid, sig }(process.kill・child.kill):
pid | 相手 |
|---|---|
> 0 | そのプロセス |
0 | 呼んだプロセス自身 |
< 0 | グループ -pid の、動いているすべてのプロセス |
sig | 効果 |
|---|---|
0 | プロセスがあって動いているかを調べる |
| 1〜64 の数、または名前 | 上のとおりに届ける |
| 0〜64 の外の数、または整数でないもの | EINVAL |
相手が無い、または終わっていれば ESRCH です。どのプロセスも、ほかのどのプロセスにもシグナルを送れます(権限は調べません)。ホストの kill(pid, signal) は pid のプロセスグループ全体に送り、失敗しません。
プロセスグループとジョブ制御
- 新しいプロセスの
pgid: 求められれば(pgid: 0)新しいグループ(pgid = pid)、そうでなく渡されればそのグループ、それも無ければ親のグループです。 - ホストが起動したプロセスは、自分のグループを持ちます。このため、ホストの
killは、そのプロセスと、それが同じグループで起動したものすべてに届きます。 - 対話でないシェルでは、パイプラインのコマンドが新しいグループ 1 つ(最初に起動したコマンドの pid)を共有します。ほかのコマンドはシェルのグループのままです。対話のシェルは、コマンドをそれぞれ自分のグループで起動し、動いているあいだ TTY の前面のグループにします。
- キーボードからのシグナル(
^C・^\・^Z)は、TTY の前面のグループへ送ります。 - ジョブ制御はありません。止まったジョブも
fg/bgも無く、jobsは何も出しません。^Zは前面のジョブを終わらせます(終了状態 148)。 - セッションは無く、プログラムは自分のプロセスグループを変えられません(Node の
detachedのオプションは無視します)。
端末(TTY)
TTY は、ホストが端末付きでプロセスを起動したとき(spawn(…, { terminal: { cols, rows } }))に作ります。プロセスは TTY を fd 0・1・2 として、制御端末として(子も受け継ぎます)、前面のグループの端末として受け取ります。出力は output のイベントとしてホストへ送ります。
| 性質 | 値 |
|---|---|
| 大きさ | 渡さなければ cols 80、rows 24。ホストからの resize(cols, rows) で変わる |
SIGWINCH | 大きさが変わるたびに、それを制御端末とする、動いているすべてのプロセスへ送る |
| 番号 | TTY ごとに 1, 2, …。tty のプログラムは /dev/pts/<番号> と出す |
isatty | その TTY である fd なら true(fstat の mode は S_IFCHR|0620) |
/dev/tty | 制御端末を開く。無ければ ENXIO |
出力
出力は、raw のときも、1 つだけ変えて通します: 同じ書き込みの中で直前に CR の無い LF を CR LF にします(ONLCR)。
行の規律(cooked のとき)
入力は行に集め、行が終わったら読み手に渡します。
| 入力 | 効果 | エコー |
|---|---|---|
| 表示できるバイト、タブ | 行に足す | そのバイト |
CR(13)または LF(10) | LF を足して行を渡す | LF(CR LF と表示) |
^C(3) | 行を捨て、前面のグループに SIGINT | ^C と改行 |
^\(28) | 行を捨て、前面のグループに SIGQUIT | — |
^Z(26) | 前面のグループに SIGTSTP(行はそのまま) | — |
空の行での ^D(4) | ファイルの終わり。1 回の読み取りが EOF を返す | — |
行に文字があるときの ^D | 改行を付けずに行を渡す | — |
DEL(127)または ^H(8) | 行の最後の UTF-8 の 1 文字を消す(空の行では何もしない) | BS SP BS |
^U(21) | 行を捨てる(空の行では何もしない) | CR ESC[K |
ESC(27) | 行に足す | —(そのあとのバイトはふつうにエコーする) |
| ほかの制御のバイト | 行に足す | — |
行の長さにも、読まれるのを待っている入力にも、上限はありません。ファイルの終わりは、それを受け取った 1 つの読み手が使い切ります。
raw とエコー
| 設定 | 効果 |
|---|---|
setRawMode(true)(process.stdin.setRawMode) | raw にしてエコーを切る。バイトは来たとおりに読み手へ。^C・^Z・^\・^D もふつうのバイト |
setRawMode(false) | raw をやめてエコーを入れる |
stty raw / stty -raw・stty sane・stty cooked | raw にする / やめる(エコーは変えない) |
stty echo / stty -echo | エコーを入れる / 切る |
stty size | <rows> <cols> を出す |
対話のシェルの前面のコマンドが終わると、シェルは raw をやめ、エコーを入れます。
上限
| 上限 | 値 |
|---|---|
| プロセス | 上限なし。pid は使い回さない |
| プロセスごとの記述子 | 上限なし |
nofile(ulimit -n) | 既定 1024。ワーカースレッドを縛る: threads × 8 + 30 > nofile のとき断る |
インタプリタのつながり(#!) | ファイル 9 つ(ELOOP) |
#! の行 | 先頭の 256 バイトから読む |
read のシステムコール 1 回 | 16 MiB |
| システムコールの通信路 | スレッドごとに 4 MiB(大きいメッセージは何回かに分ける) |
| ブロックしない書き手へのパイプの容量 | 65536 バイト。socketpair は向きごとに 212992 バイト |
fd-data の 1 回分 | 64 KiB |
| 終了時に読み手を待つ時間 | 250 ミリ秒 |
| 終わったプロセスを表に残す時間 | 60 秒 |
| CPU の時間・メモリ | カーネルには上限なし(ワーカースレッドは自分のランタイムに resourceLimits を渡せる) |
この文書の元は、リポジトリの docs/reference/processes.ja.md です(英語版は processes.md)。