项目落地:Python多进程开发与服务发布避坑
一、场景
当前有个Python项目,需要实现某个核心算法并提供api接口给其他部门调用;经过相关经验和测试分析,首选了sanic作为发布服务的框架(和flask差不多,不过sanic调用uvloop底层C性能更好)。另外,我们的核心算法需要小时级别的cpu密集型耗时计算,需要考虑api响应设计和多进程多核编程,提高核心算法的并发能力。
二、延时接口设计
对于无法立即响应的api接口,首先请求方是无法一直在请求等待,http一定会是超时的。所以,为了避免这个问题,一般采用两种方案:
- 设计一个接口,每次请求立即响应,通过设计状态码msg通知请求当前任务执行状态,当轮询这个接口时,遇到任务状态执行完毕,则从data区拉取结果。
@blue.route('/', methods=['POST'])
async def playground(request):
"""
资源竞争调用测试与示例, 共享消息队列名为“request_queue”; 结果查询id为 sr = "{0}#{1}#{2}".format(guid, tb_name, func_name)
Request:
{
‘guid’:此次任务的唯一id。
'label_name': '[]' , (列表,是用户要分析的目标字段,包含字段名称、别名名称、字段类型)
'feature_name': '[]' , (列表,是用户选择的除目标字段外的其它字段,包含字段名称、别名名称、字段类型)
'table_name': '[]', (列表,是用户分析数据表名称、表别名)
'meta_data': '{}', (字典,包含数据连接的信息:IP、端口、数据库名、用户名、密码等)
‘select_func’: ‘’ 选择要运行的功能。单一任务
}
:return:
"""
# 请求参数解析
req = sanic_request_para(request)
guid = req.get('guid')
tb_name = req.get('table_name')
func_name = req.get('func_name') # 功能代号,请求方法名
label_name = req['label_name']
feature_name = req['feature_name']
# 测试代码, 自助创建唯一id
guid = str(guid) + label_name[0]
# 初始化返回值
data = []
resp = {'code': 400, 'msg': '异常', 'data': data} # 初始化
# 请求任务处理
dispatcher_id = "{0}#{1}#{2}".format(guid, tb_name[0], func_name)
# 查看结果列
if base_redis.exists(dispatcher_id): # 存在这个任务
status = base_redis.hget(name=dispatcher_id, key='status')
logs.info('任务「{0}」存在,当前状态为「{1}」'.format(dispatcher_id, status))
if status is not None and status == 'running':
resp = {'code': 201, 'msg': '当前已存在处理进程且在处理中', 'data': data}
elif status is not None and status == 'complete':
dt = base_redis.hget(name=dispatcher_id, key='data')
data.append(dt)
resp = {'code': 200, 'msg': '结果已返回到data区', 'data': data}
elif status is not None and status == 'error':
dt = base_redis.hget(name=dispatcher_id, key='data')
data.append(dt)
resp = {'code': 402, 'msg': '任务执行ERROR,在data区中查看错误信息', 'data': data}
else:
resp = {'code': 400, 'msg': '未知问题,请检查', 'data': data}
else:
# 没人在做,则生产任务消息
r = {'guid': guid, 'table_name': tb_name, 'label_name': label_name, 'feature_name': feature_name, 'func_name': func_name}
json_r = json.dumps(r)
request_queue = request.app.config.get('request_queue')
request_queue.put('abc')
# base_redis.lpush('request_queue', json_r)
resp = {'code': 202, 'msg': '已添加到任务队列', 'data': data}
response_json = sanic_json(resp)
return response_json
2. 设计两个接口,一个接口用来接收外部来的请求并生成任务计算,同时收到并返回一个回调地址,当任务完成时,通过回调地址主动上报通知执行状态和结果。另一个接口用于查询任务状态。
from flask import Flask, jsonify, request
import requests
app = Flask(__name__)
def long_running_task():
# 执行长时间运行的计算任务
result = {'result': '计算完成'}
return result
@app.route('/api/calculate', methods=['POST'])
def calculate():
# 启动异步任务
task = long_running_task()
# 生成回调地址
callback_url = request.args.get('callback_url')
# 发送HTTP请求,通知任务已完成
requests.post(callback_url, json=task)
# 返回任务ID和回调地址
return jsonify({'task_id': 1, 'callback_url': callback_url})
@app.route('/api/task_status', methods=['GET'])
def task_status():
# 查询任务ID
task_id = request.args.get('task_id')
# 根据任务ID从数据库或缓存中获取任务状态
status = '运行中'
if status == '完成':
# 任务完成,获取结果并返回
result = {'result': '计算完成'}
return jsonify(result)
else:
# 任务未完成,返回状态
return jsonify({'status': status})
三、sanic启动服务的坑
- 实现跨域服务需要添加中间件。一般调用api的发起者,不仅有后端也可能是前端页,所以要解决跨域。
@app.middleware("request")
def cors_middle_req(request: Request):
"""路由需要启用OPTIONS方法"""
if request.method.lower() == 'options':
allow_headers = [
'Authorization',
'content-type'
]
headers = {
'Access-Control-Allow-Methods':
', '.join(request.app.router.get_supported_methods(request.path)),
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Headers': ', '.join(allow_headers),
}
return HTTPResponse('', headers=headers)
@app.middleware("response")
def cors_middle_res(request: Request, response: HTTPResponse):
"""跨域处理"""
allow_origin = '*'
response.headers.update(
{
'Access-Control-Allow-Origin': allow_origin,
}
)
- sanic启动进程的问题(sanic服务本身是多进程启动)
<1> app.run(host=‘0.0.0.0’, port=9905, workers=1, single_process=True, debug=False) 这个运行时,通过worker=1是无法控制为单进程的,worker为1时,依然会根据内核数和路由数来生成Python进程(可以看进程监控或看单例日志会启动几个)。所以真正要实现单进程启动api服务,必须要设置single_process=True才是真正的单进程。
<2> 为什么要单进程? 参考tomcat启动服务也是单进程,另外我们是计算密集型的api,请求响应的并发量并不大。如果请求量大的话, 应该把请求发布与计算功能分开在剥离一层。单进程发布服务,当你的多进程计算有相互依赖,比如输入的数据源或输出的结果等,这时会用到多进程管道或多进程消息队列。而这个 计算拆分出的并发多进程,和sanic启动的全局多进程是孤立的,无法提供一个多进程对象同时在这两块多进程之上协作。 除非你用redis或第三方数据库作为最高层的多进程存储交互,但即使这样,因为库和时延的问题,没有进程锁,一样很多资源竞争的问题。
四、Python多进程开发避坑
1. IO密集型任务使用多线程或协程
1> 多线程使用threading模块,如 t1 = threading.Thread(target=worker);t1.start()等方式,当然也可以用线程池。
2> 使用轻量级的线程(协程),通过asyncio模块实现,并配合await关键字。
两者场景大致如下:
- 多线程适合处理需要等待时间较长的I/O任务,例如网络请求、文件读写等。在I/O任务等待期间,线程可以被阻塞,释放CPU资源,提高系统并发性。
- 协程适合处理需要频繁交互的在线游戏、聊天室等场景。通过异步处理消息收发,协程可以避免线程阻塞,保持长时间运行状态,提高系统响应速度。
2. CPU密集型任务(计算密集型)用多进程
1> 使用multiprocess模块或concurrent.futures。Python执行的 main 主函数一般不是守护进程(daemon=True),而在守护进程中创建子进程是会报错的。
- 守护进程会在主进程代码执行结束后就终止。
- 守护进程内无法再启动子进程,否则抛出异常:AssertionError: daemonic processes are not allowed to have children。
- Python中的非守护进程(daemonic process)在主程序关闭后并不会自动结束。它们将继续运行,直到完成执行为止。
2> Python3.8以前的版本中,多进程池不支持设置进程为非守护进程。因此生成进程池对象的代码需要放到main主进程再引用传递到其他进程,或者是在非守护子进程中生成进程池对象。
main.py示例一
# main.py示例一
if __name__ == '__main__':
multiprocessing.set_start_method('spawn') # 选择从父进程复制资源而非继承。
process = multiprocessing.Process(target=sanic_app, daemon=False)
process.start()
manager = multiprocessing.Manager()
shared_mem = manager.dict()
share_lock = manager.Lock()
task_pool = multiprocessing.Pool(processes=3)
Dispatcher.worker(task_pool, shared_mem, share_lock)
process.close()
logs.info('主程序退出!')
main.py示例二
# main.py 示例代码二
task_pool = multiprocessing.Pool(processes=3)
msg_queue = multiprocessing.Queue()
# 共享内存
manager = multiprocessing.Manager()
shared_primary = manager.dict() # parquet读入的缓存数据
shared_result = manager.dict() # 返回的处理结果
# sanic注册消息
app.config['task_result'] = shared_result
app.config['request_queue'] = msg_queue
loop_process = multiprocessing.Process(target=Dispatcher.worker, args=((task_pool, msg_queue, shared_primary, shared_result),))
loop_process.start()
# 注册蓝图
app.blueprint(blue)
if __name__ == '__main__':
logs.info('run sanic workers num: %d', 1)
# 多进程启动服务,因为需要大数据量的读写,会导致进程之间的资源无法共享读写,也没有统一的读写锁。而tomcat也是单进程,靠代码实现多进程。
app.run(host='0.0.0.0', port=9905, workers=1, single_process=True, debug=False) # 生产模式
loop_process.close()
logs.info('主程序退出!')
3> 示例代码一中,使用了multiprocessing.set_start_method(‘spawn’),一般来说都是设置成spawn,防止不同系统带来的区别。
- multiprocessing 模块有两种启动方法:spawn 和 forkserver。
- 在 Windows 平台上,spawn 是默认方法,其他情况下,默认使用 forkserver 方法。
- spawn选择从父进程复制资源而非继承(forkserver)。
- spawn 方法比 forkserver 方法更安全,因为它避免了与子进程共享地址空间和导入模块的问题,但在其他操作系统上,它需要显式地设置。
4> 一般来说,我们写的api需要发布成服务,比如用flask、sanic等web框架发布成restful接口。这里以sanic举例,因为sanic会利用多核进行多进程发布,当我们的算法func1和func2采用了多进程,而func1\func2之间还有竞争和依赖,比如多个不同请求其实输出同一份结果,那需要共享结果对象,防止重复计算。这个时候,sanic的多进程发布将会导致每个进程存在多个重复的结果对象。
- worker设置其实不是单进程,因为worker=1时,监控系统会发现生成多个Python进程,如果使用了单例模式日志,也可以发现会多次初始化。因此单进程要采用此方法:app.run(host=‘0.0.0.0’, port=9905, workers=1, single_process=True, debug=False)
- Sanic 的 worker 进程与标准操作系统的进程不完全一致,它是一个基于 Python 的 green thread 进程,可以在一个进程中启动多个 green thread 来实现并发处理请求。因此,Sanic 的 worker_processes 参数控制的是 green thread 的数量,而不是真正的操作系统进程数量。
- Sanic 在处理请求时之所以会多次复制对象,是因为其使用了异步协程来处理请求,而每个协程都是一个独立的线程,拥有自己的内存空间和状态。
5> 一般来说,多进程之间的协作和通信,我们可以使用多进程管道、消息队列等方式实现数据传递。
- 管道(Pipe):管道是一种实现进程间通信的基本方式。在 Python 中,可以使用 multiprocessing.Pipe 创建管道。
- 队列(Queue):队列是一种线程安全的数据结构,可以用于多进程间通信。在 Python 中,可以使用 multiprocessing.Queue 创建队列。
- 共享内存(Shared Memory):共享内存是一种在多个进程间共享数据的方式。在 Python 中,可以使用 multiprocessing.Value 和 multiprocessing.Array 实现共享内存。multiprocessing.Manager()也可以生成字典或list。
- 信号量(Semaphore):信号量是一种用于控制多个进程对共享资源访问的同步机制。在 Python 中,可以使用 multiprocessing.Semaphore 实现信号量机制。
- 其他方式:还有一些其他的进程间通信方式,如redis、数据库等方式。
- 在使用多进程通信时,需要注意进程间数据同步和互斥访问的问题。可以使用锁(Lock)、信号量(Semaphore)等同步机制来保证数据的正确性和一致性。同时,还需要注意进程间通信的效率和安全性。
6>因为我们写的api需要发布成服务,所以要在更高一层抽象出公共对象,才能保证程序中的多进程和sanic中的服务进行通信。例如把sanic服务发布放到另一个进程中启动,用消息队列保证信息的收发。在用另一个进程中启动进程池,保证cpu密集型计算功能在进程池中根据消息队列进行消费。
3. sanic蓝图的坑及如何传递进程对象到路由中调用
在工程中,启动sanic服务的文件中,基本不会放路由代码,而是通过蓝图在其他py文件中写路由。我们经常会使用app.config[‘abc’]来传递对象,但发现传递进去的多进程对象一直为None。原来是因为蓝图注册动作app.blueprint(blue),必须放在app.config之后,包括要传递的多进程对象。
main.py文件:
import multiprocessing
from multiprocessing import Process, Queue, Pool
from queue import Empty
from sanic import Sanic
from example.multi_process.fun import bp
app = Sanic(__name__)
def consumer(msg_queue):
# 持续处理消息的代码
while True:
try:
msg = msg_queue.get(timeout=1)
# 处理消息的代码
print(f'processing msg: {msg}')
except Empty:
pass
msg_queue = Queue()
p1 = Process(target=consumer, args=(msg_queue,))
p1.start()
p = Pool(processes=3)
print(p)
app.config['task_pool'] = p
app.config['msg_queue'] = msg_queue
app.blueprint(bp)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, workers=1)
route.py文件:
from sanic import Blueprint
from sanic.response import text
bp = Blueprint('my_blueprint')
def fc(msg):
print('功能1')
print(msg)
@bp.route('/add_msg')
async def add_msg(request):
msg = request.args.get('msg')
if msg:
msg_queue = request.app.config.get('msg_queue')
msg_queue.put(msg)
task_pool = request.app.config.get('task_pool')
print(task_pool)
task_pool.apply_async(fc, args=(msg,))
return text('msg added to queue')
else:
return text('no msg provided')
3. Python多进程多核计算并发常用框架
- Dask: Python类似于spark的分布式计算框架,适合gpu等,anconda自带,英伟达支持。
- Pandarallel: 一个能让你的Pandas计算火力拉满的工具,https://blog.csdn.net/lemonbit/article/details/121528708
- Celery: 一个分布式任务调度框架,可以将任务分发到多个进程或者多台机器上执行。
- joblib: 一个用于科学计算的多进程框架,可以将函数并行化执行,提高代码的执行效率。