Skip to content

判题结果对哪一组输入有效:候选、判据与资源限制 ​

一个内部的编程练习平台,用自动生成的代码和测试辅助出题。一道题的参考解法改过一次,判据也跟着修了一个边界条件,但题库里那条「已通过」的记录还是旧代码在旧判据上跑出来的。另一个问题出在执行器:一个死循环的提交和一个超时的提交,在日志里都只留下了退出码 -9,排查时分不清是哪个限制起了作用。

判题看起来只是「运行代码、比较输出」。真正要回答的问题是:这个「通过」是哪一份代码、在哪一份判据上、在什么限制下得到的。代码和判据任何一个变了,旧结论都不再成立;而执行器能不能准确说出失败原因,取决于它自己记录了什么,而不是事后从退出码里猜。

本文用 Python 标准库写一个最小的判题执行器,在加固的容器里运行 8 个自己写的可信夹具,再用一张表验证结果与确认记录的绑定。这里的夹具都是可信代码,本文不讨论执行不可信代码的沙箱隔离。实验见文末。

候选代码内容摘要判据 3 组内容摘要人工确认绑定两个摘要执行结果带同样的摘要接受代码或判据变了 → 确认失效执行器的限制墙钟 2.5 秒休眠 → -9CPU 软 1、硬 2忙循环 → -24CPU 软硬都是 1忙循环 → -9地址空间 128 MiBMemoryError文件 8193 字节超限同样是 -9,分不清是哪个限制:失败原因由执行器在动作发生时记录可信夹具的故障隔离,不是执行不可信代码的沙箱
图 1 · 实测(Python 3.14.8,加固的容器,可信夹具):结果按人工确认时的候选与判据摘要接受,任一变化即失效。墙钟超时与 CPU 软硬限制相同时都得到退出码 -9;CPU 软限制低于硬限制时是 SIGXCPU(-24)。输出写进受限文件,8193 字节判为超限。

一、判据是版本化的数据 ​

题目是读入两个整数、输出它们的和。判据是三组输入与预期输出:

python
CASES = [("2 3\n", "5\n"), ("-8 3\n", "-5\n"), ("0 0\n", "0\n")]

判据和候选代码一样有内容摘要,执行中的候选不能修改它。这一点在用模型生成代码的流程里格外重要:生成器同时给出代码和「解释」,解释不能充当预期结果;候选也不能在反馈循环里改写判据,再拿新判据上的通过作为原需求已满足的证据。

text
正确求和:3 / 3 组通过
写成减法:1 / 3 组通过(每次都正常退出,错在输出)

写成减法的程序在 0 0 这一组上恰好也对。正常退出只说明程序跑完了,通过与否要逐组比较输出;判据的组数太少,错误的实现就可能蒙对。

二、执行:每种失败落在哪个限制上 ​

执行器在子进程启动前设置资源限制,输出直接写进受大小限制的文件:

python
def _apply(cpu_hard):
    resource.setrlimit(resource.RLIMIT_CPU, (1, cpu_hard))               # CPU 软限制 1 秒
    resource.setrlimit(resource.RLIMIT_AS, (128 * 1024 * 1024,) * 2)      # 地址空间 128 MiB
    resource.setrlimit(resource.RLIMIT_FSIZE, (OUTPUT_LIMIT + 1,) * 2)    # 输出上限多留 1 字节
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resource.setrlimit(resource.RLIMIT_NOFILE, (32, 32))

proc = subprocess.Popen([sys.executable, "-I", "candidate.py"], stdout=so, stderr=se, cwd=tmp,
                        env={"LANG": "C.UTF-8"}, start_new_session=True, preexec_fn=limits(cpu_hard))
try:
    proc.communicate(stdin.encode(), timeout=WALL_SECONDS)       # 墙钟 2.5 秒
except subprocess.TimeoutExpired:
    os.killpg(proc.pid, signal.SIGKILL)                           # 杀掉整个进程组

8 个夹具的结果:

text
语法错误:编译检查拒绝
运行时异常:非零退出,退出码 1,标准输出 0 字节
长时间休眠:墙钟超时,退出码 -9,标准输出 0 字节
忙循环:被信号 SIGXCPU 终止,退出码 -24,标准输出 0 字节
申请 256 MiB:内存申请失败,退出码 1,标准输出 0 字节
大量输出:输出超限,退出码 120,标准输出 8193 字节

几个限制约束的对象各不相同:

  • CPU 时间不包括休眠。time.sleep(10) 几乎不消耗 CPU,只有墙钟期限能挡住它;忙循环则在 CPU 软限制到达时收到 SIGXCPU。两种限制都要有。
  • 地址空间不等于实际占用的内存。这里的 MemoryError 是 Python 在地址空间限制下申请失败的表现,换一种语言或运行时,结果可能是直接被杀,也可能是别的错误。生产上更常用 cgroup 的内存限制,它限制的是实际使用量。
  • 输出写进受限的文件,而不是先收进内存再截断。文件上限比接受上限多 1 字节,这样「恰好 8192 字节」和「超过上限」能区分开:8193 字节就是超限。退出码 120 是 Python 在退出时刷新输出失败的结果,判断超限靠的是字节数,不是退出码。

退出码说不清原因 ​

text
忙循环、CPU 软硬限制都设为 1 秒:被信号 SIGKILL 终止,退出码 -9(与墙钟超时被杀的退出码相同)

把 CPU 的软硬限制设成相同的值,忙循环收到的就不再是 SIGXCPU,而是 SIGKILL,退出码和墙钟超时被执行器杀掉时一模一样。开头那两条都是 -9 的日志就是这种情况。这说明失败原因应该由执行器在做出动作的那一刻记录下来(「我因为墙钟超时杀了它」),而不是事后从退出码推断。容器层面的内存超限同样是 SIGKILL,要从 cgroup 的事件计数里才能确认。

外层容器也加了限制:断网、只读根文件系统、非 root 用户、去掉全部 capabilities、禁止提权,1 个 CPU、256 MB 内存、64 个进程。这些是为了让可信夹具的故障不影响宿主机,不代表这套配置能安全执行任意用户提交的代码。普通容器与宿主机共享内核,面向外部用户的判题服务需要更强的隔离边界、系统调用过滤和专门的安全评估,本文没有做这些验证。

三、结果绑定候选与判据 ​

人工确认一道题的「代码 + 判据」之后,执行结果要带着这两个摘要提交,接受时再核对一次:

python
def accept(result_id, candidate, oracle, verdict):
    reviewed = db.execute("SELECT candidate, oracle FROM review WHERE id = 'rv1'").fetchone()
    if reviewed != (candidate, oracle):
        changed = "候选代码" if reviewed[0] != candidate else "判据"
        return f"拒绝:{changed}与确认时不同,确认已失效"
    prior = db.execute("SELECT candidate, oracle, verdict FROM result WHERE id = ?", (result_id,)).fetchone()
    if prior:
        return "重复提交,保留原结果" if prior == (candidate, oracle, verdict) else "拒绝:同一结果 ID 换了内容"
    ...
text
按确认时的候选与判据提交结果 res1:已接受
res1 原样重发:重复提交,保留原结果;换成 fail 重发:拒绝:同一结果 ID 换了内容
候选代码改过之后提交 res2:拒绝:候选代码与确认时不同,确认已失效
判据改过之后提交 res3:拒绝:判据与确认时不同,确认已失效

开头那条「已通过」的记录,问题就在于结果只记了题目 ID,没有记代码和判据的摘要。绑定之后,任何一方变化都会让旧确认失效,需要重新确认、重新执行。这和候选、确认与生效里候选绑定来源哈希是同一个思路。摘要只表示内容身份,不证明代码安全或判据正确。

四、检查清单 ​

问题做法
「通过」对什么有效结果记录候选代码摘要、判据摘要与执行环境;任一变化即失效
判据被改写判据版本化,候选与生成器不能修改;反馈循环有尝试预算
休眠与忙循环墙钟期限与 CPU 限制都要有,超时杀掉整个进程组
输出过大写进受限文件,上限多留 1 字节区分「等于」与「超过」
失败原因执行器在动作发生时记录原因,不从退出码推断
不可信代码需要独立的隔离边界与安全评估,不能只靠容器加资源限制

覆盖率与「测试真的被执行」的区别,见覆盖率不等于验证。


配套实验

  • codesphere-labs/testing/judge-workflow:Python 3.14.8 标准库,加固的容器:8 个可信夹具在 CPU、地址空间、文件大小与墙钟限制下的结果,CPU 软硬限制相同时的 SIGKILL,输出比较,结果与候选、判据摘要的绑定(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权