【HUSTOJ 判题机源码解读系列01】判题机架构、判题流程浅析
HUSTOJ 判题机源码解读系列01
1. HUSTOJ
HUSTOJ 是一个开源的在线判题系统,很早之前就已经开发了,在源码里我甚至看到过 2008 年的 Git 提交记录(也有可能没这么早,记不太清了),时至本篇博客编写之际,2025 年了作者还在维护更新。
感谢 zhblue(原作者)贡献的代码。
本系列文章会介绍 HUSTOJ 的核心部分——判题机。
主要内容包括以下两个部分:
- 判题机的整体设计
- 使用到的核心技术
会介绍源码中的关键点和难点,但不会逐行的对源码进行注释。
希望本系列文章能够帮助到想要学习 OJ 的判题机怎么开发的同学(并非零基础,操作系统最基础的知识,例如进程、文件 IO 等概念需要知道)。
2. 源码文件分析
HUSTOJ 判题机的源码默认安装在 /home/judge/src/core
目录下,其结构目录如下:
./core
├── judge_client
│ ├── getindocker.sh
│ ├── judge_client
│ ├── judge_client.cc
│ ├── judge_client.http
│ ├── judge_client.o
│ ├── log.txt
│ ├── loggedcalls.sh
│ ├── makefile
│ ├── ncalls.h
│ ├── nohup.out
│ ├── okcalls.h
│ ├── okcalls32.h
│ ├── okcalls64.h
│ ├── okcalls_aarch64.h
│ ├── okcalls_arm.h
│ └── okcalls_mips.h
├── judged
│ ├── judged
│ ├── judged.cc
│ ├── judged.http
│ ├── judged.o
│ ├── judgehub
│ ├── judgehub.cc
│ ├── judgehub2.cc
│ └── makefile
├── make.sh
└── sim
├── sim.sh
└── sim_3_01
在这么多文件中,我们只需要关心 judge_client.cc
文件和 judge.cc
这两个核心文件。
除此之外,其它的文件与判题机的核心原理关系不大,这里只需要简单了解一下。
okcall
系列文件可以当作配置文件,其中是各种编程语言在各种平台上允许的系统调用编号sim
系列文件是用来做代码查重的功能的judgehub
系列文件是用于 Saas 服务的
回到核心文件 judge_client.cc
和 judge.cc
,这两个 C 语言文件会被分为编译为两个可执行文件,并一起放到 /usr/bin/
目录下。
./core/make.sh
是编译脚本,执行这个脚本就可以编译了。
编译结果:
judge_client.cc
->/usr/bin/judge_client
judge.cc
->/usr/bin/judged
编译成功后,执行命令(开启判题服务):
sudo judged
/usr/bin/judged
可执行文件就会从磁盘加载到内存中,成为一个守护进程。现在我们称这个守护进程为 judged
。
执行命令(关闭判题目服务):
sudo pkill -9 judged
就可以杀掉 judged
进程。
当判题服务启动的时候,judged
和 judge_client
就可以相互配合进行判题了,具体情况是:
judged
负责接取判题任务,然后将任务转交给judge_client
judged_client
拿到任务后开始进行判题
可以将 judged
当做一个经理,只管接活,手底下可能会有几个 judged_client
去干活(具体多少个可以通过配置文件配置)。
3. 判题流程(简略版)
因为 judged
是通过轮询数据库接取判题任务的(也支持 HTTP 判题,由于默认是数据库判题,这里就省略 HTTP 判题了),所以这里需要了解一下系统里和判题有关的几张表的相关信息。
- problem:题目信息表,保存题目的标题、描述、输入输出示例、难度、限制等信息
- solution:提交信息表,记录用户针对某道题目的提交记录、判题结果、使用语言、提交时间等
- source_code:源代码表,保存对应提交的代码内容
- compileinfo:编译信息表,主要存储编译错误信息
- runtimeinfo:运行时错误信息表,主要存储程序运行出错时的错误信息
这几个表之间通过 solution_id
、problem_id
等关键字段进行关联。
表的结构过长,放在文中影响文章结构,文章跨度过大,所以表结构和字段解释都放在文章末尾,这里知道每张表大致是什么就可以了,并不用精确到字段。
接下来是用户提交代码到判题机评测代码到最终用户查询到判题的结果的大致过程(后续系列会通过源码精讲)。
第一步:用户提交代码
用户点击题库中的题目,选了一道题目,使用一种编程语言,写了代码,然后点击提交,这里提交时候后端会得到的数据有:
problem_id
:问题 IDuser_id
: 用户 IDlanguage
:编程语言编号,例如(0 = C, 1 = C++, 2 = Java)source
:用户编写的代码
第二步:后端写数据库
solution
表中有一个 result
字段,表示用户提交状态,是一个枚举值,具体值如下:
#define OJ_WT0 0 // 提交排队
#define OJ_WT1 1 // 重判排队
#define OJ_CI 2 // 编译中(任务已派发)
#define OJ_RI 3 // 运行中
#define OJ_AC 4 // 答案正确
#define OJ_PE 5 // 格式错误
#define OJ_WA 6 // 答案错误
#define OJ_TL 7 // 时间超限
#define OJ_ML 8 // 内存超限
#define OJ_OL 9 // 输出超限
#define OJ_RE 10 // 运行错误
#define OJ_CE 11 // 编译错误
#define OJ_CO 12 // 编译完成
#define OJ_TR 13 // 测试运行结束
#define OJ_MC 14 // 等待裁判手工确认
当后端接收到用户在前端提交的数据后,将执行以下步骤:
- 初始化提交记录
首先,将problem_id
、user_id
和language
作为一条新记录插入solution
表中(此时源码尚未存入)。同时,将该记录的result
字段设置为14
,以标识该提交的源码尚未插入source_code
表,避免判题机立即进行评测。插入完成后,数据库会自动生成该记录的主键solution_id
。 - 存储源码并更新状态
随后,后端会使用生成的solution_id
和源码source
,将其作为一条新记录插入source_code
表。插入成功后,solution
表中的result
字段会被更新为0
,表示源码已成功存储,判题机可以开始评测该提交。
在做完这些工作后,后端需要 solution_id
返回给前端,前端此时需要使用solution_id
轮询后端提供的根据 solution_id
查询判题信息的接口。
第三步:judged
获取到提交
由于 judged
会轮询数据库,也就是每隔几秒执行一下这条 SQL:
SELECT * FROM solution WHERE result = 0;
当 judged
查询到 result = 0
的记录后,就会将这条记录的 problem_id
和 solution_id
告诉 judge_client
,然后 judge_client
就会准备判题了。
第四步:judge_client
开始判题(重点部分,先简略描述,后续系列会通过源码的方式十分详细的介绍)
在第三步中,judged
会用户的提交的 solution_id
和 problem_id
告诉 judge_client
后,judge_client
将执行以下步骤:
- 获取判题所需信息
judge_client
根据solution_id
和problem_id
从数据库中查询详细信息,包括题目时间限制、空间限制、用户代码、提交语言等。 - 编译用户代码(解释型语言跳过这一步
将用户的代码编译为对应的可执行文件,如果编译错误,则将错误信息与solution_id
记录至compileinfo
表,并将solution
表中该提交的状态标记为“编译错误(CE)”,随后判题流程终止并返回结果。 - 创建沙箱环境
根据获取的语言类型,创建对应的隔离沙箱环境,并在其中运行编译后的可执行文件,或直接执行无需编译的解释型语言代码。 - 运行用户代码
用户代码在沙箱中运行,同时需要处理以下各种情况。- 运行错误(RE):用户代码运行过程中异常终止(如数组越界、除零等错误)。此时需将错误信息存入
runtimeinfo
表,终止判题,并将solution
表中的提交状态设为 “运行错误(Run Error)”。 - 输出超限(OLE):用户代码运行过程中生成的标准输出文件超出限制。此时需要终止判题,并将
solution
表中的提交状态设为 “输出超限(Output Limit Error)”。 - 运行超时(TLE,未运行完毕):代码尚未完成所有测试用例,但运行时间已超过最大时间限制。此时需终止判题,并将
solution
表中的提交状态设为 “时间超限(Time Limit Error)”。 - 内存超限(MLE,未运行完毕):代码尚未完成所有测试用例,但内存使用已超出最大内存限制。此时需终止判题,并将
solution
表中的提交状态设为 “内存超限(Memory Limit Error)”。 - 错误答案(WA):代码运行完所有测试用例后,与标准答案比对发现结果不匹配。需生成用户输出与标准答案的差异比对文件,并存入
runtimeinfo
表,并将solution
表中的提交状态设为 “错误答案(Wrong Answer)”。 - 运行超时(TLE,运行完毕):代码已运行完所有测试用例,但运行时间超过题目设定的时间限制。此时需将
solution
表中的提交状态设为 “时间超限(Time Limit Error)”。 - 内存超限(MLE,运行完毕):代码已运行完所有测试用例,但内存使用超出题目设定的内存限制。此时需将
solution
表中的提交状态设为 “内存超限(Memory Limit Error)”。 - 格式错误(PE):代码输出与标准答案仅在空格、回车或其他空白字符上存在差异。此时需将
solution
表中的提交状态设为 “格式错误(Presentation Error)”。 - 答案正确(AC)。若以上情况都没出现,则将
solution
表中的提交状态设为 “答案正确(Accepted)”
- 运行错误(RE):用户代码运行过程中异常终止(如数组越界、除零等错误)。此时需将错误信息存入
- 销毁沙箱环境
销毁沙箱,清除判题过程中生成的临时文件,进程退出。
注:判题机中存在两种时间限制——最大时间限制和题目时间限制。
- 最大时间限制:用于防止判题机被恶意代码或用户写的死循环无限占用 CPU 资源。所以一旦用户代码运行时间超过该限制,系统将立即终止用户的代码运行。该限制通常由判题机统一设定,例如 10 秒或其他固定阈值。
- 题目时间限制:针对特定题目设定,主要用于评估算法效率。即使用户提交的代码能产生正确结果,但若运行时间超出题目设定的时间限制,仍将被判定为超时(TLE)。该限制存储在
problem
表中,每道题可能有所不同。
同理,内存限制也分为全部最大限制和题目特定限制,具体规则与时间限制类似。
第五步:前端获取判题结果
当判题机将判题结果写入到数据库后,前端通过 solution_id
查询到判题结果后,将判题结果显示到浏览器界面上,整个判题流程到此完毕。
4. 数据库详细结构
4.1 problem 表详细结构
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
problem_id | int | NO | PRI | NULL | auto_increment |
title | varchar(200) | NO | |||
description | text | YES | NULL | ||
input | text | YES | NULL | ||
output | text | YES | NULL | ||
sample_input | text | YES | NULL | ||
sample_output | text | YES | NULL | ||
spj | char(1) | NO | 0 | ||
hint | text | YES | NULL | ||
source | varchar(100) | YES | NULL | ||
in_date | datetime | YES | NULL | ||
time_limit | decimal(10,3) | NO | 0.000 | ||
memory_limit | int | NO | 0 | ||
defunct | char(1) | NO | N | ||
accepted | int | YES | 0 | ||
submit | int | YES | 0 | ||
solved | int | YES | 0 | ||
remote_oj | varchar(16) | YES | NULL | ||
remote_id | varchar(32) | YES | NULL |
字段解释:
- problem_id: 主键,自增,用于唯一标识题目。
- title: 题目标题。
- description / input / output: 题目详细描述、输入和输出规范的文本。
- sample_input / sample_output: 示例输入输出,用于帮助用户理解题目。
- spj: 是否采用 Special Judge(特判),
0
表示否,1
表示是特殊裁判,2
表示是文本裁判。 - hint: 提示信息,可选,用于给出解题提示。
- source: 题目来源或出处信息,如比赛名称或网站链接。
- in_date: 题目录入系统的时间。
- time_limit / memory_limit: 运行时间限制(单位秒)和内存限制
- defunct: 是否下架该题目,
N
表示正常显示,Y
表示不再显示在前台。 - accepted / submit / solved: accepted:该题目被所有用户解题成功的次数,submit:该题目的提交总次数,solved:解出该题目的用户数量
- remote_oj / remote_id: 用于支持远程判题(如同 LeetCode、Codeforces 等远程接口)的字段。记录远程 OJ 的名称和题目编号。
4.2 solution 表
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int unsigned | NO | PRI | NULL | auto_increment |
problem_id | int | NO | MUL | 0 | |
user_id | char(48) | NO | MUL | NULL | |
nick | char(20) | NO | |||
time | int | NO | 0 | ||
memory | int | NO | 0 | ||
in_date | datetime | NO | 2016-05-13 19:24:00 | ||
result | smallint | NO | MUL | 0 | |
language | int unsigned | NO | 0 | ||
ip | char(46) | NO | NULL | ||
contest_id | int | YES | MUL | 0 | |
valid | tinyint | NO | 1 | ||
num | tinyint | NO | -1 | ||
code_length | int | NO | 0 | ||
judgetime | timestamp | YES | CURRENT_TIMESTAMP | DEFAULT_GENERATED | |
pass_rate | decimal(4,3) unsigned | NO | 0.000 | ||
lint_error | int unsigned | NO | 0 | ||
judger | char(16) | NO | LOCAL | ||
remote_oj | char(16) | NO | |||
remote_id | char(32) | NO |
字段解释:
- solution_id: 提交记录主键,自增。
- problem_id: 与
problem
表的problem_id
关联。表示该提交针对哪道题目。 - user_id: 提交者的用户 ID,可与用户表进行关联(此处未展示用户表)。
- nick: 提交者的昵称,用于在榜单或判题记录中显示。
- time / memory: 程序运行时所耗费的 CPU 时间(单位毫秒或秒,视系统而定)和内存使用量。
- in_date: 提交时间。
- result: 判题结果,枚举值,见上文。
- language: 提交所使用的编程语言代码(如 0 = C, 1 = C++, 2 = Java 等)。
- ip: 提交者的 IP 地址。
- contest_id: 如果是比赛期间的提交,可存储对应比赛的 ID;非比赛情况下可为默认值 0。
- valid: 用于标识该提交记录是否有效,例如在判题故障时可能会设置为无效。
- num: 在比赛场景中,一道题在某场比赛中的编号可能与系统默认的
problem_id
不同,可通过此字段记录。 - code_length: 提交的源代码长度(单位字节)。
- judgetime: 判题时间戳,记录判题所用时间,也可在某些业务场景用于分析统计。
- pass_rate: 测试点通过率,若题目有多个测试点则可以记录通过的比例。
- lint_error: 代码静态检测或风格检测的错误数(如 Clang-Tidy、ESLint 等)。
- judger: 判题机的名称或标识,用于分布式判题时区分不同判题服务器。
- remote_oj / remote_id: 远程判题平台及对应的提交 ID。
4.3 source_code 表
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | NULL | |
source | text | NO | NULL |
- solution_id: 与
solution
表的主键相对应,表示该源代码属于哪个提交。 - source: 保存提交的完整源代码文本。由于可能非常长,使用
text
类型存储。
4.4 compileinfo 表
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | 0 | |
error | text | YES | NULL |
- solution_id: 与
solution
表的主键相对应,一对一关联。 - error: 如果编译失败,此字段记录编译器返回的错误或警告信息,便于用户查看具体原因,例如 C 语言的中文分号等。
4.5 runtimeinfo 表
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | 0 | |
error | text | YES | NULL |
- solution_id: 与
solution
表的主键相对应,一对一关联。 - error: 如果程序运行出错(如数组越界、除零等错误),或者在用户答案错误后的标准答案和用户答案的差异比对文件等。