shell编程范例之进程的操作
2008-02-21
这一小节写了很久,到现在才写完。本来关注的内容比较多,包括程序开发过程的细节、ELF格式的分析、进程的内存映像等,后来搞得“雪球越滚越大”,甚至 脱离了shell编程关注的内容。所以呢,想了个小办法,“大事化小,小事化了”,把涉及到的内容分成如下几个部分:
1、把VIM打造成源代码编辑器(源代码编辑过程:用VIM编辑代码的一些技巧)
2、GCC编译的背后 第一部分:预处理和编译 第二部分:汇编和链接(编译过程:预处理、编译、汇编、链接)
3、程序执行的那一刹那 (执行过程:当我们从命令行输入一个命令之后)
4、进程的内存映像 (进程加载过程:程序在内存里是个什么样子)
5、动态符号链接的细节(动态链接过程:函数puts/printf的地址在哪里)
6、代码测试、调试与优化小结(程序开发过后:内存溢出了吗?有缓冲区溢出?代码覆盖率如何测试呢?怎么调试汇编代码?有哪些代码优化技巧和方法呢?)
7、为你的可执行文件“减肥”(从"减肥"的角度一层一层剖开ELF文件)
8、进程和进程的基本操作(本小节)
呵呵,好多。终于可以一部分一部分地完成了,不会再有一种对着一个大蛋糕却不知道如何下口的尴尬了。
进程作为程序真正发挥作用时的“形态”,我们有必要对它的一些相关操作非常熟悉,这一节主要描述进程相关的概念和操作,将介绍包括程序、进程、作业等基本概念以及进程状态查询、进程通信等相关的基本操作等。
1、什么是程序,什么又是进程
程序是指令的集合,而进程则是程序执行的基本单元。为了让程序完成它的工作,我们必须让程序运行起来成为进程,进而利用处理器资源,内存资源,进行各种I/O操作,从而完成某项指定的工作。
在这个意思上说,程序是静态的,而进程则是动态的。
而进程有区别于程序的地方还有,进程除了包含程序文件中的指令数据意外,还需要在内核中有一个数据结构用以存放特定进程的相关属性,以便内核更好的管理和调度进程,从而完成多进程协作的任务。因此,从这个意义上可以说“高于”程序,超出了程序指令本身。
如果进行过多进程程序的开发,你又会发现,一个程序可能创建多个进程,通过多个进程的交互完成任务。在Linux下,多进程的创建通常是通过fork系统调用实现的。从这个意义上来说程序则”包含”了进程。
另外一个需要明确的是,程序可以由多种不同的程序语言描述,包括C语言程序、汇编语言程序和最后编译产生的机器指令等。
下面我们简单讨论一下Linux下面如何通过shell进行进程的相关操作。
2、进程的创建
通常在命令行键入某个程序文件名以后,一个进程就被创建了。例如,
Quote: |
$ sleep 100 & #让sleep程序在后台运行 |
当一个程序被执行以后,程序被加载到内存中,成为了一个进程。上面显示了该进程的内存映像(虚拟内存),包括程序指令、数据,以及一些用于存放程命令行参数、环境变量的栈空间,用于动态内存申请的堆空间都被分配好了。
关于程序在命令行执行过程的细节,请参考《Linux命令行下程序执行的那一刹那》。
实际上,创建一个进程,也就是说让程序运行,还有其他的办法,比如,通过一些配置让系统启动时自动启动我们的程序(具体参考"man init"),或者是通过配置crond(或者at)让它定时启动我们的程序。除此之外,还有一个方式,那就是编写shell脚本,把程序写入一个脚本文 件,当执行脚本文件时,文件中的程序将被执行而成为进程。这些方式的细节就不介绍了,下面介绍如何查看进程的属性。
需要补充一点的是,在命令行下执行程序时,我们可以通过ulimit内置命令来设置进程可以利用的资源,比如进程可以打开的最大文件描述符个数,最大的栈空间,虚拟内存空间等。具体用法见"help ulimit"。
3、查看进程的属性和状态
我们可以通过ps命令查看进程的相关属性和状态,这些信息包括进程所属用户,进程对应的程序,进程对cpu和内存的使用情况等信息。熟悉如何查看它们有助于我们进行相关的统计分析和进一步的操作。
Quote: |
$ ps -ef #查看系统所有当前进程的属性 |
由于系统所有进程之间都有“亲缘”关系,所以可以通过pstree查看这种关系,
Quote: |
$ pstree #打印系统进程调用树,可以非常清楚地看到当前系统中所有活动进程之间的调用关系 |
动态查看进程信息,
Quote: |
$ top |
该命令最大的特点是可以动态的查看进程的信息,当然,它还提供了一些有用的参数,比如-S可以按照累计执行时间的大小排序查看,也可以通过-u查看指定用户启动的进程等。
感觉有上面几个命令来查看进程的信息就差不多了,下面来讨论一个有趣的问题:如何让一个程序在同一时间只有一个在运行。
这意味着当一个程序正在被执行时,它将不能再被启动。那该怎么做呢?
假如一份相同的程序被复制成了很多份,并且具有不同的文件名被放在不同的位置,这个将比较糟糕,所以我们考虑最简单的情况,那就是这份程序在整个系统上是唯一的,而且名字也是唯一的。这样的话,我们有哪些办法来回答上面的问题呢?
总的机理是:在这个程序的开头检查自己有没有执行,如果执行了则停止否则继续执行后续代码。
策略则是多样的,由于前面的假设已经保证程序文件名和代码的唯一性,所以
Code:
ps -e -o "%c" | tr -d " " | grep -q ^init$ #查看当前程序是否执行 [ $? -eq 0 ] && exit #如果在,那么退出, $?表示上一条指令是否执行成功
[Ctrl+A Select All]
Code:
pidfile=/tmp/$0".pid" if [ -f $pidfile ]; then OLDPID=$(cat $pidfile) ps -e -o "%p" | tr -d " " | grep -q "^$OLDPID$" [ $? -eq 0 ] && exit fi echo $$ > $pidfile #... 代码主题 trap "rm $pidfile" 0 #设置信号0的动作,当程序退出时触发该信号从而删除掉临时文件
[Ctrl+A Select All]
更多实现策略自己尽情的发挥吧!
4、调整进程的优先级
在保证每个进程都能够顺利执行外,为了让某些任务优先完成,那么系统在进行进程调度时就会采用一定的调度办法,比如常见的有按照优先级的时间片轮转的调度算法。这种情况下,我们可以通过renice调整正在运行的程序的优先级,例如,
Quote: |
$ ps -e -o "%p %c %n" | grep xfs #打印的信息分别是进程ID,进程对应的程序名,优先级 |
5、结束进程
既然可以通过命令行执行程序,创建进程,那么也有办法结束它。我们可以通过kill命令给用户自己启动的进程发送一定信号让进程终止,当然“万能”的root几乎可以kill所有进程(除了init之外)。例如,
Quote: |
$ sleep 50 & #启动一个进程 |
kill命令默认会发送终止信号(SIGTERM)给程序,让程序退出,但是kill还可以发送其他的信号,这些信号的定义我们可以通过man 7 signal查看到,也可以通过kill -l列出来。
Quote: |
$ man 7 signal |
例如,我们用kill命令发送SIGSTOP信号给某个程序,让它暂停,然后发送SIGCONT信号让它继续运行。
Quote: |
$ sleep 50 & |
可见kill命令为我们提供了非常好的功能,不过kill命令只能根据进程的ID或者作业来控制进程,所以pkill和killall给我们提供了更多选择,它们扩展了通过程序名甚至是进程的用户名来控制进程的方法。更多用法请参考它们的手册。
当一个程序退出以后,如何判断这个程序是正常退出还是异常退出呢?还记得Linux下,那个经典"hello,world"程序吗?在代码的最后总是有条 “return 0”语句。这个“return 0”实际上是为了让程序员来检查进程是否正常退出的。如果进程返回了一个其他的数值,那么我们可以肯定的说这个进程异常退出了,因为它都没有执行到 “return 0”这条语句就退出了。
那怎么检查进程退出的状态,即那个返回的数值呢?
在shell程序中,我们可以检查这个特殊的变量$?,它存放了上一条命令执行后的退出状态。
Quote: |
$ test1 |
貌似返回0成为了一个潜规则,虽然没有标准明确规定,不过当程序正常返回时,我们总是可以从$?中检测到0,但是异常时,我们总是检测到一个非0的值。这 就告诉我们在程序的最后我们最好是跟上一个exit 0以便任何人都可以通过检测$?确定你的程序是否正常结束。如果有一天,有人偶尔用到你的程序,试图检查你的程序的退出状态,而你却在程序的末尾莫名的返 回了一个-1或者1,那么他将会很苦恼,会怀疑自己的程序哪个地方出了问题,检查半天却不知所措,因为他太信任你了,竟然从头至尾都没有怀疑你的编程习惯 可能会与众不同。
6、进程通信
为了便于设计和实现,通常一个大型的任务都被划分成较小的模块。不同模块之间启动后成为进程,它们之间如何通信以便交互数据,协同工作呢?在《UNIX环 境高级编程》一书中提到很多方法,诸如管道(无名管道和有名管道)、信号(signal)、报文(Message)队列(消息队列)、共享内存 (mmap/munmap)、信号量(semaphore,主要是同步用,进程之间,进程的不同线程之间)、套接口(Socket,支持不同机器之间的进 程通信)等,而在shell编程里头,我们通常直接用到的就有管道和信号等。下面主要介绍管道和信号机制在shell编程时候的一些用法。
在Linux下,你可以通过"|"连接两个程序,这样就可以用它来连接后一个程序的输入和前一个程序的输出,因此被形象地叫做个管道。在C语言里头,创建 无名管道非常简单方便,用pipe函数,传入一个具有两个元素的int型的数组就可以。这个数组实际上保存的是两个文件描述符,父进程往第一个文件描述符 里头写入东西后,子进程可以从第一个文件描述符中读出来。
如果用多了命令行,这个管子"|"应该会经常用。比如我们在上面的演示中把ps命令的输出作为grep命令的输入,从而可以过滤掉一些我们感兴趣的信息:
Quote: |
$ ps -ef | grep init |
也许你会觉得这个“管子”好有魔法,竟然真地能够链接两个程序的输入和输出,它们到底是怎么实现的呢?实际上当我们输入这样一组命令的时候,当前解释程序 会进行适当的解析,把前面一个进程的输出关联到管道的输出文件描述符,把后面一个进程的输入关联到管道的输入文件描述符,这个关联过程通过输入输出重定向 函数dup(或者fcntl)来实现。
有名管道实际上是一个文件(无名管道也像一个文件,虽然关系到两个文件描述符,不过只能一边读另外一边写),不过这个文件比较特别,操作时要满足先进先 出,而且,如果试图读一个没有内容的有名管道,那么就会被阻塞,同样地,如果试图往一个有名管道里头写东西,而当前没有程序试图读它,也会被阻塞。下面看 看效果。
Quote: |
$ mkfifo fifo_test #通过mkfifo命令可以创建一个有名管道 |
在这里echo和cat是两个不同的程序,在这种情况下,通过echo和cat启动的两个进程之间并没有父子关系。不过它们依然可以通过有名管道通信。这 样一种通信方式非常适合某些情况:例如有这样一个架构,这个架构由两个应用程序构成,其中一个通过一个循环不断读取fifo_test中的内容,以便判 断,它下一步要做什么。如果这个管道没有内容,那么它就会被阻塞在那里,而不会死循环而耗费资源,另外一个则作为一个控制程序不断地往fifo_test 中写入一些控制信息,以便告诉之前的那个程序该做什么。下面写一个非常简单的例子。我们可以设计一些控制码,然控制程序不断的往fifo_test里头写 入,然后应用程序根据这些控制码完成不同的动作。当然,也可以往fifo_test传入除控制码外的不同的数据。
Quote: |
$ cat app.sh #应用程序的代码 |
这样一种应用架构非常适合本地的多程序任务的设计,如果结合web cgi,那么也将适合远程控制的要求。引入web cgi的唯一改变是,要把控制程序./control.sh放到web的cgi目录下,并对它作一些修改,以使它符合CGI的规范,这些规范包括文档输出 格式的表示(在文件开头需要输出content-tpye: text/html以及一个空白行)和输入参数的获取(web输入参数都存放在QUERY_STRING环境变量里头)。因此一个非常简单的CGI形式控 制程序将类似下面。
Code:
#!/bin/bash FIFO=./fifo_test CI=$QUERY_STRING [ -z "$CI" ] && echo "the control info should not be empty" && exit echo -e "content-type: text/html\n\n" echo $CI > $FIFO
[Ctrl+A Select All]
在实际使用的时候,请确保control.sh能够访问到fifo_test管道,并且有写权限。这样我们在浏览器上就可以这样控制app.sh了。
http://ipaddress_or_dns/cgi-bin/control.sh?0
问号(?)后面的内容即QUERY_STRING,类似之前的$1。
这样一种应用对于远程控制,特别是嵌入式系统的远程控制很有实际意义。在去年的暑期课程上,我们就通过这样一种方式来实现马达的远程控制。首先,我们实现 了一个简单的应用程序以便控制马达的转动,包括转速,方向等的控制。为了实现远程控制,我们设计了一些控制码,以便控制马达转动相关的不同属性。
在C语言中,如果要用有名管道,和shell下的类似,只不过在读写数据的时候用read,write调用,在创建fifo的时候用mkfifo函数调用。
信号是软件中断,在Linux下面用户可以通过kill命令给某个进程发送一个特定的信号,也可以通过键盘发送一些信号,比如CTRL+C可能触发 CGIINT信号,而CTRL+\可能触发SGIQUIT信号等,除此之外,内核在某些情况下也会给进程发送信号,比如在访问内存越界时产生 SGISEGV信号,当然,进程本身也可以通过kill,raise等函数给自己发送信号。对于Linux下支持的信号类型,大家可以通过"man 7 signal"或者"kill -l"查看到相关列表和说明。
对于有些信号,进程会有默认的响应动作,而有些信号,进程可能直接会忽略,当然,用户还可以对某些信号设定专门的处理函数。在shell程序中,我们可以 通过trap命令(shell的内置命令)来设定响应某个信号的动作(某个命令或者是你定义的某个函数),而在C语言里头可以通过signal调用注册某 个信号的处理函数。这里仅仅演示trap命令的用法。
Quote: |
$ function signal_handler { #定一个signal_handler的函数,>是按下换行符号自动出现的 |
类似地,如果设定信号0的响应动作,那么就可以用trap来模拟C语言程序中的atexit程序终止函数的登记,即通过trap signal_handler SIGQUIT设定的signal_handler函数将在程序退出的时候被执行。信号0是一个特别的信号,在POSIX.1中把信号编号0定义为空信 号,这将常被用来确定一个特定进程是否仍旧存在。当一个程序退出时会触发该信号。
Quote: |
$ cat sigexit.sh |
7、作业和作业控制
当我们为完成一些复杂的任务而将多个命令通过|,>,<, ;, (, )等组合在一起的时候,通常这样个命令序列会启动多个进程,它们之间通过管道等进行通信。而有些时候,我们在执行一个任务的同时,还有其他的任务需要处 理,那么就经常会在命令序列的最后加上一个&,或者在执行命令以后,按下CTRL+Z让前一个命令暂停。以便做其他的任务。等做完其他一些任务以 后,再通过fg命令把后台的任务切换到前台。这样一种控制过程通常被成为作业控制,而那些命令序列则被成为作业,这个作业可能涉及一个或者多个程序,一个 或者多个进程。下面演示一下几个常用的作业控制操作。
Quote: |
$ sleep 50 & #让程序在后台运行,将打印[作业号]和进程号 |
不过,要在命令行下使用作业控制,需要当前shell,内核终端驱动等对作业控制支持才行。
参考资料
<UNIX环境高级编程>相关章节