Python 定時任務最佳實踐

背景

最近有個需求,需要實現一個定時或定期任務的功能,需要實現每月、每日、每時、一次性等需求,必須是輕量級不依賴其它額外組件,並能支持動態添加任務。由於當前任務信息保存在集羣 ETCD 數據庫中,因此對任務持久化要求不高,每次重啓都直接讀取 ETCD 任務信息,爲了後面擴展,還需要添加任務持久化功能。

定時任務庫對比

根據上面需求,從社區中找到了幾個 Python 好用的任務調度庫。有以下幾個庫:

  • schedule:Python job scheduling for humans. 輕量級,無需配置的作業調度庫
  • python-crontab: 針對系統 Cron 操作 crontab 文件的作業調度庫
  • Apscheduler:一個高級的 Python 任務調度庫
  • Celery: 是一個簡單,靈活,可靠的分佈式系統,用於處理大量消息,同時爲操作提供維護此類系統所需的工具, 也可用於任務調度

優缺點對比:

  • schedule 優點是簡單、輕量級、無需配置、語法簡單,缺點是阻塞式調用、無法動態添加或刪除任務
  • Python-crontab 優點是針對於系統 crontab 操作,支持定時、定期任務,能夠動態添加任務,不能實現一次性任務需求
  • Apscheduler 優點支持定時、定期、一次性任務,支持任務持久化及動態添加、支持配置各種持久化存儲源(如 redis、MongoDB),支持接入到各種異步框架(如 gevent、asyncio、tornado)
  • Celery 支持配置定期任務、支持 crontab 模式配置,不支持一次性定時任務

schedule 庫

人類的Python 任務調度庫,和 requests 庫一樣 for humans. 這個庫也是最輕量級的一個任務調度庫,schedule 允許用戶使用簡單、人性化的語法以預定的時間間隔定期運行Python函數(或其它可調用函數)。

直接使用 pip install schedule進行安裝使用,下面來看看官網給的示例:

import schedule
import time

# 定義你要週期運行的函數
def job():
    print("I'm working...")

schedule.every(10).minutes.do(job)               # 每隔 10 分鐘運行一次 job 函數
schedule.every().hour.do(job)                    # 每隔 1 小時運行一次 job 函數
schedule.every().day.at("10:30").do(job)         # 每天在 10:30 時間點運行 job 函數
schedule.every().monday.do(job)                  # 每週一 運行一次 job 函數
schedule.every().wednesday.at("13:15").do(job)   # 每週三 13:15 時間點運行 job 函數
schedule.every().minute.at(":17").do(job)        # 每分鐘的 17 秒時間點運行 job 函數

while True:
    schedule.run_pending()   # 運行所有可以運行的任務
    time.sleep(1)

通過上面示例,可以很容易學會使用 schedule 庫,可以設置秒、分鐘、小時、天、周來運行任務,然後通過一個死循環,一直不斷地運行所有的計劃任務。

schedule 常見問題

1、如何並行執行任務?

schedule 是阻塞式的,默認情況下, schedule 按順序執行所有的作業,不能達到並行執行任務。如下所示:

import arrow
import schedule

def job1():
    print("job1 start time: %s" % arrow.get().format())
    time.sleep(2)
    print("job1 end time: %s" % arrow.get().format())

def job2():
    print("job2 start time: %s" % arrow.get().format())
    time.sleep(5)
    print("job2 end time: %s" % arrow.get().format())

def job3():
    print("job3 start time: %s" % arrow.get().format())
    time.sleep(10)
    print("job3 end time: %s" % arrow.get().format())

if __name__ == '__main__':
    schedule.every(10).seconds.do(job1)
    schedule.every(30).seconds.do(job2)
    schedule.every(5).to(10).seconds.do(job3)

    while True:
        schedule.run_pending()

返回部分結果如下所示,幾個任務並不是並行開始的,是安裝時間順序先後開始的:

job3 start time: 2019-06-01 09:27:54+00:00
job3 end time: 2019-06-01 09:28:04+00:00
job1 start time: 2019-06-01 09:28:04+00:00
job1 end time: 2019-06-01 09:28:06+00:00
job3 start time: 2019-06-01 09:28:13+00:00
job3 end time: 2019-06-01 09:28:23+00:00
job2 start time: 2019-06-01 09:28:23+00:00
job2 end time: 2019-06-01 09:28:28+00:00
job1 start time: 2019-06-01 09:28:28+00:00
job1 end time: 2019-06-01 09:28:30+00:00
job3 start time: 2019-06-01 09:28:30+00:00
job3 end time: 2019-06-01 09:28:40+00:00
job1 start time: 2019-06-01 09:28:40+00:00
job1 end time: 2019-06-01 09:28:42+00:00

如果需要實現並行,那麼使用多線程方式運行任務,官方給出的並行方案如下:

import threading
import time
import schedule

def job():
    print("I'm running on thread %s" % threading.current_thread())

def run_threaded(job_func):
    job_thread = threading.Thread(target=job_func)
    job_thread.start()

schedule.every(10).seconds.do(run_threaded, job)
schedule.every(10).seconds.do(run_threaded, job)
schedule.every(10).seconds.do(run_threaded, job)
schedule.every(10).seconds.do(run_threaded, job)
schedule.every(10).seconds.do(run_threaded, job)

while 1:
    schedule.run_pending()
    time.sleep(1)
    
    
# 我在項目裏也是通過對每個任務運行後臺線程方式, 可以通過 run_daemon_thread 起一個守護線程方式來達到動態
添加任務的功能,每個任務最終通過新開線程方式執行
import threading

    
def ensure_schedule():
    schedule.every(5).seconds.do(do_some)
    
def ensure_schedule_2():
    schedule.every(10).seconds.do(print_some)

def run_daemon_thread(target, *args, **kwargs):
    job_thread = threading.Thread(target=target, args=args, kwargs=kwargs)
    job_thread.setDaemon(True)
    job_thread.start()

def __start_schedule_deamon():
    def schedule_run():
        while True:
            schedule.run_pending()
            time.sleep(1)

    t = threading.Thread(target=schedule_run)
    t.setDaemon(True)
    t.start()
    
def init_schedule_job():
		run_daemon_thread(ensure_schedule)
		run_daemon_thread(ensure_schedule_2)
		
init_schedule_job()
__start_schedule_deamon()

2、如何在不阻塞主線程的情況下連續運行調度程序?

官方推薦了這個方式,在單獨的線程中運行調度程序,如下,在單獨的線程中運行 run_pending 調度程序。通過 threading 庫的 Event 來實現

 # https://github.com/mrhwick/schedule/blob/master/schedule/__init__.py
 def run_continuously(self, interval=1):
        """Continuously run, while executing pending jobs at each elapsed
        time interval.
        @return cease_continuous_run: threading.Event which can be set to
        cease continuous run.
        Please note that it is *intended behavior that run_continuously()
        does not run missed jobs*. For example, if you've registered a job
        that should run every minute and you set a continuous run interval
        of one hour then your job won't be run 60 times at each interval but
        only once.
        """
        cease_continuous_run = threading.Event()

        class ScheduleThread(threading.Thread):
            @classmethod
            def run(cls):
                while not cease_continuous_run.is_set():
                    self.run_pending()
                    time.sleep(interval)

        continuous_thread = ScheduleThread()
        continuous_thread.start()
        return cease_continuous_run

3、是否支持時區

# 官方不計劃支持時區,可使用: 
討論:https://github.com/dbader/schedule/pull/16
時區解決:https://github.com/imiric/schedule/tree/feat/timezone 

4、如果我的任務拋出異常怎麼辦?

schedule 不捕獲作業執行期間發生的異常,因此在任務執行期間的任何異常都會冒泡並中斷調度的 run_xyz(如 run_pending ) 函數, 也就是 run_pending 中斷退出,導致其它任務無法執行

import functools

def catch_exceptions(cancel_on_failure=False):
    def catch_exceptions_decorator(job_func):
        @functools.wraps(job_func)
        def wrapper(*args, **kwargs):
            try:
                return job_func(*args, **kwargs)
            except:
                import traceback
                print(traceback.format_exc())
                if cancel_on_failure:
                    return schedule.CancelJob
        return wrapper
    return catch_exceptions_decorator

@catch_exceptions(cancel_on_failure=True)
def bad_task():
    return 1 / 0

schedule.every(5).minutes.do(bad_task)

另外一種解決方案:
https://gist.github.com/mplewis/8483f1c24f2d6259aef6

5、如何設置只跑一次的任務?

def job_that_executes_once():
    # Do some work ...
    return schedule.CancelJob

schedule.every().day.at('22:30').do(job_that_executes_once)

6、如何一次取消多個任務?

# 通過 tag 函數給它們添加唯一標識符進行分組,取消時通過標識符進行取消相應組的任務
def greet(name):
    print('Hello {}'.format(name))

schedule.every().day.do(greet, 'Andrea').tag('daily-tasks', 'friend')
schedule.every().hour.do(greet, 'John').tag('hourly-tasks', 'friend')
schedule.every().hour.do(greet, 'Monica').tag('hourly-tasks', 'customer')
schedule.every().day.do(greet, 'Derek').tag('daily-tasks', 'guest')

schedule.clear('daily-tasks')

7、如何傳遞參數給任務函數

def greet(name):
    print('Hello', name)

schedule.every(2).seconds.do(greet, name='Alice')
schedule.every(4).seconds.do(greet, name='Bob')

schedule 源碼閱讀

使用 0.6.0 版本最新代碼進行分析,加起來才 6百多行,實現很簡潔,先來看看當前的文件結構

"""
➜ tree
# 省略部分文件,代碼全部在 __init__.py 中
.
├── schedule
│   └── __init__.py
├── setup.py
├── test_schedule.py
└── tox.ini

先從一段簡單的代碼分析
"""
import schedule
import time
import arrow

def job():
    print(f"time: {arrow.now().format('YYYY-MM-DD HH:mm:ss')}, I'm working...")

if __name__ == '__main__':
    print('start schedule...')
    schedule.every(10).seconds.do(job)

    while True:
        schedule.run_pending()
        time.sleep(1)
        
"""
start schedule...
time: 2019-09-27 21:28:14, I'm working...
time: 2019-09-27 21:28:24, I'm working...
time: 2019-09-27 21:28:34, I'm working...
"""

先來看看 run_pending 做了什麼事情:

def run_pending():
    """Calls :meth:`run_pending <Scheduler.run_pending>` on the
    :data:`default scheduler instance <default_scheduler>`.
    """
    default_scheduler.run_pending()  # what? default_scheduler 又是什麼?
    
    
#: Default :class:`Scheduler <Scheduler>` object
default_scheduler = Scheduler()   

原來是 Scheduler 類的一個實例,接下來去 Scheduler 找 run_pending 看看這個方法做了什麼操作.

Scheduler 類

class Scheduler(object):
    """
    Objects instantiated by the :class:`Scheduler <Scheduler>` are
    factories to create jobs, keep record of scheduled jobs and
    handle their execution.
    """
    def __init__(self):
        self.jobs = []

1、初始化函數 __init__ 做了什麼初始化操作,結果只是簡單地設置了一個保存 Job 的列表,這也是 schedule 簡潔設計的一個重要點,所有運行的任務都使用一個列表來保存在內存中,不提供任何接入不同存儲的配置項。

class Scheduler(object):
    """
    Objects instantiated by the :class:`Scheduler <Scheduler>` are
    factories to create jobs, keep record of scheduled jobs and
    handle their execution.
    """
		# 省略部分代碼
    def run_pending(self):
        """
        運行所有計劃運行的作業
        Run all jobs that are scheduled to run.

        Please note that it is *intended behavior that run_pending()
        does not run missed jobs*. For example, if you've registered a job
        that should run every minute and you only call run_pending()
        in one hour increments then your job won't be run 60 times in
        between but only once.
        """
        runnable_jobs = (job for job in self.jobs if job.should_run)
        for job in sorted(runnable_jobs):
            self._run_job(job)

2、接着看看 run_pending 方法做了什麼,可以看到 run_pending 從 jobs 列表中獲取當前可以運行的 job 保存在 runnable_jobs, 並對 runnable_jobs 元祖進行排序並對每一個 job 執行 _run_job 方法, 這個地方可能有人會有疑問,Job 到底是個什麼對象,爲什麼可以用 sorted 方法排序,難道 Job 對象實現了一些 Python 黑魔法函數,例如 __lt__ 等?

    def _run_job(self, job):
        ret = job.run()
        if isinstance(ret, CancelJob) or ret is CancelJob:
            self.cancel_job(job)
            
    def cancel_job(self, job):
        """
        Delete a scheduled job.

        :param job: The job to be unscheduled
        """
        try:
            self.jobs.remove(job)
        except ValueError:
            pass

3、進入 _run_job 方法,可以看到這個方法只是負責調用 job 對象的 run 方法運行這個 job 而已,並且根據 run 返回的對象判斷是否取消該任務. 取消一個 job 就是從 jobs 列表中將這個 job 對象去除即可

Job 類

從上面 Schedule 追蹤下面,最後還是調用 job 的 run 方法進行操作,接下來就繼續對 Job 追蹤

1、Job 的初始化方法做了什麼

class Job(object):
    """
    A periodic job as used by :class:`Scheduler`.

    :param interval: A quantity of a certain time unit
    :param scheduler: The :class:`Scheduler <Scheduler>` instance that
                      this job will register itself with once it has
                      been fully configured in :meth:`Job.do()`.

    Every job runs at a given fixed time interval that is defined by:

    * a :meth:`time unit <Job.second>`
    * a quantity of `time units` defined by `interval`

    A job is usually created and returned by :meth:`Scheduler.every`
    method, which also defines its `interval`.
    """
    def __init__(self, interval, scheduler=None):
        self.interval = interval  # pause interval * unit between runs(配置運行任務的時間單位數量,如 seconds(10).do(job),10 就是這個時間單位數量)
        self.latest = None  # upper limit to the interval( interval 的上限)
        self.job_func = None  # the job job_func to run   (運行任務的函數,通常是我們定義需要定時執行的代碼邏輯)
        self.unit = None  # time units, e.g. 'minutes', 'hours', ... (時間單位)
        self.at_time = None  # optional time at which this job runs   (設置在某個時間點運行)
        self.last_run = None  # datetime of the last run   (上次執行的時間)
        self.next_run = None  # datetime of the next run    (下次執行的時間)
        self.period = None  # timedelta between runs, only valid for
        self.start_day = None  # Specific day of the week to start on (指定一週中的第幾天運行)
        self.tags = set()  # unique set of tags for the job  (Job 的唯一tag 標識)
        self.scheduler = scheduler  # scheduler to register with  (Scheduler 類,可繼承並使用自己實現的 Scheduler 類)
        
   def __lt__(self, other):
        """
        看這個黑魔法函數,就是上面 Scheduler 類中 job 可以使用 sorted 排序的原因
        PeriodicJobs are sortable based on the scheduled time they
        run next.
        """
        return self.next_run < other.next_run  

2、job run 方法的邏輯

    def run(self):
        """
        Run the job and immediately reschedule it.

        :return: The return value returned by the `job_func`
        """
        logger.info('Running job %s', self)
        ret = self.job_func()
        self.last_run = datetime.datetime.now()
        self._schedule_next_run()
        return ret

3、every 函數操作

可能看到這裏的朋友覺得有點混亂了,job 是什麼時候實例化的,又是什麼時候加入 Scheduler 的 jobs 列表的,其實我沒有按照 示例代碼的順序來講話,可以回到前面代碼示例中有下面這句代碼在 run_pending 之前:

schedule.every(10).seconds.do(job)

也就是這個地方實例化 Job 類的,可以跳到這段代碼分析分析:

class Scheduler(object):
		# 省略部分代碼
    def every(self, interval=1):
        """
        Schedule a new periodic job.

        :param interval: A quantity of a certain time unit
        :return: An unconfigured :class:`Job <Job>`
        """
        job = Job(interval, self)
        return job
      
def every(interval=1):
    """Calls :meth:`every <Scheduler.every>` on the
    :data:`default scheduler instance <default_scheduler>`.
    """
    return default_scheduler.every(interval)

4、do 函數操作

可以看到在調用 every 函數時,最終調用的是 Scheduler 類的 every 方法,該方法主要是根據設置的間隔時間(interval) 實例化 Job 類並返回該實例,這段代碼同樣沒有 job 加入 Scheduler 的 jobs 列表的邏輯,那就是在 seconds.do 方法進行的操作,接着翻代碼看看:

    def do(self, job_func, *args, **kwargs):
        """
        Specifies the job_func that should be called every time the
        job runs.

        Any additional arguments are passed on to job_func when
        the job runs.

        :param job_func: The function to be scheduled
        :return: The invoked job instance
        """
        self.job_func = functools.partial(job_func, *args, **kwargs)
        try:
            functools.update_wrapper(self.job_func, job_func)
        except AttributeError:
            # job_funcs already wrapped by functools.partial won't have
            # __name__, __module__ or __doc__ and the update_wrapper()
            # call will fail.
            pass
        self._schedule_next_run()
        self.scheduler.jobs.append(self)
        return self
  • 首先使用標準庫的 partial (偏函數) 先提前爲 job_func(我們提供的業務邏輯代碼)設置參數,用一些默認參數包裝一個可調用對象,返回結果是可調用對象,並且可以像原始對象一樣對待,凍結部分函數位置函數或關鍵字參數,簡化函數,更少更靈活的函數參數調用。
  • update_wrapper 做了什麼操作,這個函數的作用就是從 **被修飾的函數(job_func) ** 中取出一些屬性值來,賦值給 修飾器函數(self.job_func) 。默認 partial 對象沒有 namedoc, 這種情況下,對於裝飾器函數非常難以debug.
  • 接着就是 self._schedule_next_run 方法,這個是 schedule 庫的核心代碼,有點複雜,下面慢慢解釋
  • 接下來一行就是想要的答案了,這個時候將當前 Job 類加入 Scheduler 類的 jobs 列表中,append(self) 這個 self 就是當前的 Job 類

5、核心 _schedule_next_run

    def _schedule_next_run(self):
        """
        Compute the instant when this job should run next.
        """
        # 第一步,判斷當前運行的時間單位是否在指定範圍內,不在則報錯
        if self.unit not in ('seconds', 'minutes', 'hours', 'days', 'weeks'):
            raise ScheduleValueError('Invalid unit')
            
				# 如果 interval 的上限時間不爲 None, 判斷 interval 上限時間是否小於 interval
        # 小於則報錯,latest 用於 every(A).to(B).seconds 每 N 秒執行一次 job 任務,
        # 其中 A <= N <= B. 所以 A(interval) <= B (latest)
        if self.latest is not None:
            if not (self.latest >= self.interval):
                raise ScheduleError('`latest` is greater than `interval`')
            # 執行時間隨機從 interval 到 latest 之前取值
            interval = random.randint(self.interval, self.latest)
        else:
            interval = self.interval

        # 下面兩行用於獲取下一次執行的時間
        self.period = datetime.timedelta(**{self.unit: interval})
        self.next_run = datetime.datetime.now() + self.period
        
        # start_day 這個只會在設置一週的第幾天執行纔會有,所以 unit 時間單位不是 weeks 就報錯
        if self.start_day is not None:
            if self.unit != 'weeks':
                raise ScheduleValueError('`unit` should be \'weeks\'')
            weekdays = (
                'monday',
                'tuesday',
                'wednesday',
                'thursday',
                'friday',
                'saturday',
                'sunday'
            )
            # 如果天數的標識不是上面的,報錯
            if self.start_day not in weekdays:
                raise ScheduleValueError('Invalid start day')
            
            weekday = weekdays.index(self.start_day)
            # datetime 的 weekday() 函數,計算目標時間是否已經在本週發送
            days_ahead = weekday - self.next_run.weekday() 
            if days_ahead <= 0:  # Target day already happened this week
                days_ahead += 7
            self.next_run += datetime.timedelta(days_ahead) - self.period
        if self.at_time is not None:
            if (self.unit not in ('days', 'hours', 'minutes')
                    and self.start_day is None):
                raise ScheduleValueError(('Invalid unit without'
                                          ' specifying start day'))
            kwargs = {
                'second': self.at_time.second,
                'microsecond': 0
            }
            if self.unit == 'days' or self.start_day is not None:
                kwargs['hour'] = self.at_time.hour
            if self.unit in ['days', 'hours'] or self.start_day is not None:
                kwargs['minute'] = self.at_time.minute
            self.next_run = self.next_run.replace(**kwargs)
            # If we are running for the first time, make sure we run
            # at the specified time *today* (or *this hour*) as well
            if not self.last_run:
                now = datetime.datetime.now()
                if (self.unit == 'days' and self.at_time > now.time() and
                        self.interval == 1):
                    self.next_run = self.next_run - datetime.timedelta(days=1)
                elif self.unit == 'hours' \
                        and self.at_time.minute > now.minute \
                        or (self.at_time.minute == now.minute
                            and self.at_time.second > now.second):
                    self.next_run = self.next_run - datetime.timedelta(hours=1)
                elif self.unit == 'minutes' \
                        and self.at_time.second > now.second:
                    self.next_run = self.next_run - \
                                    datetime.timedelta(minutes=1)
        if self.start_day is not None and self.at_time is not None:
            # Let's see if we will still make that time we specified today
            if (self.next_run - datetime.datetime.now()).days >= 7:
                self.next_run -= self.period

APScheduler 庫

APScheduler(Advanced Python Scheduler)是基於Quartz的一個Python定時任務框架,實現了Quartz的所有功能, 是一個輕量級但功能強大的進程內任務調度程序。它有以下三個特點:

  • 類似於 Liunx Cron 的調度程序(可選的開始/結束時間)
  • 基於時間間隔的執行調度(週期性調度,可選的開始/結束時間)
  • 一次性執行任務(在設定的日期/時間運行一次任務)

可以按照個人喜好來混合和匹配調度系統和存儲作業的後端存儲,支持以下幾種後臺作業存儲:

  • Memory
  • SQLAlchemy (任何 SQLAlchemy 支持的關係型數據庫)
  • MongoDB
  • Redis
  • ZooKeeper
  • RethinkDB

APScheduler 集成了以下幾個 Python 框架:

  • asyncio
  • gevent
  • Tornado
  • Twisted
  • Qt

總結以上,APScheduler 支持基於日期、固定時間、crontab 形式三種形式的任務調度,可以靈活接入各種類型的後臺作業存儲來持久化作業,同時提供了多種調度器(後面提及),集成多種 Python 框架,可以根據實際情況靈活組合後臺存儲以及調度器來使用。

APScheduler 的架構及工作原理

1、APScheduler 基本概念

APScheduler 由四個組件構成(注:該部分翻譯至官方文檔):

  • triggers 觸發器

    觸發器包含調度邏輯。每個作業(job)都有自己的觸發器,用於確定下一個作業何時運行。除了最初的配置,觸發器是完全無狀態的

  • job stores 作業存儲

    job stores 是存放作業的地方,默認保存在內存中。作業數據序列化後保存至持久性數據庫,從持久性數據庫加載回來時會反序列化。作業存儲(job stores)不將作業數據保存在內存中(默認存儲除外),相反,內存只是充當後端存儲在保存、加載、更新、查找作業時的中間人角色。作業存儲不能在調度器(schedulers) 之間共享

  • executors 執行器

    執行器處理作業的運行。它們通常通過將作業中的指定可調用部分提交給線程或進程池來實現這一點。 當作業完成後,執行器通知調度器,然後調度器發出一個適當的事件

  • schedulers 調度器

    調度器是將其餘部分綁定在一起的工具。通常只有一個調度器(scheduler)在應用程序中運行。應用程序開發者通常不直接處理作業存儲(job stores)、執行器(executors)或者觸發器(triggers)。相反,調度器提供了適當的接口來處理它們。配置作業存儲(job stores)和執行器(executors)是通過調度器(scheduler)來完成的,就像添加、修改和刪除 job(作業)一樣

2、APScheduler 架構圖

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-EGQYW0xp-1575990320993)(https://s2.ax1x.com/2019/10/26/KBFQGn.png)]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章