1. 接收判题入参
判题需要作答代码、测试输入和期望输出、编译器名称、时空限制。对于支持special judge的还需要传入是否为sj和sj代码。推荐使用消息队列,应对高并发的比赛情况会比较好。
但是消息队列是异步的,我为了快点实现能提交后在当前页面获得判题结果,就单纯的用了rpc+nginx负载均衡,不过我觉得如果要实现当场获得判题结果,也可以mq+websocket
2. 编写判题镜像
我的设计是一个镜像对应一个编译器,好处是方便对于每个语言的编译运行做独立的修改,坏处是因为镜像基于ubuntu容器,至少也有1.7G的大小
下面为我的judger:base包的dockerfile,因为我需要python进行special judge,c进行判题,所以安装了gcc和python
# 使用基础镜像, Ubuntu
FROM ubuntu:latestENV DEBIAN_FRONTEND=noninteractive# 安装所需的编译器和其他依赖项
RUN apt-get update && apt-get install -y \build-essential \libssl-dev \zlib1g-dev \libbz2-dev \libreadline-dev \libsqlite3-dev \llvm \libncurses5-dev \libncursesw5-dev \xz-utils \tk-dev \libffi-dev \liblzma-dev \python3-openssl \python3-pip \wget# 将本地的 Python 压缩包复制到容器中
COPY Python-3.8.12.tar.xz .# 解压 Python 压缩包并进行安装
# RUN wget https://www.python.org/ftp/python/3.8.12/Python-3.8.12.tar.xz &&
RUN tar -xf Python-3.8.12.tar.xz && \cd Python-3.8.12 && \./configure --enable-optimizations && \make -j$(nproc) && \make altinstall# 删除临时文件
RUN rm -f Python-3.8.12.tar.xz
# 方便直接执行python
RUN ln Python-3.8.12/python /usr/bin/python
# 设置容器启动时的默认命令
CMD ["bash"]
2.1 编写判题脚本
我的是先在判题服务上选择启动对应的判题容器,然后将测试输入和期望输出以及代码保存到本地,然后将测试数量和时空限制传入判题机,所以c语言只需要接收这几个,向容器中传入的方面便是环境变量。go操作docker的操作如下
import ("context""fmt""github.com/docker/docker/api/types/container""github.com/docker/docker/api/types/image""github.com/docker/docker/api/types/mount""github.com/docker/docker/client"
)
func getClient() *client.Client{cli, err := client.NewClientWithOpts(client.WithHost("tcp://localhost:2375"), client.WithVersion("1.44"))if err != nil {panic(err)}return cli
}
func Run(params *JudgeParams,compiler string,dataDir string){cli := getClient()ctx :=context.Background()env := []string{fmt.Sprintf("special=%d", params.Special),fmt.Sprintf("timelimit=%d", params.TimeLimit),fmt.Sprintf("memorylimit=%d", params.MemoryLimit),fmt.Sprintf("casenum=%d", params.CaseNum),}// 准备配置,单位是毫秒->秒,再两倍timeout := int(params.TimeLimit)/500config := &container.Config{Image: fmt.Sprintf("judger:%s",compiler), Env: env,StopTimeout: &timeout,}// 准备 HostConfig,设置挂载点hostConfig := &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind,Source: dataDir,Target: "/app/data",},},}// 创建容器cont, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, "")if err != nil {panic(err)}// 启动容器if err := cli.ContainerStart(ctx, cont.ID, container.StartOptions{}); err != nil {panic(err)}fmt.Printf("Container %s started.\n", cont.ID)// 等待结束statusCh, errCh := cli.ContainerWait(ctx, cont.ID, container.WaitConditionNotRunning)select {case err := <-errCh:if err != nil {fmt.Println(err)}case status := <-statusCh:fmt.Println("Container exited with status:", status.StatusCode)}//删除容器cli.ContainerRemove(ctx,cont.ID,container.RemoveOptions{// Force: true,})
}
所以在传入这些参数之后,镜像内的c语言进行接收,注意需要从字符串转换
int main(int argc,char **argv) {int isSpecial = atoi(getenv("special"));int testCaseNum = atoi(getenv("casenum"));int timeLimit = atoi(getenv("timelimit"));int memoryLimit = atoi(getenv("memorylimit"));...
接着便是正式判题
可以看到即使通过Docker开辟了独立的容器空间,但内部还是要通过fork来限制程序运行的时空。
2.2 fork
fork开辟子进程
pid_t pid = fork();
if(pid<0){printf("error in fork!\n");result->status = WRONG_ANSWER;result->log = "无法创建新进程";return;
}
// 父进程监听
if(pid>0){monitor(pid, timeLimit, memoryLimit, result);
}else{
//子进程运行setProcessLimit(timeLimit,memoryLimit);_runExe(exeFile,timeLimit,memoryLimit,inputFile,outputFile);
}
限制时空是下面代码,具体为什么有两个限制内存的,我也不知道,unix的api我一点不会,java选手嗯造c语言
// ms kb
void setProcessLimit(const int timelimit, const int memory_limit) {struct rlimit rl;/* set the time_limit (second)*/rl.rlim_cur = timelimit / 1000;rl.rlim_max = rl.rlim_cur + 1;setrlimit(RLIMIT_CPU, &rl);/* set the memory_limit (b)*/rl.rlim_cur = memory_limit * 1024;rl.rlim_max = rl.rlim_cur;setrlimit(RLIMIT_DATA, &rl);rl.rlim_cur = memory_limit * 1024;rl.rlim_max = rl.rlim_cur;setrlimit(RLIMIT_AS, &rl);
}
运行可执行程序。通过重定向将输入文件内容作为程序输入,将程序输出传入实际输出文件中。
void _runExe(char *exeFile,long timeLimit, long memoryLimit, char *in, char *out) {int newstdin = open(in,O_RDWR|O_CREAT,0644);int newstdout = open(out,O_RDWR|O_CREAT|O_TRUNC,0644);if (newstdout != -1 && newstdin != -1){dup2(newstdout,fileno(stdout));dup2(newstdin,fileno(stdin));char cmd[20];char *args[] = {"./program", NULL};if (execvp(args[0], args) == -1){printf("====== Failed to start the process! =====\n");}} else {printf("====== Failed to open file! =====\n");}close(newstdin);close(newstdout);
}
注意是在运行程序,具体的api细节我不清楚。但是args[0]作为execvp
的第一个参数,只是起一个程序名的作用,没啥用,主要还是args
作为按空格分隔的多个运行参数,放在execvp的第二个位置。然后第三个参数放NULL就行了。如python的就是char *args[] = {"python","main.py", NULL};
char *args[] = {"./program", NULL};
if (execvp(args[0], args) == -1){printf("====== Failed to start the process! =====\n");
}
还是execvp这个api,如果是python这种解释性脚本语言,他语法错误时不会什么返回-1,直接打印语法错误然后就返回0了,为什么专门提这个呢?看父进程是怎么监听的。
2.3 父进程
我这里使用了rusage和wait4的api来获取子进程的返回结果和运行时空。
int status;
struct rusage ru;
// 等待进程结束
if (wait4(pid, &status, 0, &ru) == -1)printf("wait4 failure");
因为我们限制了子进程的时空,所以当子进程触碰到阈值后,就会异常终止,下方代码就是判断进入异常终止和正常结束的情况。可以自行理解TERM和EXIT。
// 异常if(WIFSIGNALED(status)){int sig = WTERMSIG(status);}else{//正常结束int sig = WEXITSTATUS(status);
}
然后接下来的异常信号量就是我在网上看别人的了,不过也确实能用。
void monitor(pid_t pid, int timeLimit, int memoryLimit, Result *rest) {int status;struct rusage ru;// 等待进程结束if (wait4(pid, &status, 0, &ru) == -1)printf("wait4 failure");rest->timeUsed = ru.ru_utime.tv_sec * 1000+ ru.ru_utime.tv_usec / 1000+ ru.ru_stime.tv_sec * 1000+ ru.ru_stime.tv_usec / 1000;// 另一个可能可行的方案:缺页错误就是使用内存的次数,乘页面大小就是内存占用,java可能用:`ru.ru_minflt * (sysconf(_SC_PAGESIZE) / 1024))` ;rest->memoryUsed = ru.ru_maxrss;// 程序异常中断if(WIFSIGNALED(status)){int sig = WTERMSIG(status);switch (WTERMSIG(status)) {case SIGSEGV:if (rest->memoryUsed > memoryLimit)rest->status = MEMORY_LIMIT_EXCEED;elserest->status = RUNTIME_ERROR;break;case SIGALRM:case SIGXCPU:rest->status = TIME_LIMIT_EXCEED;break;default:rest->status = RUNTIME_ERROR;break;}} else {// 注意语法错误和运行错误都会进这里int sig = WEXITSTATUS(status);if (sig==0){rest->status = ACCECPT;}else{rest->status = RUNTIME_ERROR;}}
}
注意看代码的正常结束判断的代码段,其实这个判断是我的python判题机里的,因为他因为语法运行错误不会做什么运行错误
的返回,而是进入正常返回,所以在这里还需要判断,0是正常结束,1是不正常。而gcc和g++就不用在这里判断(应该是的)。
正好也给一个python运行前检查语法错误的法子,万一哪个老师脑子一抽想加个和编译错误同等的语法错误的判断
import sysdef check_syntax(file_path):try:with open(file_path, 'r') as file:script = file.read()# 尝试编译脚本compile(script, file_path, 'exec')print(f"The script '{file_path}' has no syntax errors.")return Trueexcept SyntaxError as e:# 捕获语法错误print(f"Syntax error in '{file_path}': {e}")return Falseif __name__ == "__main__":file_path = sys.argv[1] if len(sys.argv) > 1 else "data/code.py"# 检查文件语法check_syntax(file_path)
2.4 比较输出结果
实际输出和期望输出的比较,就见仁见智了,毕竟有些题目要求完全一致,不然格式错误什么的,顺便一提我这里没给出输出超限格式错误的判断方法。更别说还有的什么可以忽略最后的换行符或者每行最后一个空格,那个要自己写了(指不用linux自带的diff命令)
然后关于特判,我的python代码模版如下。这里面限制了运行时间以及读取实际输出文件,并将返回的True或False的字符串写入文件中,还是通过文件通信。而出题人编写的代码,就放在这下面的第一行的上面,模版再见更下面。
import signal
import sys
from contextlib import contextmanager @contextmanager
def time_limit(seconds): def signal_handler(signum, frame): raise Exception() signal.signal(signal.SIGALRM, signal_handler) signal.alarm(seconds) try: yield finally: signal.alarm(0)try: with open(sys.argv[1], 'r') as file: lines = file.readlines() with time_limit(int(sys.argv[2])):res = judge(lines)
except Exception as e: res = False
with open(sys.argv[3], 'w') as f: f.write(str(res))
这是出题人的模板,他要负责编写这个函数,入参是实际输出的每行的字符串(所以还需要手动split和类型转换),返回值必须是True或False
def judge(lines)->bool: for line in lines:passreturn True
2.5 返回判题结果
至于为什么保存为json进行volume通信,这个见仁见智,我是用的cJSON库,还挺有意思,给你们瞟一眼,其实就是创建链表节点,然后挂载到父结点上,毕竟json也可以看作一个多叉树
void res2json(Result *compileResult,Result *runResults,int testCaseNum,char *lastOuput){// 创建 JSON 对象cJSON *root = cJSON_CreateObject();if (root == NULL) {fprintf(stderr, "Failed to create JSON object.\n");return;}//编译结果cJSON *compileNode = cJSON_CreateObject();cJSON_AddNumberToObject(compileNode, "status", compileResult->status);cJSON_AddStringToObject(compileNode, "log", compileResult->log);cJSON_AddItemToObject(root, "compile", compileNode);// 运行结果cJSON * runNodes = cJSON_CreateArray();for(int i=0; i<testCaseNum;i++){cJSON *runNode = cJSON_CreateObject();cJSON_AddNumberToObject(runNode, "status", runResults[i].status);cJSON_AddStringToObject(runNode, "log", runResults[i].log);cJSON_AddNumberToObject(runNode, "time", runResults[i].timeUsed);cJSON_AddNumberToObject(runNode, "memory", runResults[i].memoryUsed);cJSON_AddItemToArray(runNodes, runNode);}cJSON_AddItemToObject(root, "run", runNodes);//最后一次输出cJSON *lastOutputNode = cJSON_CreateString(lastOuput);cJSON_AddItemToObject(root, "lastOutput", lastOutputNode);// // 将 JSON 对象转换为 JSON 字符串char *jsonStr = cJSON_Print(root);if (jsonStr == NULL) {fprintf(stderr, "Failed to convert JSON object to string.\n");cJSON_Delete(root);return;}cJSON_Delete(root);// 打开文件,如果不存在则创建,准备写入 FILE *file = fopen(RES_FILE, "w"); if (file == NULL) { perror("Error opening file"); return; } // 写入字符串到文件fputs(jsonStr, file);fclose(file);printf("%s\n",jsonStr);free(jsonStr);
}
2.6 编译型和解释型语言
我这个每种语言各自一个镜像就是为了这种情况。像gcc、g++、java(有编译为字节码和虚拟机运行字节码两步)这种编译型就把编译步骤加上,然后运行也是运行输出的可执行文件。
像python nodejs这些就可以注释掉compile操作,然后改写运行的那句命令(execvp
那里)
2.7 请求头
我写的很困难,因为很多api都不知道,是chatgpt+stackoverflow告诉我的。姑且分享一下。cjson这里没放,读者自己学着去仓库里下.c和.h然后include h文件(只需要下载两个文件,很容易的,不要什么cmake)
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <sys/resource.h>
#include <time.h>
#include <errno.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
不足之处欢迎指正。
不欢迎讨论(因为我很菜,真的答不出什么),也不欢迎要全部代码的。