Celery 源碼學習(二)多進程模型

如前文 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 方法,往寫管道同步狀態(主進程可以從管道的另一端讀這個數據).

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373