如前文 Celery 源碼學習(一)架構分析 所言,celery 能保證高吞吐量和高性能,主要依托兩個方面:1. 多進程,2.事件驅動。
在 celery 中,多進程主要指的是一個主進程負責調度,然后多個從進程負責消費具體的任務。也就是前文中我說的調度器(Main process)和工作進程(Worker processes)。我們今天主要就是看來一下 celery 這部分的源碼。
我先說明下版本,celery 是 4.2.0,broker 和 result backend 都是用的 redis。
當我們在命令行執行一個常見的啟動命令:
# 在包含任務文件的目錄下
? celery -A tasks.example worker -c 2 --l info
然后我們用 linux 的 ps 命令查看此時的相關進程
# 在包含任務文件的目錄下
? ps -ef | grep -E "celery|PID"
UID PID PPID C STIME TTY TIME CMD
501 1344 331 0 6:27PM ?? 0:00.98 celery -A tasks.example worker -c 2 --l info
501 1348 1344 0 6:27PM ?? 0:00.01 celery -A tasks.example worker -c 2 --l info
501 1349 1344 0 6:27PM ?? 0:00.01 celery -A tasks.example worker -c 2 --l info
可以清楚的看到 celery 為我們啟動了 3 個進程,PID 分別為 1344,1348,1349。
我們可以做一個明顯的推斷,PID 為 1344 的進程,應當是主進程,也就是調度器。
PID 為 1348,1349 的 PPID 為 1344,說明這兩個進程就是主進程派生(fork)出來的從進程。
常見的手段多進程不是 celery 原創的,對于任何一個大型項目,基于主從的多進程模式都是十分常見的,這是一套十分成熟的工業化做法。
為什么這么做?
其實就是如前文所說,為了充分發揮多核計算的優勢,并在一定程度上提升程序的并發能力,緩解 IO 的壓力。
怎么做?
業內的常見方案叫做 prefork,也就是預生成。預生成指的是,主進程在執行具體的業務邏輯之前,先提前 fork 出來一堆子進程,并把他們存起來集中管理,形成一個進程池。平常的時候這些子進程都是 休眠(asleep) 狀態,只有當主進程派發任務的時候,會喚醒(awake)其中的一個子進程,并通過進程間通訊的手段,向子進程傳輸相應的任務數據。
我們先假設一下,如果不使用預生成,會有什么問題?
每當一個任務到來,主進程都會去臨時產生一個子進程,復制一份上下文數據,然后傳輸任務給這個子進程。當子進程執行后,主進程再去銷毀掉這個子進程的所有上下文數據。頻繁的對內存數據進行操作,上下文切換,會導致系統的性能很差。所以,人們基本都會使用預生成的方式。
celery 源碼結構
.
./bootsteps.py # 流程控制相關的數據結構
./signals.py # 基于各組件之間觀察者模式的數據結構
./app # 各種基礎組件,粒度較細
./platforms.py
./bin # celery 命令行的啟動命令需要用到的模塊引導文件
./security
./local.py
./backends # 存放任務結果的相關數據結構
./__init__.py
./five.py
./utils
./contrib
./result.py
./concurrency # 并發模式的相關數據結構
./_state.py
./task # 任務的數據結構
./exceptions.py
./fixups
./worker # 消費者相關的數據結構
./events # 集群內監聽事件的相關數據結構
./states.py
./apps # 按功能拆分出來的三個基礎模塊的數據結構,分別為 worker,multi,beat
./loaders
./__main__.py # 程序主入口
./beat.py
./canvas.py
./schedules.py
具體實現因為我們的主角是 celery,我們會更側重這一套流程應該如何用 python 去實現。因為 celery 代碼很多,有各種各樣的功能組件混雜其中,所以我只會挑取我認為有必要講的源碼實現。對于整個流程,讀者有興趣的話可以自行去研究。
當我們在命令行敲下 :
celery -A tasks.example worker -c 2 --l info
首先,會通過一系列的命令行解析的方法,提取出我們上面那個命令需要運行的模塊 (即Worker 模塊,具體流程因為過于復雜,就不展開講了),解析到一個 Worker 的數據結構,并創建對應的實例,其中我們主要關注 start 方法。
# celery/apps/worker.py
# ...省略
class WorkController(object):
"""Unmanaged worker instance."""
# ...省略
class Blueprint(bootsteps.Blueprint):
"""Worker bootstep blueprint."""
# 這是默認的 worker DAG 流程,會根據傳入的命令行參數不同有不同的執行流程
name = 'Worker'
default_steps = {
'celery.worker.components:Hub',
'celery.worker.components:Pool',
'celery.worker.components:Beat',
'celery.worker.components:Timer',
'celery.worker.components:StateDB',
'celery.worker.components:Consumer',
'celery.worker.autoscale:WorkerComponent',
}
# ... 省略
class Worker(WorkController):
"""Worker as a program."""
# ... 省略
def start(self):
try:
self.blueprint.start(self) # 重點關注!
except WorkerTerminate:
self.terminate()
except Exception as exc:
logger.critical('Unrecoverable error: %r', exc, exc_info=True)
self.stop(exitcode=EX_FAILURE)
except SystemExit as exc:
self.stop(exitcode=exc.code)
except KeyboardInterrupt:
self.stop(exitcode=EX_FAILURE)
# ... 省略
Worker 的 start 方法中,其實就是執行了一個 self.blueprint 的 start 方法,這里面的 blueprint,是 celery 自己實現的一個 有向無環圖(DAG)的數據結構,說起來復雜,其實功能簡單描述下就是:根據命令行傳入的不同參數,初始化不同的組件(step),并執行這些組件的初始化方法。其實就是一個對流程控制的面向對象的封裝。
我們的這個啟動命令產生的 DAG,會按順序加載三個組件,Hub,Pool,Consumer(這些組件的數據結構可以在 celery/worker/components.py 找到)。Consumer 和 Hub 是我之后會詳細講的,我們這次主要講一下 Pool 這個組件。這個組件基本囊括了 celery 多進程 prefork 的實現。
self.blueprint.start(self) 中,這個 blueprint 的數據結構定義如下,其中我們重點關注 start 方法:
# celery/bootsteps.py
# ... 省略
class Blueprint(object):
"""Blueprint containing bootsteps that can be applied to objects.
Arguments:
steps Sequence[Union[str, Step]]: List of steps.
name (str): Set explicit name for this blueprint.
on_start (Callable): Optional callback applied after blueprint start.
on_close (Callable): Optional callback applied before blueprint close.
on_stopped (Callable): Optional callback applied after
blueprint stopped.
"""
GraphFormatter = StepFormatter
name = None
state = None
started = 0
default_steps = set()
state_to_name = {
0: 'initializing',
RUN: 'running',
CLOSE: 'closing',
TERMINATE: 'terminating',
}
def __init__(self, steps=None, name=None,
on_start=None, on_close=None, on_stopped=None):
self.name = name or self.name or qualname(type(self))
self.types = set(steps or []) | set(self.default_steps)
self.on_start = on_start
self.on_close = on_close
self.on_stopped = on_stopped
self.shutdown_complete = Event()
self.steps = {}
def start(self, parent): # 重點關注!
self.state = RUN
if self.on_start:
self.on_start()
for i, step in enumerate(s for s in parent.steps if s is not None):
self._debug('Starting %s', step.alias)
self.started = i + 1
step.start(parent)
logger.debug('^-- substep ok')
# ...省略
start 方法中的 parent.steps,其實就是 Hub,Pool,Consumer 這三個組件的實例組成的列表。我們可以看到,其實就是依次調用這三個組件實例的 start 方法(。。。celery 的作者特別喜歡把方法名叫做 start)我們直接去 components.py 文件中查看 class Pool(bootsteps.StartStopStep) 組件的源碼,會發現這個 start 方法還是藏的很隱蔽的。
因為不是很直觀,且過程非常曲折,我這里就不詳細描述具體過程了,直接說結論:這個 start 方法最終會調用 celery/concurrency/prefork.py中的TaskPool 類下的 on_start 方法。我們可以看下這個 on_start 方法:
# celery/concurrency/prefork.py
# ...省略
class TaskPool(BasePool):
"""Multiprocessing Pool implementation."""
Pool = AsynPool
BlockingPool = BlockingPool
uses_semaphore = True
write_stats = None
def on_start(self):
forking_enable(self.forking_enable)
Pool = (self.BlockingPool if self.options.get('threads', True)
else self.Pool)
# 重點關注下面這個!
P = self._pool = Pool(processes=self.limit,
initializer=process_initializer,
on_process_exit=process_destructor,
enable_timeouts=True,
synack=False,
**self.options)
# Create proxy methods
self.on_apply = P.apply_async
self.maintain_pool = P.maintain_pool
self.terminate_job = P.terminate_job
self.grow = P.grow
self.shrink = P.shrink
self.flush = getattr(P, 'flush', None) # FIXME add to billiard
# ...省略
到了這里我們就清楚多了,主要是執行了 Pool 的實例化。其實這個實例化就是 prefork 的具體實現。這個 Pool 其實就是 AsyncPool,源碼在下面:
# celery/concurrency/asynpool.py
# ...省略
class AsynPool(_pool.Pool):
"""AsyncIO Pool (no threads)."""
ResultHandler = ResultHandler
Worker = Worker
def WorkerProcess(self, worker):
worker = super(AsynPool, self).WorkerProcess(worker)
worker.dead = False
return worker
def __init__(self, processes=None, synack=False,
sched_strategy=None, *args, **kwargs):
self.sched_strategy = SCHED_STRATEGIES.get(sched_strategy,
sched_strategy)
processes = self.cpu_count() if processes is None else processes
self.synack = synack
# create queue-pairs for all our processes in advance.
# 重點!創建多個讀寫的管道
self._queues = {
self.create_process_queues(): None for _ in range(processes)
}
# 省略
super(AsynPool, self).__init__(processes, *args, **kwargs) # 重點
for proc in self._pool: # 重點
# create initial mappings, these will be updated
# as processes are recycled, or found lost elsewhere.
self._fileno_to_outq[proc.outqR_fd] = proc
self._fileno_to_synq[proc.synqW_fd] = proc
# 省略
# ... 省略
看到這里,可能有的小伙伴就懵了,說好的 fork 呢?說好的 進程間通訊呢?
別急,其實 fork 和進程間通訊都藏在上面那一坨代碼里了processes = self.cpu_count() if processes is None else processes 這個 processes 的值,就是需要 fork 的子進程數量,默認是 cpu 核數,如果在命令行制定了 -c 參數,則是 -c 參數的值,在本例子中,為 2。
self.create_process_queues(): None for _ in range(processes) 其實就是創建出來了一堆讀和寫的管道,具體邏輯在 billiard/connection.py 文件中,因為邏輯較復雜,所以本文就省略了。
根據流向的不同和主進程與子進程的不同,之后會分別關閉對應的的一端的管道,比如父進程把寫關閉,子進程就把讀關閉。并會用抽象的數據結構進行封裝以便于管理。這個數據結構的實例用來為主進程和即將 fork 的子進程提供雙向的數據傳輸。
同樣的,會根據子進程的數量創建出多個管道實例來。其中有個比較奇怪的一點就是,我在父進程關閉了一端的管道,fork 了之后,結果在子進程還是可以用這一端。
這個也許是 fork 的子進程不繼承父進程的管道關閉狀態?其中最重要的方法是 super(AsynPool, self).init(processes, *args, **kwargs) 中執行的 self._create_worker_process(i),這里面就是 fork 的關鍵所在。相關源碼如下:
# 這個類在 celery 的依賴庫 billiard 中的 pool.py 文件中
# billiard/pool.py
class Pool(object):
'''
Class which supports an async version of applying functions to arguments.
'''
# 省略
def __init__(self, processes=None, initializer=None, initargs=(),
maxtasksperchild=None, timeout=None, soft_timeout=None,
lost_worker_timeout=None,
max_restarts=None, max_restart_freq=1,
on_process_up=None,
on_process_down=None,
on_timeout_set=None,
on_timeout_cancel=None,
threads=True,
semaphore=None,
putlocks=False,
allow_restart=False,
synack=False,
on_process_exit=None,
context=None,
max_memory_per_child=None,
enable_timeouts=False,
**kwargs):
# 省略
# 重點關注!
for i in range(self._processes): #cityblack !important
self._create_worker_process(i)
def _create_worker_process(self, i):
sentinel = self._ctx.Event() if self.allow_restart else None
inq, outq, synq = self.get_process_queues()
w = self.WorkerProcess(self.Worker(
inq, outq, synq, self._initializer, self._initargs,
self._maxtasksperchild, sentinel, self._on_process_exit,
# Need to handle all signals if using the ipc semaphore,
# to make sure the semaphore is released.
sigprotection=self.threads,
wrap_exception=self._wrap_exception,
max_memory_per_child=self._max_memory_per_child,
))
self._pool.append(w)
self._process_register_queues(w, (inq, outq, synq))
w.name = w.name.replace('Process', 'PoolWorker')
w.daemon = True
w.index = i
w.start() # 重點關注!
self._poolctrl[w.pid] = sentinel
if self.on_process_up:
self.on_process_up(w)
return w
inq, outq, synq = self.get_process_queues() 拿到的是一個讀和寫的管道的抽象對象。這個管道是之前預先創建好的(就是上面 self.create_process_queues() 創建的)。
主要是給即將 fork 的子進程用的,子進程會監聽這管道數據結構抽象實例中的讀事件,還可以從寫管道寫數據。
w,也就是 self.WorkerProcess 的實例,其實是對 fork 出來的子進程的一個抽象封裝。用來方便快捷的管理子進程,抽象成一個進程池,這個 w 會記錄 fork 出來的子進程的一些 meta 信息,比如 pid,管道的讀寫的 fd 等等,并注冊在主進程中,主進程可以利用它進行任務分發。w.start() 中包含具體的 fork 過程,相關源碼在:
#billiard/process.py
# 省略
class BaseProcess(object):
# 省略
def start(self):
'''
Start child process
'''
assert self._popen is None, 'cannot start a process twice'
assert self._parent_pid == os.getpid(), \
'can only start a process object created by current process'
_cleanup()
self._popen = self._Popen(self) # 重點關注!
self._sentinel = self._popen.sentinel
_children.add(self)
# 省略
我們看到其中主要是 self._popen = self._Popen(self) 比較重要,我們看下 Popen 的源碼:\
# billiard/popen_fork.py
# 省略
class Popen(object):
method = 'fork'
sentinel = None
def __init__(self, process_obj):
sys.stdout.flush()
sys.stderr.flush()
self.returncode = None
self._launch(process_obj)
# 省略
def _launch(self, process_obj):
code = 1
parent_r, child_w = os.pipe()
self.pid = os.fork()
if self.pid == 0:
try:
os.close(parent_r)
if 'random' in sys.modules:
import random
random.seed()
code = process_obj._bootstrap()
finally:
os._exit(code)
else:
os.close(child_w)
self.sentinel = parent_r
看到這里我們應該明白了。在執行 launch 方法的時候,會使用 os.fork() 派生出一個子進程,并且使用 ps.pipe() 創建出一對讀寫的管道,之后通過比較 self.pid 是否為 0,在主進程和子進程中執行不同的邏輯。子進程關閉 讀 管道,之后執行 process_obj._bootstrap() 方法。
然后就是 process_obj._bootstrap(),這個方法就是子進程執行的最后一個方法。當子進程執行完這個方法后,這個子進程已經進入了可用狀態,隨時等待著從主進程的管道接受任務。具體的流程比較復雜,我直接展示 process_obj._bootstrap() 的最后一步的源碼,他會執行 workloop 方法,進入一個無限的循環:
# billiard/pool.py
# 省略
#
# Code run by worker processes
#
class Worker(object):
# 省略
def workloop(self, debug=debug, now=monotonic, pid=None):
pid = pid or os.getpid()
put = self.outq.put
inqW_fd = self.inqW_fd
synqW_fd = self.synqW_fd
maxtasks = self.maxtasks
max_memory_per_child = self.max_memory_per_child or 0
prepare_result = self.prepare_result
wait_for_job = self.wait_for_job
_wait_for_syn = self.wait_for_syn
def wait_for_syn(jid):
i = 0
while 1:
if i > 60:
error('!!!WAIT FOR ACK TIMEOUT: job:%r fd:%r!!!',
jid, self.synq._reader.fileno(), exc_info=1)
req = _wait_for_syn()
if req:
type_, args = req
if type_ == NACK:
return False
assert type_ == ACK
return True
i += 1
completed = 0
while maxtasks is None or (maxtasks and completed < maxtasks):
req = wait_for_job()
if req:
type_, args_ = req
assert type_ == TASK
job, i, fun, args, kwargs = args_
put((ACK, (job, i, now(), pid, synqW_fd)))
if _wait_for_syn:
confirm = wait_for_syn(job)
if not confirm:
continue # received NACK
try:
result = (True, prepare_result(fun(*args, **kwargs)))
except Exception:
result = (False, ExceptionInfo())
try:
put((READY, (job, i, result, inqW_fd)))
except Exception as exc:
_, _, tb = sys.exc_info()
try:
wrapped = MaybeEncodingError(exc, result[1])
einfo = ExceptionInfo((
MaybeEncodingError, wrapped, tb,
))
put((READY, (job, i, (False, einfo), inqW_fd)))
finally:
del(tb)
completed += 1
if max_memory_per_child > 0:
used_kb = mem_rss()
if used_kb <= 0:
error('worker unable to determine memory usage')
if used_kb > 0 and used_kb > max_memory_per_child:
error(MAXMEM_USED_FMT.format(
used_kb, max_memory_per_child))
return EX_RECYCLE
debug('worker exiting after %d tasks', completed)
if maxtasks:
return EX_RECYCLE if completed == maxtasks else EX_FAILURE
return EX_OK
# 省略
這個 workloop 其實很明顯,就是監聽讀管道的數據(主進程從這個管道的另一端寫),然后執行對應的回調,期間會調用 put 方法,往寫管道同步狀態(主進程可以從管道的另一端讀這個數據).