HUSTOJ 是一个开源的在线判题系统,很早之前就已经开发了,在源码里我甚至看到过 2008 年的 Git 提交记录(也有可能没这么早,记不太清了),时至本篇博客编写之际,2025 年了作者还在维护更新。
感谢 zhblue(原作者)贡献的代码。
本系列文章会介绍 HUSTOJ 的核心部分——判题机。
主要内容包括以下两个部分:
会介绍源码中的关键点和难点,但不会逐行的对源码进行注释。
希望本系列文章能够帮助到想要学习 OJ 的判题机怎么开发的同学(并非零基础,操作系统最基础的知识,例如进程、文件 IO 等概念需要知道)。
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
去干活(具体多少个可以通过配置文件配置)。
因为 judged
是通过轮询数据库接取判题任务的(也支持 HTTP 判题,由于默认是数据库判题,这里就省略 HTTP 判题了),所以这里需要了解一下系统里和判题有关的几张表的相关信息。
这几个表之间通过 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)”,随后判题流程终止并返回结果。runtimeinfo
表,终止判题,并将 solution
表中的提交状态设为 “运行错误(Run Error)”。solution
表中的提交状态设为 “输出超限(Output Limit Error)”。solution
表中的提交状态设为 “时间超限(Time Limit Error)”。solution
表中的提交状态设为 “内存超限(Memory Limit Error)”。runtimeinfo
表,并将 solution
表中的提交状态设为 “错误答案(Wrong Answer)”。solution
表中的提交状态设为 “时间超限(Time Limit Error)”。solution
表中的提交状态设为 “内存超限(Memory Limit Error)”。solution
表中的提交状态设为 “格式错误(Presentation Error)”。solution
表中的提交状态设为 “答案正确(Accepted)”注:判题机中存在两种时间限制——最大时间限制和题目时间限制。
problem
表中,每道题可能有所不同。同理,内存限制也分为全部最大限制和题目特定限制,具体规则与时间限制类似。
第五步:前端获取判题结果
当判题机将判题结果写入到数据库后,前端通过 solution_id
查询到判题结果后,将判题结果显示到浏览器界面上,整个判题流程到此完毕。
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 |
字段解释:
0
表示否,1
表示是特殊裁判,2
表示是文本裁判。N
表示正常显示,Y
表示不再显示在前台。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 |
字段解释:
problem
表的 problem_id
关联。表示该提交针对哪道题目。problem_id
不同,可通过此字段记录。Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | NULL | |
source | text | NO | NULL |
solution
表的主键相对应,表示该源代码属于哪个提交。text
类型存储。Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | 0 | |
error | text | YES | NULL |
solution
表的主键相对应,一对一关联。Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
solution_id | int | NO | PRI | 0 | |
error | text | YES | NULL |
solution
表的主键相对应,一对一关联。