设计实现OJ平台的遇到的一些问题和解决方法

需求

毕业设计,实现一个能够自动编译、运行、监测程序运行使用资源、恶意系统调用的监控的一个OJ平台。

在设计实现的过程中的想法、碰到的问题、求解的过程以及方法,在这里记录下来。

基础结构

OJ主要由前端系统(WEB)和后端的判题程序构成,想法是后端的裁判程序做通用点,减少和前端系统的耦合,所以把后端给分离出来成一个独立的程序,大概的结构图是这样的。

解释下:

1. 前端其实可以由任何流行的web语言来实现。

2. 这里的代理可有可无,代理在这里可以实现很多功能,比如负载均衡、数据库的业务逻辑等都可以在这里实现。

3. 裁判模块主要实现程序的编译、运行、监控、给出判定结果,是无状态的,所以整个系统的扩展性高。

后端使用到的技术

操作系统选的是linux,原因是工具多,稳定,系统API丰富。

裁判模块用的语言是C++,主要原因是性能、系统编程方便。

裁判模块的网络i/o用的是cheetah,一个事件驱动的网络库。

模块间的通信采用的是protobuf。

裁判模块的设计

这里借鉴了nginx的设计,单线程多进程的方式,由master进程和minion进程(数量可配置)组成。

  1. master管理minion进程的生命周期,在minion进程挂掉之后会重启。minion进程,在初始化的时候创建一个监听socket,fork出minion进程。
  2. minion进程共享master进程的监听socket,每accept一个连接,加入到reactor里,每读取到一个请求执行一次判题的过程。
  3. 判题的过程是编译程序,fork一个子进程,将标准输入、标准输出、标准错误输出重定向到几个文件上,然后调用execv将编译好的程序替换掉当前的binary,运行完成后比对输出文件和答案文件,给出结果。

碰到的问题

  1. cpu时间、内存使用量的监控,限制程序的cpu时间、内存使用量。
  2. 系统调用的监控。
  3. execv系统调用的一些pitfalls。

问题的思考的过程和解决方法

1.cpu时间、内存使用量的监控,限制程序的cpu时间、内存使用量。

如何做到cpu时间和内存使用量的测量呢?linux操作系统上有很多方法可以获得一个进程的这些信息,这里列举几个方法以及各自的优缺点。

  1. 用system系统调用定期调用top,ps等工具,来分析这些工具的输出。优点,方便。缺点,这个定期的频率不好选择,如果频率低了程序的性能降低,高了的话监测的精准度不够,比如某个程序在1ms就运行完毕了,这样的话这些工具甚至都捕捉不到这个进程运行过。
  2. 定期扫描/proc文件系统里被监测程序的信息。优点,/proc文件系统提供了非常详细的信息,一些top、ps的实现都是读取这个系统。缺点,同1。
  3. 使用wait系统调用,在子进程结束的时候读取下程序的资源使用信息,具体使用方法可以参考manpage。优点,精准,在程序结束的时候,父进程会收到子进程的SIGCHLD信号,在这个时候使用wait系统调用能获得比较精准的cpu和内存使用量。缺点,由于对程序资源做了限制(见下一个问题),在内存超出了限制之后,程序malloc内存会失败,在C的程序里会导致SIGSEGV,errno被设置为ENOMEM,在c++程序里也可能会导致SIGABRT、SIGKILL(由于new失败抛出的bad_alloc没有被捕获导致abort的调用),但是也有其他的情况会导致SEGFAULT,比如访问不存在的内存地址,这样做无法准确区别到底是内存超了,还是程序访问了不存在的内存的情况。

在获得程序的资源使用量之后,可以通过一下结合这些方法方式来实现时间、内存的限制。

  1. 当top,ps返回的结果中发现内存、时间的使用量超出了限制之后,发送SIGKILL给子进程。这样做还是需要频率的问题,间隔时间大了,子进程可能分配了大量的内存,对整个系统安全造成威胁。
  2. 使用setrlimit系统调用来实现cpu、内存使用量的限制,当使用量超出限制就发送SIGXCPU(超时),malloc调用失败(内存超了,errno设置为ENOMEM)。这种做法比较清爽,因为进程的资源使用量操作系统是最清楚不过了。

在经过一段时间研究,资料搜寻,找不到一种完美、简单的方式完成这个功能。最后经过权衡之后还是选择了3和2,在wait4返回的子进程退出状态来判断各种情况,这里只能通过一种比较丑陋的hack来判断是否是内存超了,通过setrlimit系统调用将进程的内存限制设置为限制的125%,如本来的内存限制为1M,现将内存限制设置为1.25M,在进程退出之后,判断内存使用量是否超过1M。

    ...
    if (fork() == 0) {
        /* 子进程 */
        rlimit r;
        getrlimit(RLIMIT_CPU, &r);
        r.rlim_cur = time_limit() / 1000;
        setrlimit(RLIMIT_CPU, &r);

        getrlimit(RLIMIT_AS, &r);
        r.rlim_cur = mem_limit() * 1024 * 1.25;
        setrlimit(RLIMIT_AS, &r);
    }
    ...
    struct rusage usage;
    wait4(pid, &s, 0, &usage); // reap the process and retrieve resources usage
    mem_usage = usage.ru_maxrss;

    if (WIFEXITED(s)) {
        fprintf(stderr, "WEXITSTATUS %d\n", WEXITSTATUS(s)); /* 正常退出 */
    } else if (WIFSIGNALED(s)) { /* 程序被信号结束了 */
        fprintf(stderr, "WTERMSIG %d\n", WTERMSIG(s));
        if (WTERMSIG(s) == SIGSEGV) {
            if (mem_usage> mem_limit)
                mf = 1; /* 超出内存限制 */
            else
                sf = 1;
        } else if (WTERMSIG(s) == SIGXCPU) { //SIGXCPU indicates the process used up the CPU time assigned to it
            tf = 1; /* 超出时间限制 */
        } else if (WTERMSIG(s) == SIGKILL ||
                   WTERMSIG(s) == SIGABRT) {
            if (mem_usage> mem_limit)
                mf = 1; /* 超出内存限制 */
            else
                of = 1; /* 运行时错误 */
        }  else {
            of = 1; /* 运行时错误 */
        }
    }
2.恶意系统调用的监控

用户提交上来的程序可能会包含恶意的系统调用,如操作文件系统(unlink),进程复制(fork),网络(socket, sendto, recvfrom)等系统调用。如何限制这些调用呢?

  1. 使用strace工具来查看程序的系统调用,一旦发现非法的系统调用,就发送SIGKILL,这样做的一个很大的缺点是实时性,当一个子进程系统调用调用完毕之后,我们的程序才可能反应过来。
  2. 我们想要的是实时的监控子进程的系统调用过程,在尝试调用禁止的系统调用的时候我们就需要把程序杀死,好在linux提供了ptrace系统调用,原理是在程序trap进系统调用之前会先检查当前进程是否被traced了,如果是的话,通过SIGTRAP暂停当前进程,通知tracer,给tracer一个机会来做一些事情,如恢复子进程执行、杀死进程等。strace就是基于ptrace来实现的。

综上所述,我采用了ptrace的方式来实现监控系统调用,关于ptrace系统调用可以参考manpage。

 ...
 if (fork() == 0) {
     ...
    /* 这里设置当前进程要被trace */
    if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
        fprintf(stderr, "failed to ptrace : %s\n", strerror(errno));
        exit(1);
    }
    ...
}
...
/* 等到execv调用后的第一个信号 */
if (waitpid(pid, &s, 0) == -1) {
    fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno));
    v.set_status(verdict_result::UNKNOWN_ERROR);;
    return std::move(v);
}

/* 设置trace选项,在子进程退出的时候发送EXIT事件,监视syscall,在父进程发生错误退出的时候杀死所有的子进程 */
if (ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESYSGOOD | PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL ) == -1) {
    fprintf(stderr, "ptrace(PTRACE_SETOPTIONS, %d, 0, PTRACE_O_TRACEEXIT | PTRACE_O_EXITKILL) error %s\n", pid, strerror(errno));
    v.set_status(verdict_result::UNKNOWN_ERROR);;
    return std::move(v);
}

while (true) {
    /* 恢复运行但是监视syscall */
    if (ptrace(PTRACE_SYSCALL, pid, 0, 0) == -1) {
        fprintf(stderr, "ptrace(PTRACE_SYSCALL, %d, 0, 0) error %s\n", pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }
    /* 获取子进程的状态 */
    if (waitpid(pid, &s, 0) == -1) {
        fprintf(stderr, "waitpid(%d, &s, 0); error %s\n",pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }

    /* 这里判断进程是否退出了 */
    if (WIFEXITED(s) || (WSTOPSIG(s) == SIGTRAP && (s & (PTRACE_EVENT_EXIT << 8))))break;

    fprintf(stderr, "WSTOPSIG %d\n", WSTOPSIG(s));

    if (WSTOPSIG(s) == SIGSEGV) {
        fprintf(stderr, "child process %d received SIGSEGV, killing... ", pid);

        if (kill(pid, SIGKILL) == -1)
            fprintf(stderr, "failed. %s\n", strerror(errno));
        else
            fprintf(stderr, "done.\n");
    } else if (WIFSTOPPED(s) && (WSTOPSIG(s) & 0x80)) {
        /* 这里的WORD_SIZE和ORIG_EAX根据机器字长不同需要做特殊处理 */
        #ifdef __x86_64__
        #define ORIG_EAX ORIG_RAX
        #define WORD_SIZE 8
        #else
        #define WORD_SIZE 4
        #endif

        /* 在进入或退出系统调用前子进程被暂停下来, 暂停信号的第7位被置1, 也就是0x80中断号*/
        long call_num = ptrace(PTRACE_PEEKUSER, pid, WORD_SIZE * ORIG_EAX);/* 拿到系统调用号*/
        assert(call_num < NR_syscalls);
        fprintf(stderr, "child process calling syscall, number: %ld\n", call_num);

        if (syscall_mask[call_num]) {
            /* 调用了禁用的系统调用, 发送SIGKILL */
            fprintf(stderr, "child process %d is trying to invoke the banned system call, killing it... ", pid);

            if (kill(pid, SIGKILL) == -1)
                fprintf(stderr, "failed. %s\n", strerror(errno));
            else
                fprintf(stderr, "done.\n");

            v.set_status(verdict_result::RUNTIME_ERROR);

            return std::move(v);
        }
    }
}
3.execv系统调用的一些pitfalls。
这是我在实现的时候碰到的一个问题,提交的程序定义了一个超过内存限制的全局数组,当调用setrlimit给程序设置了内存使用限制之后,调用execv,本以为execv会返回ENOMEM表示内存不够,但是却出现了进程直接被杀死(SIGKILL)的情况。而父进程在却在等待子进程执行execv后发送的第一个信号,waitpid返回了ENOCHLD的错误,表示不存在子进程。在网络上搜索很长的时间,并没有发现这类的问题,只好看下linux kernel的实现了,版本是2.6.32.65的,后面的版本这块的实现变化不大。

首先是找到elf加载器的实现,在fs/binfmt_elf.c里,定位到load_elf_binary函数。注意到714行和719行的关键注释。

    ...读取可执行文件,一致性检查,在这里可以通过返回值来返回错误...
    /* Flush all traces of the currently running executable */
    retval = flush_old_exec(bprm);
    if (retval)
        goto out_free_dentry;

    /* OK, This is the point of no return */
    current->flags &= ~PF_FORKNOEXEC;
    current->mm->def_flags = def_flags;
    ...加载可执行文件里的elf格式信息,计算程序的bss(未初始化数据段,全局变量)大小...

    /*
     * Calling set_brk effectively mmaps the pages that we need
     * for the bss and break sections.  We must do this before
     * mapping in the interpreter, to make sure it doesn‘t wind
     * up getting placed where the bss needs to go.
     */
    retval = set_brk(elf_bss, elf_brk);
    if (retval) {
        send_sig(SIGKILL, current, 0);
        goto out_free_dentry;
    }

也就是说在执行完flush_old_exec之后,如果没有返回错误,那么就不能使用返回值了,因为老的executable的信息已经被完全的清理了,但是默认信号处理程序没有被清空。所以接下来的操作出现了错误之后只能通过发送信号来处理了。注意到874行调用set_brk来扩大堆空间,如果这个大小超过了rlimit,那么就会失败,然后这个程序就被杀掉了。

所以父进程在等待子进程的第一个信号时,要判断程序是否被kill掉了。当然也有可能是其他情况导致程序被KILL掉,无法用SIGKILL来断定程序内存超了,所以这种情况实在是没有什么好的方法来解决,希望有这方面经验的大神能指点一二。

    //wait for the first signal caused by execv
    if (waitpid(pid, &s, 0) == -1) {
        fprintf(stderr, "waitpid(%d, 0, 0) error %s\n", pid, strerror(errno));
        v.set_status(verdict_result::UNKNOWN_ERROR);;
        return std::move(v);
    }

    /*
    * there might be a case where the child process failed to execv
    * due to virtual memory limit caused by huge global variables,
    * in that case the child process is killed by kernel by sending a SIGKILL.
    */
    if (WIFEXITED(s)) {
        fprintf(stderr, "child process exited, WEXITSTATUS %d\n", WEXITSTATUS(s));
        v.set_status(verdict_result::UNKNOWN_ERROR);
        return std::move(v);
    } else if (WIFSIGNALED(s)) {
        fprintf(stderr, "child process terminated at execv, WTERMSIG %d(", WTERMSIG(s));
        if (WTERMSIG(s) == SIGSEGV) {
            fprintf(stderr, "SIGSEGV)\n");
            v.set_status(verdict_result::SEGMENTATION_FAULT);
        } else if (WTERMSIG(s) == SIGKILL) {
            fprintf(stderr, "SIGKILL) memory limit exceeded.\n");
            v.set_status(verdict_result::MEMORY_LIMIT_EXCEEDED);
            v.set_mem_usage(sub.mem_limit());
        } else {
            v.set_status(verdict_result::UNKNOWN_ERROR);;
            fprintf(stderr, ")\n");
        }

        return std::move(v);
    }

潜在优化点

  1. 目前的minion进程在执行判题的的过程中是需要等待子进程的信号或者子进程执行完毕的,可以将这个等待的过程用事件的方式加入到reactor里面来提高并发度。
  2. 多个进程共享一个监听socket,可能会出现惊群效应。
时间: 2024-10-27 12:25:16

设计实现OJ平台的遇到的一些问题和解决方法的相关文章

android设计的布局在阿拉伯语下界面错乱的解决方法

(1)正在AndroidManifest.xml声明文件的application元素中,增加" android:supportsRtl=true" (2)建] androidの设计的布局在阿拉伯语下界面错乱的解决方法 (1)在AndroidManifest.xml声明文件的元素中,添加" android:supportsRtl=true" (2)修改应用程序中layout的"left/right"布局属性,改为对应的"start/end

win平台不能访问共享:权限不足的解决方法

昨天处理一个局域网windows平台无法共享的问题.总结如下,主要有3点,请在提供共享文件夹的主机上设置 1.win+R输入services.msc在服务中找Server开启: 2.win+R输入gpedit.msc找计算机配置-windows设置-安全设置-本地策略-用户权限分配,右边拒绝本地登录和拒绝从网络访问这台计算机后面的guest删掉: 3.win+R输入gpedit.msc找计算机配置-windows设置-安全设置-本地策略-安全选项,网络访问: 本地帐户的共享和安全模型改成经典-对

企业平台项目学习总结1-问题与解决方法

网页报错: <%@ Application Codebehind="Global.asax.cs" Inherits="ServiceMvc.MvcApplication" Language="C#" %> 1.解决方法:      位于服务代码client第10行       public ServiceMvcClient(System.String serverAddress, NRails.Service.IRequestSer

华为OJ平台——矩阵乘法

题目描述: 如果A是个x行y列的矩阵,B是个y行z列的矩阵,把A和B相乘,其结果将是另一个x行z列的矩阵C. 输入: 1.第一个矩阵的行数 2.第一个矩阵的列数(也是第二个矩阵的行数) 3.第二个矩阵的列数 4.第一个矩阵的值 5.第二个矩阵的值 输出: 输出两个矩阵相乘的结果 样例输入 2 2 2 3 8 8 0 9 0 18 9 样例输出 171 72 72 0 思路: 题目意思很简单,只是实现两个矩阵乘法功能,要注意的一点是输出的格式. OJ平台中对输出的格式非常严格,经过多次尝试,验证此

华为OJ平台——字符串分隔

题目描述: 连续输入字符串,请按长度为8拆分每个字符创 后输出到新的字符串数组: 长度不是8整数倍的字符串请在后面补数字0,空字符串不处理 输入 连续输入字符串(输入两次,每个字符长长度小于100)输出 输出到长度为8,的新字符串数组样例输入 abc 123456789样例输出 abc00000 12345678 90000000 思路: 都是直接处理,没有具体的方法而言 注意点: 华为的OJ平台的输入输出有点坑,好多次的程序都在这里出问题,在Eclipse上运行的结果没问题,然后在OJ上就是编

如何设计实时数据平台(设计篇)

我抽数故我存在 | DBus 人人玩转流处理 | Wormhole 就当吾是数据库 | Moonbox 颜值最后十公里 | Davinci 导读:实时数据平台(RTDP,Real-time Data Platform)是一个重要且常见的大数据基础设施平台.在上篇(设计篇)中,我们从现代数仓架构角度和典型数据处理角度介绍了RTDP,并探讨了RTDP的整体设计架构.本文作为下篇(技术篇),则是从技术角度入手,介绍RTDP的技术选型和相关组件,探讨适用不同应用场景的相关模式.RTDP的敏捷之路就此展开

[西建大ACM协会]OJ平台如何使用

目录 [西建大ACM协会]OJ平台如何使用 0x00 前言 0x01 准备工作 0x02 注册账号 0x03 开始使用 [西建大ACM协会]OJ平台如何使用 0x00 前言 ?关于ACM比赛以及对应的练习对自己有什么好处,我不做多的解释,这次这篇文章只针对想要提升自己编程能力.但又苦于无法找到合适入门途径的新人. ?备注:本文涉及到的OJ平台需要能够访问外网才可访问. 0x01 准备工作 ?访问外网工具(防和谐,不多说,后续的操作中都需要它) ?浏览器(建议chrome,看不懂的题目就可以直接翻

柯南君:教你如何对待大型电商平台的性能优化?之 一 (方法、指标、工具、定位)

柯南君:教你如何对待大型电商平台的性能优化?之 一 (方法.指标.工具.定位) 柯南君的朋友"闲哥"最近遇到了点困扰---"大型网站平台如何对待性能优化,以及如何针对性调优?",柯南君今天,想谈一下性能优化的战略,主要是一些企业架构级别的技术和方法.柯南君将自己的个人经验和各家公司大咖的经验一起汇总给大家,如有瑕疵,请大家及时指正. 柯南君有句自律的口头语"如果你不能成为一个追求性能的疯子,那么性能将会把你变成疯子"        序曲: 如何评

spark+hadoop+sqoop+hive平台bug解决方法

bug集锦 1. hadoop平台datanode无法启动: 原因: 由于多次hdfs namenode -format导致dfs/data/current/version中的cluserID与当前的cluserID不统一,通过查看hadoop/logs中的datanode日志文件查看到此错误. 解决方法: 1). 修改每台节点上的/dfs/current/version文件中的cluserID的值为当前的值(当前值可以通过hadoop/logs日志文件中的报错获得). 2). 每次format