缺陷的背后(五)---多进程之僵尸进程,窜包

导语
     经常听到“僵尸进程”,"底层窜包"这些名词,总是似懂非懂,本章主要围绕这2个关键词做测试分析,到底是什么原因导致产生这些问题?出现这些问题的影响范围是什么?怎么预防,测试关注点有哪些?
目录
一. 僵尸进程
     1.1. 僵尸进程产生的原因
     1.2. 可怕的僵尸进程
     1.3. 解决方法和测试点
二. 多进程窜包
     2.1. 接口窜包分析
     2.2. 解决方法和测试点

一. 僵尸进程

1.1 僵尸进程产生的原因

     一个进程在调用exit命令结束自己的生命的时候,系统会回收内核分配给它的内存、关闭它打开的所有文件等等,但是还有信息,比如进程的ID号、进程的退出状态、进程运行的CPU时间(僵尸进程(Zombie)的数据结构)等还保留着,以供父进程使用。父进程按需使用后,可以使用 wait/waitpid 等系统调用来为子进程收拾,做一些收尾工作。僵尸进程是非常特殊的一种,它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。它需要它的父进程来为它收尸。

     如果父进程未对子进程僵尸进程(Zombie)的数据结构进行完成销毁。那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。 

     僵尸状态是每个子进程必经的状态。 任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。可以考虑以下3种常见的业务场景:

     a: 如果子进程在exit()后,父进程未结束且没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。

     b: 如果子进程在exit()后,父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。

     c: 如果子进程在exit()后,父进程在子进程结束之前已退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

     业务场景a下就会产生僵尸进程,有时候父进程是一个常驻的一直运行的进程,如果不主动的去给子进程‘收尸’,有可能会严重影响整台机器的性能。

1.2 可怕的僵尸进程

    僵尸进程产生的过程是:父进程调用fork创建子进程后,子进程运行直至其终止,它立即从内存中移除,但进程描述符仍然保留在内存中(进程描述符占有极少的内存空间)。子进程的状态变成EXIT_ZOMBIE,并且向父进程发送SIGCHLD 信号,父进程此时应该调用 wait() 系统调用来获取子进程的退出状态以及其它的信息。在 wait 调用之后,僵尸进程就完全从内存中移除。因此一个僵尸存在于其终止到父进程调用 wait 等函数这个时间的间隙,一般很快就消失,但如果编程不合理,父进程从不调用 wait 等系统调用来收集僵尸进程,那么这些进程会一直存在内存中。

   下面是模拟产生僵尸进程的demo: 


#!/usr/bin/python

# 僵尸进程的产生原因,怎么查看?

import os,time

print(os.getpid())

pid = os.fork()

if pid == 0:
time.sleep(2)
print("子进程执行的代码,子进程的pid为{0},主进程pid为{1}".format(os.getpid(),os.getppid()))
else:
print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
time.sleep(10000)
# os.wait()
 

 运行结果:子进程运行完成后,主进程模拟运行种,此时不调用wait函数,则出现僵尸进程,使用命令 ps -A -ostat,ppid,pid |grep -e '^[Zz]' 查看结果如下:

1057
主进程执行的代码,当前pid为1058,我真实的pid为1057
子进程执行的代码,子进程的pid为1058,主进程pid为1057
ps -A -ostat,ppid,pid |grep -e '^[Zz]'
Z     1057  1058
Z    93838 95013

1.3 解决方法和测试点

    当出现僵尸进程时,不能再能使用 kill 后接 SIGKILL 信号这样的命令像杀死普通进程一样杀死僵尸进程,因为僵尸进程是已经死掉的进程,它不能再接收任何信号。那怎么办?死僵尸进程的父进程(僵尸进程的父进程必然存在),这时僵尸进程成为"孤儿进程",过继给1号进程init,init始终会负责清理僵尸进程。

   那怎么防止僵尸进程产生呢?

   方法1 :主进程调用wait或者waitpid等待子进程返回.缺点是:wait是阻塞函数,会导致主进程无法执行其他任务。下面改进后的demo就不会出现僵尸进程,子进程处理完成后,主进程立即回收继续执行其他任务。

    方法2: 设置SA_NOCLDWAIT标志以避免子进程僵死。

    在SVR4中,如果调用signal或sigset将SIGCHLD的配置设置为忽略,则不会产生僵死子进程。另外,使用SVR4版的sigaction,则可设置SA_NOCLDWAIT标志以避免子进程僵死。
Linux中也可使用这个,在一个程序的开始调用这个函数signal(SIGCHLD,SIG_IGN);通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。
 

#!/usr/bin/python
import signal
import os,time

# 僵尸进程的产生原因,怎么查看和预防

# 怎么预防僵尸进程?
# 第一:主进程一开始,调用signal设置信号SIGCHLD由内核处理,子进程退出后,不再通知主进程处理
# 第二:fork子进程之后,由父进程调用wait函数,等待子进程结束后发送SIGCHLD信号处理
# 第三:设置子进程为守护进程,即子进程再创建孙子进程,子进程本身不运行任何任务,创建孙子进程后直接退出,主进程处理后,子进程不存在,孙子进程由init进程托管处理。

print(os.getpid())


def test():
pid = os.fork()

if pid == 0:
print("子进程执行的代码,子进程的pid为{0},主进程pid为{1}".format(os.getpid(),os.getppid()))
else:
# 方法2:使用wait函数等待子进程发送 SIGCHLD 信号回收。
os.wait()
print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
time.sleep(200)

if __name__=='__main__':
# 方法1 :调用函数signal(SIGCHLD,SIG_IGN),通知内核,子进程结束后,内核会回收, 并不再给父进程发送信号。
signal.signal(signal.SIGCHLD,signal.SIG_IGN)
test()
 
 
    方法3: fork两次,创建守护进程,父进程fork一个子进程,然后继续工作,子进程fork一 个孙进程后退出,那么孙进程被init接管,孙进程结束后,init会回收。不过子进程的回收还要主进程做。

 测试点:父进程为常驻进程,产生子进程处理任务时需要注意是否会产生僵尸进程,可以在测试过程中,子进程任务处理结束后,ps查看是否存在僵尸进程。

 

二:窜包

2.1. 接口窜包分析

     最近线上发现过一次线网事故,业务框架跟组件在交互的时候发生了窜包,好在当时处于灰度状态,及时发现了问题。业务框架是一个多进程的程序,使用组件的api,跟组件进行交互。主要是向组件获取服务的ip地址以及端口信息,出现问题的本质原因在于业务程序,在最开始创建了与组件的长链接的fd,然后就fork子进程,子进程再跟组件进行交互,出现了串包。下面给出一个场景模拟的python版demo验证,真的会窜包吗?

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import pymysql
import os
import time

# 父进程的文件fd资源,是同一个句柄,且子进程和父进程使用导致串包

def init_db():
    # 打开数据库连接
    db_conn = pymysql.connect("localhost","root","root1234","mysql" )

    # 使用 cursor() 方法创建一个游标对象 cursor
    cursor = db_conn.cursor()

    return db_conn,cursor

db_conn,db_curdsor = init_db()
pid = os.fork()

if pid<0:
    print('创建进程失败')
elif pid == 0:
    print('子进程db_curdsor的地址是:{0}'.format(id(db_curdsor)))
    for i in range(100000):
        db_curdsor.execute("SELECT VERSION()")
        version_data = db_curdsor.fetchone()
        print(version_data)
        if version_data[0] != '8.0.18':
            print('version_data error')


else:
    print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
    print('主进程db_curdsor的地址是:{0}'.format(id(db_curdsor)))
    for i in range(100000):
        db_curdsor.execute("show databases")
        data = db_curdsor.fetchone()
        if data[0] !='information_schema':
             print('databases error')

 结果:窜包了。会报以下的错误:

version_data error
databases error
databases error
version_data error
2.2. 解决方法和测试点

分析:窜包的原因还得从fork函数的本质开始,前章节已讲述过,fork函数是写时复制函数, fork执行时,Linux内核会为子进程创建一个新的虚拟内存空间,而新的虚拟空间中的内容是对主进程虚拟内存空间中的内容的一个拷贝。而子进程和主进程共享原来主进程的物理内存空间。主进程打开的文件描述符会被复制到子进程中,这样子进程通过主进程复制过来的文件描述符和主进程共享打开文件的文件表项。主进程和子进程本质上使用的实际是一个连接跟别的组件交互,从而导致了问题的产生。

解决:方法1 :主进程的fd,在子进程内创建,这样每个进程都有属于进程本身的fd。

         方法2:   主进程创建fd,fork后调用exec。一个进程一旦调用exec类函数,它本身就“死亡”了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段与堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另一个程序了。不过exec类函数中有的还允许继承环境变量之类的信息,这个通过exec系列函数中的一部分函数的参数可以得到。

        方法3:   主进程创建fd,子进程初始化的时候先重连,或者其他原因迫使子进程在一开始使用就重新连接,获得一次新的fd。

posted @ 2019-11-29 10:36  loleina  阅读(904)  评论(0编辑  收藏  举报