导语

    业务模块为实现高并发时的更快的处理速度,经常会采用多进程的方式去处理业务。多进程模式下常见的三种bug:for循环下fork子进程导致产生无数孙子进程,僵尸进程,接口窜包。本章主要介绍第一种常见的bug:for循环下fork子进程导致产生无数孙子进程。通过分析开发线上出现的问题,理解问题出现的原因以及如何避免,如何有效的测试出这类缺陷。
目录

一:缺陷引入
二:多进程概念理解
2.0 fork基本概念理解
2.1 “写时复制”的fork
2.2 for循环下fork子进程问题分析
2.3 缺陷分析
三:测试方法

一: 缺陷引入

某日下午,测试组突然炸锅了,“为什么这台机器一下这么卡?”“为什么机器的cpu占用这么高?”“为啥这台机器的这个进程ps这么多?”“这么多进程未被主进程回收,这是僵尸进程啊”,后面该进程的相关测试人员一看,赶紧停了被测程序,机器恢复。

测试同学:主进程在wait释放子进程的“空壳”时,出现了大量的失败返回值为-1(日志有打印主进程调用wait的返回值),这就导致子进程的“空壳”未被释放,出现大量僵尸进程,直接占用耗尽用户资源,导致整个系统被拖垮。

开发同学:直接上多进程3件套看看能不能修复bug:

1: 创建进程前,查看子进程个数,超过最大进程数,不再fork

2: 子进程运行前,先sleep 1s

3: 子进程运行完或者发生异常时,不抛异常,直接exit退出

测试同学:修复了,现在正常运行子进程和子进程异常退出都不会产生僵尸进程了,问题解决。

------------------------------------------------------------------------------------------------------------------------------------------

那问题出现了:ps查看进程的时候,有看进程的状态吗?为什么确定就是僵尸进程? 也有可能是创建了大量的子进程且子进程一直未退出。用python还原下当时的代码,如下所示:


#!/usr/bin/python

# 模拟缺陷问题产生

import os,time,sys

def test(is_ok):
if is_ok == 0:
return 0
else:
return -1 def CreateVerifyTask():
try:
pid = os.fork()
if pid == 0:
print("子进程执行的代码,子进程的pid为{0},主进程pid为{1}".format(os.getpid(),os.getppid()))
ret = test(-1)
if ret<0:
print("创建任务失败")
# 改进方法1:子进程运行异常直接退出(推荐)
# os._exit(0)
raise RuntimeError('error1')
else:
pid= os.wait()
print(pid)
print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
except:
print('throw 1 CreateVerifyTask')
raise RuntimeError('error1') def hand_test():
try:
CreateVerifyTask()
except:
print('throw 2 get_test')
# 改进方法2:将异常直接抛到for循环或者while循环
# raise RuntimeError('error1 hand_test')
# 不往外继续抛异常,子进程执行CreateVerifyTask产生异常在hand_test内被"消化",导致子进程在excute继续执行for循环,产生更多的孙子进程。 def excute():
try:
for i in range(10):
print(i)
hand_test()
except:
print('throw 2 get_test') if __name__=='__main__':
excute()
 

为什么会产生大量的进程呢?如果认真看了这段代码仍然不明白为何产生问题,强烈建议从第二章节开始看,else谜底在2.3 缺陷分析揭晓。

二:多进程概念理解

2.0 fork基本概念

计算机程序设计中的分叉函数。返回值: 若成功调用一次则返回两个值,子进程返回0,父进程返回子进程标记;否则,出错返回-1。

一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本,这意味着父子进程间不共享这些地址空间。UNIX将复制父进程地址空间内容给子进程,因此,子进程有了独立的地址空间。在不同的UNIX (Like)系统下,我们无法确定fork之后是子进程先运行还是父进程先运行,这依赖于系统的实现。

如下所示的代码段:

Fork语句执行后:(第二步,第三步顺序不确定)

第一步: 父进程fork子进程成功,子进程获得父进程数据空间、堆、栈等资源的副本,拥有跟父进程一样的代码段,此时父子进程都从fork的下一句开始并发执行。

第二步:    父进程执行:内核向父进程1869返回子进程的进程号pid=1870,父进程执行else内代码段。

第三步:  子进程执行:内核向子进程返回0.子进程执行elif pid ==0 内代码段。

2.1  “写时复制”的fork

进程(Process)是计算机中已运行程序的实体,是系统的基本运作单位,是资源分配的最小单位,fork子进程后,子进程会复制父进程的状态(内存空间数据等)。fork 出来的进程和父进程拥有同样的上下文信息、内存数据、与进程关联的文件描述符。这句话是否理解,可以通过下面2个demo验证下:

问题1 :全局变量list1,fork子进程后,在子进程内:打印list1的虚拟地址,修改list1[0]的值,打印list1值,打印list1虚拟地址。 主进程内:打印list1的虚拟地址,待子进程修改后,打印list1值。

子进程和主进程打印的虚拟地址值是一样的吗?打印的list1的值是一样的吗?


import os
import time list1 =[1,2,3,4]
print("list1的地址为{0}".format(id(list1)))
mainpid = os.getpid()
print(os.getpid())
pid = os.fork() if pid<0:
print('创建进程失败') elif pid == 0:
print("子进程执行的代码,子进程的pid为{0},主进程pid为{1}".format(os.getpid(),os.getppid()))
print("子进程修改前list1的地址为{0}".format(id(list1)))
list1[0]=10
print("子进程修改后list1的地址为{0}".format(id(list1)))
print("子进程list1为{0}".format(list1)) else:
print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
print("主进程list1的地址为{0}".format(id(list1)))
time.sleep(1)
print("主进程list1为{0}".format(list1))
print("主进程最后打印list1的地址为{0}".format(id(list1))) print(list1)
print('end')
 

运行结果:

list1的地址为4349698528

主进程执行的代码,当前pid为10158,我真实的pid为10157
主进程list1的地址为4349698528
子进程执行的代码,子进程的pid为10158,主进程pid为10157
子进程修改前list1的地址为4349698528
子进程修改后list1的地址为4349698528
子进程list1为[, , , ]
[, , , ]
end
主进程list1为[1, , , ]
主进程最后打印list1的地址为4349698528
[1, , , ]
end Process finished with exit code

结果:

全局变量在子进程的虚拟地址值 = 主进程的虚拟地址值;子进程内的list1的值 不等于主进程list1的值。

分析:

fork创建一个新进程。系统调用复制当前进程,在进程表中新建一个新的表项,新表项中的许多属性与当前进程是相同的。新进程几乎与主进程一模一样,执行的代码也完全相同,但是新进程有自己的数据空间、环境和文件描述符。但是新的进程只是拥有自己的虚拟内存空间,而没有自己的物理内存空间。新进程共享源进程的物理内存空间。而且新内存的虚拟内存空间几乎就是源进程虚拟内存空间的一个复制。所以父子进程都打印list1的虚拟地址时,都是同一个地址值。

进程空间可以简单地分为程序段(正文段)、数据段、堆和栈四部分(简单这样理解)。fork函数,当执行完fork后的一定时间内,新的进程(p2)和主进程(p1)的进程空间关系如下图:

fork执行时,Linux内核会为新的进程P2创建一个虚拟内存空间,而新的虚拟空间中的内容是对P1虚拟内存空间中的内容的一个拷贝。而P2和P1共享原来P1的物理内存空间。但是当父子两个进程中任意一个进程对数据段、栈区、堆区进行写操作时,上图中的状态就会被打破,这个时候就会发生物理内存的复制,这也就是叫“写时复制”的原因。发生的状态转变如下:

P2有了属于自己的物理内存空间。如果只有数据段发生了写操作那么就只有数据段进行写时复制,而堆、栈区域依然是父子进程共享。这就解释了为啥修改子进程的全局变量,不影响父进程list1值的情况。还有一个需要注意的是,正文段(程序段)不会发生写时复制,这是因为通常情况下程序段是只读的。子进程和父进程从fork之后,基本上就是独立运行,互不影响了。

此外需要特别注意的是,父子进程的文件描述符表也会发生写时复制。

#!/usr/bin/python
# -*- coding: UTF- -*- import pymysql
import os
import time 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<:
print('创建进程失败')
elif pid == :
print('子进程db_curdsor的地址是:{0}'.format(id(db_curdsor)))
for i in range():
time.sleep()
db_curdsor.execute("SELECT VERSION()")
version_data = db_curdsor.fetchone()
print(version_data) else:
print("主进程执行的代码,当前pid为{0},我真实的pid为{1}".format(pid,os.getpid()))
print('主进程db_curdsor的地址是:{0}'.format(id(db_curdsor)))
time.sleep()
db_conn.close()

运行结果:

主进程执行的代码,当前pid为13237,我真实的pid为13224
主进程db_curdsor的地址是:
子进程db_curdsor的地址是:
('8.0.18',)
Traceback (most recent call last):
File "/Users/leiliao/Downloads/loleina_excise/process/test2.py", line , in <module>
db_curdsor.execute("SELECT VERSION()")
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/cursors.py", line , in execute
result = self._query(query)
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/cursors.py", line , in _query
conn.query(q)
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line , in query
self._affected_rows = self._read_query_result(unbuffered=unbuffered)
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line , in _read_query_result
result.read()
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line , in read
first_packet = self.connection._read_packet()
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line , in _read_packet
packet_header = self._read_bytes()
File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line , in _read_bytes
CR.CR_SERVER_LOST, "Lost connection to MySQL server during query")
pymysql.err.OperationalError: (, 'Lost connection to MySQL server during query') Process finished with exit code

此时如果子进程运行中,发现断连执行重连操作,则重连后的句柄属于子进程独有资源。

2.2  for循环下fork子进程问题分析

下面代码会创建几个子进程呢?

import os
import time n = # 期望设置3个进程 for i in range(n):
pid = os.fork()
if pid<:
print('创建进程失败')
elif pid == :
print("子进程执行的代码,子进程的pid为{0},主进程pid为{1}".format(os.getpid(),os.getppid()))
# break
        # 增加break,创建的线程数就是n
    else: pass #主进程什么都不做

运行结果:

子进程执行的代码,子进程的pid为14787,主进程pid为14786
子进程执行的代码,子进程的pid为14788,主进程pid为14786
子进程执行的代码,子进程的pid为14789,主进程pid为14786
子进程执行的代码,子进程的pid为14790,主进程pid为14787

子进程执行的代码,子进程的pid为14791,主进程pid为14788

子进程执行的代码,子进程的pid为14792,主进程pid为14787
子进程执行的代码,子进程的pid为14793,主进程pid为14790

结果:7个

分析:

fork是UNIX或类UNIX中的分叉函数,fork函数将运行着的程序分成2个(几乎)完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本。如果把n设置成3,则实际会产生7个子进程。

1.i=0时,父进程进入for循环,此时由于fork的作用,产生父子两个进程(分别记为F0/S0),分别输出father和child,然后,二者分别执行后续的代码,子进程由于for循环的存在,没有退出当前循环,因此,父子进程都将进入i=1的情况;

2.i=1时,父进程继续分成父子两个进程(分别记为F1/S1),而i=0时fork出的子进程也将分成两个进程(分别记为FS01/SS01),然后所有这些进程进入i=2;

3.....过程于上面类似,已经不用多说了,相信一切都已经明了了,依照上面的标记方法,i=2时将产生

如下图所示:

对应的数学公式如下:1 + 2 + 4 + ... + 2^(n - 1) = 2^n - 1

2.3  缺陷分析

缺陷产生的根本原因就在于:在创建子进程时异常,raise抛异常之后被外层函数hand_test捕获到之后没有继续往外抛,导致子进程在excute函数种的for循环未结束,子进程继续执行excute函数,创建孙子进程,导致创建n多进程。

三件套还是有点用,虽然那会开发不知道是啥原因。。。。。

这个问题的解决,应该有2种方法:

1. 子进程运行的代码段,子进程执行的函数正常运行完,尤其是异常的时候,使用exit退出当前子进程,从根本上解决子进程fork孙子进程的问题。(最推荐方法)

2. 子进程运行异常抛异常,异常需要一直抛直到for循环处理的函数去处理。

三:测试方法

测试点:for循环内fork子进程,是否产生孙子进程

测试方法:子进程代码执行区正常执行,子进程能正常退出。

子进程代码执行区异常执行,子进程直接exit退出。(抛异常关注处理异常函数)

缺陷的背后(四)---多进程之for循环下fork子进程引发bug的更多相关文章

  1. 创建多进程之multiprocess包中的process模块

    创建多进程之multiprocess包中的process模块 1.process模块是一个创建进程的模块 Process([group [, target [, name [, args [, kwa ...

  2. JavaSE教程-03Java中分支语句与四种进制转换-思维导图

    思维导图看不清楚时: 1)可以将图片另存为图片,保存在本地来查看 2)右击在新标签中打开放大查看 if语句 a) if语句 基本语法结构: if(关系表达式) { 基本语句体 } 执行流程: 首先判断 ...

  3. JavaSE教程-03Java中分支语句与四种进制转换

    一.分支语句 计算机源于生活,程序模拟现实生活,从而服务生活 行为模式 1,起床,刷牙,洗脸,吃早餐,上课,回家,睡觉(顺序性) 2,如果时间不太够,打个滴滴快车,如果时间够,坐个地铁(选择性) 3, ...

  4. deepin linux学习笔记(四)进不去图形界面怎么办?

    目录 deepin linux学习笔记(四)进不去图形界面怎么办? 前言 更换成lxde桌面 进不去图形界面怎么办? 总结 deepin linux学习笔记(四)进不去图形界面怎么办? 前言 生命不息 ...

  5. 进程之 Process join方法其他属性与进程Queue

    Process join方法 以及其他属性 在主进程运行过程中如果想并发地执行其他的任务,我们可以开启子进程,此时主进程的任务与子进程的任务分两种情况 情况一:在主进程的任务与子进程的任务彼此独立的情 ...

  6. 多进程之multiprocessing模块和进程池的实现

    转载:https://www.cnblogs.com/xiaobeibei26/p/6484849.html Python多进程之multiprocessing模块和进程池的实现 1.利用multip ...

  7. 新手教程之:循环网络和LSTM指南 (A Beginner’s Guide to Recurrent Networks and LSTMs)

    新手教程之:循环网络和LSTM指南 (A Beginner’s Guide to Recurrent Networks and LSTMs) 本文翻译自:http://deeplearning4j.o ...

  8. 精尽MyBatis源码分析 - MyBatis初始化(四)之 SQL 初始化(下)

    该系列文档是本人在学习 Mybatis 的源码过程中总结下来的,可能对读者不太友好,请结合我的源码注释(Mybatis源码分析 GitHub 地址.Mybatis-Spring 源码分析 GitHub ...

  9. 浅谈Service Manager成为Android进程间通信(IPC)机制Binder守护进程之路

    文章转载至CSDN社区罗升阳的安卓之旅,原文地址:http://blog.csdn.net/luoshengyang/article/details/6621566 上一篇文章Android进程间通信 ...

随机推荐

  1. mysql开放远程连接

    1.检查端口是否被监听,没有的话请启动mysql. netstat -atnp | grep 3306 2.检查用户是否具备远程连接,即host字段值不是 % mysql -uroot -p你的密码 ...

  2. Element源码---初识框架

    序言 如果前期直接看源码,你会发现源码越看越看不懂,越看越难,觉得没有办法入手去写,其实首先想要了解项目结构最简单的方法就是通过目录 1.项目理念 2.解析目录 目前想不到更好的办法整理element ...

  3. 四个大点,搞懂 Redis 到底快在哪里

    来源:https://mp.weixin.qq.com/s/4kPlBE3C6lTuSvt5mY5hUQ 前言 一. 开发语言 二. 纯内存访问 三. 单线程 四. 非阻塞多路I/O复用机制 前言 R ...

  4. OpenTK学习笔记(1)-源码、官网地址

    OpenTK源码下载地址:https://github.com/opentk/opentk OpenTK使用Nuget安装命令:OpenTK:Install-Package OpenTK -Versi ...

  5. __gcd-最大公约数

    __gcd-最大公约数 最大公约数(greatest common divisor,简写为gcd:或highest common factor,简写为hcf) __gcd(x,y)是algorithm ...

  6. Python3之logging模块浅析

    Python3之logging模块浅析   目录 Python3之logging模块浅析 简单用法 日志与控制台同时输出 一个同时输出到屏幕.文件的完成例子 日志文件截取 日志重复打印问题解决 问题分 ...

  7. windows正常,linux报错:'PHPExcel_Reader_excel2007' not found

    原因:因为在linux下,大小写敏感 我的文件夹命名是大写,在window小写可以访问到,但是在linux就大小写敏感导致没找到文件没导入成功 导入文件的路径(错误)import('phpexcel. ...

  8. vue elementUi tree 懒加载使用详情

    背景:vue下使用elementUI 文档:http://element-cn.eleme.io/#/zh-CN/component/tree#tree-shu-xing-kong-jian 需求:只 ...

  9. 通过sed -i ,通过准备好的sh脚本,来设置linux iptable白名单

    通过准备好的sh脚本,来设置linux iptable白名单 特定字符串的行前插入新行 sed -i '/特定字符串/i 新行字符串' file #!/bin/bash del_stdin_buf() ...

  10. js 经常用于条件判断 大于等于0 的正整数

    /^\d+(?=\.{,}\d+$|$)/.test() // true 转:https://www.jianshu.com/p/feef5e62dd67