C语言的OJ判题机设计与实现

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);
}

注意execvp是在运行程序,具体的api细节我不清楚。但是args[0]作为execvp的第一个参数,只是起一个程序名的作用,没啥用,主要还是args作为按空格分隔的多个运行参数,放在execvp的第二个位置。然后第三个参数放NULL就行了。如python的就是char *args[] = {"python","main.py", NULL};
注意不能用system(),这样父进程是捕获不到子进程运行程序的结束状态码的,因为外面还套了一个shell

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也可以看作一个多叉树或链表套链表。只是因为是c语言,没有函数重载,所以创建和添加不同类型的结点有不同的函数,看起来代码有点臭长。

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 <stdbool.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>

不足之处欢迎指正。
不欢迎讨论(因为我很菜,真的答不出什么),也不欢迎要全部代码的。

  • 27
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
孪生素数是指两个相邻的素数,其差为2,例如(3, 5)、(5, 7)、(11, 13)等。下面是一道关于C语言中孪生素数的OJ题目: 题目描述: 输入一个正整数n,求出n以内的所有孪生素数(若存在)。输出格式为每个孪生素数对占一行,数字之间用一个空格隔开。如果不存在孪生素数,则输出"NO"。 输入格式: 输入一个正整数n(n<=1000000)。 输出格式: 按照格式输出符合条件的孪生素数对。 样例输入: 20 样例输出: 3 5 5 7 11 13 思路分析: 我们可以使用筛法来解决这道题目。先用筛法求出质数,然后遍历质数数组,如果当前质数与前一个质数相差为2,则说明是一对孪生素数。 参考代码: ```c #include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_N 1000000 bool is_prime[MAX_N + 1]; // 标记是否为质数 int prime[MAX_N + 1]; // 存储质数 int prime_cnt = 0; // 记录质数的数量 void sieve(int n) { for (int i = 2; i <= n; i++) { if (!is_prime[i]) { prime[prime_cnt++] = i; } for (int j = 0; j < prime_cnt && i * prime[j] <= n; j++) { is_prime[i * prime[j]] = true; if (i % prime[j] == 0) { break; } } } } int main() { int n; scanf("%d", &n); sieve(n); int prev_prime = -1; // 上一个质数 for (int i = 0; i < prime_cnt; i++) { if (prev_prime != -1 && prime[i] - prev_prime == 2) { printf("%d %d\n", prev_prime, prime[i]); } prev_prime = prime[i]; } if (prev_prime == -1) { printf("NO\n"); } return 0; } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值