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:latest
ENV 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)/500
config := &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;
else
rest->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 sys
def 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 True
except SyntaxError as e:
# 捕获语法错误
print(f"Syntax error in '{file_path}': {e}")
return False
if __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:
pass
return 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>
不足之处欢迎指正。
不欢迎讨论(因为我很菜,真的答不出什么),也不欢迎要全部代码的。