LoginSignup
1
2

[翻訳] シェルのジョブ制御の実装 Implementing a Job Control Shell

Last updated at Posted at 2023-11-22

(訳者コメント)
shellがジョブ管理をどう理解するためにlibcのドキュメントを翻訳しました
https://www.gnu.org/software/libc/manual/html_node/Implementing-a-Shell.html

continueは"再開"と"継続"どちらで訳すべきかよく分からなかったので、一旦"再開"で合わせています

以下翻訳


28.5 ジョブ制御シェルの実装

このセクションでは、ジョブ制御を実装するためにシェルが何をしなければならないかを、関係する概念の説明し、広範なサンプルプログラムを示しながら説明します。

28.5.1 シェルのデータ構造

この章に含まれるプログラム例はすべて、単純なシェルの一部です。このセクションでは、使用されるデータ構造とユーティリティ関数を例を紹介します。

この章のサンプルシェルは主に 2つのデータ構造を扱います。

  • job型は、パイプでリンクされたサブプロセスの集合であるジョブに関する情報を保持します
  • process型は1つのサブプロセスに関する情報を保持します

以下に関連するデータ構造の宣言を示します:

/* プロセスは1つのプロセスです */
typedef struct process
{
  struct process *next; /* パイプラインの次のプロセス */
  char **argv; /* 実行用 */
  pid_t pid; /* プロセスID */ 
  char competed; /* プロセスが官僚したらtrue */
  char stopped; /* プロセスが停止したらtrue */
  int status; /* 報告されたステータスの値 */ 
} process

/* ジョブはプロセスのパイプラインです */
typedef struct job
{
  struct job *next; /* 次にアクティブなジョブ */
  char *command; /* コマンドライン。メッセージに使われる */
  process *first_process; /* このジョブ内のプロセスのリスト */
  pid_t pgid; /* プロセス・グループID */
  char notified; /* ユーザーがジョブの停止を通知した場合 true */
  struct termios tmodes; /* 保存されたターミナルモード */
  int stdin, stdout, stderr; /* 標準的な入出力チャンネル */
} job

/* アクティブなジョブがリンクされたリスト。 これはその先頭です  */
job *first_job = NULL

以下は、jobオブジェクトを操作するために使用されるユーティリティ関数です。

/* 指定されたpgidを持つアクティブなジョブを見つける関数 */
job *
find_job (pid_t pgid)
{
  job *j;

  for (j = first_job; j; j = j->next)
    if (j->pgid == pgid)
      return j;
  return NULL;
}

/* ジョブの全プロセスが停止または完了した場合、true を返す関数 */
int
job_is_stopped (job *j)
{
  process *p;

  for (p = j->first_process; p; p = p->next)
    if (!p->completed && !p->stopped)
      return 0;
  return 1;
}

/* ジョブのすべてのプロセスが完了した場合、true を返す関数 */
int
job_is_completed (job *j)
{
  process *p;

  for (p = j->first_process; p; p = p->next)
    if (!p->completed)
      return 0;
  return 1;
}

28.5.2 シェルの初期化

通常、ジョブ制御を行うシェルプログラムを開始するとき、他のシェルから呼び出されたケースに注意しなければなりません。呼び出した側のシェルはすでにジョブ制御を行っています。

対話的に実行されるサブシェルは、それ自身がジョブ制御を有効にする前に、親シェルによってフォアグラウンドに置かれたことを確認しなければなりません。これは、getpgrp 関数で初期プロセスグループ ID を取得し、制御端末に関連付けられた現在のフォアグラウンドジョブのプロセスグループ ID (tcgetpgrp 関数で取得可能) と比較することで行います。

サブシェルがフォアグラウンドジョブとして実行されていない場合、サブシェル自身のプロセスグループに SIGTTIN シグナルを送ることで、それ自身を停止しなければなりません。任意にそれ自身をフォアグラウンドにすることはできず、ユーザーが親シェルに指示するのを待たなければなりません。もしサブシェルが再開された場合、検証を繰り返し、まだフォアグラウンドでなければ、再びそれ自身を停止すべきです。

サブシェルが親シェルによってフォアグラウンドに置かれると、それ自身のジョブ制御を有効にすることができます。これは、setpgidを呼び出して自分自身をプロセスグループに入れ、tcsetpgrpを呼び出してこのプロセスグループをフォアグラウンドにすることで行われます。

シェルがジョブ制御を有効にする場合、シェル自身を誤って停止しないように、すべてのジョブ制御停止シグナルを無視するように設定すべきです。これは、すべての停止シグナルのアクションをSIG_IGNに設定することで実現できます。

非対話的に実行されるサブシェルは、ジョブ制御をサポートできませんし、すべきでありません。作成するすべてのプロセスをシェル自身と同じプロセスグループ内に保持する必要があります。これによって、非対話型シェルとその子プロセスは、親シェルに1つのジョブとして扱われます。これは実現するのは容易で、ジョブ制御プリミティブを使う必要もありません。しかし、シェルにそれをさせることを忘れていけません。

以下は、上記を実現するサンプルシェルのうち初期化に関するコードです。

/* シェルの属性 */

#include <sys/types.h>
#include <termios.h>
#include <unistd.h>

pid_t shell_pgid;
struct termios shell_tmodes;
int shell_terminal;
int shell_is_interactive;


/* シェルがフォアグラウンドジョブとしてインタラクティブに実行されていることを確認する */

void
init_shell ()
{

  /* 対話的に実行されているかどうかを確認する */
  shell_terminal = STDIN_FILENO;
  shell_is_interactive = isatty (shell_terminal);

  if (shell_is_interactive)
    {
      /* フォアグラウンドになるまでループする */
      while (tcgetpgrp (shell_terminal) != (shell_pgid = getpgrp ()))
        kill (- shell_pgid, SIGTTIN);

      /* 対話型シグナルとジョブ制御シグナルは無視する */
      signal (SIGINT, SIG_IGN);
      signal (SIGQUIT, SIG_IGN);
      signal (SIGTSTP, SIG_IGN);
      signal (SIGTTIN, SIG_IGN);
      signal (SIGTTOU, SIG_IGN);
      signal (SIGCHLD, SIG_IGN);

      /* 自分自身をプロセス・グループに設定する */
      shell_pgid = getpid ();
      if (setpgid (shell_pgid, shell_pgid) < 0)
        {
          perror ("Couldn't put the shell in its own process group");
          exit (1);
        }

      /* ターミナルの制御を得る */
      tcsetpgrp (shell_terminal, shell_pgid);

      /* デフォルトのターミナル属性をシェルに保存する *
      tcgetattr (shell_terminal, &shell_tmodes);
    }
}

28.5.3 ジョブの起動

シェルが制御端末上でジョブ制御を行う責務を得ると、ユーザが入力したコマンドに応答してジョブを起動できるようになります。

プロセスグループ内でプロセスを作成するには、Process Creation Conceptsで説明したのと同じfork関数とexec関数を使用します。しかし、複数の子プロセスが関係するため、物事は少し複雑になり、正しい順序で行うように注意しなければなりません。そうしないと、厄介な競合状態が発生する可能性があります。

プロセスの親子関係のツリーをどのように構成するかについては、2つの選択肢があります。プロセスグループ内のすべてのプロセスをシェルプロセスの子にするか、グループ内のすべてのプロセスの先祖となる1つのプロセス作成するかです。本章で紹介するサンプルシェルでは、帳簿管理が多少簡単になるため、最初の方法を採用しています。

各プロセスがフォークされると、setpgid を呼び出して新しいプロセスグループに入ります。参照: Process Group Functions。新しいグループの最初のプロセスがそのプロセスグループリーダーになり、そのプロセスIDがそのグループのプロセスグループIDとなります。

また、シェルはsetpgidを呼び出して、各子プロセスを新しいプロセスグループに入れるべきです。これには、潜在的なタイミングの問題があります。各子プロセスは新しいプログラムの実行を開始する前にプロセスグループに入っていなければなりません。そして、シェルは実行を再開する前にすべての子プロセスがグループに入っていることに依存しています。子プロセスとシェルの両方がsetpgidを呼び出せば、どちらのプロセスが先に呼び出されようとも、正しい処理が行われるようになります。

ジョブがフォアグラウンドジョブとして起動される場合、新しいプロセスグループは tcsetpgrp を使って制御端末のフォアグラウンドに置かれる必要があります。ここでも、競合状態を避けるために、子プロセスと同様にシェルがこれを実行すべきです。

次に、各子プロセスが行うべきことは、シグナルアクションのリセットです。

初期化時に、シェルプロセスはそれ自身がジョブ制御シグナルを無視するように設定します。参照: シェルの初期化。その結果、シェルが作成した子プロセスも継承によってこれらのシグナルを無視することになります。これは間違いなく望ましくないので、各子プロセスはフォークされた直後に、これらのシグナルのアクションを明示的にSIG_DFLに戻す必要があります。

シェルはこの規約に従っているので、アプリケーションは親プロセスからこれらのシグナルの正しい処理を継承していると推測できるかもしれません。しかし、すべてのアプリケーションは、ストップシグナルの処理を乱さないようにする責務があります。SUSP文字の通常の解釈を無効にするアプリケーションは、ユーザーがジョブを停止するための他のメカニズムを提供すべきです。ユーザがこのメカニズムを呼び出したら、プログラムはプロセス自身だけでなく、そのプロセスのプロセスグループにもSIGTSTPシグナルを送るべきです。参照: Signaling Another Process

最後に、各子プロセスは通常の方法でexecを呼び出します。これは、標準入出力チャンネルのリダイレクトを処理する時点でもあります。この方法については、Duplicating Descriptorsを参照してください。

サンプルシェルプログラムの中で、プログラムの起動を担当する関数を示します。この関数は、シェルによってフォークされた直後に各子プロセスによって実行され、決してreturnしません。

void
launch_process (process *p, pid_t pgid,
                int infile, int outfile, int errfile,
                int foreground)
{
  pid_t pid;

  if (shell_is_interactive)
    {
      /* プロセスをプロセスグループに入れ、必要に応じてプロセスグループ にターミナルを与える。これは、潜在的な競合のため、シェルと各子プロセスの両方で行なわなければならない */
      pid = getpid ();
      if (pgid == 0) pgid = pid;
      setpgid (pid, pgid);
      if (foreground)
        tcsetpgrp (shell_terminal, pgid);

      /* ジョブ制御シグナルの処理をデフォルトに戻す */
      signal (SIGINT, SIG_DFL);
      signal (SIGQUIT, SIG_DFL);
      signal (SIGTSTP, SIG_DFL);
      signal (SIGTTIN, SIG_DFL);
      signal (SIGTTOU, SIG_DFL);
      signal (SIGCHLD, SIG_DFL);
    }

  /* 新しいプロセスの標準入出力チャネルを設定する */
  if (infile != STDIN_FILENO)
    {
      dup2 (infile, STDIN_FILENO);
      close (infile);
    }
  if (outfile != STDOUT_FILENO)
    {
      dup2 (outfile, STDOUT_FILENO);
      close (outfile);
    }
  if (errfile != STDERR_FILENO)
    {
      dup2 (errfile, STDERR_FILENO);
      close (errfile);
    }

  /* 新しいプロセスを実行する。終了することを確認する */
  execvp (p->argv[0], p->argv);
  perror ("execvp");
  exit (1);
}

シェルが対話的に実行されていない場合、この関数はプロセスグループやシグナルに対して何もしません。ジョブ制御を行わないシェルは、すべてのサブプロセスをシェル自身と同じプロセスグループに保持しなければならないことを思い出してください。

次は、実際に完全なジョブを起動する関数です。子プロセスを作成した後、この関数は新しく作成されたジョブをフォアグラウンドまたはバックグラウンドに置くために他の関数を呼び出します。これらについては次の章で説明します。

void
launch_job (job *j, int foreground)
{
  process *p;
  pid_t pid;
  int mypipe[2], infile, outfile;

  infile = j->stdin;
  for (p = j->first_process; p; p = p->next)
    {
      /* 必要に応じてパイプをセットアップする */
      if (p->next)
        {
          if (pipe (mypipe) < 0)
            {
              perror ("pipe");
              exit (1);
            }
          outfile = mypipe[1];
        }
      else
        outfile = j->stdout;

      /* 子プロセスをフォークする */
      pid = fork ();
      if (pid == 0)
        /* これが子プロセスだ */
        launch_process (p, j->pgid, infile,
                        outfile, j->stderr, foreground);
      else if (pid < 0)
        {
          /* フォークに失敗した */
          perror ("fork");
          exit (1);
        }
      else
        {
          /* これが親プロセスだ */
          p->pid = pid;
          if (shell_is_interactive)
            {
              if (!j->pgid)
                j->pgid = pid;
              setpgid (pid, j->pgid);
            }
        }

      /* パイプの後始末 */
      if (infile != j->stdin)
        close (infile);
      if (outfile != j->stdout)
        close (outfile);
      infile = mypipe[0];
    }

  format_job_info (j, "launched");

  if (!shell_is_interactive)
    wait_for_job (j);
  else if (foreground)
    put_job_in_foreground (j, 0);
  else
    put_job_in_background (j, 0);
}

28.5.4 フォアグラウンドとバックグラウンド

ここで、シェルがフォアグラウンドでジョブを起動するときにどのようなアクションを取らなければならないか、また、それがバックグラウンドジョブを起動するとどう異なるかを考えてみましょう。

フォアグラウンドジョブが起動されると、シェルはまずtcsetpgrpを呼び出して制御端末へのアクセスを与えなければなりません。その後、シェルはそのプロセスグループ内のプロセスが終了または停止するのを待ちます。これについては、停止したジョブと終了したジョブで詳しく説明します。

グループ内のすべてのプロセスが終了または停止したら、シェルは再度tcsetpgrpを呼び出して、自身のプロセスグループの端末の制御を取り戻さなければなりません。バックグラウンドプロセスからのI/OまたはユーザーによってタイプされたSUSP文字によって引き起こされる停止シグナルは、プロセスグループに送られるので、通常、ジョブ内のすべてのプロセスは一緒に停止します。

フォアグラウンドジョブによって端末がおかしな状態になるかもしれないので、 継続する前にシェルは保存された端末モードを復元すべきです。ジョブが単に停止しただけの場合、シェルはまず現在のターミナルモードを保存して、ジョブを継続する場合に後で復元できるようにすべきです。ターミナルモードを扱う関数は tcgetattr と tcsetattr です。これについては、Terminal Modesで説明します。

これらすべてを行うサンプルシェルの関数を以下に示します。

/* ジョブjをフォアグラウンドに置く。contが0以外の場合、保存されたターミナルモードを復元し、プロセスグループにSIGCONTシグナルを送ってブロックする前に再開させる */

void
put_job_in_foreground (job *j, int cont)
{
  /* ジョブをフォアグラウンドにする */
  tcsetpgrp (shell_terminal, j->pgid);

  /* 必要であれば、ジョブにcontinueシグナルを送る */
  if (cont)
    {
      tcsetattr (shell_terminal, TCSADRAIN, &j->tmodes);
      if (kill (- j->pgid, SIGCONT) < 0)
        perror ("kill (SIGCONT)");
    }

  /* ジョブの報告を待つ */
  wait_for_job (j);

  /* シェルをフォアグラウンドに戻す */
  tcsetpgrp (shell_terminal, shell_pgid);

  /* シェルのターミナル・モードを元に戻す */
  tcgetattr (shell_terminal, &j->tmodes);
  tcsetattr (shell_terminal, TCSADRAIN, &shell_tmodes);
}

プロセスグループがバックグラウンドジョブとして起動されている場合、シェルはフォアグラウンドのままで、ターミナルからコマンドを読み続けるはずです。

サンプルシェルでは、ジョブをバックグラウンドにするために必要なことはあまりありません。以下はその関数です。

/* ジョブをバックグラウンドに置く。cont引数がtrueの場合 プロセス・グループにSIGCONTシグナルを送り、再開させる */

void
put_job_in_background (job *j, int cont)
{
  /* 必要であれば、ジョブにcontinueシグナルを送る */
  if (cont)
    if (kill (-j->pgid, SIGCONT) < 0)
      perror ("kill (SIGCONT)");
}

28.5.5 ジョブの停止と終了

フォアグラウンドプロセスが起動されると、シェルはそのジョブ内のすべてのプロセスが終了するか停止するまでブロックしなければなりません。これは waitpid 関数を呼び出すことで実装できます。参照: Process Completion。終了したプロセスと同様に停止したプロセスについてもステータスが報告されるように、WUNTRACEDオプションを使用します。

シェルは、終了したジョブや停止したジョブをユーザーに報告できるように、バックグラウンドジョブの状態もチェックしなければなりません。これは、WNOHANGオプションでwaitpidを呼び出すことによって実装できます。終了したジョブや停止したジョブのチェックを行うのによい場所は、新しいコマンドをプロンプトする直前です。

シェルは、SIGCHLDシグナルのハンドラを確立することで、子プロセスのステータス情報が利用可能であるという非同期通知を受け取ることもできます。参照: Signal Handling

サンプルシェルプログラムでは、SIGCHLDシグナルは通常無視されます。これは、シェルが操作するグローバルデータ構造に関わるリエントランシー問題を回避するためです。しかし、シェルがこれらのデータ構造を使用していない特定の時、例えば端末で入力を待っている時などには、SIGCHLDのハンドラを有効にすることは理にかなっています。同期ステータスチェックに使用される同じ関数(この場合はdo_job_notification)は、このハンドラの中から呼び出すこともできます。

以下に、サンプルシェルプログラムのうち、ジョブのステータスをチェックし、その情報をユーザーに報告する部分を示します。

/* waitpid が返したプロセス pid のステータスを格納する。すべてがうまくいった場合は0を返し、そうでない場合は0以外を返す */

int
mark_process_status (pid_t pid, int status)
{
  job *j;
  process *p;

  if (pid > 0)
    {
      /* プロセスのレコードを更新する */
      for (j = first_job; j; j = j->next)
        for (p = j->first_process; p; p = p->next)
          if (p->pid == pid)
            {
              p->status = status;
              if (WIFSTOPPED (status))
                p->stopped = 1;
              else
                {
                  p->completed = 1;
                  if (WIFSIGNALED (status))
                    fprintf (stderr, "%d: Terminated by signal %d.\n",
                             (int) pid, WTERMSIG (p->status));
                }
              return 0;
             }
      fprintf (stderr, "No child process %d.\n", pid);
      return -1;
    }
  else if (pid == 0 || errno == ECHILD)
    /* 報告できるプロセスがない */
    return -1;
  else {
    /* その他の奇妙なエラー */
    perror ("waitpid");
    return -1;
  }
}

/* ブロックせずに、ステータス情報が利用可能なプロセスをチェックする */

void
update_status (void)
{
  int status;
  pid_t pid;

  do
    pid = waitpid (WAIT_ANY, &status, WUNTRACED|WNOHANG);
  while (!mark_process_status (pid, status));
}

/* 与えられたジョブの全プロセスが報告するまでブロックし、ステータス情報が利用可能なプロセスをチェックする */

void
wait_for_job (job *j)
{
  int status;
  pid_t pid;

  do
    pid = waitpid (WAIT_ANY, &status, WUNTRACED);
  while (!mark_process_status (pid, status)
         && !job_is_stopped (j)
         && !job_is_completed (j));
}

/* ユーザーが見ることができるように、ジョブステータスに関する情報をフォーマットする */

void
format_job_info (job *j, const char *status)
{
  fprintf (stderr, "%ld (%s): %s\n", (long)j->pgid, status, j->command);
}

/* 停止または終了したジョブについてユーザーに通知する。終了したジョブをアクティブなジョブリストから削除する */

void
do_job_notification (void)
{
  job *j, *jlast, *jnext;

  /* 子プロセスのステータス情報を更新する */
  update_status ();

  jlast = NULL;
  for (j = first_job; j; j = jnext)
    {
      jnext = j->next;

      /* すべてのプロセスが完了した場合、ユーザーにジョブの完了を通知し、アクティブなジョブ・リストから削除します */
      if (job_is_completed (j)) {
        format_job_info (j, "completed");
        if (jlast)
          jlast->next = jnext;
        else
          first_job = jnext;
        free_job (j);
      }

      /* 停止したジョブについてユーザーに通知する */
      else if (job_is_stopped (j) && !j->notified) {
        format_job_info (j, "stopped");
        j->notified = 1;
        jlast = j;
      }

      /* まだ実行中のジョブについては何も言わない */
      else
        jlast = j;
    }
}

28.5.6 停止したジョブの継続

シェルは、SIGCONTシグナルをプロセスグループに送ることで、停止したジョブを再開できます。ジョブがフォアグラウンドで再開されるなら、シェルは最初にtcsetpgrpを呼び出して、ジョブにターミナルへのアクセス権を与え、保存されたターミナル設定を復元しなければなりません。フォアグラウンドでジョブを再開した後、シェルはジョブの停止または完了を待つべきです。

サンプルシェルプログラムは、新規に作成されたジョブも再開されたジョブも、同じ関数のペア put_job_in_foreground と put_job_in_background で処理します。これらの関数の定義は「フォアグラウンドとバックグラウンド」で説明しました。停止したジョブを再開する場合、SIGCONT信号の送信と端末モードのリセットを確実にするために、cont引数として0以外の値が渡されます。

再開するジョブについてシェル内部の帳簿を更新する関数を示します。

/* 停止したジョブjを再び実行中としてマークする */
void
mark_job_as_running (job *j)
{
  Process *p;

  for (p = j->first_process; p; p = p->next)
    p->stopped = 0;
  j->notified = 0;
}

/* ジョブを再開する */
void
continue_job (job *j, int foreground)
{
  mark_job_as_running (j);
  if (foreground)
    put_job_in_foreground (j, 1);
  else
    put_job_in_background (j, 1);
}

28.5.7 足りない部分

この章に含まれるサンプルシェルのコード抜粋は、シェルプログラム全体の一部に過ぎません。特に、ジョブやプログラムのデータ構造がどのように割り当てられ、初期化されるのかについては全く何も述べられていません。

ほとんどの実際のシェルは、コマンド言語、変数、ファイル名の省略、置換、パターンマッチなどをサポートする複雑なユーザーインターフェースを提供しています。これらすべてをここで説明するには複雑すぎます!その代わりに、このようなシェルから呼び出すことができるコアなプロセス作成とジョブ制御機能の実装方法を示すことにフォーカスしました。

以下は、今回紹介した主なエントリーポイントをまとめた表です:

  • void init_shell (void)
    • シェルの内部状態を初期化する
  • void launch_job (job *j, int foreground)
    • ジョブ j をフォアグラウンドとして起動する
  • void do_job_notification (void)
    • 終了または停止したジョブをチェックし、報告する。同期的に、またはSIGCHLDシグナルのハンドラ内で呼び出すことができる
  • void continue_job (job *j, int foreground)
    • ジョブjを継続する

もちろん、実際のシェルはジョブを管理するための他の関数も提供したいと思うでしょう。例えば、すべてのアクティブなジョブをリストアップするコマンドや、ジョブにシグナル(SIGKILLなど)を送るコマンドがあると便利でしょう。

1
2
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
1
2