Python调度器设计:schedule类的代码分析

275 阅读4分钟

schedule 是一个广泛使用的 python 定时调度框架,直接上代码。

代码解析

import schedule
import time

def do_task(time_str=time.time()):
    print('task run: %s' % time_str)

# 每 10 分钟执行一次任务
schedule.every(10).minutes.do(do_task)
# 每隔 5 到 10 分钟之间的任意一个时间执行一次任务
schedule.every(5).to(10).days.do(do_task)
# 每 1 小时执行一次任务
schedule.every().hour.do(do_task, time_str='1519351479.19554')
# 每天 10:30 执行一次任务
schedule.every().day.at("10:30").do(do_task)

while True:
    schedule.run_pending()
    time.sleep(1)

schedule 模块的整体设计, 是把任务的自我管理部分做的很详细, 而把上层的调度做的很轻很薄, 关键逻辑点采用回调的方式, 依赖任务的自我管理去实现。

Scheduler 类的实例中, 维护了一个列表: jobs, 专门存储注册进来的任务快照:

Class Scheduler(object):
    def __init__(self):
        self.jobs = []

Scheduler 类最重要的方法是 run_pending(self), 其主要逻辑是遍历 jobs 列表中的所有 job, 从中找出当前时间点需要调度的 job, 并执行。这其中最重要的逻辑是判断一个 job 当前时间点是否需要被调度, 而这个过程是一个回调, 具体的逻辑则封装在 job.should_run 方法里。

def run_pending(self):
    _jobs = (job for job in self.jobs if job.should_run)
    for job in sorted(runnable_jobs):
        self._run_job(job)

值得注意的是, schedule 模块中并没有专门的逻辑去定时执行 run_pending 方法, 要想让定时调度持续跑起来, 需要自己实现:

while True:
    schedule.run_pending()
    time.sleep(1)

相比 sched 模块的 ‘伪递归’ 而言, 这样的设计算是比较人性化的了, 可以认为它基本实现了真正意义上的定时调度。 schedule 模块实现了非常详细的任务自我管理逻辑:

class Job(object):
    def __init__(self, interval, scheduler=None):
        self.interval = interval  # pause interval * unit between runs
        self.latest = None  # upper limit to the 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
        self.scheduler = scheduler  # scheduler to register with

Job 类的参数众多, 单个任务的调度肯定不可能涉及到所有的参数, 这些参数往往是以局部几个为组合, 控制调度节奏的; 但是无论什么组合, 往往都会以 scheduler.every(interval=1) 方法开始, 以 Job.do(self, job_func, *args, **kwargs) 方法结束。

schedule.every 方法构造出一个 Job 实例, 并设置该实例的第一个参数 interval:

default_scheduler = Scheduler()

def every(interval=1):
    return default_scheduler.every(interval)

class Scheduler(object):
    def every(self, interval=1):
        job = Job(interval, self)
        return job

Job.do 方法包装了传递进来的任务函数, 将其设置为自己的 job_func 参数, 并将自己作为一个任务快照放进 scheduler 的任务列表里。

Class Job(object):
    def do(self, job_func, *args, **kwargs):
        self.job_func = functools.partial(job_func, *args, **kwargs)
        try:
            functools.update_wrapper(self.job_func, job_func)
        except AttributeError:
            pass
        # 计算下一次调度的时间
        self._schedule_next_run()
       self.scheduler.jobs.append(self)
       return self

在这两个方法之间, 就是通过建造者模式, 构造出其他控制参数的组合, 以实现各种各样的调度节奏。

从关于 Scheduler 类的描述中可以看到, 上层调度中最关键的逻辑, 判断每一个注册的 job 是否应该被调度, 其实是 Job 类的一个回调方法 should_run:

def should_run(self):
    return datetime.datetime.now() >= self.next_run

而 should_run 方法中的判断的依据, 是当前时间有没有到达 next_run 这个实例字段给出的时间点; next_run 字段的设置则通过在 Job.do(self, job_func, *args, **kwargs) 方法 (上文已给出) 和 Job.run(self) 方法中调用 __schedule_next_run() 方法来实现:

def run(self):
    logger.info('Running job %s', self)
    ret = self.job_func()
    self.last_run = datetime.datetime.now()
    # 计算下一次调度的时间
    self._schedule_next_run()
    return ret

这些 Job 类的参数组合, 大致可分为这几类:

总基调: 指定调度的周期

以下方法将会设置 unit 参数, 与 interval 参数结合, 定义调度区间间隔:

def second(self):   def seconds(self):  # self.unit = 'seconds'
def minute(self):   def minutes(self):  # self.unit = 'minutes'
def hour(self):     def hours(self):    # self.unit = 'hours'
def day(self):      def days(self):     # self.unit = 'days'
def week(self):     def weeks(self):    # self.unit = 'weeks'

其对应的 _schedule_next_run() 逻辑如下(调整前参数):

def _schedule_next_run(self)
    assert self.unit in ('seconds', 'minutes', 'hours', 'days', 'weeks')
    if self.latest is not None:
        assert self.latest >= self.interval
        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

局部调整1: 指定调度的起始 weekday

以下方法将会设置 start_day 参数, 确定调度开始的时间点; 同时统一设置 unit 参数为 ‘weeks’:

def monday(self):       
    self.start_day = 'monday'
    self.unit = 'weeks'
def tuesday(self):      
    self.start_day = 'tuesday'
    self.unit = 'weeks'
def wednesday(self):    
    self.start_day = 'wednesday'
    self.unit = 'weeks'
def thursday(self):   
    self.start_day = 'thurday'
    self.unit = 'weeks'
def friday(self):       
    self.start_day = 'friday'
    self.unit = 'weeks'
def saturday(self):     
    self.start_day = 'saturday'
    self.unit = 'weeks'
def sunday(self):     
    self.start_day = 'sunday'
    self.unit = 'weeks'

其对应的 _schedule_next_run() 逻辑如下,可以发现, start_day 只是在 next_run 原有的 weekday 基础上增加了一个 offset, 相当于是 delay time:

 def _schedule_next_run(self):
    if self.start_day is not None:
        assert self.unit == 'weeks'
        weekdays = ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
        assert self.start_day in weekdays
        weekday = weekdays.index(self.start_day)
        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

局部调整2: 指定调度的起始时间

以下方法将会设置 at_time 参数, 其针对 unit == ‘hours’ 只设置 minute 变量, 而对 unit == ‘days’ 或 ‘weeks’ 才会设置 hour 变量:

def at(self, time_str):
    assert self.unit in ('days', 'hours') or self.start_day
    hour, minute = time_str.split(':')
    minute = int(minute)
    if self.unit == 'days' or self.start_day:
        hour = int(hour)
        assert 0 <= hour <= 23
    elif self.unit == 'hours':
        hour = 0
    assert 0 <= minute <= 59
    self.at_time = datetime.time(hour, minute)
    return self

其对应的 _schedule_next_run() 逻辑也与上面类似, 针对 unit == ‘days’ 或 ‘weeks’ 才设 hour 字段, 否则只设置 minute 和 second;

 def _schedule_next_run(self)
    if self.at_time is not None:
        assert self.unit in ('days', 'hours') or self.start_day is not None
        kwargs = {
            'minute': self.at_time.minute,
            'second': self.at_time.second,
            'microsecond': 0
        }
        if self.unit == 'days' or self.start_day is not None:
            kwargs['hour'] = self.at_time.hour
        self.next_run = self.next_run.replace(**kwargs)

局部调整3: 在给定范围内随机安排调度时刻

对应的就是上文提及的 latest 参数:

def to(self, latest):
    self.latest = latest
    return self

具体逻辑就是在给定的 [interval, latest) 区间内, 生成一个随机数作为下次调度的 interval

 def _schedule_next_run(self)
    if self.latest is not None:
        assert self.latest >= self.interval
        interval = random.randint(self.interval, self.latest)
    else:
        interval = self.interval

至此, Job 类的逻辑就都分析完了。

自己写的测试demo:

#coding: utf-8
import schedule
import datetime
import time

def job():
    print("作业运行时间:" + str(datetime.datetime.now()))

def finish():
    exit(0)

if __name__ == '__main__':
    schedule.every().day.at("13:17").do(job)
    schedule.every().day.at("13:18").do(finish)
    while True:
        schedule.run_pending()
        time.sleep(1)

参考链接:www.dazhuanlan.com/2019/10/05/…