Cron 按日历字段匹配时间,并在匹配时启动命令。它不会天然等待同一个命令的上一次执行结束。如果每分钟触发的任务要运行七十秒,下一分钟就可能再启动一份。因此,触发规则、并发规则和成功完成状态必须分别设计,不能因为写好了一个周期表达式,就认为重复工作的所有行为都已经确定。
把字段理解为日历选择
传统用户 crontab 一行依次包含分钟、小时、月内日期、月份、星期,然后是命令。星号表示该字段的全部取值。分钟位置的 */15 是在每个小时内选择相应分钟值,不是从上次任务结束之后再计时十五分钟。步长如果不能均匀划分字段范围,跨过边界时就可能出现不同长度的间隔,不能把它当作连续经过时长。
Debian/Vixie 格式对于两个日期字段有特殊规则:当月内日期和星期都受到限制时,满足其中一个日期条件就可能运行,而分钟、小时和月份限制仍然必须满足。这不是月份与星期之间的“或”关系。安装简短表达式之前,应列出具有代表性的实际日期核对意图,避免以为“每月某日恰好是周五”却写成两个日期条件各自都能触发。
时区、夏令时变化和停机都会影响日历调度,不同实现对跳过或重复时段的处理并不完全相同。Debian 文档为部分小幅时钟调整和固定时间任务定义了特殊行为,从其他调度器复制结论可能不适用。本实验没有修改时钟,也没有覆盖这些切换情况。日历触发同样不等于持久队列,不能自动保证停机期间错过的每次任务都会补跑。
坏掉的样子
重叠任务可能重复产生副作用、争夺资源或相互覆盖文件。如果要求同一时刻只有一个实例,就要明确实现这一政策。非阻塞建议锁可以让后来的实例记录跳过;阻塞锁则可能留下等待进程。两种选择都不自动提供业务效果恰好一次,也不能修复已经执行了一半的操作。是否允许跳过,应来自任务自己的业务要求。
本地文件锁只协调使用同一相关锁机制的参与者,不会自动串行化无关主机上的工作。网络文件系统的锁语义也需要单独确认。锁必须覆盖实际工作生命周期,跳过结果则应能被观察。如果一个实例永久卡住,后来每次触发都静默丢弃,表面上调度器还在工作,但业务可能早已停止推进,因此还要监控最近成功完成距今多久。
Cron 也并非一律没有日志。Debian 默认通过 syslog 记录任务启动,并提供更细的日志选项。命令输出可能在可用邮件链路存在时被投递,但极简容器经常没有这样的链路。应把启动、完成、失败和跳过结果写到明确目的地。启动记录只能证明任务被派发,不能证明真正的应用工作已经成功完成。
快速验证
2026-10-19 修复草案:此版本只进行了静态检查与离线命令/文件状态替身测试,没有执行 cron、crontab、账号或信号操作。技术与实际运行验证仍然受阻;下文测量值属于原始示例,不能视为修订版的执行结果。
未来执行必须另行获得授权,并使用新建的一次性 Debian 12 实验环境,预装 Python 3.11+、cron 3.0pl1-162 和 procps。环境不得挂载宿主目录,不得已有 root 任务表、cron 进程、/etc/crontab、/etc/cron.d 或其他用户任务表中启用的无关计划(包括周期目录的派发),也不得有并发管理员操作。root 只是权限前提,不能证明隔离。保存为 cron-overlap.py,未来命令为 python3 cron-overlap.py --isolated-disposable-lab。此参数仅确认由外部核实的前提,不能建立隔离。应预留约两分钟等待真实日历触发,并在结束后丢弃整个环境。
实验任务表用 MAILTO 禁用邮件。任务表安装与守护进程启动都位于清理保护范围内。只有所固定版本的准确英文“不存在任务表”诊断才被接受为不存在;权限或命令错误会中止。工作进程先完整写入临时 JSON,再在同一目录替换发布每条事件。清理先撤销正向运行许可,再移除计划并停止所拥有的守护进程,最多等待五秒取得工作生命周期锁并核对完成记录。各项清理独立尝试并保留原始异常;前序清理失败时跳过私有文件删除,但删除本身也可能只完成一部分。这只约束已获准的工作,不证明每个 cron 包装进程或延迟启动进程都已退出,也不证明隔离或崩溃持久性。
from pathlib import Path
from tempfile import mkdtemp
import fcntl
import json
import os
import shutil
import subprocess
import sys
import time
def command(arguments, **options):
return subprocess.run(arguments, capture_output=True, text=True, timeout=5,
env={**os.environ, 'LC_ALL': 'C'}, **options)
def table_absent(result):
# Debian cron 3.0pl1-162: other listing failures must abort.
return (result.returncode == 1 and result.stdout == ''
and result.stderr == 'no crontab for root\n')
def remove_table():
current = command(['crontab', '-l'])
if table_absent(current):
return
current.check_returncode()
command(['crontab', '-r']).check_returncode()
if not table_absent(command(['crontab', '-l'])):
raise RuntimeError('could not verify table removal')
def attempt(errors, name, operation):
try:
operation()
except Exception as error:
errors.append(f'{name}: {error!r}')
def stop_daemon(daemon, errors):
if daemon is None:
return
attempt(errors, 'terminate cron', daemon.terminate)
try:
daemon.wait(timeout=5)
except subprocess.TimeoutExpired:
attempt(errors, 'kill owned cron', daemon.kill)
attempt(errors, 'reap cron', lambda: daemon.wait(timeout=5))
except Exception as error:
errors.append(f'wait cron: {error!r}')
def read_events(root):
return sorted((json.loads(path.read_text()) for path in root.glob('*.json')),
key=lambda event: event['monotonic'])
def wait_workers(root):
# Workers hold a shared lifetime lock before checking the run permit or publishing.
with (root / 'lifetime.lock').open() as gate:
deadline = time.monotonic() + 5
while True:
try:
fcntl.flock(gate, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError('workers did not release lifetime lock')
time.sleep(0.1)
events = read_events(root)
started = {e['pid'] for e in events if e['event'] == 'start'}
ended = {e['pid'] for e in events if e['event'] == 'end'}
if not started <= ended:
raise RuntimeError('a started worker has no completion record')
worker_source = r'''from pathlib import Path
import fcntl,json,os,sys,time
root=Path(__file__).parent
kind=sys.argv[1]
def record(event):
destination=root/f'{os.getpid()}-{event}.json'
temporary=destination.with_suffix('.tmp')
temporary.write_text(json.dumps({'event':event,'kind':kind,
'pid':os.getpid(),'monotonic':time.monotonic()}))
temporary.replace(destination)
def main():
with (root/'lifetime.lock').open() as gate:
fcntl.flock(gate,fcntl.LOCK_SH)
if not (root/'run-permit').exists(): return
lock=None
try:
if kind=='locked':
lock=open(root/'job.lock','a')
try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
except BlockingIOError:
record('skip')
return
record('start')
try:
until=time.monotonic()+70
while time.monotonic()<until and (root/'run-permit').exists():
time.sleep(0.1)
finally: record('end')
finally:
if lock is not None: lock.close()
main()
'''
def main():
# This flag is an operator prerequisite, NOT evidence of containment.
if sys.argv[1:] != ['--isolated-disposable-lab']:
raise SystemExit('requires a separately verified disposable Debian 12 lab')
if os.geteuid() != 0:
raise RuntimeError('root is required, but does not prove isolation')
probe = command(['pgrep', '-x', 'cron'])
if probe.returncode != 1 or probe.stdout or probe.stderr:
raise RuntimeError(f'cron absence not established: {probe!r}')
if not table_absent(command(['crontab', '-l'])):
raise RuntimeError('expected absent root table; existing table or listing error')
root = Path(mkdtemp(prefix='cron-overlap-', dir='/tmp'))
daemon = None
table_attempted = False
try:
(root / 'lifetime.lock').touch()
(root / 'run-permit').touch()
worker = root / 'worker.py'
worker.write_text(worker_source)
table = 'MAILTO=""\n' + ''.join(f'* * * * * /usr/bin/python3 {worker} {kind} >>{root}/worker.log 2>&1\n'
for kind in ('plain', 'locked'))
table_attempted = True
command(['crontab', '-'], input=table).check_returncode()
daemon = subprocess.Popen(['cron', '-f'], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
deadline = time.monotonic() + 140
while time.monotonic() < deadline:
if daemon.poll() is not None:
raise RuntimeError('cron exited')
events = read_events(root)
plain = [e for e in events if (e['kind'], e['event']) == ('plain', 'start')]
locked = [e for e in events if (e['kind'], e['event']) == ('locked', 'start')]
skipped = [e for e in events if (e['kind'], e['event']) == ('locked', 'skip')]
if len(plain) >= 2 and skipped:
break
time.sleep(0.2)
else:
raise AssertionError('two real cron ticks were not observed')
assert len(plain) == 2 and len(locked) == 1 and len(skipped) == 1
assert plain[1]['monotonic'] - plain[0]['monotonic'] < 70
assert not any(e['event'] == 'end' and e['pid'] == plain[0]['pid'] for e in events)
print(json.dumps({'actual_cron_starts': len(plain), 'overlap_observed': True,
'seconds_between_starts': round(plain[1]['monotonic']-plain[0]['monotonic'], 3),
'locked_starts': len(locked), 'locked_skips': len(skipped)}), flush=True)
finally:
original = sys.exc_info()[1]
errors = []
attempt(errors, 'revoke run permit', lambda: (root / 'run-permit').unlink(missing_ok=True))
if table_attempted:
attempt(errors, 'remove root table', remove_table)
stop_daemon(daemon, errors)
attempt(errors, 'wait for workers', lambda: wait_workers(root))
# Retain private files after failed cleanup; discard the whole lab.
if not errors:
attempt(errors, 'remove private files', lambda: shutil.rmtree(root))
if errors:
message = 'cleanup incomplete; discard lab: ' + '; '.join(errors)
if original is not None:
original.add_note(message)
else:
raise RuntimeError(message)
if __name__ == '__main__':
main()
两条每分钟执行的记录会运行原本持续七十秒的工作进程。plain 分支没有工作互斥锁,两分支只共享用于清理的生命周期锁;locked 分支使用共享文件和非阻塞独占 flock,未取得锁时记录 skip 并退出。每个事件包含进程标识和单调时间,能够直接区分不同实例,并检查第二次启动发生时第一次是否仍在执行,而不是仅根据配置推测并发。
原始示例的历史 Debian cron 3.0pl1-162 运行中,普通分支相隔 60.003 秒启动两次,而且第二次启动时第一次尚未结束。加锁分支只真正启动一次,另一次记录跳过。原始运行报告断言通过,并通过停止文件进行清理。本次修订改为撤销正向许可,尚未复现这些实际运行结果,也不能声称所有后台进程均已退出。
这个实验验证本机的重叠执行与显式互斥,没有实现持久重试、分布式锁、重启后恢复或业务效果恰好一次。真实任务需要这些能力时,应按当前业务契约选择并验证相应机制,不能认为日历表达式已经隐含提供了全部保障。
来源
Debian 12: crontab(5),日历字段、步进及两个日期字段的组合,已核验 2026-09-13。
Debian 12: cron(8),守护进程执行、日志和时钟变化处理,已核验 2026-09-13。
Linux: flock(2),建议锁及非阻塞获取,已核验 2026-09-13。
Python 3.11 subprocess,超时与子进程清理语义,已核验 2026-10-19。
Debian cron 3.0pl1-162 crontab.c,区分不存在任务表的诊断与其他失败,已核验 2026-10-19。
Python 3.11 os.replace,同文件系统原子发布,已核验 2026-10-19。