趁热打铁,学习一下 APScheduler
的 python
的源码,很好奇任务调度控制的实现。
分析源码主要还是针对 APScheduler
下的几个关键的模块
events
事件executors
执行器job
任务jobstores
任务存储triggers
触发器schedulers
调度程序
这一篇主要瞅瞅 triggers
触发器
分类
triggers
触发器主要分为 4 种
date
: 在给定的日期时间触发一次。如果选择的是data
, 但是没有设置具体执行时间,则使用当前时间。interval
: 以固定时间间隔运行任务。如果设置了start_time
, 则从start_time
开始触发,否则从当前时间开始+间隔
开始触发cron
: 在匹配指定条件时触发, 与UNIX cron
调度程序的工作原理类似。combining
: 以上触发器的混合使用模式and
: 始终返回所有给定触发器同时满足条件的最早的下次触发时间。or
: 始终返回由任何给定触发器产生的最早的下一次触发时间。
因为存在这几种触发器的选择,实现的时候基本也是按照这个思路封装的类,接下来从基类开始,逐个分析这些实现
BaseTrigger
基类中只定义了2个函数,其实也是 trigger
的核心
get_next_fire_time()
: 根据触发器定义的规则,返回下一次执行的时间,如果没有则返回None
, 而规则如何转换成具体的时间,就需要交由子类自己实现,所以基类中定义的是abstractmethod
_apply_jitter()
: 给返回的下一次执行时间添加一个误差, 来提前或延迟job
的执行
因为 get_next_fire_time()
是交由子类实现的,这里主要看一下 _apply_jitter()
的实现
def _apply_jitter(self, next_fire_time, jitter, now):
if next_fire_time is None or not jitter:
return next_fire_time
next_fire_time_with_jitter = next_fire_time + timedelta(
seconds=random.uniform(-jitter, jitter))
if next_fire_time_with_jitter < now:
return next_fire_time
return next_fire_time_with_jitter
next_fire_time
:get_next_fire_time()
获取到的下一次执行时间jitter
: 误差允许的时间值,单位秒now
: 当前时间
这个函数很简单,就是在 next_fire_time
基础上加上一个 [-jitter, jitter]
的误差,如果加完误差的新时间 next_fire_time_with_jitter
小于当前时间,则返回 next_fire_time
, 不然返回 next_fire_time_with_jitter
BaseTrigger
部分只有这么多内容,接下来主要看具体的子类实现
DateTrigger
在传入的固定时间执行一次的模式.而且这个时间是初始化的时候传入的
初始化
def __init__(self, run_date=None, timezone=None):
timezone = astimezone(timezone) or get_localzone()
if run_date is not None:
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
else:
self.run_date = datetime.now(timezone)
将传入时间根据时区调整一下时间,如果没有设置,则选择当前时间
get_next_fire_time()
所以获取下次执行时间,只需要返回 self.run_date
即可
def get_next_fire_time(self, previous_fire_time, now):
return self.run_date if previous_fire_time is None else None
如何保证 data
模式下只执行一次, 这是一个挺有意思的方式, 但是需要结合一下 job
中这一段代码,它是先将 self.next_run_time
加到返回结果中,然后再调用 get_next_fire_time()
函数, 得到结果是 None
, 返回了
def _get_run_times(self, now):
run_times = []
next_run_time = self.next_run_time
while next_run_time and next_run_time <= now:
run_times.append(next_run_time)
next_run_time = self.trigger.get_next_fire_time(next_run_time, now)
print(next_run_time)
return run_times
schedulers
调度中为了确保任务只执行一次,也做了一些设计, 大致是下面这样的流程,想象一下 schedulers
在一直轮询查询下面这个过程, 实际代码逻辑比这个稍微再复杂一点
schedulers
先从jobstores
中获取一些待执行的job
- 再从
job
中获取下次执行的时间信息, 其实也就是这次运行的时间 - 在执行后,在通过
job
的get_next_fire_time()
查询下次执行时间- 如果查到的是
None
, 直接删除任务,保证只运行一次 - 反之将值更新到
job
中
- 如果查到的是
序列化和反序列化
而 trigger
和 job
一样,都需要提供一个序列化和反序列化的接口,其实也不太算直接提供
因为如果看过上一章关于 jobstore
中对 redis
的介绍会知道,我们实际上是通过 cPickle
的 dumps()
序列化和 loads()
反序列对象
cPickle
对 python
内置的类型有自己逻辑,但是像 trigger
和 job
这些自定义的类在 序列化 和 反序列化 的过程中需要调用它们的 __getstate__()
和 __setstate__()
接口
def __getstate__(self):
return {
'version': 1,
'run_date': self.run_date
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only version 1 can be handled' %
(state['version'], self.__class__.__name__))
self.run_date = state['run_date']
DateTrigger
比较简单,只有 version
, run_date
信息需要序列化
但在我的认知里,version
其实可以不用保存下来,可能是为了在版本混用的情况下可以区分不同版本,但是我只看了 3.6.3
这一个版本,不确定它真正的作用
DateTrigger
算是很详细的分析完了,接下来换一个 子类
再来
IntervalTrigger
以固定时间间隔运行任务,我们还是从初始化开始看
初始化
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None,
end_date=None, timezone=None, jitter=None):
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
seconds=seconds)
self.interval_length = timedelta_seconds(self.interval)
if self.interval_length == 0:
self.interval = timedelta(seconds=1)
self.interval_lefuzhingth = 1
if timezone:
self.timezone = astimezone(timezone)
elif isinstance(start_date, datetime) and start_date.tzinfo:
self.timezone = start_date.tzinfo
elif isinstance(end_date, datetime) and end_date.tzinfo:
self.timezone = end_date.tzinfo
else:
self.timezone = get_localzone()
start_date = start_date or (datetime.now(self.timezone) + self.interval)
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
self.jitter = jitter
从传入参数就可以知道它提供的所有功能
weeks
,days
,hours
,minutes
,seconds
: 间隔需要配置的时间,通过这些字段算出一个时间间隔start_date
:job
的开始时间,如果未设置,则是datetime.now(self.timezone) + self.interval
当前时间加时间间隔作为开始时间end_date
:job
的结束时间timezone
: 时区jitter
: 提前或延迟job
执行的误差时间
get_next_fire_time()
def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time:
next_fire_time = previous_fire_time + self.interval
elif self.start_date > now:
next_fire_time = self.start_date
else:
timediff_seconds = timedelta_seconds(now - self.start_date)
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
next_fire_time = self.start_date + self.interval * next_interval_num
if self.jitter is not None:
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
if not self.end_date or next_fire_time <= self.end_date:
return self.timezone.normalize(next_fire_time)
因为是以固定时间间隔运行任务,所以计算下次执行时间的核心部分就是 next_fire_time = previous_fire_time + self.interval
剩下就是需要保证这个时间在 [开始时间,结束时间]
范围内,以及需要判断是否调用 _apply_jitter()
计算 next_fire_time
值部分 if
中前 2 种判断很好理解,我这里主要解释一下 previous_fire_time
为 None
且 self.start_date < now
是在哪种情况下触发
大部分时候我们在使用 IntervalTrigger
时, 只会设置开始时间,结束时间以及时间间隔,不会设置 next_run_time
, 此时 next_run_time
为空
而 schedulers
在添加任务时候会先判断 job
中 next_run_time
是否为空,为空时手动调用一下 get_next_fire_time(None, now)
函数,获取任务的下次执行时间,并重新赋值给 job
, 此时也就出现了一种新的逻辑
举个更详细的例子, 我开始时间设置的 13 点, 每隔 1 小时执行一次,所以我预期的结果是 14 点, 15 点, 16 点等等整点执行, 但是当我启动程序的时候是 14 点 30 分, 也就是满足了上面previous_fire_time
为 None
且 self.start_date < now
的条件, 此时我要计算下次执行的时间, 就是计算 开始时间 和 现在时间 中间相当于有几个 (next_interval_num
) 时间间隔, 时间间隔通过 ceil()
向上取整, self.start_date + self.interval * next_interval_num
就可以得出新的 next_fire_time
最后的 return
又省略了一部分代码,下次执行时间大于 self.end_date
, 直接返回 None
return self.timezone.normalize(next_fire_time) if (not self.end_date or next_fire_time <= self.end_date) else None
序列化和反序列化
和 DateTrigger
差不多,保存核心信息 timezone
start_date
, end_date
, interval
, jitter
def __getstate__(self):
return {
'version': 2,
'timezone': self.timezone,
'start_date': self.start_date,
'end_date': self.end_date,
'interval': self.interval,
'jitter': self.jitter,
}
反序列化的代码也比较简单,这里就不粘贴
小结
比较详细的介绍了基类 BaseTrigger
以及在固定时间执行一次的 DateTrigger
和以固定时间间隔运行任务的 IntervalTrigger
,整体代码难度不大~