• Stars
    star
    224
  • Rank 177,792 (Top 4 %)
  • Language
    C
  • Created over 11 years ago
  • Updated about 9 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

ACM在线测评系统评测程序设计与实现

ACM在线测评系统评测程序设计与实现

写此文目的

  • 让外行人了解ACM,重视ACM
  • 让ACMer了解评测程序评测原理以便更好得做题
  • 让pythoner了解如何使用更好的使用python

在讲解之前,先给外行人补充一些关于ACM的知识。

什么是ACM?

我们平常指的ACM是ACM/ICPC(国际大学生程序设计竞赛),这是由ACM(Association for Computing Machinery,美国计算机协会)组织的年度性竞赛,始于1970年,是全球大学生计算机程序能力竞赛活动中最有影响的一项赛事。被誉为计算机界奥林匹克。

了解更多关于ACM的信息可以参考:

什么是ACM测评系统?

为了让同学们拥有一个练习和比赛的环境,需要一套系统来提供服务。

系统要提供如下功能:

  • 用户管理
  • 题目管理
  • 比赛管理
  • 评测程序

典型的ACM评测系统有两种

评测程序是做什么的?

评测程序就是对用户提交的代码进行编译,然后执行,将执行结果和OJ后台正确的测试数据进行比较,如果答案和后台数据完全相同就是AC(Accept),也就是你的程序是正确的。否则返回错误信息,稍后会详细讲解。

ACM在线测评系统整体架构

为了做到低耦合,我们以数据库为中心,前台页面从数据库获取题目、比赛列表在浏览器上显示,用户通过浏览器提交的代码直接保存到数据库。

评测程序负责从数据库中取出用户刚刚提交的代码,保存到文件,然后编译,执行,评判,最后将评判结果写回数据库。

评测程序架构

评测程序要不断扫描数据库,一旦出现没有评判的题目要立即进行评判。为了减少频繁读写数据库造成的内存和CPU以及硬盘开销,可以每隔0.5秒扫描一次。为了提高评测速度,可以开启几个进程或线程共同评测。由于多线程/进程会竞争资源,对于扫描出来的一个题目,如果多个评测进程同时去评测,可能会造成死锁,为了防止这种现象,可以使用了生产者-消费者模式,也就是建立一个待评测题目的任务队列,这个队列的生产者作用就是扫描数据库,将数据库中没有评测的题目列表增加到任务队列里面。消费者作用就是从队列中取出要评测的数据进行评测。

为什么任务队列能防止出现资源竞争和死锁现象?

python里面有个模块叫Queue,我们可以使用这个模块建立三种类型的队列:

  • FIFO:先进先出队列
  • LIFO:后进先出队列
  • 优先队列

这里我们用到的是先进先出队列,也就是先被添加到队列的代码先被评测,保持比赛的公平性。

队列可以设置大小,默认是无限大。

生产者发现数据库中存在没有评测的题目之后,使用put()方法将任务添加到队列中。这时候如果队列设置大小并且已经满了的话,就不能再往里面放了,这时候生产者就进入了等待状态,直到可以继续往里面放任务为止。在等待状态的之后生产者线程已经被阻塞了,也就是说不再去扫描数据库,因此适当设置队列的大小可以减少对数据库的读写次数。

消费者需要从任务队列获取任务,使用get()方法,一旦某个线程从队列get得到某个任务之后,其他线程就不能再次得到这个任务,这样可以防止多个评测线程同时评测同一个程序而造成死锁。如果任务队列为空的话,get()方法不能获得任务,这时候评线程序就会阻塞,等待任务的到来。在被阻塞的时候评测程序可以被看做停止运行了,可以明显减少系统资源消耗。

队列还有两个方法:

一个是task_done(),这个方法是用来标记队列中的某个任务已经处理完毕。

另一个是join()方法,join方法会阻塞程序直到所有的项目被删除和处理为止,也就是调用task_done()方法。

这两个方法有什么作用呢?因为评测也需要时间,一个任务从队列中取出来了,并不意味着这个任务被处理完了。如果没有处理完,代码的状态还是未评判,那么生产者会再次将这个代码从数据库取出加到任务队列里面,这将造成代码重复评测,浪费系统资源,影响评测速度。这时候我们需要合理用这两个方法,保证每个代码都被评测并且写回数据库之后才开始下一轮的扫描。后面有代码示例。

我们使用如下代码创建一个FIFO队列:

#初始化队列
q = Queue(config.queue_size)

如何有效得从数据库获取数据?

这里我们以mysql为例进行说明。python有数据库相关的模块,使用起来很方便。这里我们需要考虑异常处理。

有可能出现的问题是数据库重启了或者偶尔断开了不能正常连接,这时候就需要不断尝试重新连接直到连接成功。然后判断参数,如果是字符串就说明是sql语句,直接执行,如果是列表则依次执行所有的语句,如果执行期间出现错误,则关闭连接,返回错误信息。否则返回sql语句执行结果。

下面这个函数专门来处理数据库相关操作

def run_sql(sql):
    '''执行sql语句,并返回结果'''
    con = None
    while True:
        try:
            con = MySQLdb.connect(config.db_host,config.db_user,config.db_password,
                                  config.db_name,charset=config.db_charset)
            break
        except: 
            logging.error('Cannot connect to database,trying again')
            time.sleep(1)
    cur = con.cursor()
    try:
        if type(sql) == types.StringType:
            cur.execute(sql)
        elif type(sql) == types.ListType:
            for i in sql:
                cur.execute(i)
    except MySQLdb.OperationalError,e:
        logging.error(e)
        cur.close()
        con.close()
        return False
    con.commit()
    data = cur.fetchall()
    cur.close()
    con.close()
    return data

需要注意的是这里我们每次执行sql语句都要重新连接数据库,能否一次连接,多次操作数据库?答案是肯定的。但是,这里我们需要考虑的问题是如何将数据库的连接共享?可以设置一个全局变量。但是如果数据库的连接突然断开了,在多线程程序里面,问题就比较麻烦了,你需要在每个程序里面去判断是否连接成功,失败的话还要重新连接,多线程情况下如何控制重新连接?这些问题如果在每个sql语句执行的时候都去检查的话太麻烦了。

有一种方法可以实现一次连接,多次操作数据库,还能方便的进行数据库重连,那就是使用yield生成器,连接成功之后,通过yield将sql语句传递进去,执行结果通过yield反馈回来。这样听起来很好,但是有个问题容易被忽略,那就是yield在不支持多线程,多个线程同时向yield发送数据,yield接收谁?yield返回一个数据,谁去接收?这样yield就会报错,然后停止执行。当然可以使用特殊方法对yield进行加锁,保证每次都只有一个线程发送数据。

通过测试发现,使用yield并不能提高评测效率,而每次连接数据库也并不慢,毕竟现在服务器性能都很高。所以使用上面的每次连接数据库的方法还是比较好的。

还有一个问题,当多线程同时对数据库进行操作的时候,也容易出现一些莫名其妙的错误,最好是对数据库操作加锁:

#创建数据库锁,保证一个时间只能一个程序都写数据库
dblock = threading.Lock()
# 读写数据库之前加锁
dblock.acquire()
# 执行数据库操作
runsql()
# 执行完毕解锁
dblock.release()

生产者如何去实现?

为了隐藏服务器信息,保证服务器安全,所有的SQL语句都用五个#代替。

生产者就是一个while死循环,不断扫描数据库,扫描到之后就向任务队列添加任务。

def put_task_into_queue():
    '''循环扫描数据库,将任务添加到队列'''
    while True:
        q.join() #阻塞安程序,直到队列里面的任务全部完成
        sql = "#####"
        data = run_sql(sql)
        for i in data:
            solution_id,problem_id,user_id,contest_id,pro_lang = i
            task = {
                "solution_id":solution_id,
                "problem_id":problem_id,
                "contest_id":contest_id,
                "user_id":user_id,
                "pro_lang":pro_lang,
            }
            q.put(task)
        time.sleep(0.5) #每次扫面完后等待0.5秒,减少CPU占有率

消费者如何实现?

基本是按照上面说的来的,先获取任务,然后处理任务,最后标记任务处理完成。

def worker():
    '''工作线程,循环扫描队列,获得评判任务并执行'''
    while True:
        #获取任务,如果队列为空则阻塞
        task = q.get()  
        #获取题目信息
        solution_id = task['solution_id']
        problem_id = task['problem_id']
        language = task['pro_lang']
        user_id = task['user_id']
        # 评测
        result=run(problem_id,solution_id,language,data_count,user_id)
        #将结果写入数据库
        dblock.acquire()
        update_result(result) 
        dblock.release()
        #标记一个任务完成
        q.task_done()   

如何启动多个评测线程?

def start_work_thread():
    '''开启工作线程'''
    for i in range(config.count_thread):
        t = threading.Thread(target=worker)
        t.deamon = True
        t.start()

这里要注意t.deamon=True,这句的作用是当主线程退出的时候,评测线程也一块退出,不在后台继续执行。

消费者获取任务后需要做什么处理?

因为代码保存在数据库,所以首先要将代码从数据库取出来,按文件类型命名后保存到相应的评判目录下。然后在评判目录下对代码进行编译,如果编译错误则将错误信息保存到数据库,返回编译错误。编译通过则运行程序,检测程序执行时间和内存,评判程序执行结果。

如何编译代码?

根据不同的编程语言,选择不同的编译器。我的评测程序支持多种编程语言。编译实际上就是调用外部编译器对代码进行编译,我们需要获取编译信息,如果编译错误,需要将错误信息保存到数据库。

调用外部程序可以使用python的subprocess模块,这个模块非常强大,比os.system()什么的牛逼多了。里面有个Popen方法,执行外部程序。设置shell=True我们就能以shell方式去执行命令。可以使用cwd指定工作目录,获取程序的外部输出可以使用管道PIPE,调用communicate()方法可以可以获取外部程序的输出信息,也就是编译错误信息。

可以根据编译程序的返回值来判断编译是否成功,一般来说,返回值为0表示编译成功。

有些语言,比如rubyperl是解释型语言,不提供编译选项,因此在这里仅仅加上-c参数做简单的代码检查。

pythonluajava等可以编译成二进制文件然后解释执行。

ACMer们着重看一下gccg++pascal的编译参数,以后写程序可以以这个参数进行编译,只要在本地编译通过一般在服务器上编译就不会出现编译错误问题。

可能有些朋友会有疑问:为什么加这么多语言?正式ACM比赛只让用C,C++JAVA语言啊!对这个问题,我只想说,做为一个在线测评系统,不能仅仅局限在ACM上。如果能让初学者用这个平台来练习编程语言不是也很好?做ACM是有趣的,用一门新的语言去做ACM题目也是有趣的,快乐的去学习一门语言不是学得很快?我承认,有好多语言不太适合做ACM,因为ACM对时间和内存要求比较严格,好多解释执行的语言可能占内存比较大,运行速度比较慢,只要抱着一种学习编程语言的心态去刷题就好了。此外,对于新兴的go语言,我认为是非常适合用来做ACM的。牛逼的haskell语言也值得一学,描述高级数据结果也很方便。感兴趣的可以试试。

我的评测程序是可以扩展的,如果想再加其他编程语言,只要知道编译参数,知道如何执行,配置好编译器和运行时环境,在评测程序里面加上就能编译和评测。

def compile(solution_id,language):
    '''将程序编译成可执行文件'''
    build_cmd = {
        "gcc"    : "gcc main.c -o main -Wall -lm -O2 -std=c99 --static -DONLINE_JUDGE",
        "g++"    : "g++ main.cpp -O2 -Wall -lm --static -DONLINE_JUDGE -o main",
        "java"   : "javac Main.java",
        "ruby"   : "ruby -c main.rb",
        "perl"   : "perl -c main.pl",
        "pascal" : 'fpc main.pas -O2 -Co -Ct -Ci',
        "go"     : '/opt/golang/bin/go build -ldflags "-s -w"  main.go',
        "lua"    : 'luac -o main main.lua',
        "python2": 'python2 -m py_compile main.py',
        "python3": 'python3 -m py_compile main.py',
        "haskell": "ghc -o main main.hs",
    }
    p = subprocess.Popen(build_cmd[language],shell=True,cwd=dir_work,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
    out,err =  p.communicate()#获取编译错误信息
    if p.returncode == 0: #返回值为0,编译成功
        return True
    dblock.acquire()
    update_compile_info(solution_id,err+out) #编译失败,更新题目的编译错误信息
    dblock.release()
    return False

用户代码在执行过程中是如何进行评判的(ACMer必看)?

前面说了,如果出现编译错误(Compile Error),是不会执行的。每个题目都有一个标准的时间和内存限制,例如时间1000ms,内存65536K,程序在执行的时候会实时检查其花费时间和使用内存信息,如果出现超时和超内存将会分别返回Time Limit ExceededMemory Limit Exceeded错误信息,如果程序执行时出现错误,比如非法指针,数组越界等,将会返回Runtime Error信息。如果你的程序没有出现上面的信息,说明程序顺利执行结束了。接下来,就是对你的程序的输出也就是运行结果进行检查,如果你的执行结果和我们的标准答案完全一样,则返回Accepted,也就说明你这个题目做对了。如果除去空格,换行,tab外完全相同,则说明你的代码格式错误,将返回Presentation Error,如果你输出的内容有一部分和标准答案完全一样,但是还输出了一些其他内容,则说明你多输出了,这时候将返回Output Limit Exceeded错误信息,出现其他情况,就说明你的输出结果和标准答案不一样,就是Wrong Answer了。

总结一下错误的出现顺序:

Compile Error -> Memory Limit Exceeded = Time Limit Exceeded = Runtime Error -> Wrong Answer -> Output Limit Exceeded -> Presentation Error -> Accepted

直接说难免有些空洞,做了张流程图:

result.jpg

如果你得到了其他信息,比如System error,则说明服务器端可能出问题了,我们技术人员会想法解决。如果看到waiting,说明等待评测的代码比较多,你需要稍作等待,直到代码被评测。如果你得到了Judging结果,说明你的代码正在评测,如果长时间一直是Judging,则说明评测程序在评测过程中可能出问题了,没有评判出结果就停止了。技术人员会为你重判的。

希望ACMer们能根据上面的评测流程,在看到自己的评判结果的时候,能够分析出你离AC还有多远,以及如何改进你的代码才能AC。

评判答案的那部分源码:

def judge_result(problem_id,solution_id,data_num):
    '''对输出数据进行评测'''
    currect_result = os.path.join(config.data_dir,str(problem_id),'data%s.out'%data_num)
    user_result = os.path.join(config.work_dir,str(solution_id),'out%s.txt'%data_num)
    try:
        curr = file(currect_result).read().replace('\r','').rstrip()#删除\r,删除行末的空格和换行
        user = file(user_result).read().replace('\r','').rstrip()
    except:
        return False
    if curr == user:       #完全相同:AC
        return "Accepted"
    if curr.split() == user.split(): #除去空格,tab,换行相同:PE
        return "Presentation Error"
    if curr in user:  #输出多了
        return "Output limit"
    return "Wrong Answer"  #其他WA

注意一下,代码中有个replace('\r','')方法,它的作用就是将\r替换成空字符串。为什么要做这个替换呢?因为在windows下,文本的换行是"\r\n",而在Linux下是"\n"。因为不能确定测试数据来源与windows还是Linux,增加一个\r,就是增加一个字符,如果不删除的话,两个文本就是不一样的,就会造成wrong answer结果。或许你曾经遇到过在windows下用记事本打开一个纯文本文件,格式全乱了,所有文本都在一行内,非常影响阅读。你可以通过用写字板打开来解决这个问题。据说"\r\n"来源于比较古老的打印机,每打印完一行,都要先“回车(\r)”,再“换行”(\n)。同样一个C语言的printf("\n")函数,在windows下将生成"\r\n",而在Linux下生成"\n",因为评测程序为你自动处理了,因此你就不必关注这些细节的东西了。

评测程序是如何检测你的程序的执行时间和内存的?

这个问题困扰了我好久,也查了好多资料。

用户的程序要在服务器上执行,首先不能让用户的程序无限申请内存,否则容易造成死机现象,需要将程序的内存限制在题目规定的最大内存内。其次要限制用户程序的执行时间,不能让用户的程序无限制运行。

一般解决方案是:在用户的程序执行前,先做好资源限制,限制程序能使用的最大内存和CPU占用,当用户的程序一旦超出限制就自动终止了。还有个比较重要的问题是如何获取程序执行期间的最大内存占用率。用户的代码在执行前需要申请内存,执行期间还能动态申请和释放内存,执行完毕释放内存。程序执行时还有可能使用指针等底层操作,这无疑给检测内存造成更大的困难。在windows下,程序执行结束后,可以调用系统函数获取程序执行期间的最大内存,貌似在Linux下没用现成的函数可以调用。

在Linux下,我们可以使用pstop命令来获取或监视在某个时刻应用程序的内存占用率,要获取程序的最大执行内存,就要不断去检测,不断去比较,直到程序结束,获取最大值就是用户程序执行期间的最大内存。根据这个设想,我写了一个程序来实现这个想法:

def get_max_mem(pid):
    '''获取进程号为pid的程序的最大内存'''
	glan = psutil.Process(pid)
	max = 0
	while True:
		try:
			rss,vms = glan.get_memory_info()
			if rss > max:
				max = rss
		except:
			print "max rss = %s"%max
			return max

def run(problem_id,solution_id,language,data_count,user_id):
    '''获取程序执行时间和内存'''
    time_limit = (time_limit+10)/1000.0
    mem_limit = mem_limit * 1024
    max_rss = 0
    max_vms = 0
    total_time = 0
    for i in range(data_count):
        '''依次测试各组测试数据'''
        args = shlex.split(cmd)
        p = subprocess.Popen(args,env={"PATH":"/nonexistent"},cwd=work_dir,stdout=output_data,stdin=input_data,stderr=run_err_data)
        start = time.time()
        pid = p.pid
        glan = psutil.Process(pid)
        while True:
            time_to_now = time.time()-start + total_time
            if psutil.pid_exists(pid) is False:
                program_info['take_time'] = time_to_now*1000
                program_info['take_memory'] = max_rss/1024.0
                program_info['result'] = result_code["Runtime Error"]
                return program_info
            rss,vms = glan.get_memory_info()
            if p.poll() == 0:
                end = time.time()
                break
            if max_rss < rss:
                max_rss = rss
                print 'max_rss=%s'%max_rss
            if max_vms < vms:
                max_vms = vms
            if time_to_now > time_limit:
                program_info['take_time'] = time_to_now*1000
                program_info['take_memory'] = max_rss/1024.0
                program_info['result'] = result_code["Time Limit Exceeded"]
                glan.terminate()
                return program_info
            if max_rss > mem_limit:
                program_info['take_time'] = time_to_now*1000
                program_info['take_memory'] = max_rss/1024.0
                program_info['result'] =result_code["Memory Limit Exceeded"]
                glan.terminate()
                return program_info

        logging.debug("max_rss = %s"%max_rss)
#        print "max_rss=",max_rss
        logging.debug("max_vms = %s"%max_vms)
#        logging.debug("take time = %s"%(end - start))
    program_info['take_time'] = total_time*1000
    program_info['take_memory'] = max_rss/1024.0
    program_info['result'] = result_code[program_info['result']]
    return program_info

上面的程序用到了一些进程控制的一些知识,简单说明一下。

程序的基本原理是:先用多进程库subprocess的Popen函数去创建一个新的进程,获取其进程号(pid),然后用主线程去监测这个进程,主要是监测实时的内存信息。通过比较函数,获得程序的执行期间的最大内存。什么时候停止呢?有四种情况:

  1. 程序运行完正常结束。这个我们可以通过 subprocess.Popen里面的poll方法来检测,如果为0,则代表程序正常结束。
  2. 程序执行时间超过了规定的最大执行时间,用terminate方法强制程序终止
  3. 程序执行内存超过了规定的最大内存,terminate强制终止。
  4. 程序执行期间出现错误,异常退出了,这时候我们通过检查这个pid的时候就会发现不存在。

还有一点是值得注意的:上文提到在编译程序的时候,调用subprocess.Popen,是通过shell方式调用的,但是这里没有使用这种方式,为什么呢?这两种方式有什么区别?最大的区别就是返回的进程的pid,以shell方式执行,返回的pid并不是子进程的真正pid,而是shell的pid,当我们去检查这个pid的内存使用率的时候得到的并不是用户进程的pid!不通过shell方式去调用外部程序则是直接返回真正程序的pid,而不用去调用shell。官方文档是这么说的:if shell is true, the specified command will be executed through the shell.

如果不用shell方式去执行命令的话,传递参数的时候就不能直接将字符串传递过去,例如ls -l这个命令ls和参数-l,当shell=False时,需要将命令和参数变成一个列表['ls','-l']传递过去。当参数比较复杂的时候,将命令分隔成列表就比较麻烦,幸好python为我们提供了shlex模块,里面的split方法就是专门用来做这个的,官方文档是这么说的:Split the string s using shell-like syntax.,最好不要自己去转换,有可能会导致错误而不能执行。

上面的检测内存和时间的方法靠谱吗?

不靠谱,相当不靠谱!(当然学学python如何对进程控制也没坏处哈!)为什么呢?有点经验的都知道,C语言的运行效率比python高啊!执行速度比python快!这会造成什么后果?一个简单的hello world小程序,C语言“瞬间”就执行完了,还没等我的python程序开始检测就执行完了,我的评测程序什么都没检测到,然后返回0,再小的程序内存也不可能是0啊!在OJ上显示内存为0相当不科学!

那怎么办?能不能让C语言的程序执行速度慢下来?CPU的频率是固定的,我们没法专门是一个程序的占用的CPU频率降低,在windows下倒是有变速齿轮这款软件可以让软件执行速度变慢,不知道在Linux下有没有。还有没有其他办法?聪明的你也许会想到gdb调试,我也曾经想用这种方法,用gdb调试可以使程序单步执行,然后程序执行一步,我检测一次,多好,多完美!研究了好一阵子gdb,发现并不是那么简单。首先,我们以前用gdb调试C/C++的时候,在编译的时候要加上一个-g参数,然后执行的时候可以单步执行,此外,还有设置断点什么的。有几个问题:

  1. 其他语言如何调试?比如java,解释执行的,直接调试java虚拟机吗?
  2. 如何通过python对gdb进行控制?还有获取执行状态等信息。

这些问题都不是很好解决。

那上面的方法测量的时间准吗?不准!为什么?我们说的程序的执行时间,严格来说是占用CPU的时间。因为CPU采用的是轮转时间片机制,在某个时刻,CPU在忙别的程序。上面的方法用程序执行的结束时间减去开始时间,得到的时间一定比它实际执行的时间要大。如果程序执行速度过快,不到1毫秒,评测程序也不能检测出来,直接返回0了。

如何解决时间和内存的测量问题?

后来在v2ex上发了一个帖子提问,得到高人指点,使用lorunlorun是github上的一个开源项目,项目地址:https://github.com/lodevil/Lo-runner,这是用C语言写的一个python扩展模块,让程序在一个类似沙盒的环境下执行,然后精准的获取程序的执行时间和内存,还能对程序进行限制,限制程序的系统调用。原文是这么说的:We use this python-c library to run program in a sandbox-like environment. With it, we can accurately known the resource using of the program and limit its resource using including system-call interrupt.。安装使用都非常方便。我主要用它来测量执行时间和内存,后期代码检查还是用我的程序。

感兴趣的同学可以将这个模块下载下来,作为本地测试使用,可以预先生成一些测试数据,然后测量你的代码的执行时间和内存,比对你的答案是否正确。

不同编程语言时间内存如何限定?

一般来说,假设C/C++语言的标程是时间限制:1000ms,内存限制32768K,那么java的时间和内存限制都是标准限制的2倍,即2000ms,65536K。

由于后来我再OJ增加了好多其他语言,我是这样规定的:编译型的语言和速度较快的解释型语言的时间和内存限制和C/C++是一样的,这样的语言包括:C、C++、go、haskell、lua、pascal,其他速度稍慢的解释执行的语言和JAVA是一样的,包括:java、python2、python3、ruby、perl。毕竟使用除C,C++,JAVA外的语言的朋友毕竟是少数,如果限制太严格的话可以根据实际情况对其他编程语言放宽限制。

多组测试数据的题目时间和内存如何测算?

多组测试数据是一组一组依次执行,时间和内存取各组的最大值,一旦某组测试数据时间和内存超出限制,则终止代码执行,返回超时或超内存错误信息。

如何防止恶意代码破坏系统?

我们可以使用以下技术来对用户程序进行限制:

  1. lorun模块本身就有限制,防止外部调用
  2. 降低程序的执行权限。在Linux下,目录权限一般为755,也就是说,如果换成一个别的用户,只要不是所有者,就没有修改和删除的权限。python里面可以使用os.setuid(int(os.popen("id -u %s"%"nobody").read()))来将程序以nobody用户的身份执行
  3. 设置沙盒环境,将用户执行环境和外部隔离。Linux下的chroot命令可以实现,python也有相关方法,但是需要提前搭建沙盒环境。用jailkit可以快速构建沙盒环境,感兴趣的朋友可以看看
  4. 使用ACL访问控制列表进行详细控制,让nobody用户只有对某个文件夹的读写权限,其他文件夹禁止访问
  5. 评判机和服务器分离,找单独的机器,只负责评判
  6. 对用户提交的代码预先检查,发现恶意代码直接返回Runtime Error
  7. 禁止评测服务器连接外网,或者通过防火墙限制网络访问

如何启动和停止评测程序以及如何记录错误日志?

启动很简单,只要用python执行protect.py就行了。

如果需要后台执行的话可以使用Linux下的nohup命令。

为了防止同时开启多个评测程序,需要将以前开启的评测程序关闭。

为了方便启动,我写了这样一个启动脚本:

#!/bin/bash
sudo kill `ps aux | egrep "^nobody .*? protect.py" | cut -d " "  -f4`
sudo nohup python protect.py &

第一条命令就是杀死多余的评测进程,第二条是启动评测程序。

在程序里面使用了logging模块,是专门用来记录日志的,这么模块很好用,也很强大,可定制性很强,对我们分析程序执行状态有很大帮助。下面是一些示例:

2013-03-07 18:19:04,855 --- 321880 result 1
2013-03-07 18:19:04,857 --- judging 321882
2013-03-07 18:19:04,881 --- judging 321883
2013-03-07 18:19:04,899 --- judging 321884
2013-03-07 18:19:04,924 --- 321867 result 1
2013-03-07 18:19:04,950 --- 321883 result 7
2013-03-07 18:19:04,973 --- 321881 result 1
2013-03-07 18:19:05,007 --- 321884 result 1
2013-03-07 18:19:05,012 --- 321882 result 4
2013-03-07 18:19:05,148 --- judging 321885
2013-03-07 18:19:05,267 --- judging 321886
2013-03-07 18:19:05,297 --- judging 321887
2013-03-07 18:19:05,356 --- judging 321888
2013-03-07 18:19:05,386 --- judging 321889
2013-03-07 18:19:05,485 --- 321885 result 1

python的配置文件如何编写?

最简单有效的方式就是建立一个config.py文件,里面写上配置的内容,就像下面一样:

#!/usr/bin/env python
#coding=utf-8
#开启评测线程数目
count_thread = 4
#评测程序队列容量
queue_size = 4
#数据库地址
db_host = "localhost"
#数据库用户名
db_user = "user"
#数据库密码
db_password = "password"
#数据库名字
db_name = "db_name"

使用的时候只需要将这个文件导入,然后直接config.queue_size就可以访问配置文件里面的内容,很方便的。

评测程序的评测效率如何?

自从服务器启用新的评测程序之后,已经经历了两次大的比赛和几次大型考试,在几百个人的比赛和考试中,评测基本没用等待现象,用户提交的代码基本都能立即评测出来。大体测了一下,单服务器平均每秒能判6个题目左右(包括获取代码,编译,运行,检测,数据库写入结果等流程)。评测程序目前已经稳定运行了几个月,没有出现大的问题,应该说技术比较成熟了。

评测程序还能继续改进吗?

当时脑子估计是被驴踢了,居然使用多线程来评测!有经验的python程序猿都知道,python有个全局GIL锁,这个锁会将python的多个线程序列化,在一个时刻只允许一个线程执行,无论你的机器有多少个CPU,只能使用一个!这就明显影响评测速度!如果换成多进程方式,一个评测进程占用一个CPU核心,评测速度将会是几倍几十倍的性能提升!到时候弄个上千人的比赛估计问题也不大,最起码评测速度能保证。

此外,还可以构建一个分布式的评测服务器集群,大体设想了一下可以这样实现:

首先,可以选一台服务器A专门和数据库交互,包括从数据库中获取评测任务以及评测结束将结果写回数据库。然后选择N台普通计算机作为评测机,评测机只和数据库A打交道,也就是从服务器A获取任务,在普通机器上评测,评测完后将结果反馈到服务器A,再由A将结果写入到数据库。服务器A在这里就充当一个任务管理和分配的角色,协调各个评测机去评测。这样可以减少对数据库的操作,评测机就不用去一遍一遍扫数据库了。评测的速度和安全性可以得到进一步提升。

judger.png

其他

  • 上面的程序和方法仅供学习和研究用,严禁任何非法用途
  • 本人学识有限,如有错误欢迎批评指正