小心bash的管道

standard管道是Shell中非常常用的东西,*nix的神奇,一大部分要归功于各式各样全能的小工具和管道。

简而言之,管道就是把若干个程序连接起来,一个管道符号的前一个程序的输出作为后一个程序的输入,比如,统计hello.c里面有多少行带有双斜杠可以这样写:

cat hello.c | grep '//' | wc -l

这篇日志不是普及管道的,而是因为今天自己写Shell脚本时遇到了一个问题,记在这里作为提醒。

Shell中可以打开另外一个Shell,新打开的Shell就是Subshell,对于这个Subshell,打开它的Shell叫做Parent Shell。这两个Shell的最大区别在于环境变量,Parent Shell中所有被export过的环境变量会被Subshell继承,而Subshell对环境变量做的任何修改都不会影响Parent Shell中的环境变量。

在圆括号括起来的命令会在Subshell中执行:

export a=1; ( echo 'Sub 1: '$a; a=2; echo 'Sub 2: '$a; unset a; ); echo 'Parent: '$a

会得到这样的结果

Sub 1: 1
Sub 2: 2
Parent: 1

今天,我写了一个脚本对一个ZOJ比赛的Runs页面进行不停地监视,一旦有新的状态产生,就用libnotify把它显示出来,这个脚本大致是这样的:

#!/bin/bash
 
c=0
while true; do
	wget --load-cookies cookies.txt "http://xxxxxx" -qO a.html
	cat a.html | grep 'runId">[0-9]' -A  13 | sed 's/<[^>]*>//g;s/  //g' | uniq | while read i; do
		[ -n "$i" ] && i=`printf '%s' "$i" | tr -d '\r'`
		if [ "$i" = '--' ]; then
			c=0
		elif [ -n "$i" ]; then
			(( c++ ))
			case "$c" in
			('1')
				id=$i;;
			('2')
				time="$i";;
			('3')
				result="$i";;
			('4')
				prob="$i";;
			('5')
				lang="$i";;
			('6')
				during="$i";;
			('7')
				mem="$i";;
			('8')
				user="$i";
				if [ -z "${a[$id]}" ] && echo $result | grep -v 'ing' &>/dev/null; then
					a[$id]=1
					notify-send "$prob - $user" "$result, ${during}ms ${mem}KB"
				fi
				;;
			esac
		fi
	done
	sleep 10;
done

这个脚本运行起来会反复地显示获得的内容,也就是说if [ -z “${a[$id]}” ]检测被无视了。

为什么会这样子呢?经过调试,我发现bash遇到管道会创建一个Subshell,而zsh不会

考虑一个简单的脚本,统计输入的行数:

c=0
while read; do
	(( c++ ));
done
echo 'Line count: '$c

这是可以工作的,稍微修改一下:

c=0
cat | while read; do
	(( c++ ));
done
echo 'Line count: '$c

在bash下就不能正常工作了,其中的c++会在Subshell中执行,导致最终结果是0,如果用zsh执行这段脚本仍然可以得到期待的结果。

那如何解决这个问题呢?把管道拆成两个重定向就可以啦 :-)

对于上面这段

cat | while read; do
	(( c++ ));
done

的写法,可以改成:

cat > tempfile
while read; do
	(( c++ ));
done < tempfile

第一个脚本也可以类似地修改。

虽然直接使用zsh可以解决问题,但是由于zsh并不是每个地方都有的,而zsh也不是便于携带的,安装起来需要管理员权限往往自己没有,在这种情况下,对于类似这样的“有歧义”的写法,还是使用比较有“兼容性”的做法比较好。

当然,一味地追求“兼容性”也不是好事情。比如目前的Ubuntu/Debian发行版中,/bin/sh是链接到dash的。dash是一个极其轻量的Shell,一般zsh或者bash需要占用1到3MB的内存,而dash往往只需要几十KB的内存就可以工作了,没有自动补全,适合脚本使用。但是dash有严重问题,即便用它执行很简单的脚本。比如对于中文文件名,dash可能会在参数传递时出现编码问题,一个具体表现是使用for filename in *遍历时,把$filename当作参数传入cp这样的程序后,会说文件找不到 -.-b。

不追求“兼容性”,也不要迷信某一个程序会很“标准”,或者周围的人都在按照“标准”做事情。Ubuntu的Wiki上面说第一行是#!/bin/sh的脚本都应该兼容dash,这是个符合POSIX标准的Shell,但是实际情况绝对不是这样的。我使用Archlinux,把/bin/sh改成dash后,升级系统就发现系统无法启动了,折腾了好长时间才发现是这个原因导致的,Archlinux的kernel软件包的安装后执行脚本不兼容dash。

《三国演义》中第一句话就是“话说天下大势,分久必合,合久必分。”,期待一下zsh把Shell天下“合”的那一天 :-P

6 thoughts on “小心bash的管道

  1. Bash 4 出来大有抢 zsh 用户之势,毕竟是默认 shell ,不过 Bash 4 能否有现在的 bash 这种优势还不一定,毕竟是很底层的东西,不能随便升级,Ubuntu 现在都还不肯把 Python 升级到 2.6 ,升级到不兼容性超级好的 Python 3 就更不用说了。

  2. @pluskid: 经你这么一说我才发现有Bash 4,再一看,我现在用的已经是GNU bash, version 4.0.24(1)-release (i686-pc-linux-gnu)了。这样看来,对Bash的印象很不好 -.-|

  3. dash那个东西bug也不少吧。。。
    还是最原始的那个posix shell最王道。。。

    solaris一直是posix shell作为默认的。。。

  4. Fantastic piece of writing, this is very similar to a site that I have. Please check it out sometime and feel free to leave me a comenet on it and tell me what you think. Im always looking for feedback.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>