LoginSignup
143
143

More than 5 years have passed since last update.

原理原則で理解するフォアグラウンドプロセスとバックグラウンドプロセスの違い

Last updated at Posted at 2016-11-08

topはバックグラウンドで実行できない?

突然ですが、Unixライクな端末上での処理で下記のようになる理由はわかりますか?
また、こうなってしまうのを防ぐ方法はないのでしょうか?

$ top &
[1] 11424
$ jobs -l
[1]+ 11424 停止しました (端末出力)         top

シェルでOSを操作するときに、バックグラウンドプロセスとフォアグラウンドプロセスという言葉をしばしば耳にしていると思います。

エンジニアであれば、聞いたことがないという人はいないでしょうし、その違いも何となく知っているとは思いますが、体系的に理解する機会があまりなかったという人もいるのではないでしょうか?

もともとはデーモンプロセスについてQiitaに投稿しようと思って記事を書いていたのですが、デーモンプロセスの性質上どうしてもバックグラウンドプロセスというものに言及しなければならなく、その説明も含めて一個のエントリにしてしまうと煩雑になると思い、今回はバックグラウンドプロセスにフォーカスしてみました。

上記はプログラムtopをバックグラウンドで実行した時の挙動です。topはシステムリソースをリアルタイムに監視するプログラムですが、バックグラウンドで実行させると即座にプロセスが停止してしまいます。もちろん、そう実装しているからなのですが、何故停止してしまうのでしょうか?

そもそも、バックグラウンドプロセスとは?
そして、フォアグラウンドプロセスとは?

まずはバックグラウンドプロセスと、フォアグラウンドプロセスの違いを明確にしましょう。

フォアグラウンドプロセスとバックグラウンドプロセス

通常、ユーザーが起動したプロセスはシェル(この場合はコマンドラインのインターフェイスを想定しています)によって起動(フォーク)されるはずなので、キーボードなどの周辺装置から端末ドライバを介する場合はCtrl + Cを入力することによってプロセスにSIGINTを通知することができます。
そしてSIGINTを通知されたプロセスは、実装次第ですが、大抵の状況で終了するでしょう。

このように端末からの入力を受け付ける状態になっているプロセスグループのことをフォアグラウンドプロセスグループといい、そうでない他の全てのプロセスグループのことをバックグラウンドプロセスグループといいます。

あるいは、単にフォアグラウンドプロセス、バックグラウンドプロセスと言ったりもします。

このへんの違いが少しややこしいのですが、一般的に処理をバックグラウンドで実行すると言う場合は、フォアグラウンドプロセスがシェルであることを意味しているケースが多いです。ケースが多いというのは、そうでもないケースも含まれるからです。

その状態を確かめるプログラムを作成してみましょう。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main (int c, char *argv[]) {

    for (;;){
        fputs("test\n", stdout);
        //0 is file descriptor associated with the streams stdin.
        printf("stdin is %d\n", (int)tcgetpgrp(0));
        //1 is file descriptor associated with the streams stdout.
        printf("stdout is %d\n", (int)tcgetpgrp(1));
        sleep(5);
    }   

    return EXIT_SUCCESS;
}

tcgetpgrp関数は引数としてファイルディスクリプタをとり、それに対応するフォアグラウンドプロセスのプロセスグループIDを取得します。今回は標準入力と標準出力のファイルディスクリプタを渡しています。

ファイルディスクリプタについては以前書かせて頂いた【Linuxのファイルディスクリプタをハックする】を参照下さい。

$ ./a.out

コンパイルしてできた実行ファイルをフォアグラウンドで実行します。フォアグラウンドでの起動はただ単に実行するだけです。

test
stdin is 10455
stdout is 10455
test
stdin is 10455
stdout is 10455

すると画面上にtestという文字列と、標準入力と標準出力に参照されている端末のフォアグラウンドプロセスグループのプロセスIDが表示されます。

このプロセスはソフトウェア的に終了条件が存在しないので、外部からプロセスに対してSIGINTなどのシグナルを通知しないと永遠に終了しません。

ps -jf fで確認してみると、

UID        PID  PPID  PGID   SID  C STIME TTY      STAT   TIME CMD
tajima   10360 10359 10360 10360  0 21:55 pts/1    Ss     0:00 -bash
tajima   10455 10360 10455 10360  0 22:25 pts/1    S+     0:00  \_ ./a.out

実行中のPGIDが10455なので、./a.outがフォアグラウンドプロセスであることがわかります。
すなわち、端末から./a.outのプロセスに対してシグナルの通知や入力、出力が可能であるということです。

ためしにCtrl + Cをキーボードから入力し、SIGINTを通知すると終了しました。

では今度はバックグランドで実行してみます。

$ ./a.out &

末尾に&をつけると、シェルが対象のプログラムをバックグラウンドで実行します。

test
stdin is 10360
stdout is 10360
test
stdin is 10360
stdout is 10360

当然プロセスグループIDは変わっているものの、表示上、フォアグラウンドでのプログラム実行と同じといっても過言ではありません。

ps -jf fで確認してみると、

UID        PID  PPID  PGID   SID  C STIME TTY      STAT   TIME CMD
tajima   10360 10359 10360 10360  0 21:55 pts/1    Ss+    0:00 -bash
tajima   10460 10360 10460 10360  0 22:52 pts/1    S      0:00  \_ ./a.out

端末のフォアグラウンドプロセスグループのPGIDが10360だったので、bash(シェル)がフォアグラウンドプロセスであることがわかります。そして、./a.outがバックグラウンドプロセスであることがわかります。
すなわち、端末からbashのプロセスに対してシグナルの通知や入力が可能であるということです。

ためしにCtrl + Cをキーボードから入力し、SIGINTを通知すると一瞬終了したかのように見えます。しかし、すぐに

test
stdin is 10360
stdout is 10360
test
stdin is 10360
stdout is 10360

が延々と繰り返されます。

何故ならさきほどのCtrl + Cはbashに対して通知されたのであって、./a.outに対して通知されたものではないからです。

このプロセスをフォアグラウンドプロセスにするには、すなわち端末の入出力を./a.outに紐付けてやるには、ジョブ番号をシェル(bash)のビルトインコマンドjobsで調べて、その番号をfgでフォアグラウンドプロセスに切り替えてやります。
ちなみに、プロセスはOSによって管理されるものですが、ジョブは起動中のシェルによって管理されるものです。

$ jobs
[1]   実行中               ./a.out &
$ fg %1

としてやると、

test
stdin is 10460
stdout is 10460
test
stdin is 10460
stdout is 10460

この通り、フォアグラウンドプロセスのプロセスグループIDが変化して./a.outに変わりました。

では内部的に、バックグラウンドプロセスをfgによって強制的にフォアグラウンドにする時、プロセスの状態はどう変化しているのでしょうか?

さきほど、tcgetpgrpという関数がフォアグラウンドプロセスのプロセスグループIDを取得する関数だと説明しましたが、それとは逆にtcsetpgrp関数というAPIも用意されていて、こちらはファイルディスクリプタに対応する端末を特定のプロセスグループIDに設定するという役割を持っています。

実際にシェルの力を借りずに、フォアグラウンドプロセスがバックグラウンドプロセスになるコードを書いてみることにしましょう。

要はシェルのプロセスグループIDをフォグラウンドプロセスIDにしてやれば良いのです。

まずは現在のシェルのプロセスグループIDを調べます。

 PID  PGID   SID TTY      STAT   TIME COMMAND
15243 15243 15243 pts/1    Ss     0:00 -bash
25023 25023 15243 pts/1    R+     0:00  \_ ps -j f

PGIDが15243なので、これを覚えておきます。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main (int c, char *argv[]) {

    for (;;){
        fputs("test\n", stdout);
        //0 is file descriptor associated with the streams stdin.
        printf("stdin is %d\n", (int)tcgetpgrp(0));
        //1 is file descriptor associated with the streams stdout.
        printf("stdout is %d\n", (int)tcgetpgrp(1));
        sleep(5);

        if (tcsetpgrp(0, 15243) < 0) {
            perror("tcsetpgrp() for stdin error");
        }   
        if (tcsetpgrp(1, 15243) < 0) {
            perror("tcsetpgrp() for stdout error");
        }   
    }   

    return EXIT_SUCCESS;

単純にこの方法でやればフォアグラウンドがプロセスが5秒後にバックグラウンドプロセスに変わってくれるかと思いや、うまくいきません。

というのものtcsetpgrpが成功してバックグラウンドプロセスとなったプロセスが、fputsにより端末に書き込もうとすると、SIGTTOUというシグナルが送られるのですが、このデフォルトの動作がプロセスの停止の為に、プロセスが停止してしまうからです。

$jobs -l
[1]+ 29669 停止しました (端末出力)         ./a.out

ただ、これには他に説明しなければならないちょっとした理由があるのですが、それについては後述します。

topのバックグラウンド実行

ここからが、冒頭の問いに対する説明です。

最初の問いをもう一度確認してみましょう。
現在のシステム状況を把握する為のtopコマンドをバックグラウンドで実行してみます。

$ top &

この状態でプロセスの状況を確認すると、

jobs -l
[1]+  6432 停止しました (端末出力)         top

として端末出力によって停止したことがわかります。これはSIGTTOUの通知によってプロセスが停止したことを意味しています。

topのソースコードを確認してみましょう。

top.c3342-3363

int main (int dont_care_argc, char *argv[])
{
   (void)dont_care_argc;
   before(*argv);
   windows_stage1();                    //                 top (sic) slice
   configs_read();                      //                 > spread etc, <
   parse_args(&argv[1]);                //                 > lean stuff, <
   whack_terminal();                    //                 > onions etc. <
   windows_stage2();                    //                 as bottom slice
                                        //                 +-------------+
                                        //                 +-------------+
   signal(SIGALRM,  end_pgm);
   signal(SIGHUP,   end_pgm);
   signal(SIGINT,   end_pgm);
   signal(SIGPIPE,  end_pgm);
   signal(SIGQUIT,  end_pgm);
   signal(SIGTERM,  end_pgm);
   signal(SIGTSTP,  suspend);
   signal(SIGTTIN,  suspend);
   signal(SIGTTOU,  suspend);
   signal(SIGCONT,  wins_resize_sighandler);
   signal(SIGWINCH, wins_resize_sighandler);

main関数内のwhack_terminalという関数内部でそれは起こります。

top.c1932-1954
static void whack_terminal (void)
{
   struct termios newtty;

   if (Batch) {
      setupterm("dumb", STDOUT_FILENO, NULL);
      return;
   }
   setupterm(NULL, STDOUT_FILENO, NULL);
   if (tcgetattr(STDIN_FILENO, &Savedtty) == -1)
      std_err("failed tty get");
   newtty = Savedtty;
   newtty.c_lflag &= ~(ICANON | ECHO);
   newtty.c_oflag &= ~(TAB3);
   newtty.c_cc[VMIN] = 1;
   newtty.c_cc[VTIME] = 0;

   Ttychanged = 1;
   if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &newtty) == -1) {
      putp(Cap_clr_scr);
      std_err(fmtmk("failed tty set: %s", strerror(errno)));
   }
   tcgetattr(STDIN_FILENO, &Rawtty);

この中のtcsetattrという端末の属性値を変更する関数の実行によっておこります。こちらの関数がバックグラウンドプロセスとして実行されると、SIGTTOUがプロセスが送られ、実行が一時停止してしまいます。

なので、たとえば下記のようにtopのソースコードを変更してコンパイルすると、バックグラウンドでも継続実行可能なtopコマンドが出来上がります。

top.c1932-1954
int main (int dont_care_argc, char *argv[])
{
   signal(SIGTTOU, SIG_IGN);
   signal(SIGTTIN, SIG_IGN);
   (void)dont_care_argc;
   before(*argv);
   windows_stage1();                    //                 top (sic) slice
   configs_read();                      //                 > spread etc, <
   parse_args(&argv[1]);                //                 > lean stuff, <
   whack_terminal();                    //                 > onions etc. <
   windows_stage2();                    //                 as bottom slice
                                        //                 +-------------+
                                        //                 +-------------+
   signal(SIGALRM,  end_pgm);
   signal(SIGHUP,   end_pgm);
   signal(SIGINT,   end_pgm);
   signal(SIGPIPE,  end_pgm);
   signal(SIGQUIT,  end_pgm);
   signal(SIGTERM,  end_pgm);
   signal(SIGTSTP,  suspend);
   //signal(SIGTTIN,  suspend);
   //signal(SIGTTOU,  suspend);
   signal(SIGCONT,  wins_resize_sighandler);
   signal(SIGWINCH, wins_resize_sighandler);

単純にプロセスに対してSIGTTOUシグナルとSIGTTINシグナルを無視するように通知し、シグナルハンドラsuspendの実行をコメントアウトしています。この状態でtopのビルドを行うと、

$ top &

停止することなく非同期で標準出力にシステムリソースの状態が定期的に吐き出されます。一般的な実用性はほとんどないと思いますが、私は無限topと呼んで気に入っております。興味のある方は実験してみて下さい。

さて、一般にバックグラウンドプロセスが停止してしまうケースとしては他に、端末が入力を受け付ける状態になった時が想定されます。こちらはSIGTTINが通知されるのですが、そのデフォルトの挙動もSIGTTOUとおなじくプロセス停止です。こちらも簡単なサンプルコードで確かめて見ましょう。

input.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main (int argc, char *argv[]) {

    char a[10];

    if (fgets(a, sizeof(a), stdin) == NULL) {
        fprintf(stderr, "invalid input string.\n");
        exit(EXIT_FAILURE);
    }   

    a[strlen(a)-1] = '\0';

    printf("%s\n", a); 

    return EXIT_SUCCESS;
}

こちらをバックグランドで実行します。

$ ./a.out &

プロセスの状態を確認してみると、

$ jobs -l
[1]+  6490 停止しました (端末入力)         ./a.out

のように端末入力(SIGTTIN)によって停止しています。

それでは、SIGTTOUについても簡単なプログラムによって念のため確認してみましょう。
さきほどのtopの例で、私はバックグラウンドプロセスに対するSIGTTOUの通知によってプロセスの停止が行われると説明したので、その通りになるはずです。

out.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main (int argc, char *argv[]) {

    char a[10] = "test";

    for (;;) {
        printf("%s\n", a); 
        sleep(3);
    }   

    return EXIT_SUCCESS;
}

このプログラムをバックグラウンドで実行してみると、

$ ./a.out &
test
test
jobs -l
[1]+  6562 実行中               ./a.out &

プロセスは実行中で、停止しません。これはどういうことでしょうか?

実はSIGTTOUはSIGTTINほど単純ではなく、端末ドライバの制御情報にまで踏み込んだ設定が必要になります。

プロセスと外部端末との間で、データフローを制御するカーネルの処理群を、端末ドライバ(ttyドライバ)といいますが、この端末ドライバの設定によってSIGTTOUに関する挙動が変わります。

現在の端末の設定は、sttyというコマンドで確かめられます。

$ stty
speed 9600 baud; line = 0;
eol = M-^?; eol2 = M-^?;
-brkint ixany
-echok

環境によって違いますが、この設定の中で、tostopというフラグが立っている場合のみ、プログラムのバックグラウンド実行時にSIGTTOUをプロセスに送出します。

先程のコマンドでtostopを設定するのは下記のようにします。

$ stty tostop
$ stty
speed 9600 baud; line = 0;
eol = M-^?; eol2 = M-^?;
-brkint ixany
-echok tostop

tostopという設定が追加されました。この状態で、先程のプログラムを実行してみます。

$ ./a.out &
jobs -l
[1]+ 11236 停止しました (端末出力)         ./a.out

このように先程とは違い、端末への出力を検知してプロセスが停止しています。なので、はじめからtostopフラグが立っている環境では、特に設定の必要なしにバックグラウンド実行の際の端末への出力で停止することが確認できるはずです。

sttyコマンドで設定を解除するにはフラグ名の前に-をつけて実行します。

$ stty -tostop
$ stty
speed 9600 baud; line = 0;
eol = M-^?; eol2 = M-^?;
-brkint ixany
-echok

では今度は、sttyを使うことなくプログラム内部でその設定をしてみましょう。

out2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <termios.h>
#include <signal.h>

int main (int argc, char *argv[]) {

    struct termios newtty, oldtty;

    if (tcgetattr(1, &oldtty) == -1) {
        fprintf(stderr, "tcgetattr() failed.\n");
        exit(EXIT_FAILURE);
    }   

    newtty = oldtty;
    newtty.c_lflag |= TOSTOP;

    if (tcsetattr(1, TCSAFLUSH, &newtty) == -1) {
        fprintf(stderr, "tcsetattr() failed.\n");
        exit(EXIT_FAILURE);
    }

    char a[10] = "test";

    for (;;) {
        printf("%s\n", a);
        sleep(3);
    }

    return EXIT_SUCCESS;
}

termios構造体というデータ型を用いて、端末ドライバの設定と取得を行っています。
このコードは先程紹介したtopのコードの設定部分と近いのですが、こちらの方が何故端末への出力でバックグラウンドプロセスが停止したのかわかりやすいと思います。

要は現在の端末ドライバの設定を取得した後、それにTOSTOPフラグを立てて再度端末ドライバに設定しているという処理を行っています。

bashの改造

さて、先程はSIGTTOUの通知によって力ずくでのフォアグラウンドプロセスからのバックグラウンドプロセス化に失敗したので、bashを改造してみます。

シェルによってその動作は違うでしょうが、bashの場合はシグナル制御の処理を下記の部分で実施しています。

jobs.c1904-1910

 void
 default_tty_job_signals ()
 {
   set_signal_handler (SIGTSTP, SIG_DFL);
   set_signal_handler (SIGTTIN, SIG_DFL);
   set_signal_handler (SIGTTOU, SIG_DFL);
 }

なので、子プロセスを生成する時の処理を下記のように変更します。

jobs.c1762-1767
       if (pipeline_pgrp == shell_pgrp)
         ignore_tty_job_signals ();
       else
         default_tty_job_signals ();                                        

       set_signal_handler (SIGTTOU, SIG_IGN);  

set_signal_handler (SIGTTOU, SIG_IGN)をこのあたりに仕込んで、bashをビルドします。
SIGTTIN、SIGTSTPは今回は特に関係ないので、無視の指定は行いません。

こうすると、さきほどの停止してしまったプロセスは停止フォアグラウンドで起動された後、バックグラウンドで実行されるようになります。端末からの入力は起動したプロセスに届きません。

ただ、このままでは端末からの入力と出力が起動したプロセスに届かないというだけなので、厳密にはバックグラウンド実行しているとは言えないのですが、フォアグラウンドとバックグラウンドの実行の原理的な違いは理解できたと思います。

完全なかたちで実装するにはジョブコントロールの処理について理解する必要がありますが、こちらもまた別途探求したいと思います。

今回はフォアグラウンドプロセスとバックグラウンドプロセスについて迫りました。
次回はこれらを踏まえて、デーモンプロセスについて迫ろうと思います。

参考にした各ソースコード

proc系のコマンド:procps-3.2.8
GNU bash, version 4.1.2
CPUはx86_64です。
※バージョンについては特に理由がありませんが、古すぎず新しすぎずみたいなところです。

OS

CentOS release 6.8

コンパイラ

gcc (GCC) 4.4.7 20120313 (Red Hat 4.4.7-17)

143
143
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
143
143