home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他)

技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他)

技術 / UNIX / なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他)
id: 854 所有者: msakamoto-sf    作成日: 2010-11-29 17:52:28
カテゴリ: C言語 Linux UNIX 

何をいまさら当たり前の事を・・・と思われるだろう。

$ nohup long_run_batch.sh &

SSHからログアウト後も実行を続けたいバッチジョブを、"&"を付けてバックグラウンドジョブとしてnohupから起動するのは定番中の定番である。

しかし、「nohupを使わなくても実行を続けることが出来る」やり方があったり、さらには「nohupを付けてもログアウト時に終了してしまう」パターンがあるとしたらどうだろう?

そして、ある日あなたの後輩や同僚がこれらについてあなたに質問してきたら、あなたはどう答えるだろうか?

「Web上で検索したら見つかったのでそれに従ってる」

と答えてお茶を濁すだろうか?
それとも、

「OK, いい質問だ。それはシェルが終了時にSIGHUPをだね・・・」

のように理路整然とした華麗な語り口で受け答えるべきだろうか?

「お茶を濁せればそれでよい」と答えた方、あるいは「よし、じゃぁ一緒にBashのソースコードやプロセス終了時のカーネルのソースを追ってみようか。おっとその前にSUSv3をベースに端末制御とSIGHUPについて復習だ・・・」と颯爽とリードできる人はこの先読む必要は無い。


ここから先はそれなりに長丁場になる。しかし、舞台裏を解き明かすことで「nohupのNGパターン」や「nohupを使わなくてもOKなパターン」のWHYについて説明できるようになるだろう。・・・なってくれると、いいなぁ。いや、なってほしいです。・・・なれなかったとしたら、説明が下手だったということでmsakamoot-sfに責任転嫁してください。

内容的に分量や密度は高めとなる。NetBSD1.6やCentOS5.xでの検証でサンプルコードや動作結果などの分量が多いので、そこは読み飛ばしていただいてもかまわない。

では、しばしお付き合い願います。



WHY?の発端

改めてタイトルを確認すると、

なぜnohupをバックグランドジョブとして起動するのが定番なのか?

というWHY?になっている。バックグラウンドではなくフォアグラウンドジョブとして起動し、

$ nohup long_run_batch.sh
(フォアグラウンド)

この状態でputtyなどの端末エミュレータを終了させてしまってもバッチジョブは残り、実行を続ける。
なのになぜ、わざわざnohupをバックグランドジョブとして起動するのが定番になっているのか?あるいはそうした方がよい理由はあるのか?

まずはnohupの中身を確認すべきだが、それについては既に以下の記事で確認している。

実は今回のWHY?の発端は、nohupが直接の原因ではない。上の記事でnohup単体の謎解きは終えている。
その後、「念のためセッションやプロセスグループ、擬似端末についてAPUEを確認しておこう」と思いAPUEをぱらぱらめくっていくうちに、「あれ?バックグラウンドジョブってSIGHUPを受信することは有り得ないんじゃない?」という疑念が立ち上がり、そこで初めて「じゃぁなんでnohupをバックグランドジョブとして起動するのが定番なんだ?」という疑問が表れた。それが今回のWHY?の発端である。

詳細は後ほど解説するとして、まずは冒頭で述べた

  • nohupを使わなくても実行を続けることが出来るパターン
  • nohupを付けてもログアウト時に終了してしまうパターン

この2パターンを紹介する。本記事を最後まで読んでいただければ、この2パターンの挙動を完璧に説明できるように・・・なってくれると嬉しいです。

nohupを使わなくても実行を続けることが出来るパターン

実験用のサンプル, hello.sh:

#!/bin/sh
i=1
while :
do
    echo "Hello : $i"
    sleep 1
    i=`expr $i + 1`
done

何の変哲も無いシェルスクリプトだが、これだけでもnohup無しで、バックグランドでジョブを続けられる。

手順:

$ ./hello.sh > out.txt &
[1] 219
$ jobs
[1]+  Running                 ./hello.sh >out.txt &
$ exit
→端末終了

別の端末エミュレータを立ち上げてpsコマンドを実行すれば、"./hello.sh"とそのwhileループで起動されたsleepプロセスが残っている事を確認できる。また out.txt を見てみると処理は続行し更新が続けられていることも確認できる。

プラットフォームや端末エミュレータの組み合わせによっては、"exit"後、端末エミュレータ側の画面が真っ白のまま終了しない場合がある。その場合は、端末エミュレータ側を強制終了させる。Linux(CentOS5.x)とputtyの組み合わせでこの現象に遭遇した。NetBSD1.6の場合はputty側も"exit"後にすぐ終了した。

nohupを付けてもログアウト時に終了してしまうパターン

「うっかり"&"をつけずにフォアグラウンドで起動してしまったジョブを、"^Z"でバックグラウンドにしてからログアウトする」手順を踏むと、たとえnohupを付けていても終了してしまう。うっかりやってしまい、「アレ?」となった人もいるだろう。自分もしょっちゅうやってました。

$ nohup ./hello.sh
sending output to nohup.out
^Z
[1]+  Stopped                 nohup ./hello.sh
$ jobs
[1]+  Stopped                 nohup ./hello.sh
$ exit

別の端末エミュレータを立ち上げてpsコマンドを実行すれば、"./hello.sh"やsleepプロセスともに終了したためプロセス一覧に表示されない。また out.txt の更新も止まっている。

nohupの仕組みだけでは説明しきれない

上記2パターンのように、バックグランドジョブの継続にはnohupが必須というわけではなく、nohupを使えば全く問題が無いわけでもない。したがってこれらの挙動を正確に把握するにはnohup単体の仕組みだけではなく、端末制御やセッション、プロセスグループ、SIGHUPなど周辺要素について調査する必要がある。

nohupはSIGHUPのシグナルハンドラをSIG_IGNに設定、つまり「受信しても無視」する設定にして、コマンドラインで指定されたジョブを実行する。逆に言えばnohupはSIGTERM, SIGQUITなど他のシグナルハンドラをデフォルト設定のままにしている。たとえnohupで起動していたとしても、それらデフォルトでプロセス終了となるシグナルを受信すれば当然、プロセスは終了する。
つまりnohupでも終了してしまうパターンでは、それらSIGHUP以外のプロセス終了シグナルを受信したために終了した可能性が考えられる。ではそのシグナルは何なのか?誰が、いつ送信したのか?SIGHUPとそれ以外のシグナル、どちらを送信するのか判断する基準は?
本記事では、それらの疑問についても回答する予定である。

「誰がSIGHUP送ったの?」(「誰がこまどり殺したの?」)

と問われれば、

「ログインシェルがSIGHUP送ってるんじゃないの?」

と思われる方もいるだろう。実は自分も、この記事を書くまではそう思ってました。

半分正解で、半分不正解。
ログインシェルがSIGHUPを送る場合・送らない場合の両方が有り、さらに端末のデバイスドライバやカーネルが送る場合もある。
それらの詳細については以降の記事で解説するが、実際、ややこしい。実験・検証しているときも、しょっちゅう間違えて時間をとられてしまった。

「誰がSIGHUPを送ったの?」

記事のネタとしてはそれなりだが、実際のお仕事や運用中に一々考えるのは面倒である。

身も蓋も無い言い方だが、使う側が何も知らず、考えなくてもバックグラウンドジョブを継続できるnohupは、やはりスゴイのだ。

本記事を読むときの前提知識、実験・検証環境について

本格的な調査に進む前に、前提知識、実験・検証環境について説明する。

前提知識

最低限、以下の前提知識や技能があることを前提とする。

  • UnixでのC言語と標準Cライブラリを使ったプログラミング・コンパイル経験
  • Unixのシステムコール:分野としては File I/O, Signal, Process, Error Handlingの4つ
  • Bashシェルの使い方、ジョブ制御

サンプルコードで主に使うのは次のシステムコールとCライブラリ関数になる。必要に応じてmanpageを参照のこと。

open(), read(), write(), close()
signal(), sigaction()
fork(), exec()シリーズ
perror()
printf(), fprintf(), fflush(), 

セッション・プロセスグループ・端末制御系はサンプルコードではほとんど使わない。解説はするので、必要であればmanpageを参照のこと。

サンプルコードは掲載しているが、そのコンパイル方法についてはSUIDが必要な場合を除き、省略している。基本的にはmakeコマンドのデフォルトルールに基づき

$ make (ソースファイル名から".c"を除去した実行ファイル名)

または

$ cc -o 実行ファイル名 ソースファイル名
$ gcc -o 実行ファイル名 ソースファイル名

でコンパイルしている。

環境

NetBSD 1.6 および CentOS 5.x を使っている。古いNetBSD 1.6 を使っている理由は、 C言語系/「デーモン君のソース探検」読書メモ で使った環境を引き続き使用しているためである。いずれもVMware仮想マシンとして実行している。

VMwareホスト:

CPU : Intel CORE i3 (2 core)
RAM : 4GB
OS : Windows7 (32bit), Japanese
VMware : 7.1.2 build-301548

今回使用したクライアント側の端末エミュレータ:「PuTTY 0.60 ごった煮版 2007年8月6日版」

NetBSD 1.6:

NetBSD 1.6 i386 GENERIC kernel (32bit)

$ bash --version
GNU bash, version 2.05.0(1)-release (i386--netbsdelf)
Copyright 2000 Free Software Foundation, Inc.

$ cc --version
2.95.3

CentOS 5.x:

Linux kernel : 2.6.18-92.1.22.el5 (SMP, i686) (32bit)

$ bash --version
GNU bash, version 3.2.25(1)-release (i686-redhat-linux-gnu)
Copyright (C) 2005 Free Software Foundation, Inc.

$ gcc --version
gcc (GCC) 4.1.2 20080704 (Red Hat 4.1.2-48)
Copyright (C) 2006 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
参考資料の表記について
"APUE"
"Advanced Programming in the UNIX Environment Second Edition"
"AUP"
"Advanced UNIX Programming Second Edition"
"SUSv3"
Single UNIX ® Specification, Version 3

参考資料の詳細については記事の最後に一覧を載せている。

退屈な復習から生まれたWHY?の発端

セッションやプロセスグループ、擬似端末についてAPUEを確認したことが今回のWHY?の発端である。
前提知識の再確認も兼ねて、本記事に特に関連するトピックに絞って、ごく簡単に要点をまとめていく。
単調で退屈な文章が続くが、暫くご辛抱願いたい。

セッション・プロセスグループの全体像

  • 1つのプロセスは1つのプロセスグループに所属する。
  • 1つのプロセスグループには1つまたはそれ以上のプロセスが所属する。
  • 1つのプロセスグループにはグループリーダーとなるプロセスが0 or 1つ存在する。
  • 1つのプロセスグループは1つのセッションに所属する。
  • 1つのセッションには1つまたはそれ以上のプロセスグループが所属する。
  • 1つのセッションにはセッションリーダーとなるプロセスが1つ存在する。


参考:

  • APUE : p269, "9.4 Process Groups"
  • APUE : p270, "9.5 Sessions"

セッションと制御端末(Controlling Terminal)

セッションは制御端末の有無で二種類に分けることができる。

  • 制御端末を持たないセッション(initやinitから起動されたデーモンプロセスなど)
  • 制御端末を持つセッション(ログインシェルやログインシェルから起動されたプロセスなど)
    • 端末からの入出力を受け付けるプログラムはこちらのセッションに所属することになる。


参考:

  • APUE : p272, "9.6 Controlling Terminal"

セッションの新規作成

セッションの新規作成でポイントとなる箇所を示す。

初期状態:

まずfork()で子プロセスを作成する。

fork()した子プロセス側でsetsid()を呼ぶ。これにより新しいセッションが開始される。

  1. setsid()を呼んだプロセスは新しいセッションのセッションリーダーとなる。
  2. 同プロセスは新しいプロセスグループのグループリーダーとなる。新しいプロセスグループのIDは同プロセスのIDと等しくなる。
  3. 新しいセッションはまだ制御端末を持っていない。親プロセスの所属するセッションが制御端末を持っていたとしても、引き継がれない。(ログインシェル上からデーモンを起動したときのイメージ)


参考:

  • APUE : p270, "9.5 Sessions"

プロセスグループの新規作成

プロセスグループの新規作成でポイントとなる箇所を示す。

初期状態:

まずfork()で子プロセスを作成する。

子プロセスと親プロセスの両方でsetpgid()を呼ぶ。なぜ両方で呼ぶ必要があるのかは、APUE参照。
これにより、プロセスグループが(まだ存在していなければ)新規作成され、子プロセスがそのプロセスグループの最初のプロセスであれば子プロセスはグループリーダーになる。
もしパイプなどで複数のプロセスがひとつのプロセスグループとして起動した場合は、シェルの実装にも依存するが、新しいプロセスグループを作成した後、そのプロセスグループに所属するようにfork()を繰り返していく。以下の図ではシェルからfork()したプロセスがさらにfork()していく例を示している。シェル側でfork()とsetpgid()を繰り返す場合もあるだろう。

参考:

  • APUE : p269, "9.4 Process Groups"
  • APUE : p278, "9.9 Shell Execution of Programs"

コンソールログインの舞台裏

昔は"ダム端末"(dumb-terminal)と呼ばれるディスプレイ・キーボードのセットがあり、複数のダム端末が1つのマシンに接続される事で複数人による並行作業が行われていたらしい。
時代が少し進むと、ダイアルアップ回線で通信するモデム経由でログインできるようになったらしい。
2010年現在でも1つのマシンに0または複数のディスプレイ・キーボードが接続される場合があるが、複数人による同時ログインを提供するためのものではなく、マルチディスプレイ環境や、キーボード・モニタ切り替え器を間に挟んで複数マシンで共有する目的が殆どである。リモート接続する場合はtelnetやSSHなどを使う。

マシンに接続されたディスプレイ・キーボードのセットを「コンソール」と呼んでみる。
コンソールからのログインの舞台裏をまとめる。

  1. システム起動時にinitプロセスがコンソール入力を受け付けるプロセスを起動する。
    1. NetBSD1.6の場合は "/usr/libexec/getty", CentOS5.x の場合は "/sbin/mingetty" がコンソール入力を受け付けるプログラム。以降、まとめて "getty" と表記する。
  2. どの端末デバイスをopen()し、いくつ起動するのかはinitプロセス用の設定ファイルに書かれている。
    1. NetBSD1.6の場合は "/etc/ttys", CentOS5.x の場合は "/etc/inittab" に書かれている。
  3. gettyはinitとは別にそれぞれ独立したセッションを持ち、セッションリーダーとして起動する。
  4. gettyはログインプロンプトをそれぞれの端末に出力し、ログインユーザー名の入力を待つ。


  1. 端末からログインユーザー名が入力されると、gettyは"login"プログラムをexec()する。
  2. "login"プログラムはパスワード入力プロンプトを表示し、端末へのechoをOFFにし、パスワードの入力を待つ。
  3. パスワードが入力されると"login"プログラムは認証処理を行い、有効なユーザー名とパスワードであればログイン処理に進む。ログイン処理の概要は以下の通り。
    1. カレントディレクトリをユーザーのホームディレクトリに変更
    2. 端末デバイスのデバイスファイルをchown()
    3. 同デバイスファイルのアクセス権限をユーザーがread/writeできるよう変更
    4. プロセスのグループIDを変更
    5. 環境変数を調整する。"HOME", "SHELL", "USER", "PATH"など
    6. プロセスのユーザーIDを変更
    7. 最後にユーザーのログインシェルをexec()で実行

プラットフォームにより多少の差異はあるかもしれないが、大筋としては上記の流れになる。

参考:

  • APUE : p261, "9.2 Terminal Logins"
NetBSD1.6でのコンソールログイン例

"login"プロンプト表示時点:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
  0    0    0 67ae00 30001 ??    DKs  [swapper]
  1    0    1 bf9880 30001 ??    Is   init
...
190    1  190 c8cd40   190 ttyE0 Is+  /usr/libexec/getty Pc console
191    1  191 c8cc80   191 ttyE1 Is+  /usr/libexec/getty Pc ttyE1
192    1  192 c8cd00   192 ttyE2 Is+  /usr/libexec/getty Pc ttyE2
193    1  193 c8ccc0   193 ttyE3 Is+  /usr/libexec/getty Pc ttyE3

四つのgettyが起動している。

ユーザー名入力→"Password:" プロンプト表示の時点:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
  0    0    0 67ae00 30001 ??    DKs  [swapper]
  1    0    1 bf9880 30001 ??    Is   init
...
190    1  190 c8cd40   190 ttyE0 S<s+ login -p -- msakamoto
191    1  191 c8cc80   191 ttyE1 Is+  /usr/libexec/getty Pc ttyE1
192    1  192 c8cd00   192 ttyE2 Is+  /usr/libexec/getty Pc ttyE2
193    1  193 c8ccc0   193 ttyE3 Is+  /usr/libexec/getty Pc ttyE3

PID:190が "getty" から "login" に変化している。exec()されたことが確認できる。

パスワード入力→ログインシェル起動後:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
  0    0    0 67ae00 30001 ??    DKs  [swapper]
  1    0    1 bf9880 30001 ??    Ss   init
...
365    1  365 c6a740   365 ttyE0 Ss+  -bash
191    1  191 c8cc80   191 ttyE1 Is+  /usr/libexec/getty Pc ttyE1
192    1  192 c8cd00   192 ttyE2 Is+  /usr/libexec/getty Pc ttyE2
193    1  193 c8ccc0   193 ttyE3 Is+  /usr/libexec/getty Pc ttyE3

"ttyE0"で"bash"が起動されたことが確認できる。ただしPIDが変化しているため、fork()後にexec()された可能性がある。またPID:190が消えてしまっており、fork()後の親プロセスは終了している可能性が高い。

ログアウト後:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
  0    0    0 67ae00 30001 ??    DKs  [swapper]
  1    0    1 bf9880 30001 ??    Ss   init
...
491    1  491 c97240   491 ttyE0 Ss+  /usr/libexec/getty Pc console
191    1  191 c8cc80   191 ttyE1 Is+  /usr/libexec/getty Pc ttyE1
192    1  192 c8cd00   192 ttyE2 Is+  /usr/libexec/getty Pc ttyE2
193    1  193 c8ccc0   193 ttyE3 Is+  /usr/libexec/getty Pc ttyE3

新しいPIDでgettyが起動されている。おそらくinit側でログインシェルの終了を検出し、自動的にgettyを再起動しているのだろう。

CentOS5.xでのコンソールログイン例

"login"プロンプト表示時点:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
   1     0     1     1    -1 ?        Ss   init [3]
...
2612     1  2612  2612  2612 tty1     Ss+  /sbin/mingetty tty1
2613     1  2613  2613  2613 tty2     Ss+  /sbin/mingetty tty2
2616     1  2616  2616  2616 tty3     Ss+  /sbin/mingetty tty3
2625     1  2625  2625  2625 tty4     Ss+  /sbin/mingetty tty4
2626     1  2626  2626  2626 tty5     Ss+  /sbin/mingetty tty5
2627     1  2627  2627  2627 tty6     Ss+  /sbin/mingetty tty6

mingettyが6つ起動している。

ユーザー名入力→"Password:" プロンプト表示の時点:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
   1     0     1     1    -1 ?        Ss   init [3]
...
2612     1  2612  2612  2612 tty1     Ss+  /bin/login --
2613     1  2613  2613  2613 tty2     Ss+  /sbin/mingetty tty2
2616     1  2616  2616  2616 tty3     Ss+  /sbin/mingetty tty3
2625     1  2625  2625  2625 tty4     Ss+  /sbin/mingetty tty4
2626     1  2626  2626  2626 tty5     Ss+  /sbin/mingetty tty5
2627     1  2627  2627  2627 tty6     Ss+  /sbin/mingetty tty6

PID:2612が "mingetty" から "login" に変化している。exec()されたことが確認できる。

パスワード入力→ログインシェル起動後:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
   1     0     1     1    -1 ?        Ss   init [3]
...
2612     1  2612  2612    -1 ?        Ss   login -- msakamoto
15179  2612 15179 15179 15179 tty1     Ss+  -bash
2613     1  2613  2613  2613 tty2     Ss+  /sbin/mingetty tty2
2616     1  2616  2616  2616 tty3     Ss+  /sbin/mingetty tty3
2625     1  2625  2625  2625 tty4     Ss+  /sbin/mingetty tty4
2626     1  2626  2626  2626 tty5     Ss+  /sbin/mingetty tty5
2627     1  2627  2627  2627 tty6     Ss+  /sbin/mingetty tty6

ログインシェルが別プロセスとして起動されている。起動した"login"プロセス自体は残っている。

ログアウト後:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
   1     0     1     1    -1 ?        Ss   init [3]
...
15332     1 15332 15332 15332 tty1     Ss+  /sbin/mingetty tty1
2613     1  2613  2613  2613 tty2     Ss+  /sbin/mingetty tty2
2616     1  2616  2616  2616 tty3     Ss+  /sbin/mingetty tty3
2625     1  2625  2625  2625 tty4     Ss+  /sbin/mingetty tty4
2626     1  2626  2626  2626 tty5     Ss+  /sbin/mingetty tty5
2627     1  2627  2627  2627 tty6     Ss+  /sbin/mingetty tty6

ログインシェル・"login"両方とも終了し、"mingetty"が新しいプロセスで起動された。

擬似端末(Pseudo Terminal)を使ったリモートログインの舞台裏

telnetやSSHなどのリモートログインでは、最終的に起動されるログインシェルに対して仮想的な端末を提供するため、「擬似端末」(Pseudo Terminal)という機能を使用している。リモートログイン以外でも、scriptコマンドで擬似端末が活用されている。

擬似端末は"master"側と"slave"側の二つのデバイスを提供する。最初は"slave"側のデバイスは利用できない。"master"側のデバイスをopen()すると、対応する"slave"側のデバイスが利用可能となる。"master"側と"slave"側は双方向パイプのようにread/writeを中継する。双方向パイプと異なるのは、端末ならではの機能を利用できる点にある。これにより、単なるread/writeだけでなく、CANONICALモードやECHOモードを初めとする各種端末設定、バッファリングが可能となり、"slave"側を使うプロセスはあたかも実際の端末が接続されたかのように利用することができる。

リモート端末からの入出力がsocket/pipe/fileなどのファイル記述子として利用できる段階をスタート地点として、擬似端末を使ってログインシェルに対して仮想的な端末を提供し、リモート端末からの入出力を接続する流れを紹介する。
これらの処理はデバイスファイルの操作を含むため、実効(Effective)ユーザーIDがrootになっている必要がある。

  1. まず擬似端末の"master"側デバイスをopen()する。
  2. 擬似端末のデバイスドライバが"slave"側デバイスを用意する。
    1. あらかじめ用意されていたデバイスファイルをリネームする場合もあれば、実行時に動的にデバイスファイルを作成する場合もある。
    2. 下の図では実行時に動的にデバイスファイルを作成する例を示している。
  3. "slave"側デバイスの所有者やパーミッションを適切に設定する。(このため、親プロセスではroot権限が必要)
  4. fork()して子プロセスを生成する。


次は子プロセス側の処理が中心となる。

  1. 子プロセス側でsetsid()を呼び、新しいセッションを作成する。
  2. 子プロセス側では不要となる"master"側ファイル記述子や、リモート端末と接続されたsocket/pipe/fileのファイル記述子をclose()する。
  3. "slave"側デバイスをopen()する。
    1. SysV系(Solaris, Linux):セッションリーダーが擬似端末の"slave"側デバイスを最初にopen()することで、自動的にその端末がセッションの「制御端末」(Controlling Terminal)になる。なお非セッションリーダーのプロセスが最初にopen()しても制御端末にはならない。SysV系ではsetsid()でセッション作成後にさらにfork()を一つ挟んだ子プロセス側でDaemon処理を行う場合がある。このとき、親プロセス(=セッションリーダー)はexit()し、非セッションリーダーである子プロセスがDaemon処理を続行することになる。これにより、もしもDaemonが端末デバイスをopen()しても制御端末となることを回避できる。
    2. BSD:open()しただけではControlling Terminalにならない。TIOCSCTTYを引数にioctl()を呼ぶことで、その端末がセッションのControlling Terminalになる。
  4. "slave"側をopenしたファイル記述子を、dup2()で標準入力・標準出力・標準エラー出力のファイル記述子にコピーする。


この段階で、擬似端末を使った入出力の基本が整った。リモートログインの場合なら、続けて子プロセス側のユーザーIDやグループID、環境変数やカレントディレクトリを調整後、ログインシェルをexec()する。


参考:

  • APUE : p675 - 707, "19 Pseudo Terminals"

SysVにおけるDaemon作成時の制御端末の注意点:

  • APUE : P272, "9.6 Controlling Terminal"
  • APUE : P425, "13.3 Coding Rules"

端末入出力とジョブコントロール

端末の入出力は、その端末を制御端末とするセッションのフォアグラウンドプロセスグループに接続される。


端末からSIGTSTP(^Z)が送信された場合、シェルのジョブコントロール機能によりフォアグラウンド・バックグラウンドが切り替わる。

ログインシェルがバックグラウンドで、他にバックグラウンドのプロセスグループが存在しない場合:

シェルがtcsetpgrp()を呼び、端末に新しいフォアグラウンドプロセスグループを通知する。
以降はシェルがフォアグラウンドプロセスグループに切り替わり、端末の入出力を引き受ける:

一旦シェルがフォアグラウンドに切り替わった後、別のバックグラウンドジョブをフォアグラウンドジョブに切り替える場合:

対象のバックグラウンドジョブがSTOP状態になっていれば、SIGCONTを送信し再開させる。また、tcsetpgrp()を呼び出し新しいフォアグラウンドプロセスを通知する。
以降は切り替わったフォアグラウンドジョブが端末の入出力を引き受ける:

参考:

  • APUE : p272, "9.6 Controlling Terminal"
  • APUE : p274, "9.8 Job Control"

端末とSIGHUP

ダム端末やモデム経由でのログインでは、モデムの回線またはダム端末の物理的な切断が"hangup"の合図だったらしい。
擬似端末の場合、"master"側デバイスのファイル記述子が全てclose()されたのを"hangup"の合図として、端末のデバイスドライバがセッションリーダーのプロセスにSIGHUPを送信する。

フォアグラウンド・バックグラウンドの区別ではなく、「セッションリーダー」に対して送信される点に注意する。
したがって、ログインシェルから起動されたフォアグラウンドプロセスが実行中であったとしても、バックグラウンドに切り替わっているログインシェルのプロセスにSIGHUPが送信される。

ログインシェルに限らず、対話的に動作するプログラムのほとんどはSIGHUPを受信したら終了処理を開始する。またSIGHUPのデフォルト処理はプロセス終了となっている。デーモンプロセスの場合は終了処理ではなく、設定ファイルやログファイルの再読み込みといった処理が慣例となっている。

SIGHUPの受信にかかわらず、プロセスが終了するとき、そのプロセスがセッションリーダーだった場合、カーネルからそのセッションのフォアグラウンドプロセスグループに対してSIGHUPが送信される。

セッションリーダ終了時のSIGHUPについて、NetBSD1.6のカーネルを追ったときのメモがあるので、参考にどうぞ:

  • C言語系/「デーモン君のソース探検」読書メモ/A11(Intermission), exit(2) → wait4(2)
    • http://www.glamenv-septzen.net/view/851
      • "「へ~」その1:セッションリーダーのプロセスが終了するとき、同じ制御端末のプロセス群にSIGHUP送信してる箇所"

参考:

  • APUE : p272, "9.6 Controlling Terminal", Figure 9.7
  • APUE : p294, "10.2 Signal Concepts", SIGHUP

WHY?

単調で退屈な復習だったが、ここまでまとめなおした段階で、自分は以下の疑問を抱いた。

バックグラウンドジョブはそもそもSIGHUPを受信しない?

セッションリーダーであるプロセスが終了するとき、カーネルがSIGHUPを送信する対象はそのセッションのフォアグラウンドジョブであり、バックグラウンドジョブは放置されている。つまりバックグラウンドジョブは基本的にはSIGHUPを受信することは無いのではないか?
さらに、そもそもSIGHUPを受信しないのであればnohupをわざわざバックグラウンドで起動する意味は無い。

ここでようやく、タイトルにも掲げた「なぜnohupをバックグランドジョブとして起動するのが定番なのか?」という疑問が生じる。

端末デバイスやカーネルは、おそらくAPUEの通りに動いているととりあえず信用してみる。となると、残る構成要素であるログインシェルが怪しい。

・・・と、記事を書いている今だからすんなりと目星をつけてますが、調査の段階ではそこまで分析できていない状態でいろいろサンプルコードを作ってはputtyを強制終了したり、ログアウトしたりと組み合わせを弄ってました。それでもなかなかシグナルの発生パターンの切り分けができず、そこでようやく「あ、もしかしてログインシェルが何かしてる?」と気づいた次第。
最初はCentOS5.x上で弄ってたのですが、これに気づいたあとはカーネルを含めてソースを読める環境を整えていたNetBSD1.6に移り、次の順序で徐々に標的を追い詰めていきました。

  1. 簡単なサンプルコードで擬似端末の動作確認
  2. 端末デバイスドライバがSIGHUPをセッションリーダーに送信する挙動の確認
  3. Bashのソースを読んで、SIGTERMとSIGCONTを送信するケースを確認
  4. ここまでの確認に基づいた「ログインシェル終了時のシグナル送信パターン」をnohupで確認

これらの確認作業の詳細や、それに使用したサンプルコードの解説は長くなるので後回しにし、"WHY?"に対する"BECAUSE"を先にまとめる。
なお、以降の記事ではログインシェル = Bashとして話を進めていく。
他のシェルでも同じ動作が成立するかは確認していない。使用するシェルのmanページを調べるか、"nohup シェル名"でWeb検索してみてほしい。

BECAUSE : ログインシェル(今回はBash)が、終了時にSIGHUP/SIGTERM/SIGCONTをバックグランドジョブに送信する「場合がある」から。

まずセッションリーダであるbash終了時、カーネルが放置するバックグランドジョブはbash側でSIGHUP/SIGTERM/SIGCONTを適宜組み合わせて送信し、終了させている。もちろん後述するようにシグナルを送信せず放置し、結果としてジョブを継続させる場合もある。

bashが終了する流れは、大きく次の二通りがある。

  1. コマンドラインで"exit"や"logout"コマンドを入力されて終了する流れ
  2. 端末デバイスドライバがセッションリーダでもあるbashにSIGHUPを送信し、終了する流れ
    1. 原因1:モデム回線のhangup
    2. 原因2:端末エミュレータの終了などで擬似端末の"master"側のファイル記述子が全てcloseされる

また"exit"や"logout"による終了時にSIGHUPの送信有無を設定することもできる。huponexitオプションがbashでは提供されている。

# 現在設定の確認
$ shopt huponexit
huponexit       off

# ONに設定
$ shopt -s huponexit

# OFFに設定
$ shopt -u huponexit

さらに"disown"シェルコマンドを使うことで、SIGHUPの送信対象から除外するための内部的な印を、ジョブに対して設定することもできる。以降、この印の有無を実際のbashソースコード上でのフラグ名である「"J_NOHUP"の有無」として表記する。

実際の調査ではbashのソースコードをgrepして流れを掴んでいった。ポイントとなるソースコードは後ほど紹介する。
ここでは、調査結果をまとめたシグナル送信のパターン表を先に解説する。

シグナル送信パターン1:"exit" or "logout" による終了時

"huponexit" ON:

SIGHUP SIGCONT SIGTERM
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o - -
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o

"huponexit" OFF:

SIGHUP SIGCONT SIGTERM
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 - - -
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 - o o
シグナル送信パターン2:SIGHUP受信による終了時
SIGHUP SIGCONT SIGTERM
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o - -
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o

複数回送信される可能性があっても一つの"o"にまとめている。

よく見ると、シグナル送信パターン1の"huponexit" ONのケースはシグナル送信パターン2と同じである。

この表を使うことで、本記事の冒頭で紹介した2つのパターンを説明できるようになる。

「nohupを使わなくても実行を続けることが出来るパターン」の解説

このパターンはnohup無しでバックグランド実行し、"exit"でbashを終了している。また"huponexit"はデフォルト=OFFの状態となっている。jobsコマンドの結果ではバックグランドジョブは"Running"となっている。disownは呼んでいないのでJ_NOHUPは無し。

以上より、「シグナル送信パターン1」の"huponexit" OFF, 「RUNNING状態 + J_NOHUP無」の行を見ればよい:

SIGHUP SIGCONT SIGTERM
RUNNING状態 + J_NOHUP無 - - -

このように、SIGHUP/SIGCONT/SIGTERMのいずれも送信されない。またバックグラウンドジョブなので、カーネルからSIGHUPが送信されることも無い。これがnohupを使わなくても実行を続けられる理由である。

「nohupを付けてもログアウト時に終了してしまうパターン」の解説

このパターンでは、"exit"によるbash終了時、nohupで起動したジョブはバックグラウンドで"Stopped"になっている。その他の条件は上記と同じなので、「シグナル送信パターン1」の"huponexit" OFF, 「STOP状態 + J_NOHUP無」の行を見ればよい:

SIGHUP SIGCONT SIGTERM
STOP状態 + J_NOHUP無 - o o

SIGCONTとSIGTERMが送信される。前述の通りnohupではSIGHUP以外のシグナルハンドラはデフォルトのままジョブを起動するため、SIGTERMによるデフォルト動作、すなわちプロセス終了となる。これがnohupを使ったとしても、ログアウト時にジョブも一緒に終了してしまった理由である。

「nohupをバックグラウンドジョブとして起動する必要があるパターン」 = SIGHUPが送信されるパターン

前半のまとめとして、nohupをバックグランドジョブとして起動する必要があるパターン = SIGHUPが送信されるパターンを解説する。

"huponexit"がデフォルトのOFFのままだとすれば、このパターンは二つに絞られる。「シグナル送信パターン2」、つまりモデムhangupや端末エミュレータの終了などによりセッションリーダであるbashがSIGHUPを受信したときの、J_NOHUP無の行である:

SIGHUP SIGCONT SIGTERM
RUNNING状態 + J_NOHUP無 o - -
STOP状態 + J_NOHUP無 o o o

nohupではSIGTERMを防げないので "STOP状態" を除外すれば、残るは「RUNNING状態 + J_NOHUP無」の行となる。

hello.shを使って確認してみる。まずnohupを使わない場合:

$ ./hello.sh > out.txt &
[1] 465
$ jobs
[1]+  Running                 ./hello.sh >out.txt &
$

ここで端末エミュレータ側を終了させてみる。puttyウインドウの「x」ボタンをクリックして終了させてみた。
別端末で確認してみると、終了すると同時にout.txtの更新も止まり、psコマンドからもhello.shやsleepプロセスが消えたことを確認できた。SIGHUP受信により終了したものと思われる。

次にnohupを使う場合、こちらはジョブが残り、実行継続されると予想される:

$ nohup ./hello.sh &
[1] 512
$ sending output to nohup.out   # nohupからの出力

$ jobs
[1]+  Running                 nohup ./hello.sh &
$

ここで上と同様、端末エミュレータ側を終了させる。別端末で確認してみると、hello.shの実行は継続しておりnohup.outの出力も更新されている。SIGHUPを受信しても、nohupにより無視され、ジョブの実行が継続されることを確認できた。

小休憩

以上で本記事の前半が終わる。後半はNetBSD1.6およびCentOS5.x上で、サンプルコードを使った擬似端末のSIGHUP確認やBashのソースコードの確認、nohupの動作パターンの追加確認などを詳しく紹介していく。
最後に「いともたやすく行われるえげつないnohup」と題しnohupを使うときの注意点をまとめ、そして参考資料の一覧を載せて本記事は終わる。

NetBSD1.6とCentOS5.xの二種類のプラットフォーム上で、それぞれで同じようなサンプルコードと動作確認を行う過程を紹介していくため、また長丁場となる。時間が惜しい方やサンプルコードと実験結果などの詳細までは興味が無い方などは、「いともたやすく行われるえげつないnohup」まで読み飛ばしてもらっても構わない。


それでは小休憩の後、サンプルコードと実験・動作確認の詳細を解説する。

NetBSD 1.6 における擬似端末, Bash, nohupの確認

NetBSD1.6上で以下の実験をしていく。

  1. 擬似端末の使い方を簡単なサンプルで確認
  2. Bash終了時のシグナル送信の組み合わせを実際のソースコードを読んで確認
  3. Bash終了時のシグナル送信とnohupの組み合わせを再確認

擬似端末を使ったサンプル

擬似端末を使ってみる。特に、擬似端末のmaster側のファイル記述子を全てcloseすると、slave側擬似端末の制御プロセスにSIGHUPが送信される動作に注目する。NetBSD側の close(2) manpageには記載されていないが、SUSv3のclose(2)の解説ではこの挙動が載っている。

NetBSD版 擬似端末実験用サンプルコード mypty.c
  • 参考:APUE 19.3 Opening Pseudo-Terminal Devices
  • NetBSD1.6提供のoptnpty()(libutil)を使用
  • プログラム簡素化のため端末属性は変更しない。
  • EOF(^D)入力後 master 側のファイル記述子を全てclose()し、10秒間sleep()する。この間にslave側擬似端末の制御プロセスがSIGHUPを受信したか確認できるようにしている。

mypty.c:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <termios.h>
#include <sys/ioctl.h>
/* for CentOS5.x */
/* #include <pty.h> */
/* for NetBSD1.6 */
#include <util.h>
pid_t
pty_fork(int *ptrfdm, char *slave_name,
    struct termios *slave_termios,
    struct winsize *slave_winsize)
{
    int fdm, fds;
    pid_t pid;
    char pts_name[20];
    if (-1 == openpty(&fdm, &fds, slave_name, slave_termios, slave_winsize)) {
        perror("openpty()"); exit(1);
    }
    if (-1 == (pid = fork())) {
        perror("fork()"); exit(1);
    } else if (0 == pid) {
        /* child */
 
        /* create new session, child becomes session leader,
         * new process group leader, has no control terminal yet.
         */
        if (-1 == setsid()) {
            perror("setsid()"); exit(1);
        }
        /* open pty slave device 1st, then child becomes
         * control process.
         */
        if (-1 == open(slave_name, O_RDWR)) {
            perror("open(slave_name)"); exit(1);
        }
        /* close unused pty master device */
        close(fdm);
 
        /* set control terminal */
        if (-1 == ioctl(fds, TIOCSCTTY, (char*)0)) {
            perror("ioctl(fds, TIOCSCTTY)"); exit(1);
        }
 
        if (NULL != slave_termios) {
            if (-1 == tcsetattr(fds, TCSANOW, slave_termios)) {
                 perror("tcsetattr(slave_termios)"); exit(1);
            }
        }
        if (NULL != slave_winsize) {
            if (-1 == ioctl(fds, TIOCSWINSZ, slave_winsize)) {
                 perror("ioctl(TIOCSWINSZ)"); exit(1);
            }
        }
        /* stdin/out/err fileno duplication */
        if (STDIN_FILENO != dup2(fds, STDIN_FILENO)) {
            perror("dup2(STDIN_FILENO)"); exit(1);
        }
        if (STDOUT_FILENO != dup2(fds, STDOUT_FILENO)) {
            perror("dup2(STDOUT_FILENO)"); exit(1);
        }
        if (STDERR_FILENO != dup2(fds, STDERR_FILENO)) {
            perror("dup2(STDERR_FILENO)"); exit(1);
        }
        close(fds);
        return 0;
    } else {
        /* parent */
        *ptrfdm = fdm;
        return pid;
    }
}
 
#define BUFSIZE 512
static volatile sig_atomic_t sigcaught;
static void sig_term(int signo) { sigcaught = 1; }
void loop(int ptym)
{
    pid_t child;
    int nread;
    char buf[BUFSIZE];
    struct sigaction sa;
    sa.sa_handler = sig_term;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
#ifdef SA_INTERRUPT
    sa.sa_flags |= SA_INTERRUPT;
#endif
    if (-1 == (child = fork())) {
        perror("fork() for loop"); exit(1);
    } else if (0 == child) {
        /* child copies stdin to ptym */
        for (;;) {
            if (-1 == (nread = read(STDIN_FILENO, buf, sizeof(buf)))) {
                perror("read(stdin)"); exit(1);
            } else if (0 == nread) {
                fprintf(stderr, "child(read stdin, write ptym) detect EOF from stdin.\n");
                break; /* EOF */
            }
            if (nread != write(ptym, buf, nread)) {
                perror("write(ptym)"); exit(1);
            }
        }
        fprintf(stderr, "send SIGTERM to parent...\n");
        kill(getppid(), SIGTERM);
        close(ptym);
        fprintf(stderr, "PID[%d] closed ptym, sleeping(10)...\n", getpid());
        sleep(10);
        fprintf(stderr, "PID[%d] awaken, terminates.\n", getpid());
        exit(0);
    }
    if (-1 == sigaction(SIGTERM, &sa, NULL)) {
        perror("sigaction(SIGTERM)"); exit(1);
    }
    for (;;) {
        errno = 0;
        if (0 >= (nread = read(ptym, buf, sizeof(buf)))) {
            /* error or signal or EOF */
            break;
        }
        if (nread != write(STDOUT_FILENO, buf, nread)) {
            perror("write(stdout)"); exit(1);
        }
    }
    if (0 == nread) {
        fprintf(stderr, "parent(read ptym, write stdin) detect EOF from ptym.\n");
    }
    if (errno) {
        perror("read(ptym)");
    }
    if (0 == sigcaught) {
        fprintf(stderr, "send SIGTERM to child(%d)...\n", child);
        /* error or EOF, tell child termination */
        kill(child, SIGTERM);
    } else {
        fprintf(stderr, "child sent SIGTERM, maybe terminated.\n");
    }
    close(ptym);
    fprintf(stderr, "PID[%d] closed ptym, sleeping(10)...\n", getpid());
    sleep(10);
    fprintf(stderr, "PID[%d] awaken, terminates.\n", getpid());
}
 
int main(int argc, char *argv[])
{
    int fdm;
    pid_t pid;
    char slave_name[20];
    struct termios orig_termios;
    struct winsize size;
 
    if (2 > argc) {
        fprintf(stderr, "usage: %s commands...\n", argv[0]);
        return 1;
    }
 
    if (-1 == tcgetattr(STDIN_FILENO, &orig_termios)) {
        perror("tcgetattr()"); exit(1);
    }
    if (-1 == ioctl(STDIN_FILENO, TIOCGWINSZ, &size)) {
        perror("ioctl(TIOCGWINSZ)"); exit(1);
    }
    pid = pty_fork(&fdm, slave_name, &orig_termios, &size);
    if (0 == pid) {
        if (-1 == execvp(argv[1], &argv[1])) {
            perror("execvp()"); exit(1);
        }
    }
    fprintf(stderr, "slave name = %s\n", slave_name);
 
    loop(fdm);
 
    fprintf(stderr, "done\n");
 
    return 0;
}

コンパイル : openpty()を使うので所有者をrootにしてset-user-idをセット + "-lutil"をコンパイルオプションに追加

$ su
# cc -o mypty mypty.c -lutil && chmod +s ./mypty
NetBSD版 slave側擬似端末の制御プロセス用サンプルコード mycat_detectHUP.c
  • 標準C関数のfgets()で標準入力を読み、fprintf()で標準出力に自分のPIDを付けて出力する。
  • SIGHUPを受信すると標準エラー出力にメッセージを出力する。

mycat_detectHUP.c:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
 
void sig_hup(int signo)
{
    fprintf(stderr, "detect SIGHUP(%d)\n", signo);
    fflush(stderr);
}
 
int main(int argc, char *argv[])
{
    char buf[200];
    struct sigaction sa;
    int fderr;
 
    /* mypty.cから直接起動され、slave側の擬似端末の制御プロセスとなることを想定している。
     * よって、標準エラー出力のシェルによるリダイレクトが使えない。
     * このため、いったんSTDERR_FILENOをclose()し、自分で標準エラー出力用のファイルをopen()する。
     */
    close(STDERR_FILENO);
    if (-1 == (fderr = open("/tmp/mycat_detectHUP", O_CREAT | O_TRUNC | O_WRONLY, S_IRWXU))) {
        perror("open(log)"); exit(1);
    }
 
    sa.sa_handler = sig_hup;
    sigemptyset(&sa.sa_mask);
    /* fgets()がSIGHUP受信で割り込みされるように、
     * sa_flags=0(もし定義されていればSA_INTERRUPTを設定)
     * でシグナルハンドラをインストールする。
     */
    sa.sa_flags = 0;
#ifdef SA_INTERRUPT
    sa.sa_flags |= SA_INTERRUPT;
#endif
    sigaction(SIGHUP, &sa, NULL);
 
    while (NULL != fgets(buf, sizeof(buf), stdin)) {
        fprintf(stdout, "PID[%d] : %s\n", getpid(), buf);
        fflush(stdout);
    }
    if (feof(stdin)) {
        fprintf(stderr, "stdin detect EOF\n");
    } else {
        perror("fgets(stdin)");
    }
    return 0;
}
サンプルコードによるSIGHUPの確認
$ ./mypty ./mycat_detectHUP
slave name = /dev/ttyp4
abc   # <- input from keyboard + RETURN
abc   # echo back in parent terminal
PID[1215] : abc    # output from slave

def   # <- input from keyboard + RETURN
def   # echo back in parent terminal
PID[1215] : def    # output from slave

psコマンドによる現在状況の確認:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
347  346  347 c8c000  1214 ttyp1 Ss   -bash
1214  347 1214 c8c000  1214 ttyp1 S+   ./mypty ./mycat_detectHUP
1216 1214 1214 c8c000  1214 ttyp1 S+   ./mypty ./mycat_detectHUP
1215 1214 1215 cd2d00  1215 ttyp4 Ss+  ./mycat_detectHUP

全体図:

[putty on Windows]
  +
  | (TCP/IP)
  +
[sshd]
  +
  |
  +
[/dev/ptyp1] : master
[/dev/ttyp1] : slave
  +
  |
  +
[-bash] -> fork(),exec()
                 |
                 v     fork()
            [mypty(1)] ----->[mypty(2)]
                 +                +
                 |                |
                 +----------------+
                 |
                 +
           [/dev/ptyp4] : master
           [/dev/ttyp4] : slave
                 +
                 |
                 +
           [mycat_detectHUP]

中心部分:

     write(STDOUT_FILENO)        read(ptym)
               +<--- [mypty(1)] <---+
               |                    |
[/dev/ttyp1]---+                    +---[/dev/ptyp4]=[/dev/ttyp4]<--+
               |                    |                               |
               +---> [mypty(2)] --->+           [mycat_detectHUP]<--+
    read(STDIN_FILENO)           write(ptym)

このようにmaster/slave間のread/writeが確認できたら、EOFを入力し、master側のファイル記述子をclose()させる。

(input ^D , no echo back)
child(read stdin, write ptym) detect EOF from stdin.
send SIGTERM to parent...
read(ptym): Interrupted system call
child sent SIGTERM, maybe terminated.
PID[1214] closed ptym, sleeping(10)...
PID[1216] closed ptym, sleeping(10)...

この時点でのps:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
347  346  347 c8c000  1214 ttyp1 Ss   -bash
1214  347 1214 c8c000  1214 ttyp1 S+   ./mypty ./mycat_detectHUP
1216 1214 1214 c8c000  1214 ttyp1 S+   ./mypty ./mycat_detectHUP
1215 1214 1215 cd2d00 30001 ttyp4 ZW   (mycat_detectHUP)

mycat_detectHUPがゾンビ状態になっている。
ファイルに保存された標準エラー出力を確認してみる:

$ cat /tmp/mycat_detectHUP
detect SIGHUP(1)
fgets(stdin): Interrupted system call
$

SIGHUPの受信を確認できた。

10秒後、myptyがsleep()から復帰し、終了する。

PID[1214] awaken, terminates.
PID[1216] awaken, terminates.
done

終了時にBashがジョブに送信するシグナル

Bashバージョン(NetBSD 1.6 インストールCD付属のpkgより)

$ bash --version
GNU bash, version 2.05.0(1)-release (i386--netbsdelf)
Copyright 2000 Free Software Foundation, Inc.

Bashの二種類の終了方法:

  • "exit" or "logout" シェルコマンドを実行する
  • SIGHUPを受信する
"exit" or "logout" シェルコマンドを実行して終了するときにBashが送信するシグナル
exit_shell() (shell.c)
-> hangup_all_jobs() (jobs.c) : 対話シェルかつログインシェルかつshopt huponexitがセットの場合
   -> killpg(<PGID>, SIGHUP)  : 対象:シェルの内部フラグでJ_NOHUPが未設定のジョブ
   -> killpg(<PGID>, SIGCONT) : 対象:STOP状態のジョブ
-> end_job_control() (jobs.c) : 非サブシェル
   -> terminate_stopped_jobs() (jobs.c) : 対話シェルの場合
      -> killpg(<PGID>, SIGTERM)  : 対象:STOP状態のジョブ
      -> killpg(<PGID>, SIGCONT)  : 対象:同上

"huponexit"有 ("shopt -s huponexit"):

SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o - -
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o

"huponexit"無 ("shopt -u huponexit"):

SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 - - - 実験1
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 - o o 実験2
SIGHUPを受信して終了するときにBashが送信するシグナル
  1. bashの初期処理で initialize_terminating_signals() (sig.c) がSIGHUPのシグナルハンドラにtermination_unwind_protect()を設定
  2. (シェル上での作業開始 ... 終了)
  3. クライアントの終了:端末エミュレータ側でのネットワークコネクションまたはウインドウのclose
  4. サーバー側の終了処理開始 → 擬似端末のマスター側のファイル記述子が全てclose
  5. 擬似端末のターミナルドライバが制御プロセス(=bash)へSIGHUPを送信
SIGHUP : termination_unwind_protect() (sig.c)
-> hangup_all_jobs() (jobs.c) : 対話シェルかつSIGHUPの場合
   -> killpg(<PGID>, SIGHUP)  : 対象:シェルの内部フラグでJ_NOHUPが未設定のジョブ
   -> killpg(<PGID>, SIGCONT) : 対象:STOP状態のジョブ
-> end_job_control() (jobs.c)
   -> terminate_stopped_jobs() (jobs.c) : 対話シェルの場合
      -> killpg(<PGID>, SIGTERM)  : 対象:STOP状態のジョブ
      -> killpg(<PGID>, SIGCONT)  : 対象:同上
SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o - - 実験3,4
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o 実験5
実際のソースコード(参考)

exit_shell() (shell.c) :

/* Exit the shell with status S. */
void exit_shell (int s)
{
/* 省略 */
 
#if defined (JOB_CONTROL)
  /* If the user has run `shopt -s huponexit', hangup all jobs when we exit
     an interactive login shell.  ksh does this unconditionally. */
  if (interactive_shell && login_shell && hup_on_exit)
    hangup_all_jobs ();
 
  /* If this shell is interactive, terminate all stopped jobs and
     restore the original terminal process group.  Don't do this if we're
     in a subshell and calling exit_shell after, for example, a failed
     word expansion. */
  if (subshell_environment == 0)
    end_job_control ();
#endif /* JOB_CONTROL */
 
/* 省略 */
}

end_job_control(), terminate_stopped_jobs(), hangup_all_jobs() (jobs.c) :

/* If this shell is interactive, terminate all stopped jobs and
   restore the original terminal process group.  This is done
   before the `exec' builtin calls shell_execve. */
void end_job_control ()
{
  if (interactive_shell)  /* XXX - should it be interactive? */
    {
      terminate_stopped_jobs ();
      /* 省略 */
    }
    /* 省略 */
}
 
/* Cause all stopped jobs to exit. */
void terminate_stopped_jobs ()
{
  register int i;
 
  for (i = 0; i < job_slots; i++)
    {
      if (jobs[i] && STOPPED (i))
        {
          killpg (jobs[i]->pgrp, SIGTERM);
          killpg (jobs[i]->pgrp, SIGCONT);
        }
    }
}
 
/* Cause all jobs, running or stopped, to receive a hangup signal.  If
   a job is marked J_NOHUP, don't send the SIGHUP. */
void
hangup_all_jobs ()
{
  register int i;
 
  for (i = 0; i < job_slots; i++)
    {
      if (jobs[i])
        {
          if  ((jobs[i]->flags & J_NOHUP) == 0)
            killpg (jobs[i]->pgrp, SIGHUP);
          if (STOPPED (i))
            killpg (jobs[i]->pgrp, SIGCONT);
        }
    }
}

termination_unwind_protect() (sig.c):

sighandler termination_unwind_protect (int sig)
{
/* (省略) */
#if defined (JOB_CONTROL)
  if (interactive && sig == SIGHUP)
    hangup_all_jobs ();
  end_job_control ();
#endif /* JOB_CONTROL */
/* (省略) */
}
実験用ソース : detectHUPCONTTERM.c

ジョブとして動かすサンプルプログラム(細かいお作法やエラー処理は無視)
detectHUPCONTTERM.c:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
 
void sig_hup(int signo)
{
    fprintf(stderr, "detect SIGHUP(%d)\n", signo);
    fflush(stderr);
}
 
void sig_cont(int signo)
{
    fprintf(stderr, "detect SIGCONT(%d)\n", signo);
    fflush(stderr);
}
 
void sig_term(int signo)
{
    fprintf(stderr, "detect SIGTERM(%d)\n", signo);
    fflush(stderr);
}
 
int main(int argc, char *argv[])
{
    int i;
 
    signal(SIGHUP, sig_hup);
    signal(SIGCONT, sig_cont);
    signal(SIGTERM, sig_term);
 
    for (i=0;;i++) {
        fprintf(stdout, "stdout:PID[%d],PPID[%d],PGID[%d] i = %d\n",
            getpid(), getppid(), getpgrp(), i);
        fflush(stdout);
        fprintf(stderr, "stderr:PID[%d],PPID[%d],PGID[%d] i = %d\n",
            getpid(), getppid(), getpgrp(), i);
        fflush(stderr);
        sleep(1);
    }
    return 0;
}
実験1:"exit"でBash終了時のRUNNNING状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)

putty + SSH接続:

$ ./detectHUPCONTTERM 1>out.txt 2>err.txt &
[1] 1056
$ exit

結果:シグナル未検出

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1054 1053 1054 c8c680  1054 ttyp4 Ss+  -bash
1056 1054 1056 c8c680  1054 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)


PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1056    1 1056 c8c680 30001 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)
実験2:"exit"でBash終了時のSTOP状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)

putty + SSH接続:

$ ./detectHUPCONTTERM 1>out.txt 2>err.txt
^Z
[1]+  Stopped                 ./detectHUPCONTTERM >out.txt 2>err.txt
$ exit
logout
There are stopped jobs.
$ exit

結果:SIGCONT, SIGTERM 検出

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1096 1095 1096 cd2080  1096 ttyp4 Ss+  -bash
1104 1096 1104 cd2080  1096 ttyp4 T    ./detectHUPCONTTERM (detectHUPCONTTER)


PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1104    1 1104 cd2080 30001 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)
実験3:SIGHUPでBash終了時のRUNNNING状態フォアグラウンドジョブ(J_NOHUP無, huponexit未設定)

putty + SSH接続:

$ ./detectHUPCONTTERM 1>out.txt 2>err.txt

この後、putty側を終了。

結果:SIGHUP検出

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1009 1008 1009 cafcc0  1032 ttyp4 Ss   -bash
1032 1009 1032 cafcc0  1032 ttyp4 S+   ./detectHUPCONTTERM (detectHUPCONTTER)


PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1032    1 1032 cafcc0 30001 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)
実験4:SIGHUPでBash終了時のRUNNNING状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)

putty + SSH接続:

$ ./detectHUPCONTTERM 1>out.txt 2>err.txt &
[1] 1045
$

この後、putty側を終了。

結果:SIGHUP検出

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1042 1041 1042 cbd7c0  1042 ttyp4 Ss+  -bash
1045 1042 1045 cbd7c0  1042 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)


PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1045    1 1045 cbd7c0 30001 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)
実験5:SIGHUPでBash終了時のSTOP状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)

putty + SSH接続:

$ ./detectHUPCONTTERM 1>out.txt 2>err.txt
^Z
[1]+  Stopped                 ./detectHUPCONTTERM >out.txt 2>err.txt
$ jobs
[1]+  Stopped                 ./detectHUPCONTTERM >out.txt 2>err.txt
$

この後、putty側を終了。

結果:SIGHUP, SIGCONT x 2, SIGTERM検出

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1067 1066 1067 cd2e40  1067 ttyp4 Ss+  -bash
1069 1067 1069 cd2e40  1067 ttyp4 T    ./detectHUPCONTTERM (detectHUPCONTTER)


PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1069    1 1069 cd2e40 30001 ttyp4 S    ./detectHUPCONTTERM (detectHUPCONTTER)

nohupの確認

ここまでの擬似端末およびBashの知識を元に、nohupの挙動を再確認してみる。
まずnohupはSIGHUPをSIG_IGNに設定し、コマンドラインで指定されたプログラムとそのコマンドラインオプションを"sh -c"に続けてexec()する。SIG_IGNを設定されたシグナルはexec()後も引き継がれるため、プログラム側でSIGHUPのシグナルハンドラを再設定する必要は無い。
nohupはSIGHUPにSIG_IGNを設定する。SIGHUP以外のシグナルハンドラは操作しない。起動されるプログラム側でシグナルハンドラを設定しなければ、たとえばSIGTERMを受信したらデフォルトの処理としてプロセスは終了する。

では、nohupで起動したジョブがSIGTERMを受信するケースはどのようなケースか?
これまでのBashの調査で、SIGHUPにせよexitコマンドからにせよ、Bashは終了時に、STOP状態のジョブに対してSIGTERMを送信することが判明している。

ではnohupで起動したジョブがSTOP状態になるのはどのようなケースか?

  1. フォアグラウンドで起動した後、Ctrl+Z(SIGTSTP)によりSTOP状態のバックグランドジョブになる
  2. 標準入力をread()するプロセスをバックグランドジョブとして実行する。
    1. 制御端末に対してread/writeできるのはフォアグラウンドジョブだけ。
    2. バックグラウンドジョブが制御端末に対してread/writeしようとすると、制御端末のターミナルドライバはバックグラウンドジョブに対してSIGTTINを送信する。
    3. シェルはwait()系で子プロセスに送られたSIGTTINを検知し、バックグラウンドジョブをSTOPする。

この2パターンにおいて、Bash終了時にnohupで起動されたジョブも終了すると予想される。

そこで、まずnohupの一般的な使い方について実験し、続いて上記2パターンについて実験してみる。
また、nohupで起動した時点ではRUNNNINGだが、端末が閉じられた後、標準入力に対してread()するとどうなるか確認する。

nohupの一般的な使い方:RUNNING状態でバックグランドジョブ続行

標準出力・標準エラー出力に対して1秒間隔でメッセージを出力する簡単なサンプルプログラムを作成し、確認する。
nohuptest.c:

#include <stdio.h>
#include <unistd.h>
 
int main(int argc, char *argv[])
{
    int i = 0;
    for (;;i++) {
        fprintf(stdout, "stdout, i = %d\n", i);
        fflush(stdout);
        fprintf(stderr, "stderr, i = %d\n", i);
        fflush(stderr);
        sleep(1);
    }
    return 0;
}

まずnohup経由でフォアグラウンドジョブとして起動し、端末エミュレータを終了した後もジョブが続行されるか確認する。

$ nohup ./nohuptest
sending output to nohup.out

ここで端末エミュレータ(putty)をcloseする。

close前:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
204  203  204 c97f80   214 ttyp1 Ss   -bash
214  204  214 c97f80   214 ttyp1 S+   ./nohuptest

close後:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
214    1  214 c97f80 30001 ttyp1 S    ./nohuptest

ジョブが続行されるのを確認できた。

次はnohupをバックグランドジョブとして起動し、bashからlogoutした後もジョブが続行されるか確認する。

$ nohup ./nohuptest &
[1] 230
$ sending output to nohup.out  # <<< output from nohup

$ jobs
[1]+  Running                 nohup ./nohuptest &
$ exit

nohup後:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
225  224  225 c977c0   225 ttyp1 Ss+  -bash
230  225  230 c977c0   225 ttyp1 S    ./nohuptest

exitによるbash終了後:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
230    1  230 c977c0 30001 ttyp1 S    ./nohuptest

ジョブが続行されるのを確認できた。

次はnohupをバックグランドジョブとして起動し、端末エミュレータを終了した後もジョブが続行されるか確認する。

$ nohup ./nohuptest &
[1] 243
$ sending output to nohup.out  # <<< output from nohup

$ jobs
[1]+  Running                 nohup ./nohuptest &
$

ここで端末エミュレータ(putty)をcloseする。

nohup後:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
239  238  239 c97800   239 ttyp1 Ss+  -bash
243  239  243 c97800   239 ttyp1 S    ./nohuptest

端末close後:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
243    1  243 c97800 30001 ttyp1 S    ./nohuptest

ジョブが続行されるのを確認できた。

ここまででnohupの一般的な使い方である、端末終了後もジョブが続行するパターンを確認できた。
続けて、nohupで起動したとしても、端末終了時にジョブも終了してしまうパターンを確認していく。

nohupで起動しても終了するパターン1:ジョブをCtrl+Z(SIGTSTP)でSTOP状態にしてlogout

detectHUPCONTTERM サンプルプログラムを使う。

$ nohup ./detectHUPCONTTERM 1>out.txt 2>err.txt
^Z
[1]+  Stopped                 nohup ./detectHUPCONTTERM >out.txt 2>err.txt
$ exit
logout
There are stopped jobs.
$ exit

nohupにより起動した時点でのps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1297 1296 1297 cd2b80  1303 ttyp1 Ss   -bash
1303 1297 1303 cd2b80  1303 ttyp1 S+   ./detectHUPCONTTERM (detectHUPCONTTER)

Ctrl-Z(SIGTSTP)によりSTOP状態にした時点でのps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1297 1296 1297 cd2b80  1297 ttyp1 Ss+  -bash
1303 1297 1303 cd2b80  1297 ttyp1 T    ./detectHUPCONTTERM (detectHUPCONTTER)

exit後のps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1303    1 1303 cd2b80 30001 ttyp1 S    ./detectHUPCONTTERM (detectHUPCONTTER)

detectHUPCONTTERMはSIGHUP/SIGCONT/SIGTERMを受信しても終了しないようにプログラムされている。
これら三つのシグナルを受信すると標準エラー出力にメッセージを出力するので、リダイレクトにより保存されたerr.txtを確認してみる。

$ cat out.txt
...
stdout:PID[1303],PPID[1297],PGID[1303] i = 42
stdout:PID[1303],PPID[1297],PGID[1303] i = 43
stdout:PID[1303],PPID[1],PGID[1303] i = 44
stdout:PID[1303],PPID[1],PGID[1303] i = 45
...
$ cat err.txt
...
stderr:PID[1303],PPID[1297],PGID[1303] i = 41
stderr:PID[1303],PPID[1297],PGID[1303] i = 42
detect SIGCONT(19)
detect SIGTERM(15)
stderr:PID[1303],PPID[1297],PGID[1303] i = 43
stderr:PID[1303],PPID[1],PGID[1303] i = 44
stderr:PID[1303],PPID[1],PGID[1303] i = 45
...

SIGCONT, SIGTERM を受信したことを確認できた。
もしnohupにより起動されたプログラムがこれらのシグナルハンドラをカスタマイズしていなければ、SIGTERMによりプロセスは終了する。

nohupで起動しても終了するパターン2:バックグランドジョブで標準入力(=擬似端末のslave側)をread()

mycat_detectHUP.cをSIGTERMに対応させたmycat_detectTERM.cを使う。
mycat_detectTERM.c:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>
 
void sig_term(int signo)
{
    fprintf(stderr, "detect SIGTERM(%d)\n", signo);
    fflush(stderr);
}
 
int main(int argc, char *argv[])
{
    char buf[200];
    int nread;
    struct sigaction sa;
    sa.sa_handler = sig_term;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;
#ifdef SA_INTERRUPT
    sa.sa_flags |= SA_INTERRUPT;
#endif
    /*signal(SIGTERM, sig_term);*/
    sigaction(SIGTERM, &sa, NULL);
    for (;;) {
        if (-1 == (nread = read(STDIN_FILENO, buf, sizeof(buf)))) {
            perror("read()"); exit(1);
        }
        if (0 == nread) {
            break;
        }
        if (nread != write(STDOUT_FILENO, buf, nread)) {
            perror("write()"); exit(1);
        }
    }
    return 0;
}
$ nohup ./mycat_detectTERM 2>err.txt &
[1] 1387
$ jobs
[1]+  Stopped                 nohup ./mycat_detectTERM 2>err.txt
$ exit

nohupで起動した時点でのps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1376 1375 1376 cd2b80  1376 ttyp1 Ss+  -bash
1387 1376 1387 cd2b80  1376 ttyp1 T    ./mycat_detectTERM

exitした時点でのps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
(ttyp1のプロセスは存在しない)

標準エラー出力のリダイレクト先であるerr.txtを確認してみる:

$ cat err.txt
sending output to nohup.out
detect SIGTERM(15)
read(): Interrupted system call
$

SIGTERM を受信したことを確認できた。
また、SIGTERM受信によりread()がEINTR(Interrupted system call)で終了している。

nohupで起動した時点ではRUNNNINGだが、端末が閉じられた後、標準入力に対してread()するとどうなるか

予想:端末がcloseされる時点ではRUNNINGなのでSIGTERMは送信されない。よって端末close後もプロセスは存在するが、標準入力をread()しようとすると既にclose()されているためEOFが返される。

1分間sleep()後、標準入力をfgets()で読み込む delay_read.c:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
 
int main(int argc, char *argv[])
{
    char buf[200];
 
    fprintf(stderr, "after 60 seconds, read() and write()...\n");
    sleep(60);
    fprintf(stderr, "60 seconds passed, now read() and write():");
 
    errno = 0;
    while (NULL != fgets(buf, sizeof(buf), stdin)) {
        fprintf(stdout, "PID[%d] : %s\n", getpid(), buf);
        fflush(stdout);
    }
    if (feof(stdin)) {
        fprintf(stderr, "stdin detect EOF\n");
    } else {
        perror("fgets(stdin)");
    }
    return 0;
}

実行してみる:

$ nohup ./delay_read 1>out.txt 2>err.txt &
[1] 1462
$ jobs
[1]+  Running                 nohup ./delay_read >out.txt 2>err.txt &
$ exit

nohup起動時点:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1437 1436 1437 c9a040  1437 ttyp1 Ss+  -bash
1462 1437 1462 c9a040  1437 ttyp1 S    ./delay_read

exit後、60秒経過前:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
1462    1 1462 c9a040 30001 ttyp1 S    ./delay_read

60秒経過後のps:

$ ps x -o pid,ppid,pgid,sess,tpgid,tty,stat,command
PID PPID PGID   SESS TGPID TTY   STAT COMMAND
(ttyp1のプロセスは存在しない)

out.txt, err.txtを確認する:

$ wc -c out.txt
       0 out.txt
$ cat err.txt
after 60 seconds, read() and write()...
60 seconds passed, now read() and write():stdin detect EOF
                                          ^^^^^^^^^^^^^^^^
$

EOFが返されることを確認できた。

Linux(CentOS5.x) における擬似端末, Bash, nohupの確認

NetBSD1.6と同様に、CentOS5.x上で以下の実験をしていく。

  1. 擬似端末の使い方を簡単なサンプルで確認
  2. Bash終了時のシグナル送信の組み合わせを実際のソースコードを読んで確認
  3. Bash終了時のシグナル送信とnohupの組み合わせを再確認

擬似端末を使ったサンプル

mypty.cはNetBSD1.6と同様。ただし、

#include <util.h>


#include <pty.h>

に変更してコンパイル。

$ su
# cc -o mypty mypty.c -lutil && chmod +s ./mypty

mycat_detectHUP.cもNetBSD1.6と同様。変更点無し。

"master"側close時のSIGHUP送信を確認する。

$ ./mypty ./mycat_detectHUP
slave name = /dev/pts/3
abc   # <- input from keyboard + RETURN
abc   # echo back in parent terminal
PID[15481] : abc    # output from slave

def   # <- input from keyboard + RETURN
def   # echo back in parent terminal
PID[15481] : def    # output from slave

psコマンドによる現在状況の確認:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
3287  3286  3287  3287  3910 pts/2    Ss   -bash
3910  3287  3910  3287  3910 pts/2    S+   ./mypty ./mycat_detectHUP
3911  3910  3911  3911  3911 pts/3    Ss+  ./mycat_detectHUP
3912  3910  3910  3287  3910 pts/2    S+   ./mypty ./mycat_detectHUP

全体図はNetBSD1.6と同様なため省略。Linuxの場合はslave側デバイスが"/dev/pts/N"というデバイスファイル名になる。

master/slave間のread/writeが確認できたら、EOFを入力し、master側のファイル記述子をclose()させる。

(input ^D , no echo back)
child(read stdin, write ptym) detect EOF from stdin.
send SIGTERM to parent...
read(ptym): Interrupted system call
child sent SIGTERM, maybe terminated.
PID[3910] closed ptym, sleeping(10)...
PID[3912] closed ptym, sleeping(10)...

この時点でのps:

$ ps ax -o pid,ppid,pgid,sess,tpgid,tty,stat,command
 PID  PPID  PGID  SESS TPGID TT       STAT COMMAND
3287  3286  3287  3287  3910 pts/2    Ss   -bash
3910  3287  3910  3287  3910 pts/2    S+   ./mypty ./mycat_detectHUP
3911  3910  3911  3911    -1 ?        Zs   [mycat_detectHUP] <defunct>
3912  3910  3910  3287  3910 pts/2    S+   ./mypty ./mycat_detectHUP

mycat_detectHUPがゾンビ状態になっている。

ファイルに保存された標準エラー出力を確認してみる:(Linuxの場合はrootユーザーで/tmp/mycat_detectHUPが作成されている)

# cat /tmp/mycat_detectHUP
detect SIGHUP(1)
stdin detect EOF
#

SIGHUPの受信を確認できた。

10秒後、myptyがsleep()から復帰し、終了する。

PID[3910] awaken, terminates.
PID[3912] awaken, terminates.
done

終了時にBashがジョブに送信するシグナル

Bashバージョン

$ bash --version
GNU bash, version 3.2.25(1)-release (i686-redhat-linux-gnu)
Copyright (C) 2005 Free Software Foundation, Inc.

Bashの二種類の終了方法:

  • "exit" or "logout" シェルコマンドを実行する
  • SIGHUPを受信する
"exit" or "logout" シェルコマンドを実行して終了するときにBashが送信するシグナル
exit_shell() (shell.c)
-> hangup_all_jobs() (jobs.c) : 対話シェルかつログインシェルかつshopt huponexitがセットの場合
   -> killpg(<PGID>, SIGHUP)  : 対象:シェルの内部フラグでJ_NOHUPが未設定のジョブ
   -> killpg(<PGID>, SIGCONT) : 対象:STOP状態のジョブでJ_NOHUPが未設定のジョブ
-> end_job_control() (jobs.c) : 非サブシェル
   -> terminate_stopped_jobs() (jobs.c) : 対話シェルの場合
      -> killpg(<PGID>, SIGTERM)  : 対象:STOP状態のジョブ
      -> killpg(<PGID>, SIGCONT)  : 対象:同上

"huponexit"有 ("shopt -s huponexit"):

SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o - -
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o

"huponexit"無 ("shopt -u huponexit"):

SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 - - - 実験1
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 - o o 実験2
SIGHUPを受信して終了するときにBashが送信するシグナル
  1. bashの初期処理で initialize_terminating_signals() (sig.c) がSIGHUPのシグナルハンドラにtermsig_sighandler()を設定
  2. (シェル上での作業開始 ... 終了)
  3. (シェル上での作業開始 ... 終了)
  4. クライアントの終了:端末エミュレータ側でのネットワークコネクションまたはウインドウのclose
  5. サーバー側の終了処理開始 → 擬似端末のマスター側のファイル記述子が全てclose
  6. 擬似端末のターミナルドライバが制御プロセス(=bash)へSIGHUPを送信
SIGHUP : termsig_sighandler() -> termsig_handler() (sig.c)
-> hangup_all_jobs() (jobs.c) : 対話シェルかつSIGHUPの場合
   -> killpg(<PGID>, SIGHUP)  : 対象:シェルの内部フラグでJ_NOHUPが未設定のジョブ
   -> killpg(<PGID>, SIGCONT) : 対象:STOP状態のジョブでJ_NOHUPが未設定のジョブ
-> end_job_control() (jobs.c)
   -> terminate_stopped_jobs() (jobs.c) : 対話シェルの場合
      -> killpg(<PGID>, SIGTERM)  : 対象:STOP状態のジョブ
      -> killpg(<PGID>, SIGCONT)  : 対象:同上
SIGHUP SIGCONT SIGTERM 実験による確認
RUNNING状態 + J_NOHUP有 - - -
RUNNING状態 + J_NOHUP無 o ? - 実験3,4
STOP状態 + J_NOHUP有 - o o
STOP状態 + J_NOHUP無 o o o 実験5

"RUNNING状態 + J_NOHUP無"のSIGCONTについては実験4で説明する。

実際のソースコード(参考)

諸事情によりバイナリと異なるバージョンのソースコードを載せる。
・・・いえ、その、決してSRPM入れて展開するのが面倒くさかったからとかじゃありませんよ?

ソースコードのバージョンは bash-3.2.48 。(ログインシェルとして使っているのはCentOSのRPM, 3.2.25)
NetBSD1.6のbash-2.x.xから殆ど変わっていない。hangup_all_jobs()中でのループで、J_NOHUPの条件だけがわずかに変更されている。

exit_shell() (shell.c) :

/* Exit the shell with status S. */
void exit_shell (int s)
{
/* 省略 */
 
#if defined (JOB_CONTROL)
  /* If the user has run `shopt -s huponexit', hangup all jobs when we exit
     an interactive login shell.  ksh does this unconditionally. */
  if (interactive_shell && login_shell && hup_on_exit)
    hangup_all_jobs ();
 
  /* If this shell is interactive, terminate all stopped jobs and
     restore the original terminal process group.  Don't do this if we're
     in a subshell and calling exit_shell after, for example, a failed
     word expansion. */
  if (subshell_environment == 0)
    end_job_control ();
#endif /* JOB_CONTROL */
 
/* 省略 */
}

end_job_control(), terminate_stopped_jobs(), hangup_all_jobs() (jobs.c) :

void end_job_control ()
{
  if (interactive_shell)  /* XXX - should it be interactive? */
    {
      terminate_stopped_jobs ();
      /* 省略 */
    }
    /* 省略 */
}
 
/* Cause all stopped jobs to exit. */
void terminate_stopped_jobs ()
{
  register int i;
 
  for (i = 0; i < job_slots; i++)
    {
      if (jobs[i] && STOPPED (i))
        {
          killpg (jobs[i]->pgrp, SIGTERM);
          killpg (jobs[i]->pgrp, SIGCONT);
        }
    }
}
 
/* Cause all jobs, running or stopped, to receive a hangup signal.  If
   a job is marked J_NOHUP, don't send the SIGHUP. */
void
hangup_all_jobs ()
{
  register int i;
 
  for (i = 0; i < job_slots; i++)
    {
      if (jobs[i])
        {
          if  (jobs[i]->flags & J_NOHUP)
            continue;
          killpg (jobs[i]->pgrp, SIGHUP);
          if (STOPPED (i))
            killpg (jobs[i]->pgrp, SIGCONT);
        }
    }
}

termsig_sighandler() , termsig_handler() (sig.c):

sighandler
termsig_sighandler (sig)
     int sig;
{
  terminating_signal = sig;
 
  if (terminate_immediately)
    {
      terminate_immediately = 0;
      termsig_handler (sig);
    }
 
  SIGRETURN (0);
}
 
void termsig_handler (int sig)
{
/* (省略) */
#if defined (JOB_CONTROL)
  if (interactive && sig == SIGHUP)
    hangup_all_jobs ();
  end_job_control ();
#endif /* JOB_CONTROL */
/* (省略) */
}
実験用ソース : detectHUPCONTTERM.c, 実験1 - 5

detectHUPCONTTERM.c : NetBSD1.6と同じ。変更点無し。

各実験については結果として検出したシグナルのみ記す。シェル上での作業やpsコマンドの出力は省略する。

・実験1:"exit"でBash終了時のRUNNNING状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)
→結果:シグナル未検出

・実験2:"exit"でBash終了時のSTOP状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)
→結果:SIGCONT, SIGTERM 検出

・実験3:SIGHUPでBash終了時のRUNNNING状態フォアグラウンドジョブ(J_NOHUP無, huponexit未設定)
→結果:SIGHUP, SIGCONT, SIGTERM 検出

・実験4:SIGHUPでBash終了時のRUNNNING状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)
→結果:SIGHUP, SIGCONT検出
ざっとソースを読んだ限りではSIGCONTが送信されるとは思えないのだが、それはソース読解が浅いせいかもしれない。想像以上にシグナルの送受信が発生し、何度かbashとジョブの間でステータスの変化やそれにともなうシグナルのやりとり、bashの内部情報の変更などが発生しているのかもしれない。

・実験5:SIGHUPでBash終了時のSTOP状態バックグラウンドジョブ(J_NOHUP無, huponexit未設定)
→結果:SIGCONT, SIGTERM検出

nohupの確認

NetBSD1.6と同様、nohupの使い方やnohupを使っても終了してしまうパターンを実験してみる。

nohuptest.c:NetBSD1.6と同じ。変更無し。

・nohupの一般的な使い方:RUNNING状態でフォアグランドジョブ続行

  1. nohup経由でフォアグラウンドジョブとして起動
  2. 端末エミュレータを終了した後もジョブが続行されるか確認する。

結果:ジョブが続行されるのを確認できた。

・nohupの一般的な使い方:RUNNING状態でバックグランドジョブ続行その1

  1. nohup経由でバックグラウンドジョブとして起動(RUNNING)
  2. exitコマンドでログアウト後もジョブが続行されるか確認する。

結果:ジョブが続行されるのを確認できた。

・nohupの一般的な使い方:RUNNING状態でバックグランドジョブ続行その2

  1. nohup経由でバックグラウンドジョブとして起動(RUNNING)
  2. 端末エミュレータを終了した後もジョブが続行されるか確認する。

結果:ジョブが続行されるのを確認できた。

ここまででnohupの一般的な使い方である、端末終了後もジョブが続行するパターンを確認できた。
続けて、nohupで起動したとしても、端末終了時にジョブも終了してしまうパターンを確認していく。

・nohupで起動しても終了するパターン1:ジョブをCtrl+Z(SIGTSTP)でSTOP状態にしてlogout

結果:SIGTERM, SIGCONT受信を確認できた。

・nohupで起動しても終了するパターン2:バックグランドジョブで標準入力(=擬似端末のslave側)をread()

mycat_detectTERM.c:NetBSD1.6と同じ。変更無し。

$ nohup ./mycat_detectTERM 2>err &
[1] 12704
$
[1]+  Exit 1                  nohup ./mycat_detectTERM 2> err
$ jobs
$
$ which nohup
/usr/bin/nohup
$ rpm -qf /usr/bin/nohup
coreutils-5.97-14.el5

CentOS5.xの場合、即座に終了してしまった。straceでnohupが呼んでいるシステムコールを追跡したところ、標準入力をclose()してからexec()していることが分かった。標準エラー出力のリダイレクト先に保存されたperror()メッセージも、read()でEBADFが発生したことを示している。

$ cat err
nohup: appending output to `nohup.out'
read(): Bad file descriptor

・nohupで起動した時点ではRUNNNINGだが、端末が閉じられた後、標準入力に対してread()するとどうなるか

delay_read.c:NetBSD1.6と同じ、変更点無し。

結果:端末がcloseされる時点ではRUNNINGなのでSIGTERMは送信されない。よって端末close後もプロセスは存在するが、標準入力をread()しようとすると最初からclose()されている(exec()の時点でclose()されている)ためEBADFになる。

実験・検証のまとめと反省

まとめ

  • bash終了時の流れと、RUNNING/STOPPEDジョブへのシグナル送信の組み合わせ表を作成し、NetBSD1.6ではその通りに、CentOS5.xではほぼその通りに動作することが確認できた。
  • カーネル側では放置しているバックグラウンドジョブに対しても、bash側で終了時にSIGTERM/SIGCONTを送信する事を確認できた。

反省

  • CentOS5.xでの実験結果が安定しなかった。環境によっては、1回では本記事と同じ結果にはならないかもしれない。
    • シグナルハンドラでfprintf()などシグナルセーフでない関数を呼んでいたり、bashとジョブの間で、何度かシグナルがやりとりされ、その結果bash内部のジョブ状態が変更されるなどして、今回取り上げたソース以外の場所でいろいろと処理が走っているのかもしれない。
    • 使用しているBashと全く同じソースを用意できなかったので、その影響もあるかもしれない。SRPMのバージョンや、内部で適用しているpatchの影響もあるかもしれない。

まとめ:「いともたやすく行われるえげつないnohup」

総括として、nohupを使うときの注意事項をまとめておく。

  1. nohupで防げるのはSIGHUPだけ、SIGTERM/SIGCONTに注意すること。
  2. バックグラウンドジョブをSTOPPED状態にして端末終了orログアウトするとSIGTERMが送信される点に注意すること。
  3. nohupで続行させるバックグラウンドジョブの中で、標準入力をreadしないようにすること。

上記3点に注意すれば、"nohupを使ったのに端末終了orログアウトでジョブが終了してしまった"と戸惑うことは無いだろう。
ログインシェルによっては、終了時のシグナル送信の条件やシグナルの種類が異なるかもしれないので、気になる場合はmanページ等で確認しておくと良いだろう。

また、nohupを使わなくともバックグランドジョブを続行することは可能である。ただし上記nohup時の注意点に加え、nohup無しなのでSIGHUPについても注意を払う必要がある。

ログインシェル終了時のシグナル送信条件やパターンについて細かく把握するのは非常に手間がかかる。
nohupを挟むことで少なくともSIGHUPについて頭を悩ませる必要は無くなる。"「誰がSIGHUP送ったの?」"の項でも書いたが、使う側が何も知らず、考えなくてもバックグラウンドジョブを(それなりに)継続できるnohupは、やはりスゴイのだ。

参考資料

他、UNIXの規格と便利なman検索:

nohupの使い方:

SSHとバックグラウンドジョブ, nohupについて:

擬似端末全般:

"/dev/console"とか"/dev/ttyN"の話は本記事では全力でスルーさせて貰いましたww
いずれその辺も調べてみたいっすね・・・。

擬似端末(Pseudo Terminal) Master側close()時のSIGHUP送信:


以下、後書き。

Webアプリケーション全盛の昨今、Unixの端末の仕組みについて知らなくても殆ど困らないのが現実です。
知ってるから、勉強したからといってお給料が上がるわけでもありません。実際、自分も未だに自宅警備員ですし。
いやほんと、こーゆーの調べたり記事にしたりすることでお給料もらえるところあったら、就職したいですわー。

話を戻して。
ただまぁ、Unixの端末の仕組みというのも、知らない人間から見れば結構ミステリアス。オカルトですな。
今回は全く取り上げていませんが、というかそこまで調べ始めたら話がまとまらないのでお手上げというか戦略的撤退として全力でスルーさせてもらった"/dev/console"なども結構黒魔術めいてます。キーボードに打ち込んだ文字が、いったいカーネル内部をどのように伝播してディスプレイに表示されるのか、気になりだすと夜も眠れません。
・・・うそです。眠れます。本当に眠れなくなったら、心療内科とか行った方が良いですね。不眠症です。

で、"nohup"コマンドというのは自分の中でかなり長い間、「喉に刺さった魚の小骨」だったんです。気にならないっちゃーならないんだけど、意識しちゃうと暫くそこから離れられない・・・って感じです。
そこにようやく、ピリオドを打てたので「あー、すっきりした」ってところです。

こうして調べてみますと、Unixというのが本当に、小さなパーツ、独立した仕様が絡み合って蠢いているそれなりに混沌とした"システム"であることがよく分かります。ホント、POSIXとかSUS策定した人たち、マジすげー。

Webアプリケーション全盛の昨今、・・・って同じ書き出しですが、とにかく昨今は、そうした混沌としたシステムの裏側を覗く必要性は殆ど無いと思います。セキュリティ系や大規模なインフラ、金融、通信系は別かもしれませんが。
つーかそれ以前に、アプリの仕様だのプロジェクトマネジメントだのチーム内の人間関係だのがカオスなんだから、それに加えてもう一つカオスな代物の裏側まで覗きたくねーよ、お腹一杯だぁ。って感じですね。

たとえ裏側を覗こうとしても、たとえば今回のようにnohup一つとっても、nohup単体だけでは不十分で、ログインシェルであるとかシグナルであるとか擬似端末であるとか、いろいろな要素が絡み合っていて、それら全てを、「ほどほど」だけじゃ不十分でSUSであるとか実際のソースコードであるとか、そこまで探索して初めて、全体の「絵」が描けるようになる・・・んだと思います。シグナルにしたって、プロセス間で送受信してる様子を「ビジュアルに」追えるわけではありませんし。

何よりも「地図」が無い。それが困ってます。

個別要素の地図はあるんですよ。擬似端末に絞った記事とか、nohup単体のmanページとか。
ところがいざ、裏側の全体像に目を凝らそうとすると、地図をつなぎ合わせる横串がどこにあるのか、それがさっぱり見えてこないんです。
たとえてみれば、地図はあるんですけど、全ページ切り離されてばらばらです。おまけに全体地図が欠落してる。そんな感じです。

自分のオツムが悪いだけですかね・・・。あるいは、調査不足とか。あ、APUEは結構助かります。といってもUnix全般を扱ってるため、Linuxなどプラットフォーム特有の問題までは書いてありません。
今回、この後書きを書くまで実に5日間、ほぼフルに使いましたが、半分以上がLinux上での悪戦苦闘で占められています。なんというか、なかなか実験結果が安定しないんですよね・・・。

ともあれ、自宅警備員ならではの社会貢献として・・・言い訳ですよ、ハイ。自分で書いてて虚しくなった。
とにかく、とりあえずnohup周辺の地図を自分なりにつなぎあわせてみました。

今回つなぎ合わせた地図が、自分同様、裏舞台が気になって夜も眠れなくなっちゃうけどまぁちゃっかり眠ってる、そんな読者の、何かしらの糧になれば幸いです。



プレーンテキスト形式でダウンロード
現在のバージョン : 1
更新者: msakamoto-sf
更新日: 2010-12-02 11:32:32
md5:025c3334149088744205184cc5828c1a
sha1:0adaaa3000a875b8170cb483e3563153cfda4d37
コメント
コメントを投稿するにはログインして下さい。