OpenVessel

プロセス

このページの見出し
  1. プロセスの記録
  2. 始まりから終わりまで
  3. プログラムの見つけ方
  4. 組み込みのプログラムと node のプロセス
  5. node のプロセスの起動
  6. ファイル記述子と標準入出力
  7. パイプ
  8. シグナル
  9. プロセスグループとジョブ制御
  10. 端末(TTY)
  11. 上限

このページでは、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。どのプロセスも同じ
nofileRLIMIT_NOFILE。1024 で、親から受け継ぐ。シェルの ulimit -n で変える
fdsファイル記述子の表
tty制御端末。親から受け継ぐ
kindbuiltin(カーネルの中で動く)または node(Worker で動く)
titlenode、または組み込みのプログラムの名前
state・statusrunning か exited。終了状態 { code, signal }
children子プロセスの pid
handledSignalsプロセスが受け手を登録したシグナル
threadsnode のプロセスの Node のスレッド(メインスレッドとワーカースレッド)

ホストの ps() と ps のプログラムは、動いているプロセスだけを出します(PID・PPID・argv)。

始まりから終わりまで

起動

kernel.spawn() は、次のことを同期で行います。

  1. 環境変数(渡されたもの、無ければ親の写し、それも無ければ 既定の環境変数)と、作業ディレクトリ(渡されたもの、無ければ親のもの、それも無ければ /home/user)を決めます。
  2. argv をどう動かすかを調べます(プログラムの見つけ方)。エラーは、pid を取る前のここで投げます: ENOENT(<cmd>: command not found)、EACCES(<cmd>: Permission denied)、 ELOOP(インタプリタのつながりが深すぎる)、EINVAL(argv が空)。
  3. 次の pid を取り、プロセスグループ・制御 TTY・nofile を決め、渡された標準入出力の記述子を fd 0, 1, 2, … に置きます(それぞれ参照を 1 つ増やします)。
  4. プログラムを始めます。組み込みならカーネルのイベントループの上で、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)。

  1. / を含む名前はパスで、cwd からの相対です。あって、ディレクトリでないことが必要です。
  2. / を含まない名前は、PATH の項目を順に探します(相対の項目は cwd から、空の項目は飛ばします)。 x のビットを 1 つでも持つ最初の通常のファイルを使います(シンボリックリンクはたどります)。
  3. 見つからない: その名前の組み込みのプログラムが登録されていれば、それを動かします(/bin にファイルが無くても)。そうでなければ ENOENT、<cmd>: command not found です(シェルは 127 で終わります)。
  4. 見つかった: ファイルは x のビットを持つ必要があります。無ければ EACCES、<cmd>: Permission denied です(シェルは 126 で終わります)。カーネルは先頭の 256 バイトを読みます。
最初の行・ファイル動かし方
#!/ov/builtin nodenode のプロセス(これが /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・denonode のプロセス: 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・curlnode・スクリプト・パッケージのコマンド
動く場所カーネルの Worker の中の async 関数自分の Web Worker
カーネルへの触れ方直接(ProgramContext: fd・VFS・spawn)システムコール を通して
待つときカーネルを止めない。await で待つシステムコールのあいだ、自分のスレッドを Atomics.wait で止める
シグナルonSignal で受ける。受けなければ終わる(シグナル)受け手に届く。受け手が無ければ終わる
起動にかかるものマイクロタスク 1 つ待機列の Worker と、Node.js の起動処理

node のプロセスの起動

  1. カーネルは、/etc/openvessel/node で選んだランタイムの Worker を取ります(port → 移植ランタイムの node-process.js、それ以外か無いとき → 旧ランタイムの process.js。起動のたびに読みます)。ランタイムごとに、何もしていない Worker を 2 つ読み込んでおきます(温めた Worker の待機列)。
  2. そのスレッドのシステムコールのために、4 MiB の SharedArrayBuffer の通信路を作ります。
  3. init を送ります。
項目値
tinit
kindmain(ワーカースレッドなら thread)
pid・ppid・tid番号
argv・env・cwdそのプロセスのもの
sabシステムコールの通信路
ttysfd 0, 1, 2 について、TTY なら { cols, rows }、そうでなければ null
hostnameopenvessel
スレッドの項目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 のスレッドは、それぞれ自分の記述子を持つためです)。既定の nofile 1024 では、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 のプロセス組み込みのプログラム
SIGHUP1終わる終わる
SIGINT2終わる終わる
SIGQUIT3終わる終わる
SIGILL4終わる終わる
SIGTRAP5終わる終わる
SIGABRT6終わる終わる
SIGBUS7終わる終わる
SIGFPE8終わる終わる
SIGKILL9終わる(受け手があっても)終わる
SIGUSR110終わる終わる
SIGSEGV11終わる終わる
SIGUSR212終わる終わる
SIGPIPE13終わる終わる
SIGALRM14終わる終わる
SIGTERM15終わる終わる
SIGCHLD17無視無視
SIGCONT18無視無視
SIGSTOP19終わる終わる
SIGTSTP20終わる終わる
SIGTTIN21終わる終わる
SIGTTOU22終わる終わる
SIGWINCH28無視(メッセージは送る)無視

止まった状態はありません。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 cookedraw にする / やめる(エコーは変えない)
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)。