Shell の仕組みと簡単な実装
¶ Shell 紹介
ローカルの Unix、Windows、あるいはサーバーへ接続するときでも、私たちは端末(Terminal)を開きます。ログイン直後に表示される画面が Shell で、開発者はここにコマンドを入力してプログラムを実行します。ある意味では、「端末を使う」ということは「Shell を使う」とほぼ同義に語られることもあります(厳密には元々の意味は一致しません)。Shell の詳細は英語版 Wikipedia の「C shell」も参考になります。
Unix を普段使っていると、端末から様々なコマンドを実行します。例えば ls はカレントディレクトリ内のファイルを表示し、cat はファイル内容を表示し、grep は文字列の抽出に使えます。あるいは g++ main.cc や node index.js のようにコンパイルや実行をします。
Shell が強力な理由の 1 つは、日常的な作業を素早く片付けられる点です。例えば、今 Chrome がどんなプロセスを起動しているか知りたければ ps au | grep chrome を実行します。ここで ps はプロセス一覧を出し、grep は chrome に一致する行を抽出します。| は「pipe」で、A | B は A の出力を B に渡すことを意味します。B は stdin を受け取って処理を開始します。
さらに、Chrome のプロセスを全部止めたいなら ps au | grep chrome | awk -F ' ' '{print $2}' | xargs kill のようにできます。平たく言うと、ps でプロセス一覧を出し、出力を grep に渡して chrome を含む行を抽出し、出力を awk に渡して 2 列目(PID)だけ取り出し、最後に xargs を使って各 PID に対して kill を実行します。結果として Chrome のプロセスがすべて終了します。
このように 1 行で多くのことをしています。一般的なプログラミング言語(C++ や Python など)で同じことをやろうとすると、何十行も書かないと実現できませんが、Shell と pipe を使えば 1 行でできます。Shell を使った効率的な開発に興味があれば、「打造高效的工作环境 – SHELL 篇」も参考になります。
Shell には次のような機能があります:
- ワイルドカード(Wildcarding):例
rm *.cpp - I/O リダイレクト(Redirection)
>stdout をファイルへ出力>&stdout と stderr をファイルへ出力
- コマンド結合(Joining)
A && B:A が成功したら B を実行A || B:A が失敗したら B を実行
- パイプ(Piping)
A | B:A と B を同時に実行し、B が A の stdout を受け取るA |& B:A と B を同時に実行し、B が A の stdout と stderr を受け取る
- そのほか変数、簡単な制御構造、バックグラウンド実行など。
この中でも特に重要なのは I/O と Piping です。Shell が強力なのは、多数のコマンドを連結し、input と output を 1 本のパイプライン(Pipeline)として繋げられるからです。さらに、前段の出力を次段がそのまま input として受け取れるため、前段が完全に終了するのを待たずに次段が動き始められ、効率面でも有利になります。
¶ Shell の仕組み
では Shell はどのようにしてプログラムを実行するのでしょうか?
Shell(pid 0)が ls のようなコマンドを受け取ると、まず fork() で新しいプロセス(pid 1)を作ります。続いて pid 1 は exec 系の関数を呼び、fork された Shell を ls に置き換えて実行します。このとき Shell(pid 0)は waitpid() で ls(pid 1)が終了して出力を終えるのを待ち、終わったら次の入力へ進みます。
Shell:
$ # pid 0
-----------------------------------------------------
$ ls # pid 0 fork() 出 pid 1
A B C D # pid 1 執行 ls
-----------------------------------------------------
$ # pid 0 用 waitpid() 等 pid 1 結束才繼續
次に知っておくべきなのが pipe() という system call です。これはプロセス間通信の手段の 1 つで、Shell を実装するときには A | B を実現するために pipe() と dup2() を使います。先に次の 3 つの記事を読んでから、以下の例を見てください。
¶ Shell の例
g++ shell.cpp && ./a.out を実行します。
// shell.cpp
#include <errno.h>
#include <fcntl.h>
#include <iostream>
#include <signal.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char **argv) {
// 處理 SIGCHLD,可以避免 Child 疆屍程序
struct sigaction sigchld_action = {.sa_handler = SIG_DFL,
.sa_flags = SA_NOCLDWAIT};
// 原本指令 ls | cat | cat | cat | cat | cat | cat | cat | cat
// 假設 Shell 已經將指令 Parse 好
char **cmds[9];
char *p1_args[] = {"ls", NULL};
cmds[0] = p1_args;
char *p2_args[] = {"cat", NULL}; // 只是 DEMO,所以重複利用
for (int i = 1; i < 9; i++)
cmds[i] = p2_args;
int pipes[16]; // 需要共 8 條 pipe
for (int i = 0; i < 8; i++)
pipe(pipes + i * 2); // 建立 i-th pipe
pid_t pid;
for (int i = 0; i < 9; i++) {
pid = fork();
if (pid == 0) { // Child
// 讀取端
if (i != 0) {
// 用 dup2 將 pipe 讀取端取代成 stdin
dup2(pipes[(i - 1) * 2], STDIN_FILENO);
}
// 用 dup2 將 pipe 寫入端取代成 stdout
if (i != 8) {
dup2(pipes[i * 2 + 1], STDOUT_FILENO);
}
// 關掉之前一次打開的
for (int j = 0; j < 16; j++) {
close(pipes[j]);
}
execvp(*cmds[i], cmds[i]);
// execvp 正確執行的話,程式不會繼續到這裡
fprintf(stderr, "Cannot run %s\n", *cmds[i]);
} else { // Parent
printf("- fork %d\n", pid);
if (i != 0) {
close(pipes[(i - 1) * 2]); // 前一個的寫
close(pipes[(i - 1) * 2 + 1]); // 當前的讀
}
}
}
waitpid(pid, NULL, 0); // 等最後一個指令結束
std::cout << "===" << std::endl;
std::cout << "All done." << std::endl;
}
出力は次のようになります:
$ g++ shell.cpp && ./a.out
- fork 8244
- fork 8245
- fork 8246
- fork 8247
- fork 8248
- fork 8249
- fork 8250
- fork 8251
- fork 8252
FILE_A
FILE_B
FILE_C
===
All done.