「技評×オングスこんなシェルスクリプトは書いちゃダメだ!」に参加してきました

技評×オングスこんなシェルスクリプトは書いちゃダメだ!」に参加してきました

技評×オングスこんなシェルスクリプトは書いちゃダメだ!に参加してきました。FreeBSD勉強会を開催している@daichigotoさんが主催されています。FreeBSD勉強会は若干お堅い内容なので、もう少しカジュアルな内容の勉強会にしたいとのことです。

シェルスクリプトの3つの側面

シェルスクリプトには以下の3つの側面があるとのことです。

  • システムを組み上げるためのソフトウェア
  • ユーザが操作するインタフェース
  • 業務システムを組み上げるためのソフトウェア

シェルスクリプトはエンジニアの腕前の差がはっきりと出るものであり、学ぶことによって効率のよいシェルスクリプトが書けるようになるということ、また、シェルとカーネルをよく知るエンジニアが書くスクリプトは効率がよい処理になるという説明がありました。

ポイントとしては"at a glance"(一瞥してわかる)な書き方が重要とのことです。
(私の英語力はまったくダメダメなので思わず辞書を引いてしまいました……)

ダメなスクリプトの例

ダメなスクリプトの例として以下が挙げられていました。

手続き型的な発想のシェルスクリプト

手続き型的なプログラミングでは、データに対する順次処理を書いてしまいがちですが、「まず最初に処理するデータを減らす」ことが重要とのことで、悪い書き方と良い書き方として、以下のような例が示されていました。

  • (悪い書き方)データレコードの各フィールドを都度if文でチェックしてから集計する方法
  • (良い書き方)awkでマッチする行を抜き出してから集計する方法

前者と後者では1000倍くらい速度が違うという結果を示しながらの説明となっていました。料理でも下ごしらえが重要だったりするので、データ処理においても下ごしらえに相当する処理をどうするかは重要なのだなと思いました。

変数を多用するシェルスクリプト

シェルスクリプトの変数展開は遅い処理であるという説明がありました。例として文字列の連結をシェル変数上で行った場合とファイルに追加出力した場合の結果が示されていました。

私の環境でもちょっと試してみました。26Kbyteほどのテキストファイルに対して先の手順を実施してみます。

$ w3m -dump http://www.aozora.gr.jp/cards/000081/files/4601_11978.html > furandon.txt
$ ls -hl furandon.txt
-rw-r--r--  1 fpig  users   26K Sep  2 04:49 furandon.txt

結果は以下となりました。データサイズが小さいとはいえ、すでに10倍程度の速度差が出ています。数メガ、数ギガ単位のデータであれば、見過ごせないほどの速度差になってくるはずです。

$ # シェル変数上で文字列連結した場合
$ time (cat furandon.txt | while read i; do a=$a$i; done)

real    0m1.348s
user    0m1.047s
sys     0m0.031s

$ # ファイルに追加出力した場合
$ time (cat furandon.txt | while read i; do echo $i >> out.txt; done)

real    0m0.124s
user    0m0.040s
sys     0m0.053s

関数(ただし、これは場合によりけりのようです)

これはシェル変数のスコープに関連してくる話で、シェルスクリプト関数の実態はグルーピングした処理であるため、文脈によってグローバルな変数を書き換えてしまう、という話でした。ただ、これは必ずしも悪手というわけではなく、シェルスクリプトの振る舞いをよく理解せずにコードを書くとハマる可能性があるよ、という意味での説明のようでした。

なぜこんな仕組みになっているのか?

シェルスクリプトのダメな例/良い例の話があった後、なぜこんな仕組みになっているのかの説明がありました。

シェルスクリプトの深い部分を理解しようと考えた場合、それはOSレイヤに踏み込んで行く形になります。取っ掛かりとして、シェルと結びつきが強い以下のシステムコールがどういう振る舞いをするか把握するとよいとのことでした。

  • fork(2)
  • execve(2)
  • pipe(2)
  • wait3(2)

ちょっと興味があったので、別エントリでNetBSDでの場合を調べてみました。

シェルによるシステム開発方式

一通り「こんなシェルスクリプトは書いちゃダメだ!」という説明があった後、株式会社オングスさんで実践しているttt開発方法を例にしたシェルによるシステム開発方式の説明がありました。

どのシステム開発にも言えることだと思いますが、シェルによるシステム開発でもうまく行くパターンと失敗するパターンがあるようです。

うまく行くパターンとしては、開発するシステムに対する業務フロー図やER図がちゃんと記述できること(記述できないということは、どんなシステムにするかを明確化できていない)、データの配置が事前に考えられているころ、UIにはHTML5/CSS3アダプティブデザインなど、最新の技術が活用されていることや、バックエンド側は長くても数百行程度のシェルスクリプトになっていること等が挙げられていました。

上記のパターンと逆の状態になっているケースが、いわゆる失敗するパターンとして説明されていました。

スキルアップのための書籍やサイト

これからシェルスクリプトやシェルによる開発を始めてみようという人向けに、以下の情報源が提示されていました。

次回の勉強会について

今回の勉強会は満員ということもあり、次回の開催も決定しています!とのことです。

また、FreeBSD勉強会が今月中頃に開催されます。

参加者からの質問

参加者から出た質問については、シェルスクリプトデバッグ手法に関する内容が多かった気がします。また、BSD系OSをこれから使ってみようと考えている方もおられたようで、BSD人気を増やす向きの勉強会はありがたいものです。

シェルのデバッグ手法としては、シェバンに"/bin/sh -exv"を指定する方法、パイプの途中をteeで眺める方法などが紹介されていました。

他にもシェル変数の意味(「$$」など)の意味をどう調べたらよいか(man shに説明があるようですが、もう少しカジュアル(?)な変数の意味リストが欲しいかと……)、といった質問や、jsonやpdf等のデータをシェルスクリプトで扱う方法(オングスさんではズバリjsonというコマンドを用意しているそうです)といった質問が出ていました。

まとめ

技評×オングスこんなシェルスクリプトは書いちゃダメだ!に参加してきました。シェルスクリプトは日々の小さな作業をパッとこなすために使っており、大きなシステムを作ったことはなかったのですが、この勉強会の内容を元に比較的大きなシステムを手早く作れるようになれれば良いなと思います。

NetBSDの/bin/shでコマンドがexecve(2)されるまでの流れを追いかけてみた

NetBSD/bin/shでコマンドがexecve(2)されるまでの流れを追いかけてみた

技評×オングスこんなシェルスクリプトは書いちゃダメだ!にてシェル上でコマンドを実行する際のユーザランドからカーネルまでの流れが説明されていました。

ちょっと興味が出てきたので、NetBSD/bin/shでコマンドを実行した場合のコマンド入力からexecve(2)までの流れをソースコードレベルで追いかけてみました。

NetBSDソースコードにおける/bin/shの場所

NetBSDソースコードは、ユーザランド/カーネル共に/usr/srcの下に置かれます。ユーザランドのコマンドは/usr/src/<コマンドのパス>の位置に用意されており、/bin/shの場合は/usr/src/bin/shソースコードのあるディレクトリになります。

$ which sh
/bin/sh
$ cd /usr/src/bin/sh
$ ls
CVS/            error.h         jobs.h          mktokens        redir.h
Makefile        eval.c          machdep.h       myhistedit.h    sh.1
TOUR            eval.h          mail.c          mystring.c      shell.h
USD.doc/        exec.c          mail.h          mystring.h      show.c
alias.c         exec.h          main.c          nodes.c.pat     show.h
alias.h         expand.c        main.h          nodetypes       syntax.c
arith.y         expand.h        memalloc.c      options.c       syntax.h
arith_lex.l     funcs/          memalloc.h      options.h       trap.c
bltin/          histedit.c      miscbltin.c     output.c        trap.h
builtins.def    init.h          miscbltin.h     output.h        var.c
cd.c            input.c         mkbuiltins      parser.c        var.h
cd.h            input.h         mkinit.sh       parser.h
error.c         jobs.c          mknodes.sh      redir.c

main()からexecve(2)までの流れ

/bin/shを実行し、ユーザがコマンドを入力・実行する場合は、以下の関数を通過しています(細々とした関数は除き、主要な関数のみ抽出しています)。

  • /usr/src/bin/sh/main.c:main()
    • /usr/src/bin/sh/main.c:cmdloop()
      • /usr/src/bin/sh/eval.c:evaltree()
        • /usr/src/bin/sh/eval.c:evalcommand()
          • /usr/src/bin/sh/jobs.c:forkchild()
          • /usr/src/bin/sh/exec.c:shellexec()
            • /usr/src/bin/sh/exec.c:tryexec()
              • execve(2)

main()から順に追いかけてみます。

main()

何やら不穏なコメントがありますが、単純に/bin/shを起動してキーボードからコマンドを入力する場合は、cmdloop()に入って行くようです。

/usr/src/bin/sh/main.c:
 95 /*
 96  * Main routine.  We initialize things, parse the arguments, execute
 97  * profiles if we're a login shell, and then call cmdloop to execute
 98  * commands.  The setjmp call sets up the location to jump to when an
 99  * exception occurs.  When an exception occurs the variable "state"
100  * is used to figure out how far we had gotten.
101  */
102
103 int
104 main(int argc, char **argv)
105 {
...中略...
218         if (sflag || minusc == NULL) {
219 state4: /* XXX ??? - why isn't this before the "if" statement */
220                 cmdloop(1);
221         }

cmdloop()では、255行目のparsecmd()の戻り値からEOFの入力の有無を判定し、EOFでなければevaltree()を呼んでいます。

/usr/src/bin/sh/main.c:
230 /*
231  * Read and execute commands.  "Top" is nonzero for the top level command
232  * loop; it turns on prompting if the shell is interactive.
233  */
234
235 void
236 cmdloop(int top)
237 {
...中略...
245         for (;;) {
...中略...
255                 n = parsecmd(inter);
256                 /* showtree(n); DEBUG */
257                 if (n == NEOF) {
258                         if (!top || numeof >= 50)
259                                 break;
260                         if (!stoppedjobs()) {
261                                 if (!Iflag)
262                                         break;
263                                 out2str("\nUse \"exit\" to leave shell.\n");
264                         }
265                         numeof++;
266                 } else if (n != NULL && nflag == 0) {
267                         job_warning = (job_warning == 2) ? 1 : 0;
268                         numeof = 0;
269                         evaltree(n, 0);
270                 }

単純なコマンド実行の場合、evaltree()内の301行のcase文が実行されます。

/usr/src/bin/sh/eval.c:
 214 /*
 215  * Evaluate a parse tree.  The value is left in the global variable
 216  * exitstatus.
 217  */
 218
 219 void
 220 evaltree(union node *n, int flags)
 221 {
...中略...
 235         switch (n->type) {
...中略...
 293         case NNOT:
...中略...
 297         case NPIPE:
...中略...
 301         case NCMD:
 302                 evalcommand(n, flags, NULL);
 303                 do_etest = !(flags & EV_TESTED);
 304                 break;

マクロ定数NCMDはnodes.hで定義されています。nodes.hはmake時に生成され、元になる定義はファイルnodetypesにあり、NCMDについては"a simple command"というコメントがあります。

nodetypesにはNCMDの他に論理演算子やパイプに関する定義があり、evaltree()内のcase文でNNOTやNPIPEをチェックしています。

$ grep NCMD *.h
nodes.h:#define NCMD 1
$ grep -A1 'create.*nodes\.h' my_make.log
#    create  sh/nodes.h
AWK=awk  SED=sed /bin/sh mknodes.sh nodetypes nodes.c.pat /home/fpig/work/sh
$ grep ^N nodetypes
NSEMI nbinary                   # two commands separated by a semicolon
NCMD ncmd                       # a simple command
NPIPE npipe                     # a pipeline
NREDIR nredir                   # redirection (of a complex command)
NBACKGND nredir                 # run command in background
NSUBSHELL nredir                # run command in a subshell
NAND nbinary                    # the && operator
NOR nbinary                     # the || operator
NIF nif                         # the if statement.  Elif clauses are handled
NWHILE nbinary                  # the while statement.  First child is the test
NUNTIL nbinary                  # the until statement
NFOR nfor                       # the for statement
NCASE ncase                     # a case statement
NCLIST nclist                   # a case
NDEFUN narg                     # define a function.  The "next" field contains
NARG narg                       # represents a word
NTO nfile                       # fd> fname
NCLOBBER nfile                  # fd>| fname
NFROM nfile                     # fd< fname
NFROMTO nfile                   # fd<> fname
NAPPEND nfile                   # fd>> fname
NTOFD ndup                      # fd<&dupfd
NFROMFD ndup                    # fd>&dupfd
NHERE nhere                     # fd<<\!
NXHERE nhere                    # fd<<!
NNOT nnot                       # ! command  (actually pipeline)

NCMDの場合はevalcommand()が呼ばれます。この関数の中でプロセスのフォークが行われます(vfork()が呼ばれています)。実行されるコマンドを追いかけたいので、858行目からの子プロセス側の処理を追って行きます。

/usr/src/bin/sh/eval.c:
 672 /*
 673  * Execute a simple command.
 674  */
 675
 676 STATIC void
 677 evalcommand(union node *cmd, int flgs, struct backcmd *backcmd)
 678 {
...中略...
 845                 if (cmdentry.cmdtype == CMDNORMAL) {
 846                         pid_t   pid;
 847
 848                         savelocalvars = localvars;
 849                         localvars = NULL;
 850                         vforked = 1;
 851                         switch (pid = vfork()) {
 852                         case -1:
 853                                 TRACE(("Vfork failed, errno=%d\n", errno));
 854                                 INTON;
 855                                 error("Cannot vfork");
 856                                 break;
 857                         case 0:
 858                                 /* Make sure that exceptions only unwind to
 859                                  * after the vfork(2)
 860                                  */
 861                                 if (setjmp(jmploc.loc)) {
 862                                         if (exception == EXSHELLPROC) {
 863                                                 /* We can't progress with the vfork,
 864                                                  * so, set vforked = 2 so the parent
 865                                                  * knows, and _exit();
 866                                                  */
 867                                                 vforked = 2;
 868                                                 _exit(0);
 869                                         } else {
 870                                                 _exit(exerrno);
 871                                         }
 872                                 }
 873                                 savehandler = handler;
 874                                 handler = &jmploc;
 875                                 listmklocal(varlist.list, VEXPORT | VNOFUNC);
 876                                 forkchild(jp, cmd, mode, vforked);
 877                                 break;
 878                         default:
 879                                 handler = savehandler;  /* restore from vfork(2) */

一見すると、子プロセス側の処理はforkchild()側で完結する(別プロセスがexecされる)ように見えますが、forkchild()は生成したプロセスに対するシグナルハンドリング処理などの便利関数となっていました。なので、今回はforkchild()の中身についてはスキップします。

/usr/src/bin/sh/jobs.c:
 891 void
 892 forkchild(struct job *jp, union node *n, int mode, int vforked)
 893 {
...中略...
 953 }

forkchild()から戻ってきた子プロセス側の処理は、vfork()の戻り値による親・子プロセスの判別を抜け、evalcommand()の916行目以降の処理に入って行きます。

ここからの処理では、シェル関数、組み込みコマンド、通常コマンドのいずれかにより処理が分かれます。今回は通常コマンドなので、1051行目以降の処理となります。

/usr/src/bin/sh/eval.c:
 676 STATIC void
 677 evalcommand(union node *cmd, int flgs, struct backcmd *backcmd)
 678 {
...中略...
 876                                 forkchild(jp, cmd, mode, vforked);
 877                                 break;
...中略...
 916         /* This is the child process if a fork occurred. */
 917         /* Execute the command. */
 918         switch (cmdentry.cmdtype) {
 919         case CMDFUNCTION:
 920 #ifdef DEBUG
 921                 trputs("Shell function:  ");  trargs(argv);
 922 #endif
...中略...
 973 #ifdef DEBUG
 974                 trputs("builtin command:  ");  trargs(argv);
 975 #endif
...中略...
1051         default:
1052 #ifdef DEBUG
1053                 trputs("normal command:  ");  trargs(argv);
1054 #endif
1055                 clearredir(vforked);
1056                 redirect(cmd->ncmd.redirect, vforked ? REDIR_VFORK : 0);
1057                 if (!vforked)
1058                         for (sp = varlist.list ; sp ; sp = sp->next)
1059                                 setvareq(sp->text, VEXPORT|VSTACK);
1060                 envp = environment();
1061                 shellexec(argv, envp, path, cmdentry.u.index, vforked);
1062                 break;
1063         }
1064         goto out;
1065
1066 parent: /* parent process gets here (if we forked) */

通常コマンドの場合はshellexec()が呼ばれ、ここからtryexec()が呼ばれます。

/usr/src/bin/sh/exec.c:
 116 /*
 117  * Exec a program.  Never returns.  If you change this routine, you may
 118  * have to change the find_command routine as well.
 119  */
 120
 121 void
 122 shellexec(char **argv, char **envp, const char *path, int idx, int vforked)
 123 {
 124         char *cmdname;
 125         int e;
 126
 127         if (strchr(argv[0], '/') != NULL) {
 128                 tryexec(argv[0], argv, envp, vforked);
 129                 e = errno;
 130         } else {
 131                 e = ENOENT;
 132                 while ((cmdname = padvance(&path, argv[0])) != NULL) {
 133                         if (--idx < 0 && pathopt == NULL) {
 134                                 tryexec(cmdname, argv, envp, vforked);
 135                                 if (errno != ENOENT && errno != ENOTDIR)
 136                                         e = errno;
 137                         }
 138                         stunalloc(cmdname);
 139                 }
 140         }
 141
 142         /* Map to POSIX errors */
 143         switch (e) {
 144         case EACCES:
 145                 exerrno = 126;
 146                 break;
 147         case ENOENT:
 148                 exerrno = 127;
 149                 break;
 150         default:
 151                 exerrno = 2;
 152                 break;
 153         }
 154         TRACE(("shellexec failed for %s, errno %d, vforked %d, suppressint      %d\n",
 155                 argv[0], e, vforked, suppressint ));
 156         exerror(EXEXEC, "%s: %s", argv[0], errmsg(e, E_EXEC));
 157         /* NOTREACHED */
 158 }

tryexec()内で晴れて(?)execve(2)が呼ばれる運びとなります。

/usr/src/bin/sh/exec.c:
 161 STATIC void
 162 tryexec(char *cmd, char **argv, char **envp, int vforked)
 163 {
...中略...
 169 #ifdef SYSV
 170         do {
 171                 execve(cmd, argv, envp);
 172         } while (errno == EINTR);
 173 #else
 174         execve(cmd, argv, envp);
 175 #endif

まとめ

NetBSD/bin/shでコマンドが実行されるまでの流れをソースコードレベルで追いかけてみました。今回は単純なコマンド実行だけでしたが、パイプやリダイレクトと組み合わせたコマンド実行の場合についても調べてみたいと思います。