在自动部署博客脚本中,我提到了用nohup
让程序在后台运行(nohup的原理是使进程不对SIGHUP信号进行处理),并用ps
命令查找”auto-blog-submit.sh”的进程号,用kill
干掉进程。但这种做法其实存在问题,杀掉”auto-blog-submit.sh”后用ps
仍然可以找到”fswatch”的进程,于是就有了这篇文章。
现象
为了便于研究测试,将原脚本简化如下:
#!/bin/sh
nohup fswatch -o ./test | while read
do
echo "file changed."
done &
当上述脚本运行结束后,ps -Ao pid,pgid,ppid,command
得到由该脚本启动的进程有两个:
PID PGID PPID COMMAND
24719 24716 1 fswatch -o ./test
24720 24716 1 sh ./test.sh
这两个进程是怎么来的,如何优雅地杀掉这些进程?就是接下来的内容。
什么是进程 - Process
首先要有进程的概念,进程是程序的具体实现,即执行程序的过程。程序与进程的关系和面向对象语言中的类与实例的关系类似。同个类可以实例化多次;而同个程序也可以执行多次,每次都可以在内存中开辟独立的空间来装载,从而产生多个进程。
每一个进程都有一个唯一的PID来代表自己的身份,进程也可以根据PID来识别其他的进程。
进程的创建 - fork&exec
当计算机启动时,内核(kernel)只建立了一个init进程。Linux内核并不提供直接建立新进程的系统调用,剩下的所有进程都是init进程通过fork机制建立的。当进程fork的时候,Linux在内存中开辟出一片新的内存空间给新的进程,并将老的进程空间中的内容复制到新的空间中,此后两个进程同时运行。需要注意的是,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别(Copy-on-write)。下面这段代码演示了使用fork的基本框架:
int main(void) {
if(fork() == 0) {
// 子进程
} else {
// 父进程
}
}
通常fork与exec函数簇结合使用来实现一个进程启动另一个程序的执行。exec函数的作用是”启动参数指定的程序,代替自身进程”,如果不配合fork使用,它是”当前进程结束,执行指定进程”;配合fork使用,就成了”当前进程启动另一个进程”。一个进程一旦调用exec类函数,它本身就”死亡”了,系统把代码段替换成新的程序的代码,并为新程序分配新的数据段与堆栈段,唯有进程号不变,对系统而言虽然还是同一个进程,但已经是另一个程序了。下面以exec函数簇中的execlp为例(该程序从终端读入命令并执行,执行完成后父进程继续等待从终端读入命令):
#include <sys/types.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char command[256];
int main(void) {
int rtn; // 子进程的返回值
while(1) {
// 从终端读取要执行的命令
fgets(command, 256, stdin);
command[strlen(command)-1] = '\0';
if(fork() == 0) {
// 子进程执行此命令
execlp(command, NULL);
perror(command); // 如果exec函数返回,则表明命令没有正常执行,打印错误信息
exit(errno);
} else {
// 父进程等待子进程结束,并打印子进程的返回值
wait(&rtn);
printf("child process return %d\n", rtn);
}
}
}
当一个进程通过fork建立一个新的进程,老进程作为新进程的父进程(parent process),相应的新进程作为老进程的子进程(child process)。一个进程除了有一个PID之外,还会有一个PPID(parent PID)来存储的父进程PID。如果我们循着PPID不断向上追溯的话,总会发现其源头是init进程。所以说,所有的进程也构成一个以init为根的树状结构。
进程组与会话 - Process Group&Session
每个进程都会属于一个进程组(process group),每个进程组中可以包含多个进程。进程组的第一个进程即进程组领导进程 (process group leader),领导进程的PID成为进程组ID (process group ID, PGID),以识别进程组。
多个进程组还可以构成一个会话 (session),会话通常由登录过程设置。同样,会话的第一个进程即会话领导进程(session leader),会话领导进程的PID成为识别会话的SID(session ID)。会话中的每个进程组称为一个工作(job)。会话可以有一个进程组成为会话的前台工作(foreground),而其他的进程组是后台工作(background)。每个会话可以连接一个控制终端(control terminal)。当控制终端有输入输出时,都传递给该会话的前台进程组。由终端产生的信号,比如CTRL+Z, CTRL+\,会传递到前台进程组。
会话的意义在于将多个工作囊括在一个终端,并取其中的一个工作作为前台,来直接接收该终端的输入输出以及终端信号。 其他工作在后台运行。
下面这张图说明在一个终端执行下列命令后,各个进程之间的进程组和会话关系:
$ echo $$ # Display the PID of the shell
400
$ find / 2 > /dev/null | wc -l & # Creates 2 processes in background group
[1] 659
$ sort < longlist | uniq -c # Creates 2 processes in foreground group
find, wc, sort, uniq都是bash的子进程,当子进程的父进程终结时,子进程的PPID会发生改变(变为已终结的父进程的PPID)。进程组领导进程的PID即进程组PGID,进程组领导进程可以先终结,此时进程组依然存在,并且PGID不发生改变。会话领导进程的PID即会话SID,当会话领导进程终结时,会向前台进程组中的所有进程发送SIGHUP信号。
之前的脚本在运行中发生了什么
回到最开始的问题,如何优雅地杀掉脚本中的进程。
如果用pstree
命令来显示脚本运行过程中的进程树,将得到如下结构:
...─┬─...
└─sh test.sh─┬─fswatch -o ./test
└─sh test.sh
他们的PID,PGID与PPID如下:
PID PGID PPID COMMAND
5068 5068 599 sh test.sh
5071 5068 5068 fswatch -o ./test
5072 5068 5068 sh test.sh
这里PID=5068的sh test.sh
进程作为父进程新创建了PID=5072的子进程sh test.sh
,这里新进程其实是脚本中的do...while
部分。可以看到这些进程同属于同一个进程组,PGID为该进程组的第一个进程的PID即5068。
当脚本执行结束,PID=5068的父进程终结,进程组依然存在,且PGID不发生改变。两个子进程PPID发生变更:
...─┬─fswatch -o ./test
└─sh test.sh
PID PGID PPID COMMAND
5071 5068 1 fswatch -o ./test
5072 5068 1 sh test.sh
因此,我们可以根据这些进程在同一个进程组这个特点,一次性将进程组内所有进程kill掉,就达成了目标。
修改后的自动部署博客脚本
修改后的脚本在原来弹窗的基础上添加了”Kill Processes”按钮,以便遇到异常直接杀掉进程。同时为了代码复用,我将几个比较通用的函数分离出来,构建一个自己的Helper库。
auto-blog-submit.sh
#!/bin/sh
. ~/Helper/Bash/plog.sh # log function
. ~/Helper/Bash/showDialog.sh # show dialog function
BLOG_PATH=/Users/barriery/Blog/
LOG_PATH=${BLOG_PATH}/auto-blog-submit.log
ARTICLE_PATH=${BLOG_PATH}/source/_posts
cd ${BLOG_PATH} # Otherwise the script will run in the home path
if [ ! -f ${LOG_PATH} ]; then
touch ${LOG_PATH}
fi
PGID=$$ # 当前进程组ID
plog -t -l OK -c green -m "Current group process ID is \033[33m${PGID}\033[0m" >> ${LOG_PATH}
nohup fswatch -o ${ARTICLE_PATH} -l 900 | while read
do
plog -t -m "Start deploying..." >> ${LOG_PATH}
hexo g -d >/dev/null 2>> ${LOG_PATH}
if [ $? -ne 0 ]; then
plog -t -l ERROR -c red -m "Hexo deploy failed." >> ${LOG_PATH}
say "自动部署博客脚本出现异常"
button_returned=$(showDialog -t "auto-blog-submit.sh" -m "[ERROR] Hexo deploy failed" -b "View Log,Kill Process,Cancel" -d ",")
plog -t -l INFO -c yellow -m "You choose \033[33m${button_returned}\033[0m" >> ${LOG_PATH}
# ${button_returned} 加引号防止变量中空格造成影响
if [ "${button_returned}" = "Cancel" ]; then
: # Do nothing beyond expanding arguments and performing redirections. The return status is zero.
elif [ "${button_returned}" = "View Log" ]; then
# 新建一个iTerm窗口 并执行命令
osascript ~/Helper/AppleScript/newTerminalAndRunCMD.scpt "cd ${BLOG_PATH} && tail ${LOG_PATH}"
elif [ "${button_returned}" = "Kill Process" ]; then
pkill -g ${PGID}
if [ $? -ne 0 ]; then
plog -t -l ERROR -c red -m "pkill -g ${PGID} is failed." >> ${LOG_PATH}
showDialog -t "pkill" -m "[ERROR] kill process failed." -b "OK"
else
plog -t -l OK -c green -m "pkill -g ${PGID} success." >> ${LOG_PATH}
exit 0
fi
fi
fi
done &
plog.sh
#!/bin/sh
function plog(){
# getopts: https://wiki.bash-hackers.org/howto/getopts_tutorial
usage(){
echo "Usage: plog [-t] [-l level] [-c level_color] [-n level_number] -m message"
}
# something must to be init
OPTIND=1 # The index of params
timestamp=""
level=""
level_color=""
level_number="" # log will not echo if level_number < PLOG_LEVEL
message=""
while getopts "tn:l:c:m:" OPT; do
case "$OPT" in
t)
timestamp=[$(date "+%Y-%m-%d %H:%M:%S")];;
l)
level=[${OPTARG}];;
c)
level_color=${OPTARG};;
n)
level_number=${OPTARG};;
m)
message=${OPTARG};;
?)
usage && return 1;;
:)
usage && return 1;;
esac
done
# []是shell内部命令 [[]]是shell关键字
# []中一些逻辑符号会被shell解释(比如>解释为重定向符号)但关键字不这样
if [[ -z "$message" ]]; then
usage && return 1
fi
if [[ -n "$level" && -n ${level_color} ]]; then
case ${level_color} in
black|k|黑|30)
level="\033[30m${level}\033[0m";;
red|r|红|31)
level="\033[31m${level}\033[0m";;
green|g|绿|32)
level="\033[32m${level}\033[0m";;
yellow|y|黄|33)
level="\033[33m${level}\033[0m";;
blue|b|蓝|34)
level="\033[34m${level}\033[0m";;
purple|p|紫|35)
level="\033[35m${level}\033[0m";;
cyan|c|青|36)
level="\033[36m${level}\033[0m";;
white|w|白|37)
level="\033[37m${level}\033[0m";;
*)
plog -l ERROR -c red -m "Unknown param color ${level_color}"
return 1;;
esac
fi
str_list=("${level}" "${timestamp}")
# 字符串数组中若串中带空格 建议用下标取值
for i in ${!str_list[@]}; do
if [[ -n "${str_list[$i]}" ]]; then
message="${str_list[$i]} ${message}"
fi
done
if [[ -z ${level_number} ]]; then
level_number=${PLOG_LEVEL}
fi
if [[ ${level_number} -lt ${PLOG_LEVEL} ]]; then
return 0
else
echo "$message"
fi
}
showDialog.sh
#!/bin/sh
function showDialog(){
usage() {
echo "Usage: showDialog -m message -b button[, button]* [-d delimiters] [-t title]"
}
# something must to be init
OPTIND=1 # The index of params
message=""
button=""
delimiters=" " # the defalut delimiters is " "
title=""
while getopts "m:b:d:t:" OPT; do
case "$OPT" in
m)
message=${OPTARG};;
b)
buttons=${OPTARG};;
d)
delimiters=${OPTARG};;
t)
title=${OPTARG};;
?)
usage && return 1;;
:)
usage && return 1;;
esac
done
required_param_list=("m" "b")
required_value_list=("$message" "$buttons")
for i in ${!required_value_list[@]}; do
if [ -z "${required_value_list[$i]}" ]; then
echo "[ERROR] Missing parameter ${required_param_list[$i]}"
return 1
fi
done
osascript <<-EndOfScript
set AppleScript's text item delimiters to "$delimiters"
set btn_list to every text item of "$buttons"
display dialog "$message" buttons btn_list with title "$title"
get the button returned of the result
EndOfScript
}
newTerminalAndRunCMD.scpt
#!/usr/bin/osascript
on run {command}
if application "iTerm" is running then
tell application "iTerm"
create window with default profile
tell current session of current window
write text command
end tell
end tell
else
activate application "iTerm"
end if
end run
参考
[1] Andries Brouwer.The Linux kernel: Process[EB/OL].https://www.win.tue.nl/~aeb/linux/lk/lk-10.html, 2003-02-01.
[2] 老邮局.Linux下Fork与Exec使用[EB/OL].https://www.cnblogs.com/hicjiajia/archive/2011/01/20/1940154.html, 2011-01-20.
[3] invalid s.为什么Linux下要把创建进程分为fork()和exec()(一系列函数)两个函数来处理? - invalid s的回答 - 知乎[EB/OL].https://www.zhihu.com/question/66902460/answer/247277668, 2017-10-21.
[4] Michael Kerrisk.The Linux Programming Interface國際中文版[DB/OL].http://epaper.gotop.com.tw/PDFSample/AXP015900.pdf, 2016-10.
About
Unix与Linux还是有较大的区别,该考虑使用docker了。