Linuxで簡単なシェルを自作してみた(5)|バックグラウンド実行

Linuxで簡単なシェルを自作してみた(4)|組み込みコマンドのつづき。

バックグラウンド実行の機能を追加

バックグラウンド実行の機能を追加するには、ユーザーがコマンドの最後に & を入力した場合、そのコマンドをバックグラウンドで実行するようにシェルを変更する。 バックグラウンドで実行する際、シェルは新しいプロセスを生成し、そのプロセスが終了するのを待機せずに次のコマンドを受け付けるようにする。 ただしバックグラウンドプロセスが終了したときに親プロセスが waitpid() を呼び出す必要はある(放置したままだとゾンビプロセスとして残ってしまうため)。

バックグラウンド実行をサポートするために、シェルに以下の変更を加える:

  1. コマンドの末尾に & があるかどうかを確認。
  2. & があれば、コマンドをバックグラウンドで実行し、親プロセスはその終了を待たずに次のコマンドを実行可能とする。また、組み込みコマンドの場合は exec を使って置き換える必要はない。
  3. 親プロセスはバックグラウンドプロセスの完了を待ってはいけない。ただし waitpid() を実行して消滅させる。
  4. & がなければ、通常通りフォアグラウンドで実行し、終了を待つ。

修正したシェルのソースコード

  1#include <stdio.h>
  2#include <stdlib.h>
  3#include <string.h>
  4#include <unistd.h>   // fork, execvp
  5#include <sys/wait.h> // waitpid
  6#include <glob.h>     // glob
  7#include <errno.h>    // errno
  8#include <limits.h>   // PATH_MAX
  9
 10#define MAX_CMD_LEN 1024
 11#define MAX_ARGS 100
 12
 13// 組み込みコマンド関連の宣言
 14int cd (char *argv[]);
 15int pwd(char *argv[]);
 16struct builtin {
 17    char cmd[256];              // コマンド名
 18    int (*func)(char *argv[]);  // 関数へのポインタ
 19};
 20struct builtin blt_in[] = {{"cd",cd}, {"pwd",pwd}, {"",0}};
 21
 22// コマンドをスペースで分割する関数
 23void parse_command(char *cmd, char *argv[]) {
 24    char *token = strtok(cmd, " \n");
 25    int i = 0;
 26    while (token != NULL) {
 27        argv[i++] = token;
 28        token = strtok(NULL, " \n");
 29    }
 30    argv[i] = NULL; // 最後の引数をNULLで終わらせる
 31}
 32
 33// 引用符を削除する関数
 34void remove_quotes(char *str) {
 35    char *src = str, *dst = str;
 36    while (*src) {
 37        if (*src == '"' || *src == '\'') {
 38            src++;  // 引用符をスキップ
 39        } else {
 40            *dst++ = *src++;
 41        }
 42    }
 43    *dst = '\0';  // 文字列の終端を追加
 44}
 45
 46// 引数リストにパス名展開を適用する関数
 47int expand_arguments(char *argv[], char **expanded_argv) {
 48    glob_t glob_result;
 49    int argc = 0; // 展開後の引数数
 50
 51    for (int i = 0; argv[i] != NULL; i++) {
 52        // 引用符が含まれている場合は展開しない
 53        if ((strchr(argv[i], '*') || strchr(argv[i], '?') || strchr(argv[i], '[')) &&
 54            (argv[i][0] != '"' && argv[i][0] != '\'')) { // 引用符外でパス名展開
 55            if (glob(argv[i], GLOB_NOCHECK | GLOB_TILDE, NULL, &glob_result) != 0) {
 56                perror("glob failed");
 57                return -1;
 58            }
 59            // 展開された結果を追加
 60            for (size_t j = 0; j < glob_result.gl_pathc; j++) {
 61                if (argc >= MAX_ARGS - 1) {
 62                    fprintf(stderr, "Too many arguments after glob expansion\n");
 63                    globfree(&glob_result);
 64                    return -1;
 65                }
 66                expanded_argv[argc++] = strdup(glob_result.gl_pathv[j]);
 67            }
 68            globfree(&glob_result); // メモリを解放
 69        } else {
 70            // 引用符を取り除く
 71            remove_quotes(argv[i]);
 72
 73            // パス名展開が不要な場合
 74            if (argc >= MAX_ARGS - 1) {
 75                fprintf(stderr, "Too many arguments\n");
 76                return -1;
 77            }
 78            expanded_argv[argc++] = strdup(argv[i]);
 79        }
 80    }
 81    expanded_argv[argc] = NULL; // 最後にNULLを追加
 82    return argc;
 83}
 84
 85// 組み込みコマンドの判定・実行
 86int built_in(char *argv[], int bg){
 87    struct builtin *p;
 88    // 組み込みコマンド配列(blt_in)の走査
 89    for (p = blt_in; *p->cmd != '\0'; p++) {
 90        // 組み込みコマンドでない場合は次をチェック
 91        if (strcmp(p->cmd, argv[0]) != 0) continue;
 92        // バックグラウンド実行の場合
 93        if (bg) {
 94            pid_t pid = fork();
 95            if (pid == -1) {
 96                perror("fork failed");
 97                exit(1);
 98            }
 99            // 子プロセス
100            if (pid == 0) exit ((p->func)(argv)); // 組み込みコマンドの関数を呼び出し
101            // 親プロセス
102            fprintf(stderr, "Background process started with PID: %d\n", pid);
103            return 0;
104        }
105        // バックグラウンド実行でない場合
106        // 組み込みコマンドの関数を呼び出し
107        (p->func)(argv);
108        return 0;
109    }
110    return -1;
111}
112
113// cd コマンド
114int cd(char *argv[]) {
115    if (argv[1] == NULL) {
116        // 引数なしの場合はホームディレクトリに移動
117        const char *home_dir = getenv("HOME");
118        if (home_dir == NULL) {
119            perror("cd: HOME not set");
120            return -1;
121        } else {
122            if (chdir(home_dir) != 0) {
123                perror("cd failed");
124                return -1;
125            }
126        }
127    } else {
128        // 引数が指定された場合、そのディレクトリに移動
129        if (chdir(argv[1]) != 0) {
130            perror("cd failed");
131            return -1;
132        }
133    }
134    return 0;
135}
136
137// pwd コマンド
138int pwd(char *argv[]){
139    char buf[PATH_MAX+1];
140    if (getcwd(buf, sizeof(buf)) == NULL) {
141        perror("getcwd failed");
142        return -1;
143    }
144    printf("%s\n", buf);
145    return 0;
146}
147
148// バックグラウンド実行判定
149// コマンドの末尾が '&' かどうかを確認し、'&' を取り除いて戻り値として返す
150int is_background_command(char *argv[]) {
151    int i = 0;
152    while (argv[i] != NULL) i++;
153    if (i > 0 && strcmp(argv[i - 1], "&") == 0) {
154        argv[i - 1] = NULL; // '&' をコマンド引数から削除
155        return 1;
156    }
157    return 0;
158}
159
160int main() {
161    char cmd[MAX_CMD_LEN]; // コマンド入力用のバッファ
162    char *argv[MAX_ARGS];  // コマンド引数
163    char *expanded_argv[MAX_ARGS]; // 展開後の引数
164    pid_t pid, wpid;
165    int status, bg;
166
167    // シェルのメインループ
168    while (1) {
169        // ゾンビを消滅させる
170        while ((wpid = waitpid(-1, &status, WNOHANG)) > 0)
171            fprintf(stderr, "process %d done\n", wpid);
172
173        // プロンプトを表示
174        fprintf(stderr, "mysh> ");
175
176        // コマンドを読み込む
177        if (fgets(cmd, sizeof(cmd), stdin) == NULL) {
178            if (feof(stdin)) {
179                break; // EOFが入力された場合、終了
180            }
181            perror("fgets failed");
182            continue;
183        }
184
185        // コマンドを解析
186        parse_command(cmd, argv);
187
188        // 空のコマンドの場合は次の入力へスキップ
189        if (argv[0] == NULL) {
190            continue;
191        }
192
193        // 終了コマンドの場合
194        if (strcmp(argv[0], "exit") == 0) {
195            break;
196        }
197
198        // バックグラウンド実行判定
199        bg = is_background_command(argv);
200
201        // 引数を展開
202        if (expand_arguments(argv, expanded_argv) == -1) {
203            fprintf(stderr, "Argument expansion failed\n");
204            continue;
205        }
206
207        // 組み込みコマンド判定・実行
208        if (built_in(expanded_argv, bg) == 0) {
209            continue;
210        }
211
212        // 子プロセスを作成してコマンドを実行
213        pid = fork();
214        if (pid == -1) {
215            perror("fork failed");
216            exit(1);
217        }
218
219        // 子プロセス
220        if (pid == 0) {
221            // execvpを使用してコマンドを実行
222            if (execvp(expanded_argv[0], expanded_argv) == -1) {
223                perror("exec failed");
224                exit(1);
225            }
226        }
227
228        // 親プロセス
229        while (1) {
230            // バックグラウンド実行の場合
231            if (bg) {
232                fprintf(stderr, "Background process started with PID: %d\n", pid);
233                break; // バックグラウンド実行の場合は待機しない
234            }
235            // バックグラウンド実行でない場合
236            if (waitpid(pid, &status, 0) == (pid_t)-1) { // 子プロセスの終了を待つ
237                if (errno == EINTR) {
238                    continue; // 割り込み発生時はリトライする
239                } else {
240                    perror("waitpid failed");
241                    exit(1);
242                }
243            }
244            break;
245        }
246
247        // メモリを解放
248        for (int i = 0; expanded_argv[i] != NULL; i++) {
249            free(expanded_argv[i]);
250        }
251    }
252
253    printf("Exiting shell...\n");
254    return 0;
255}

Linuxで簡単なシェルを自作してみた(4)|組み込みコマンドのソースコードと 今回修正したソースコードの差異(diff)を、以下リンク先に示す。
修正前後の比較

主な変更点

  1. main() 関数

    • 子プロセスを消滅させるため、ループ先頭で waitpid() を呼び出している。 全てのバックグラウンドプロセスをチェックする必要があるため、 waitpid() を繰り返し実行する。

      wait(2) リファレンスより抜粋)
      #include <sys/wait.h>
      pid_t waitpid(pid_t pid, int *_Nullable wstatus, int options);

      このシステムコールは呼び出し元プロセスの子プロセスにおける状態変化を待機し、子プロセスの終了など、状態変化した子プロセスに関する情報を取得するために使用される。 子プロセスが終了した場合、wait を実行することでシステムがその子プロセスに関連付けられたリソースを解放できる。 wait が実行されない場合、終了した子プロセスは「ゾンビ」状態のままとなる。
      waitpid() システムコールは、第一引数 pid で指定された子プロセスが状態変化するまで、呼び出し元スレッドの実行を一時停止する。
      pid 引数の値によって挙動が以下のように変わる:

      < -1
      : 指定された pid の絶対値と等しいプロセスグループIDを持つすべての子プロセスを待機する。
      -1
      : 任意の子プロセスを待機する。
      0
      : 呼び出し元プロセスと同じプロセスグループIDを持つ子プロセスを待機する。
      > 0
      : 指定された pid に一致するプロセスIDを持つ子プロセスを待機する。

      第二引数 wstatus が NULL でない場合、 waitpid() は状態情報を指し示された int 型変数に格納する。 この整数はマクロを使用して調査することができる。(省略)
      デフォルトでは waitpid() は終了した子プロセスのみを待機するが、この動作は第三引数 options で変更可能。
      options 引数の値は、以下の定数を0個以上論理和(OR)で組み合わせたもの:

      WNOHANG :
      子プロセスが終了していない場合でも、すぐに戻る(待機しない)。
      WUNTRACED :
      子プロセスが停止している場合も戻る。追跡中の子プロセスが停止している場合は、このオプションを指定しなくても戻る。
      WCONTINUED (Linux 2.6.10以降) :
      SIGCONT シグナルの受信により再開された停止中の子プロセスの状態も戻る。
      戻り値:
      成功時は状態変化した子プロセスの PID(プロセスID)を返す。 失敗時は -1 を返す。
    • is_background_command() の呼び出しを組み込みコマンド実行前に追加する必要がある。 また、展開処理の後に追加すると若干 & を探す処理が非効率となるため、その前に追加。
    • バックグラウンド実行の場合、親プロセスは waitpid() を呼ばず、すぐに次のコマンドを受け付ける。 また、バックグラウンドで実行されるプロセスの PID を表示する。
  2. is_background_command() 関数
    コマンドの最後に & があるかどうかを確認する。 & があれば、その部分を削除し、バックグラウンド指定有無の判定結果を返す。

  3. built_in() 関数
    組み込みコマンドのバックグラウンド実行に対応するよう修正する。

    • 追加された引数を参照し、バッグクラウンド実行が指定されている場合は、 子プロセスを生成して組み込みコマンドの関数を呼び出す。 この時、 exec を使わない(組み込み機能を実行するのであって、外部に組み込みコマンドと同盟の実行形式ファイルがあっても exec で実行しない)。
    • 子プロセスは、組み込み関数の実行後は即時でプロセスを終了する( exit )。
    • 親プロセスは、子プロセスの PID を出力するのみで、他は何もせずリターンする。

使用方法

  1. 上記のコードをファイルに保存(例えば myshell.c)する。
  2. コンパイルする:
    1gcc myshell.c -o myshell
    
  3. 実行する:
    1./myshell
    

サンプル実行

 1mysh> ls
 2Desktop  Documents  Downloads  myshell.c
 3mysh> sleep 5 &
 4Background process started with PID: 12345
 5mysh> echo Hello, world!
 6Hello, world!
 7mysh>
 8mysh>
 9mysh>
10process 12345 done
11mysh>
12mysh> pwd &
13Background process started with PID: 12348
14mysh> /Users/dam
15
16process 12348 done
17mysh>
18mysh> exit
19Exiting shell...

上記の例では、sleep 5 & をバックグラウンドで実行し、その後、すぐに次のコマンド(echo Hello, world!)を実行している。 さらに数回Enterで空振りさせて、数秒後に子プロセスの実行完了メッセージが出ることを確認している。
バックグラウンドプロセスが終了するのをシェルが待つことなく、シェルは次のコマンドを受け付けることができる。
また、組み込みコマンド pwd についても動作確認した。

関連ページ