python项目在对外提供api服务时,利用多核的多进程开发避坑指南

项目落地:Python多进程开发与服务发布避坑

一、场景

  当前有个Python项目,需要实现某个核心算法并提供api接口给其他部门调用;经过相关经验和测试分析,首选了sanic作为发布服务的框架(和flask差不多,不过sanic调用uvloop底层C性能更好)。另外,我们的核心算法需要小时级别的cpu密集型耗时计算,需要考虑api响应设计和多进程多核编程,提高核心算法的并发能力。
 

二、延时接口设计

   对于无法立即响应的api接口,首先请求方是无法一直在请求等待,http一定会是超时的。所以,为了避免这个问题,一般采用两种方案:

  1. 设计一个接口,每次请求立即响应,通过设计状态码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启动服务的坑

  1. 实现跨域服务需要添加中间件。一般调用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,
        }
    )

  1. 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: 一个用于科学计算的多进程框架,可以将函数并行化执行,提高代码的执行效率。
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值