Celery基本原理探讨

本文对Celery进行了研究,由于其实现相对比较复杂没有足够的时间和精力对各方各面的源码进行分析,因此本文根据Celery的使用方法以及实际行为分析其运行原理,并根据查阅相关代码进行了一定程度的验证。
希望本文能有助于读者理解celery是如何工作的,从而能够更好地使用这个任务框架,而不仅仅是复制官网上的例子来配置。

Celery是Python中任务队列的事实标准。其特点在于:

  • 启动后,本身是一个任务分发进程,会启动若干个worker进程完成任务
  • 需要依赖一个消息队列来负责任务从客户端到Celery进程的派发。这样的好处是,客户端代码只需要向MQ中派发任务请求以及参数,Celery进程就可以从MQ中读取消息并派发给worker,从而达到了客户端程序与Celery进程解耦的效果。而且Celery进程并不需要监听任何端口,减少了配置的复杂性。常用的消息队列实现可以使用RabbitMQ,Redis等等。

下面我们结合Celery的基本使用来分析一下Celery是怎么工作的。本文以Python2为例。

1. 定义Celery配置文件并启动

首先,我们需要定义我们的Celery进程访问哪个Redis进程(假设我们使用Redis作为message backend,在celery的术语中叫做broker)。
Celery提供的方式是创建一个celery instance。我们假设文件目录如下:

lab
    - play
        - __init__.py
        - celery.py
        - tasks.py

然后创建lab/play/celery.py文件:

from __future__ import absolute_import, unicode_literals
from celery import Celery

app = Celery('play',
    broker='redis://127.0.0.1:6379',
    include=['play.tasks'])

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

由于可能会有多个celery进程访问同一个redis,为了让它们之间隔离开就需要给每个celery实例一个名字,我们这里就叫play
除了name和broker参数以外,还使用了include参数来告诉所有的works到哪里去import tasks的代码,因为workers才是真正执行所有这些任务的单位。

好了,接下来就可以启动celery了。在lab目录下执行:

celery -A play.celery worker -l info

即可启动celery进程。Python的路径和模块系统还是比较复杂的,因此在指定包名的时候要注意。

除了使用celery命令以外,由于我们再celery.py中已经加了if __name__ == '__main__':部分代码,因此也可以在lab下直接执行:
python -m play.celery -A play.celery worker -l info

在启动了celery以后,celery进程监听redis消息,并fork出多个worker进程准备将监听到的消息分发给它们执行。

2. 编写任务并执行

现在执行的部分有了,我们开始定义真正需要执行的部分。

我们可以专门写一个文件来存放任务代码(也可以直接写在celery.py里面):

# lab/play/tasks.py
from __future__ import absolute_import, unicode_literals
import time
from celery import Celery

app = Celery('play',
    broker='redis://127.0.0.1:6379')

@app.task
def say_hi():
    print 'hi!'

使用另一个Python进程(也可以使用交互式python或者ipython),在lab下执行:

>>> from play.tasks import say_hi
>>> say_hi.delay()
>>> <AsyncResult: db6737ba-ecee-4fd2-8227-a76c594ba338>
>>>

结果就是say_hi函数向消息队列中发出了一个调用请求由某个worker执行。Celery进程会输出:

[2017-09-03 13:49:57,340: INFO/MainProcess] Received task: play.tasks.say_hi[85ff01ca-d7c9-4401-bfa3-0a9ad96c7192]  
[2017-09-03 13:49:57,343: WARNING/ForkPoolWorker-1] hi!
[2017-09-03 13:49:57,344: INFO/ForkPoolWorker-1] Task play.tasks.say_hi[85ff01ca-d7c9-4401-bfa3-0a9ad96c7192] succeeded in 0.0016004400095s: None

现在我们来分析一下tasks.py这个文件。很奇怪的一点是,一上来我们又创建了一个app实例。当我们import了task文件后会不会又创建了一个celery进程呢?答案是不会的,因为只有调用了app.start()才会启动。这只有手动调用或者借助celery命令执行后才会发生。如果只是new了一个instance出来,相当于创建了一个配置文件,不会发生任何重要的实质性的操作。
但是这个app对象也不是什么都不干的。接下来我们定义了两个task函数,并将这个两个函数使用@app.task包装了起来。这样的效果是把这两个普通函数包装成了celery的task对象,这样他们就有了delay方法。当我们执行delay方法时,这些task会找自己所属的那个celery instance,从中获取配置信息(主要是broker的地址)后将调用请求发往消息队列。

不过,这样定义task的方法并不是很好,因为需要在代码中就显式将task函数和一个具体的celery instance绑定了起来。这就使得我们无法复用这些tasks。因此我们可以使用celery的另一种定义tasks的方式来重写我们现有的代码(这也是推荐给django使用的方案):

from __future__ import absolute_import, unicode_literals
import time
from celery import shared_task

@shared_task
def say_hi():
    print 'hi!'

这里我们不再创建app实例,而是直接使用@shared_task来包装。这样就没有绑定哪个app的问题了。但是正如我们之前所说,在调用tasks的时候,task还是会去寻找自己属于哪个celery instance从而获取配置信息。如果你都不绑定app instance,配置信息哪里来呢?

答案是,tasks和celery instance之间仍然具有绑定或关联的关系,只不过不再是显式的了。简单来说,每个celery instance被创建以后,它就会被自动的注册到某个全局的位置。当一个shared task被执行时,这个task就会自己去这个全局的位置找有哪些celery instances可以从中获取配置信息。如果有多个celery instance都注册了,那么可能它们的消息队列都会被这个task发消息(没有确认过,只是猜测。但这可能就是shared_task的来源)。这就意味着,只要在我们Python进程的任何一个地方(对Django服务器进程也是如此),只要随便哪个地方创建一个celery instance就可以,然后只要import tasks然后使用delay执行即可。这样就解决了celery tasks复用的问题。代码之间的耦合也更小。

更进一步,在我们的python进程中,甚至都不用再手写一遍celery instance的创建调用。直接import play.celery 就可以了,这个文件虽然被celery进程用作了配置文件,但这不妨碍我们在自己的进程中也用这个文件。不如说这是更好的一种解决方案。

posted on 2017-09-03 14:28  luMinO  阅读(2279)  评论(1编辑  收藏  举报

导航