关于Python3多进程机制的一些探索

1、背景

我的实验是:利用Flask开放一个服务,用户可以请求这个服务,以执行一个耗时非常长的任务。由于这个任务耗时非常长,因此Flask需要使用异步的方式,即用户请求后马上返回状态,将耗时任务交给另外一个进程去执行。

2、实验过程

如果不采用异步的方式,那么要实现上述任务是非常简单的,示例代码如下:

from flask import Flask
from time import sleep

app = Flask(__name__)

@app.route('/')
def func():
    long_time_task()
    return "OK"

def long_time_task():
    # 模拟一个长时间任务
    print('task start')
    sleep(10)
    print('task end')

if __name__ == '__main__':
    app.run()

启动该服务后,利用GET方法请求对应的路由后,可以在控制台看到正常打印的信息以及时间结束后返回的内容。

2.1 利用subprocess模块

subprocess模块是python的标准库之一,它允许你生成新的进程,连接它们的输入、输出、错误管道。这看上去和我的需求很匹配。subprocess常用的方式是通过调用其中的run()方法来将要执行的指令发送到新的进程。注意,这个方法会等待指令完成,然后返回一个CompletedProcess实例。

这说明,使用run()方法会导致主进程暂停,来等待子进程执行结束后再继续执行主进程。这显然不是我想要的。

为此,我尝试了其中的Popen()方法,代码如下:

# task.py
from time import sleep

def long_time_task():
    # 模拟一个长时间任务
    print('task start')
    sleep(10)
    print('task end')

if __name__ == '__main__':
	long_time_task()

这时需要将待执行的任务代码另外写到一个脚本中,以便于从主进程中向子进程发送执行指令。my_app.py的内容修改如下:

# my_app.py
from flask import Flask
import subprocess

app = Flask(__name__)

@app.route('/')
def func():
	# 即发送`python task.py`指令到一个新的进程来执行任务
	subprocess.Popen(['python', 'task.py'])
    return "OK"

if __name__ == '__main__':
    app.run()

服务启动后,我通过Postman进行测试后发现,每次请求之后,服务器马上返回"OK";通过ps命令查看相关进程,发现确实生成了一个子进程(下图中红线框出的进程)来执行任务:
在这里插入图片描述
由于在调用Popen()是没有设置stdout参数的值,因此子进程中的标准输出也会发送到主进程,于是在启动进程的shell就看到了long_time_task()打印的输出。

至此,事情似乎是完美的。然而,当子进程的任务执行完毕后,再次查看相关进程时,发现如下内容:
在这里插入图片描述
我迅速发送了5次请求,因此主进程生成了5个子进程来执行任务,任务结束后,这些子进程都变成了僵尸进程(Zombie)。

关于僵尸进程的进一步描述,可以参考这里

这时,如果再发送一次请求,那么上述5个僵尸进程就会消失,1个新的子进程被创建来执行这次新的请求任务,新任务结束后,这个子进程又变成了僵尸进程。

我目前还没有搞清楚这里面的运行机制,我的猜想是:主进程会按需创建一定数量的子进程来执行任务,由于主进程并未等待子进程的返回结果,因此这些子进程执行完成后就变成了僵尸进程;当一个新的任务被提交到主进程,主进程创建一个新的子进程来执行任务,同时重新获取到所有子进程的状态,关闭了那些僵尸进程。

如果真得如我猜想的那样,通过上述方式来实现异步,似乎不会出现太多的僵尸进程而导致系统资源的浪费(除非一次性提交大量的任务,生成大量子进程,而主进程不再接受新的任务)。

2021.6.11更新:我想我应该是找到了来证明我的猜想是正确的证据:编程指导

2021.11.17更新:有一种简单的方法来避免僵尸进程的产生,在主进程中加入以下代码:

import signal
signal.signal(signal.SIGCHLD, signal.SIG_IGN)

该代码通知内核,主进程对子进程的状态不关心,全部交由内核来处理。具体地,可以参考这里

2.2 利用concurrent.futures

concurrent.futures模块提供异步执行回调高层接口。可以通过这个模块来批量创建进程池,然后将耗时任务提交到进程池中,让操作系统自动分配进程来执行。

代码如下:

# my_app.py
from flask import Flask
from task import long_time_task
from concurrent.futures import ProcessPoolExecutor

executor = ProcessPoolExecutor(2)  # 进程池中只有两个进程

app = Flask(__name__)

@app.route('/<task_id>')  # 为了打印的时候更加清晰的区分任务,添加了task_id标识符
def func(task_id):
    # 将任务提交到进程池
    executor.submit(long_time_task, task_id)
    return "OK"

if __name__ == '__main__':
    app.run()
from time import sleep

def long_time_task(task_id):
    # 模拟一个长时间任务
    print(f'task {task_id} start')
    sleep(10)
    print(f'task {task_id} end')

启动成功后,发送三个请求,发现每个请求均可以马上返回"OK",这说明实现了异步;服务控制台打印出如下内容:
在这里插入图片描述
与直观感觉一致,由于进程池中只有两个进程来执行任务,因此,task1和task2可以马上执行;而task3要等到task1执行完成后释放了一个进程,才能执行。

另外,通过ps查看,发现没有产生僵尸进程。

3、利用gunicorn部署服务时的多进程

直接执行python my_app.py启动服务时,在控制台会打印一个鲜明的警告:

WARNING: This is a development server. Do not use it in a production deployment.

这是因为flask内置的服务器在性能方面不能满足正式生产环境的需要,测试用用还行,如果要上线部署,那么就得另寻出路了。

可选择的不少,我用的是gunicorn

安装好之后,可以选择通过命令行启动服务了:

gunicorn -w 4 -b 0.0.0.0:5000 my_app:app 

3.1 利用subprocess模块时的效果

此时的代码内容如下:

# my_app.py
from flask import Flask
import subprocess

app = Flask(__name__)

@app.route('/<task_id>')
def func(task_id):
    # 即发送`python task.py`指令到一个新的进程来执行任务
    subprocess.Popen(['python', 'task.py', '--task_id', task_id])
    return "OK"

if __name__ == '__main__':
    app.run('0.0.0.0')

为了更明显地区分不同的任务,我修改了task.py的内容:

# task.py
from time import sleep
import os
import argparse
parser = argparse.ArgumentParser()

if not os.path.exists('./results'):
    os.makedirs('./results')

def long_time_task(task_id):
    # 模拟一个长时间任务
    print(f'task {task_id} start')
    sleep(3)
    with open(f'results/{task_id}.txt', 'w') as f:
        f.write(f'this is the {task_id}\' output.\n')
    print(f'task {task_id} end')

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--task_id', type=str, required=True)
    args = parser.parse_args()
    task_id = args.task_id
    long_time_task(task_id)

然后,我写了一个定时调度的脚本来批量发送请求:

# batch_requests.py
import time
import requests

if __name__ == '__main__':
    for i in range(30):
        http = f'http://1.234.567.89:5000/{i}'  # 这里的ip要更换
        r = requests.get(http)
        time.sleep(0.5)  # 每个请求间隔0.5s

利用gunicorn启动服务之后,先看一下进程情况:
在这里插入图片描述
进程号为27835的是主进程,由于指定了-w 4,因此,主进程会生成4个子进程——即剩余的4个——来接受请求。

执行定时调度的脚本后可以发现,在控制台会打印许多task <int> starttask <int> end的信息,由于各个子进程都是竞争向主进程发送打印的内容,因此,打印顺序是随机不可控的;执行完成后,在results文件夹就会看到每个请求对应生成的文件了。

这时,看一下进程情况:
在这里插入图片描述
这里,所有名称为[python] <defunct>的进程都是僵尸进程。通过观察它们的进程号可以发现,这些僵尸进程都是由gunicorn的子进程产生的,这也进一步说了这些子进程才是具体完成工作的进程。

按照我在2.1节中的猜想,如果再重新发送一个请求,假如它被进程号为27861的进程进行处理,那么这个进程对应的4个僵尸进程就会消失,然后生成一个新的活跃进程来执行任务,任务接手后,这个活跃进程又变成了僵尸进程。

关于这一点,大家可以自己去验证,确实是这样的。有一点需要靠运气的成分,那就是如何让指定的进程来接受请求。我们可以记住每一个子进程对应的僵尸进程的数量,这样,无论哪个子进程执行了任务,那么它对应的僵尸进程最终都会变成1。

3.2、利用concurrent.futures部署服务时的多进程

新建一个脚本:

# my_app2.py
from flask import Flask
from task import long_time_task
from concurrent.futures import ProcessPoolExecutor

executor = ProcessPoolExecutor(1)  # 进程池中只有1个进程

app = Flask(__name__)

@app.route('/<task_id>')  # 为了打印的时候更加清晰的区分任务,添加了task_id标识符
def func(task_id):
    # 将任务提交到进程池
    executor.submit(long_time_task, task_id)
    return "OK"

if __name__ == '__main__':
    app.run('0.0.0.0')

task.py的内容不变。

按照理解,如果利用gunicorn -w 4 -b 0.0.0.0:5000 my_app2:app启动服务并接受大量请求时,4个子进程会各自生成只有一个进程的进程池来执行任务。

然而,在实际实验时发现,尽管我调高了发送请求的频率(把间隔的时间降低为0.1s),但是有时候四个子进程并不会每一个都生成进程池来执行任务。

也就是说,尽管1s有多达10次请求,而实际四个子进程只有一个子进程生成了进程池,其他三个子进程仍然处于等待的情况。

关于这种现象出现的原因,我的分析如下:

假设gunicorn启动后的主进程为m,四个子进程为c1,c2,c3,c4。对于子进程c1而言,它本身并不执行任务,而是在接收到执行任务的信号时,生成一个子进程(记为cc1)去执行任务,c1可以马上返回状态;这就等于告诉主进程m:c1执行完成。因此,下一个请求就仍然交给c1去执行,但c1的子进程池实际上只有一个进程(cc1),cc1拿到新的任务后,必须等到上一个任务执行完成才能执行新任务。

这就解释了为什么请求后状态很快就能返回,而真正的任务执行却仍然在顺序进行。

增大进程池中的进程数量可以解决这个问题。但是,这样似乎就失去了用gunicorn启动时指定-w 4的意义:因为其他的三个子进程可能永远不会被分配任务!

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

芳樽里的歌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值