原文地址,转载请注明出处: https://blog.youkuaiyun.com/qq_34021712/article/details/115587702 ©王赛超
目录
背景介绍
使用Go语言写的一个推送程序,最终打包成可执行的二进制文件上传到服务器,使用以下命令启动服务。
nohup ./MISS.GO.PayNotifyService >/dev/null &
MISS.GO.PayNotifyService
是打包之后的二进制名称,但是第二天来了发现服务已经停止了。看到这个结果的第一想法就是宕机了, 先查看磁盘容量,内存使用情况,发现服务器一切正常,并非资源问题。之前无论是java程序还是其他的go服务都是使用nohup启动的,完全没问题啊,为什么会宕机呢?
难道是触发了panic恐慌导致程序宕机?
我们知道,go语言如果触发了恐慌panic
没有recover
进程会直接挂掉,通过查看日志最后的几行内容:
get signal hangup, application will shutdown.
Graceful shutdown --- Destroy all registries.
Exporter unexport.
Destroy invoker: dubbo://:28555/......
Exporter unexport.
Destroy invoker: dubbo://:28555/......
(ZkProviderRegistry)reconnectZkRegistry goroutine exit now...
zkClient Conn{name:zk registry, zk addr:102523761507391298} exit now.
get signal hangup
Selector.watch() = error{listener have been closed}
client.done(), listen(path{......}) goroutine exit now...
看上面的日志 get signal hangup, application will shutdown
是dubbo go
源代码,接受 hangup
之后 各种销毁动作。get signal hangup
是程序收到了hangup
信号,我自己打印的日志,通过以上内容可以确认是程序收到了hangup
信号终止了程序。
原因我们已经知道了,就是因为程序接收到了SIGHUP
信号,然后经过一系列收尾动作,最终结束进程。
问题复现
第一种: 使用nohup启动,然后直接点击x号关闭SecureCRT。
第一种方式最简单,可以立刻看到结果, 直接点击x号之后,再重新开一个终端进入服务器,查看进程已经结束,并且打印以上日志。
第二种: 使用nohup启动,然后等SecureCRT终端超时。
我自己是因为这种情况,因为开发环境联调,同事需要调用我的服务,我就是使用SecureCRT登录,然后使用nohup启动服务就不管了,第二天过来发现服务已经停止。
这种情况复现比较麻烦,我直接告诉大家步骤和结果:
- 使用SecureCRT登录服务器,然后使用nohup启动, 在终端输入tty命令回车,查看当前伪终端为: pts/0。
- 等待SecureCRT超时,然后重新进入服务器,查看进程.进程存在。查看当前伪终端为: pts/1。
- 第二步再次进入的时候,伪终端为: pts/1 第一步使用的伪终端为: pts/0。
- 使用 w 命令查看当前活跃用户, 有 pts/0 和 pts/1 两个,但是 pts/0目前已经类似于游离状态,没人使用。
- 使用 w 命令查看当前活跃用户, 等 pts/0 从活跃用户列表消失,再次查看进程,进程结束.
(等pts/0超时大概等了2个半小时)
结论: 使用nohup
启动,等SecureCRT
超时,但是启动的伪终端还在,并且处于在线状态,大概过2个半小时,伪终端进程被结束,然后启动的服务进程也结束。
因为测试的时候就测试那么几分钟,可能一天也就测试一次。等SecureCRT
超时,再到伪终端最终被结束,有几个小时的时间,所以每次都是第二天再测试的时候发现进程结束了。
第三种: 使用nohup启动,然后在终端执行 kill -1 发送SIGHUP信号给服务。
服务还是会收到了SIGHUP
信号,然后服务停止,并且打印了以上的日志。
通过以上3种
方式,可以复现我们nohup
启动之后,然后进程收到SIGHUP
信号,最终进程停止的原因。那么问题来了。
产生的问题
1.信号是谁发送的?
2.nohup是忽略SIGHUP信号的,为什么还能收到?nohup为什么不起作用?
先来了解一下nohup
nohup介绍
nohup
,顾名思义,就是使得运行的命令可以忽略SIGHUP
信号。因此,即使突然断网或者关闭终端,该后台任务依然可以继续执行。这里需要指明的是,nohup
并不会自动将任务运行在后台,我们需要在命令行末尾加上&
来显示的指明。
一般我们的使用方法如下:
# 启动一个jar包
nohup java -jar XXX.jar &
# 启动一个shell脚本
nohup sh test.sh &
# 启动一个二进制文件
nohup ./MISS.GO.PayNotifyService &
生产环境的服务都是用nohup
启动的服务,功能测试环境有的是脚本启动,有的是直接执行nohup
命令来启动的, 都没有问题,只有这个服务有问题。
我启动的命令为: nohup ./MISS.GO.PayNotifyService >/dev/null &
难道是nohup
后面跟的参数有问题?于是我把下面的这些命令都测试了一遍:
nohup ./MISS.GO.PayNotifyService &
nohup ./MISS.GO.PayNotifyService >/dev/null &
nohup ./MISS.GO.PayNotifyService >/dev/null 2>&1 &
nohup ./MISS.GO.PayNotifyService 1>/dev/null 2>&1 &
nohup ./MISS.GO.PayNotifyService </dev/null >/dev/null 2>&1 &
最终的结果跟问题复现的结果一样,进程都会被结束。nohup
根本不起作用。
了解一下linux信号以及相关知识。
既然nohup
不起作用,暂时也没有头绪,那就看一下信号是谁发出来的? 看能不能找到点线索,终端结束的时候,为什么之上运行的进程也会结束? 我们先来了解以下linux基础知识。
进程
linux
是以进程为单位来执行程序, 当计算机开机的时候,内核(kernel)
只建立了一个init进程(centos7之后是systemd)
。剩下的所有进程都是init进程
通过fork机制
建立的。我们执行的每一条指令,像ps
、sh
等等都是一个进程。都是从父进程fock
过来的。每个进程都有父进程,而所有的进程以init进程
为根,形成一个树状结构。使用pstree
命令可以查看进程树状结构。每一个进程都有一个唯一的PID
来代表自己的身份。
进程组
每个进程都属于唯一的一个进程组(process group)
,每个进程组中可以包含多个进程。每个进程组都有一个领导进程(process group leader)
,领导进程的PID成为进程组的ID (process group ID, PGID),以识别进程组。发送给一个进程组的信号会发送给进程组中的每一个进程。
会话
会话(Session)是一个或者多个进程组的集合,通常一个会话开始于用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话。登录后的第一个进程叫做会话领导进程,通常我们都是通过ssh连接,所以领导进程就是bash进程。会话领导进程PID 为会话的 SID。
控制终端:
控制终端通常是登陆到其上的终端设备(终端登陆)或伪终端设备(网络登录),一个会话最多可以有一个控制终端。一般我们都是通过网络ssh连接服务器,所以控制终端就是一个伪终端。使用tty命令可以查看当前的终端名。控制进程:
与控制终端连接的会话首进程被称为控制进程。前台进程组:
如果一个会话有一个控制终端,则它有一个前台进程组。一个会话最多只能有一个前台进程组。后台进程组:
除了一个前台进程组, 会话中的其他进程组则为后台进程组。作业(job)
: 会话中的每个进程组
称为一个作业(job)
。会话可以有一个进程组成为会话的前台作业(前台进程组),而其他的进程组是后台作业(后台进程组),可以通过jobs
命令查看后台运行的进程组。也可以通过fg
命令将后台进程组切换到前端,这样就可以继续接收用户的输入了。如果作业中的某个进程创建了子进程,那此子进程属于进程组,不属于作业。如何查看前台进程组和后台进程组:
使用命令ps -ajxf
查看TPGID(终端前台进程组)
一栏,如果该值为-1
,表示该进程没有控制终端,是属于后台进程组,反之则为前台进程组。
守护进程
守护进程是运行在后台的一种特殊进程。它独立于控制终端,并且周期性地执行某种任务或等待处理某些发生的事件。
守护进程和后台进程组的区别:
后台进程的文件描述符是继承于父进程,例如shell
,所以它也可以在当前终端下显示输出数据。但是daemon进程
自己变成了进程组长,其文件描述符号和控制终端没有关联,是控制台无关的。基本上任何一个程序都可以后台运行,但守护进程是具有特殊要求的进程,守护进程肯定是后台进程,但反之不成立。一个进程成为daemon进程
,可以不随会话的退出而退出,但是进程的uid/gid
并不会因此而改变。
如何判断一个进程是否为守护进程:
所有的守护进程都是以超级用户启动的(UID为0)
、没有控制终端(TTY为?)
、TPGID(终端前台进程组)为-1
。
孤儿进程
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)
所收养,并由init进程
对它们完成状态收集工作。
孤儿进程和守护进程的区别:
孤儿进程和守护进程都脱离终端运行,在其运行的过程中在终端中使用jobs
命令发现不了。从这点来看,孤儿进程与守护进程都可以脱离终端运行。那么他们的本质区别是什么?
- 孤儿进程是因为父进程异常结束了,然后被1号进程init收养。
- 守护进程是创建守护进程时有意把父进程结束,然后被1号进程init收养。
- 虽然他们都会被init进程收养,但是他们是不一样的进程。
- 守护进程会随着系统的启动默默地在后台运行,周期地完成某些任务或者等待某个事件的发生,直到系统关闭守护进程才会结束。
- 孤儿进程则不是,孤儿进程会因为完成使命后结束运行。
僵尸进程
一个子进程退出,但是其父进程并没有调用子进程的wait()
或waitpid()
的情况下。这个子进程就是僵尸进程。杀死父进程可以直接回收僵尸进程。
注意:僵尸进程将会导致资源浪费,而孤儿进程则不会
。
信号
信号由内核(kernel)管理的。它可以是内核自身产生的,也可以是其它进程产生的,发送给内核,再由内核传递给目标进程。
关于进程、进程组、会话等关系,网上找了一张图大家看一下:
SIGHUP信号介绍
kill -l
命令可以查看所有的信号,这里就不展示了,在对以上的概念有所了解之后,我们现在开始正式了解一下SIGHUP
信号。
SIGHUP
会在以下4种
情况下被发送给相应的进程:
kill -1 PID
这种就是a进程
向b进程
发送了一个sighup
信号。我们前面也说了,信号可以是其它进程产生的,发送给内核,再由内核传递给目标进程。- 终端关闭时,该信号被发送到
session首进程
以及作为作业(job)提交的进程(即用 & 符号提交的进程)。 - session首进程退出时,该信号被发送到该session中的前台进程组和后台进程组中的每一个进程。
- 若进程的退出,导致一个进程组变成了孤儿进程组,且新出现的孤儿进程组中有进程处于停止状态,则
SIGHUP
和SIGCONT
信号会按顺序先后发送到新孤儿进程组中的每一个进程。
系统对SIGHUP
信号的默认处理是终止收到该信号的进程。所以若程序中没有捕捉该信号,当收到该信号时,进程就会退出。
我们主要看一下session
退出后,linux执行的大概步骤:
- 用户退出 session (正常退出、远程登录时的网络断开、sshd挂掉、手动叉掉 ssh 登陆窗口)
- 系统向该 session 发出SIGHUP信号
- session 将SIGHUP信号发给所有子进程(包括前台进程和后台进程)。
- 子进程收到SIGHUP信号后,自动退出。
第一个问题: sighup信号是谁发的?为什么会发送sighup信号?
到这里,我们上面的第一个问题已经解决了, 一般情况下sighup
信号是在session
退出后,内核向session的子进程发送的信号。
/proc/{pid}/status命令
这里又新学了一个命令,可以使用 cat /proc/{pid}/status
来查看进程忽略和接受的信号。命令查看的信息有很多,可以使用grep
过滤一下,只查看信号相关的字段。命令为:grep Sig /proc/{pid}/status
# grep Sig /proc/890/status
SigQ: 0/15078
SigPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: fffffffe57f0d8fc
SigCgt: 00000000280b2603
解释:
SigQ:
待处理信号的个数
SigPnd:
屏蔽位,存储了该线程的待处理信号,等同于线程的PENDING信号.
SigBlk:
存放被阻塞的信号,等同于BLOCKED信号.
SigIgn:
存放被忽略的信号,等同于IGNORED信号.
SigCgt:
存放捕获的信号,等同于CAUGHT信号.
右边的数字是位掩码。如果将其从十六进制转换为二进制,则每个1位代表捕获的信号,从1开始从右到左计数。因此,通过解析SigCgt
行,可以看到进程正在捕获的信号:
00000000280b2603 ==> 101000000010110010011000000011
| | | || | || |`-> 1 = SIGHUP
| | | || | || `--> 2 = SIGINT
| | | || | |`----------> 10 = SIGUSR1
| | | || | `-----------> 11 = SIGSEGV
| | | || `--------------> 14 = SIGALRM
| | | |`-----------------> 17 = SIGCHLD
| | | `------------------> 18 = SIGCONT
| | `--------------------> 20 = SIGTSTP
| `----------------------------> 28 = SIGWINCH
`------------------------------> 30 = SIGPWR
每次这样算也挺麻烦的,下面是一个写好的脚本,使用命令:sh signals.sh 890
#read -p "PID=" pid
pid=$1
cat /proc/$pid/status|egrep '(Sig|Shd)(Pnd|Blk|Ign|Cgt)'|while read name mask;do
bin=$(echo "ibase=16; obase=2; ${mask^^*}"|bc)
echo -n "$name $mask $bin "
i=1
while [[ $bin -ne 0 ]];do
if [[ ${bin:(-1)} -eq 1 ]];then
kill -l $i | tr '\n' ' '
fi
bin=${bin::-1}
set $((i++))
done
echo
done
# vim:et:sw=4:ts=4:sts=4:
第二个问题:nohup为什么没有忽略SIGHUP信号?
既然session
在退出的时候会向子进程发送sighup
信号,那么忽略这个信号不就得了嘛? nohup
就是用来忽略SIGHUP
信号的,但是为什么不起作用呢?
其他的java服务和go服务也是用nohup启动的,为什么它们没问题呢?于是我写了一个很简单的http服务,代码如下:
func main() {
http.HandleFunc("/tree/", HelloServer)
_ = http.ListenAndServe(":8080", nil)
}
func HelloServer(w http.ResponseWriter, r *http.Request) {
all, _ := ioutil.ReadAll(r.Body)
fmt.Println("===> "+string(all))
all, _ = ioutil.ReadAll(r.Body)
fmt.Println("===> "+string(all))
_, err := w.Write([]byte(r.URL.Path))
if err != nil {
fmt.Println(err)
}
}
将这个goweb服务
打包成二进制,上传到服务器使用nohup
启动,无论是网络超时断开,还是直接点x号
关闭终端,或者使用exit
退出,进程都在。为什么会这样?
1.查看goweb服务的进程信息
# 先查看进程号
[root@localhost goweb]# ps -ef|grep goweb
root 890 750 0 12:52 pts/0 00:00:00 ./goweb
root 898 750 0 12:52 pts/0 00:00:00 grep --color=auto goweb
# 查看进程的信号信息
[root@localhost goweb]# grep Sig /proc/890/status
SigQ: 0/15078
SigPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000000000001
SigCgt: fffffffe7fc1fefe
解析SigIgn
可以看到,该进程是忽略 1 SIGHUP
这个信号的。
2.查看有问题的 MISS.GO.PayNotifyService 进程
[root@localhost MISS.GO.PayNotifyService]# ps -ef|grep MISS.GO.PayNotifyService
root 8049 6487 8 15:09 pts/0 00:00:00 ./MISS.GO.PayNotifyService
root 8061 6487 0 15:09 pts/0 00:00:00 grep --color=auto MISS.GO.PayNotifyService
[root@localhost MISS.GO.PayNotifyService]# grep Sig /proc/8049/status
SigQ: 0/15078
SigPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000000000000
SigCgt: fffffffe7fc1feff
解析SigIgn
可以看到,该值的所有位都是0
证明没有忽略任何信号,也就是说会接受到 SIGHUP
这个信号。
启动方式是nohup + &
为什么MISS.GO.PayNotifyService
进程没有忽略 SIGHUP
信号呢? 而goweb进程
却可以呢?难道是代码里有对 SIGHUP信号做了处理
?查看代码,如下:
func initSignal() {
signals := make(chan os.Signal, 1)
// It is not possible to block SIGKILL or syscall.SIGSTOP
signal.Notify(signals, os.Interrupt, os.Kill, syscall.SIGHUP,
syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
for {
sig := <-signals
fmt.Println("get signal %s", sig.String())
switch sig {
case syscall.SIGHUP:
fmt.Println("app exit now by SIGHUP...")
os.Exit(1)
default:
time.AfterFunc(time.Duration(10e9), func() {
fmt.Println("app exit now by force...")
os.Exit(1)
})
// The program exits normally or timeout forcibly exits.
fmt.Println("app exit now...")
return
}
}
}
重点关注signal.Notify
这行代码,其实这个 signal.Notify
就是对信号做了处理,相当于修改了 SIGHUP
信号的默认处理。
nohup
原本是忽略SIGHUP
信号的,但是程序启动后,又被signal.Notify
重新修改了SIGHUP
的处理方式。
nohup
启动只是启动了一个后台作业。session
退出之后,前台进程组和后台进程组都会收到SIGHUP
信号。
所以这也是为什么 nohup
启动之后没有忽略 SIGHUP
信号的原因。
3.那么goweb进程没有结束的原因?
goweb程序
本身没有对SIGHUP
信号做处理,因为用nohup
启动,所以启动的进程是忽略SIGHUP
信号的,session退出
或者直接点击x号
关闭终端,goweb进程
是收不到SIGHUP
信号的。
但是由于 父进程 已经退出,goweb进程
还在运行,所以goweb进程
会被init进程
接管,变成一个孤儿进程
。我们可以测试一下:
1.先使用nohup+&启动。
2.使用 命令查看
3.直接点击x号关闭终端。
4.重新登录终端
5.使用 命令查看
# ======> 1.第一次启动 ,然后使用 ps查看进程信息
[root@localhost goweb]# nohup ./goweb &
[1] 9435
[root@localhost goweb]# nohup: ignoring input and appending output to ‘nohup.out’
[root@localhost goweb]# ps ajxf
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 3539 3539 3539 ? -1 Ss 0 0:00 /usr/sbin/sshd -D
3539 9141 9141 9141 ? -1 Ss 0 0:00 \_ sshd: root@pts/1
9141 9143 9143 9143 pts/1 9496 Ss 0 0:00 \_ -bash
9143 9435 9435 9143 pts/1 9496 Sl 0 0:00 \_ ./goweb
9143 9496 9496 9143 pts/1 9496 R+ 0 0:00 \_ ps ajxf
# 可以看到 TTY为:伪终端 pts/1
# PPID为:父进程为9143,就是bash进程。
# SID为:bash 还有启动的 goweb进程 以及我们使用的命令 ps ajxf 产生的进程都属于 9143 这个会话(session)。
# ======> 2.然后我们点击 x号直接关闭终端。
# ======> 3.我们再重新登录终端。
# ======> 4.我们再重新登录终端。
# ======> 5.再次使用ps命令查看进程信息
[root@iz2ze5uw2drhkk7vdxkeftz ~]# ps -ajxf
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1 9435 9435 9143 ? -1 Sl 0 0:00 ./goweb
# 可以看到 TTY为:?号 代表没有终端
# PPID为:1 直接被init进程接管。
# SID和PGID还是之前的值。这个孤儿进程也可以被称为守护进程。
为什么进程的SID
和PGID
还是之前的值?因为没有调用setsid
将进程设置为领导进程,这就是为什么有sid != pid
守护程序并不少见的原因。
解决方法
1.使用nohup启动之后,手动执行 exit 退出终端。
使用exit
命令退出终端,我们的程序还是会继续运行,这是为什么呢?这是因为使用exit
命令退出终端时不会向终端所属任务发SIGHUP
信号,这是huponexit
配置项控制的,默认是off
,可以使用shopt
命令查看。
[root@localhost ~]# shopt |grep huponexit
huponexit off
将huponexit
配置成on
,再次使用exit
命令退出,所属的任务就会跟随退出。可以再次使用 shopt -u huponexit
设置为off
。
[root@localhost ~]# shopt -s huponexit
[root@localhost ~]# shopt |grep huponexit
huponexit on
大多数Linux
系统,这个参数默认关闭(off)
。因此,使用exit
退出session
的时候,不会把SIGHUP
信号发给"后台任务"
。
但是 nohup + &
显然不是够安全的。因为有的系统的huponexit
参数可能是打开的(on)
。
2.使用 disown 命令
disown
可以将指定任务从"后台任务"列表(jobs命令的返回结果)
之中移除。一个"后台任务"
只要不在这个列表之中,session
就不会向它发出SIGHUP
信号。
nohup ./MISS.GO.PayNotifyService & disown
执行上面的命令以后,MISS.GO.PayNotifyService
进程从"后台任务"
列表移除。再执行jobs
命令验证,输出结果里面,不会有这个作业。
3.使用 setsid 命令
setsid
可以新建一个会话,调用setsid是有条件的:即调用进程自己不能是进程组长,因此,调用setsid之前需要先fork,然后由产生的子进程调用setsid(这里我们不需要关心,我们使用ssh登录,在终端执行的每一条命令都是fock自bash进程)
。
为什么不允许进程组长调用setsid?
调用setsid
的进程将成为一个新的进程组的组长,如果允许一个进程组长调用setsid
的话,那这个人将成为两个组的组长,再说,进程组长的定义是"其进程ID=进程组ID"
,如果某个进程是两个组的组长,那么这两个组的进程组ID是相同的?肯定不允许。
当进程是进程组长时调用setsid
失败,setsid
调用成功后,进程成为新的会话组长和新的进程组长,并与原来的登录会话和进程组脱离。
setsid
的使用也非常方便,只需要在要处理的命令之前添加setsid
即可。
setsid ./MISS.GO.PayNotifyService
4.使用 screen 命令
screen
命令用于管理多个终端,它可以创建终端,让程序在里面运行。screen
使用之前需要先执行yum install screen
。
screen ./MISS.GO.PayNotifyService &
以上的解决方法还有很多其他的功能,大家可以选择一种方式,自行查阅一下相关文档。