【HUSTOJ 判题机源码解读系列02】judged 守护进程工作流程
【HUSTOJ 判题机源码解读系列02】judged 守护进程工作流程
1. judged 工作流程
上回说到,HUSTOJ 判题机源码中最重要的两个 C 语言文件为:
judge.cc
judge_client.cc
judge.cc
会被编译为可执行性文件 judged
后复制到 /usr/bin
目录下。
judge_client.cc
会被编译为可执行文件 judge_client
后复制到 /usr/bin
目录下。
随后,执行 sudo judged
命令就可以启动 judged
进程,此时,judged
进程作为一个守护进程,会定时的去查询数据库中的 solution
表,从 solution
表中获取等待评测的题目,最后指派一个 judge_client
进程去执行判题的逻辑。
以上就是 judged
的大致的工作原理。
在复习完毕 judged
原理后,在这里我们先把后文的结构整体安排介绍一下:
- 通过简化的核心代码,分析源码的整体框架
- 解析源码中持续判题的流程,这点比较重要
2. 源码整体框架
在解读源码时,首先需要梳理 main 函数的整体框架和主要功能。
由于关注的是整体结构,这里省略了大部分代码,仅保留最核心的逻辑。部分内容进行了简化,或以注释替代,详细版本将在后文展开。
#define MAX_JOBS 100
static int STOP = false;
// 执行判题程序
void run_client(int runid, int clientid) {
char runid_str[10], clientid_str[10];
execl("/usr/bin/judge_client", "/usr/bin/judge_client", runid_str, clientid_str, NULL);
}
// 从数据库获取任务
void get_jobs(int* jobs) {
int* data = get_data_from_mysql();
if (data != NULL) {
memcpy(jobs, data, MAX_JOBS * sizeof(int));
}
}
void work() {
int jobs[MAX_JOBS] = {0};
get_jobs(jobs);
for (int i = 0; jobs[i] > 0; i++) {
pid_t pid = fork();
if (pid == 0) { // 子进程
run_client(jobs[i], i);
exit(0);
}
}
}
int main() {
// 主循环,不断获取任务并处理,直到 STOP 标志被设置为 true
while (!STOP) {
work();
sleep(1); // 休息一秒,免得对数据库造成的压力过大
}
return 0;
}
整体逻辑如下:
main()
函数不断的调用work()
函数从数据库中获取题目work()
函数不断的创建子进程去执行run_client()
函数run_client()
函数驱动judge_client
去真正的执行判题任务STOP
变量控制了主循环执行,因为是全局变量,其他地方可以修改这个变量让主循环停止执行,终止判题
整个框架逻辑都十分简单,接下来我们再看一下源码中是怎么以比较简单的方式实现持续判题的功能的。
3. 持续判题流程实现
在前文对源码整体框架的解读中,main
函数中只展示了一个 while
死循环:
while (!STOP) {
work();
sleep(1);
}
但是在完整的代码里面,其实是有两个 while
死循环的,这两个 while
死循环和 work()
函数与 get_jobs()
函数一起相互配合工作。解决了上面单个死循环会出现的一个比较严重的问题,同时这也是我个人认为 judge.cc
文件中设计的挺优秀的一段代码。
OK,我们来分析一下上面的这个死循环的问题,其实就两句代码:work()
和 sleep()
,work()
执行派发判题的任务,sleep()
休息一秒钟,避免无限轮询数据库,给数据库干崩了。咋一看没什么问题,但是如果有一个提交,刚好在 sleep()
函数休息前的那一刻进来了,那这个提交就会有一个 1 秒的空窗期,因为判题机休息了。。。。。。好像没什么问题,休息完再给你判嘛(bushi
一个提交好像并不能看出问题,那现在想想一个场景,此时判题机正在给一个大型比赛判题,每次进来的题目都很多,但是这个判题机每次都固定只判一批题目,然后都要休息一秒后再判,雷打不动(判题机:“work life balance”)。那判题的效率就十分低效了。
所以现在我们希望判题机最起码需要符合以下两点要求:
- 不能干崩数据库
- 在大型比赛这种场景,我们希望判题机加班判题
在不采用事件驱动实现判题机代码的限制下,我们现在就来看一下作者是怎么通过双 while
死循环来实现以上的两点要求的。
以下的代码是我化简后的代码模型。
/**
* @return int 本地从数据库获取了多少题
*/
int get_jobs(int* jobs) {
// 从数据库中获取需要判题
int* solution_ids = get_data_from_mysql();
jobs = solution_ids;
// 这里直接返回从数据库中获取的题目数量
return solution_ids 的长度;
}
/**
* @return int 本次 work () 函数执行,总共判了多少题
*/
int work() {
// retcnt 记录本次 work 函数判了多少道题
int retcnt;
int jobs[100];
if (!get_jobs(jobs)) {
return 0;
}
for (int i = 0; jobs[i] > 0; i++) {
// 开始判 jobs[i] 对应的题目,
// judge 函数是我虚构的, 在这里知道是判题即可
judge(jobs[i]);
// 判了一题, retcnt + 1
retcnt++;
}
return retcnt;
}
int main() {
int j = 1;
int n = 0;
while (!STOP) {
n = 0;
while (j) {
j = work();
n += j;
}
if (n == 0) {
sleep(1);
}
j = 1;
}
}
在这里,我们来看一下 work()
函数的返回值和 get_jobs()
的返回值分别代表什么:
work()
:本次work()
函数执行完毕后判了多少道题get_jobs()
:本次get_jobs()
函数从数据库中获取了多少题
明确了这两个返回值之后,再来看一下主函数中,j
变量和 n
变量分别是什么:
j = work();
n += j;
j
就是单次 work()
函数执行后判了多少题。 work()
每执行一次,就会返回当前获取到 jobs
中的判题数,并将这个判题数赋值给 j
。
n
则表示当前判题机从休息状态(sleep(1)
)恢复执行判题以来,在这一轮的循环中累计判了多少题。它在内层循环不断的累加 j
的值,直到 j == 0
(while (j)
退出,表示没有题目可以判了)。
简单来说:
j = work()
:代表执行一次判题任务,并记录了本次的判题数量。n += j
:代表在当前轮次中累计的判题数量。
所以我们现在就可以明确了。
在内层循环 while (j)
中,j == work()
,如果 work() 判到了题,那么内层循环会一直执行,持续进行判题。
其次,在外层循环 while (!STOP)
中,由于 n
会不断累加内层的 j
,即使某次 work()
没有判到题,导致内层循环退出,但由于此时 n > 0
,意味着在当前较大的外层判题周期内,仍然有判题发生。因此,外层循环依然会继续执行判题逻辑,不会进入 sleep()
的分支。
吐槽一下:作者要是把 n 变量和 j 变量的命名规范化一下就好了,这里属实花了一点心思。
通过以上的这个双 while 死循环机制,我们就可以较好的完成我们上面的两点要求了。
到这里,judge.cc
相对来说比较难理解的地方就已经解读完毕了,其余一些具体的实现细节,由于提取出核心逻辑出来后也不好解读,需要以完整源码 + 注释的方式来展示了,但是毕竟源码有 900 + 行的,篇幅所限,下期再见。