「技評×オングスこんなシェルスクリプトは書いちゃダメだ!」に参加してきました
「技評×オングスこんなシェルスクリプトは書いちゃダメだ!」に参加してきました
技評×オングスこんなシェルスクリプトは書いちゃダメだ!に参加してきました。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アダプティブデザインなど、最新の技術が活用されていることや、バックエンド側は長くても数百行程度のシェルスクリプトになっていること等が挙げられていました。
上記のパターンと逆の状態になっているケースが、いわゆる失敗するパターンとして説明されていました。
スキルアップのための書籍やサイト
これからシェルスクリプトやシェルによる開発を始めてみようという人向けに、以下の情報源が提示されていました。
- USP MAGAZINE
- UEC unicage engineers' community site
次回の勉強会について
今回の勉強会は満員ということもあり、次回の開催も決定しています!とのことです。
また、FreeBSD勉強会が今月中頃に開催されます。
- 第32回 FreeBSD勉強会 /etc/の下の設定ファイルを知ろう!
参加者からの質問
参加者から出た質問については、シェルスクリプトのデバッグ手法に関する内容が多かった気がします。また、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()
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でコマンドが実行されるまでの流れをソースコードレベルで追いかけてみました。今回は単純なコマンド実行だけでしたが、パイプやリダイレクトと組み合わせたコマンド実行の場合についても調べてみたいと思います。